mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
seedance omni video (#1148)
This commit is contained in:
Generated
+1
@@ -11598,6 +11598,7 @@ dependencies = [
|
||||
"tts_common",
|
||||
"url",
|
||||
"url_config",
|
||||
"url_utils",
|
||||
"user_input_common",
|
||||
"user_traits_component",
|
||||
"users",
|
||||
|
||||
+6
@@ -23,6 +23,12 @@ pub struct Seedance2p0MultiFunctionVideoGenRequest {
|
||||
/// Optional reference images (reference mode). When present, takes priority over start/end frames.
|
||||
pub reference_image_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Optional reference videos.
|
||||
pub reference_video_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Optional reference audio files.
|
||||
pub reference_audio_media_tokens: Option<Vec<MediaFileToken>>,
|
||||
|
||||
/// Video aspect ratio / resolution.
|
||||
pub aspect_ratio: Option<Seedance2p0AspectRatio>,
|
||||
|
||||
|
||||
+3
@@ -31,6 +31,9 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
let cost_in_usd_cents = args.estimate_cost_in_usd_cents();
|
||||
|
||||
+2
@@ -18,6 +18,8 @@ pub async fn execute_artcraft_seedance2p0(
|
||||
start_frame_media_token: plan.start_frame.map(|t| t.to_owned()),
|
||||
end_frame_media_token: plan.end_frame.map(|t| t.to_owned()),
|
||||
reference_image_media_tokens: plan.reference_images.map(|tokens| tokens.to_owned()),
|
||||
reference_video_media_tokens: None,
|
||||
reference_audio_media_tokens: None,
|
||||
aspect_ratio: plan.aspect_ratio,
|
||||
duration_seconds: plan.duration_seconds,
|
||||
batch_count: Some(plan.batch_count),
|
||||
|
||||
@@ -31,8 +31,23 @@ pub struct GenerateVideoArgs<'a> {
|
||||
/// Optional end frame image URL (keyframe mode).
|
||||
pub end_frame_url: Option<String>,
|
||||
|
||||
/// Optional reference image URLs (reference mode). When present, takes priority over start/end frames.
|
||||
/// Optional reference image URLs (reference mode).
|
||||
/// When present, takes priority over start/end frames.
|
||||
pub reference_image_urls: Option<Vec<String>>,
|
||||
|
||||
/// Optional reference video URLs (reference mode).
|
||||
/// Can be combined with reference_image_urls.
|
||||
/// Videos are referenced in prompts as @video1, @video2, etc.
|
||||
/// When present, takes priority over start/end frames.
|
||||
pub reference_video_urls: Option<Vec<String>>,
|
||||
|
||||
/// Optional reference audio URLs (reference mode).
|
||||
/// Audio is referenced in prompts as @audio1, @audio2, etc.
|
||||
/// Sent in a separate `audioUrls` field (not in `uploadedUrls`).
|
||||
pub reference_audio_urls: Option<Vec<String>>,
|
||||
|
||||
/// Controls the `faceBlurMode` field: true sends "on", false sends "off", None omits it.
|
||||
pub use_face_blur_hack: Option<bool>,
|
||||
}
|
||||
|
||||
impl GenerateVideoArgs<'_> {
|
||||
@@ -126,12 +141,23 @@ pub struct GenerateVideoResponse {
|
||||
// --- Implementation ---
|
||||
|
||||
pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideoResponse, Seedance2ProError> {
|
||||
let is_reference_mode = args.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_images = args.reference_image_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_videos = args.reference_video_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
let has_reference_audio = args.reference_audio_urls.as_ref().is_some_and(|urls| !urls.is_empty());
|
||||
|
||||
let is_reference_mode = has_reference_images || has_reference_videos || has_reference_audio;
|
||||
|
||||
let video_input_mode = if is_reference_mode { "reference" } else { "keyframe" };
|
||||
|
||||
let uploaded_urls: Option<Vec<String>> = if is_reference_mode {
|
||||
args.reference_image_urls
|
||||
let mut urls = Vec::new();
|
||||
if let Some(video_urls) = args.reference_video_urls {
|
||||
urls.extend(video_urls);
|
||||
}
|
||||
if let Some(image_urls) = args.reference_image_urls {
|
||||
urls.extend(image_urls);
|
||||
}
|
||||
if urls.is_empty() { None } else { Some(urls) }
|
||||
} else {
|
||||
let mut urls = Vec::new();
|
||||
if let Some(url) = args.start_frame_url {
|
||||
@@ -143,6 +169,18 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
|
||||
if urls.is_empty() { None } else { Some(urls) }
|
||||
};
|
||||
|
||||
let audio_urls: Option<Vec<String>> = if has_reference_audio {
|
||||
args.reference_audio_urls
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let face_blur_mode = match args.use_face_blur_hack {
|
||||
Some(true) => Some("on"),
|
||||
Some(false) => Some("off"),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let batch_count_value = args.batch_count.as_u8();
|
||||
let batch_count = if batch_count_value > 1 { Some(batch_count_value) } else { None };
|
||||
|
||||
@@ -164,7 +202,9 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
|
||||
model: "seedance-20",
|
||||
duration,
|
||||
mode: video_input_mode,
|
||||
face_blur_mode,
|
||||
uploaded_urls,
|
||||
audio_urls,
|
||||
batch_count,
|
||||
},
|
||||
},
|
||||
@@ -240,12 +280,15 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use super::*;
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{prepare_file_upload, PrepareFileUploadArgs};
|
||||
use crate::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
|
||||
fn dummy_session() -> Seedance2ProSession {
|
||||
Seedance2ProSession::from_cookies_string(String::new())
|
||||
@@ -263,6 +306,9 @@ mod tests {
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +371,9 @@ mod tests {
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
let result = generate_video(args).await?;
|
||||
println!("Task ID: {}", result.task_id);
|
||||
@@ -349,6 +398,9 @@ mod tests {
|
||||
start_frame_url: Some("https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string()),
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
let result = generate_video(args).await?;
|
||||
println!("Task ID: {}", result.task_id);
|
||||
@@ -360,7 +412,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_generate_reference_video() -> AnyhowResult<()> {
|
||||
async fn test_generate_reference_image_video() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let args = GenerateVideoArgs {
|
||||
@@ -375,6 +427,9 @@ mod tests {
|
||||
"https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png".to_string(),
|
||||
"https://static.seedance2-pro.com/materials/20260219/1771496300184-fb32e08c.jpg".to_string(),
|
||||
]),
|
||||
reference_video_urls: None,
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
let result = generate_video(args).await?;
|
||||
println!("Task ID: {}", result.task_id);
|
||||
@@ -383,4 +438,113 @@ mod tests {
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_generate_reference_video_only() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let args = GenerateVideoArgs {
|
||||
session: &session,
|
||||
prompt: "Change the Video @video1 to night time.".to_string(),
|
||||
resolution: Resolution::Landscape16x9,
|
||||
duration_seconds: 5,
|
||||
batch_count: BatchCount::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
|
||||
]),
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
let result = generate_video(args).await?;
|
||||
println!("Task ID: {}", result.task_id);
|
||||
println!("Order ID: {}", result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_generate_reference_video_and_image() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let session = test_session()?;
|
||||
let args = GenerateVideoArgs {
|
||||
session: &session,
|
||||
prompt: "Put the robot in @video1 next to the house in @image1".to_string(),
|
||||
resolution: Resolution::Landscape16x9,
|
||||
duration_seconds: 5,
|
||||
batch_count: BatchCount::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260315/1773595053724-07a1d500.png".to_string(),
|
||||
]),
|
||||
reference_video_urls: Some(vec![
|
||||
"https://static.seedance2-pro.com/materials/20260315/1773594284659-3a46d231.mp4".to_string(),
|
||||
]),
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
let result = generate_video(args).await?;
|
||||
println!("Task ID: {}", result.task_id);
|
||||
println!("Order ID: {}", result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies and a test image
|
||||
async fn test_video_ref_file_that_is_too_long() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
|
||||
// Step 1: Get a signed upload URL
|
||||
let cookies = get_test_cookies()?;
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
let prepare_args = PrepareFileUploadArgs {
|
||||
session: &session,
|
||||
extension: "mp4".to_string(),
|
||||
};
|
||||
let prepare_result = prepare_file_upload(prepare_args).await?;
|
||||
println!("Upload URL: {}", prepare_result.upload_url);
|
||||
|
||||
// Step 2: Read a test image
|
||||
let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?;
|
||||
println!("File size: {} bytes", file_bytes.len());
|
||||
|
||||
// Step 3: Upload
|
||||
let upload_args = UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes,
|
||||
};
|
||||
let result = upload_file(upload_args).await?;
|
||||
println!("Public URL: {}", result.public_url);
|
||||
|
||||
let args = GenerateVideoArgs {
|
||||
session: &session,
|
||||
prompt: "Change @video1 to night time".to_string(),
|
||||
resolution: Resolution::Landscape16x9,
|
||||
duration_seconds: 5,
|
||||
batch_count: BatchCount::One,
|
||||
start_frame_url: None,
|
||||
end_frame_url: None,
|
||||
reference_image_urls: None,
|
||||
reference_video_urls: Some(vec![
|
||||
result.public_url,
|
||||
]),
|
||||
reference_audio_urls: None,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
let result = generate_video(args).await?;
|
||||
println!("Task ID: {}", result.task_id);
|
||||
println!("Order ID: {}", result.order_id);
|
||||
assert!(!result.task_id.is_empty());
|
||||
assert_eq!(1, 2); // NB: Intentional failure to inspect output.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,12 @@ pub (super) struct ApiParams {
|
||||
pub model: &'static str,
|
||||
pub duration: String,
|
||||
pub mode: &'static str,
|
||||
#[serde(rename = "faceBlurMode", skip_serializing_if = "Option::is_none")]
|
||||
pub face_blur_mode: Option<&'static str>,
|
||||
#[serde(rename = "uploadedUrls", skip_serializing_if = "Option::is_none")]
|
||||
pub uploaded_urls: Option<Vec<String>>,
|
||||
#[serde(rename = "audioUrls", skip_serializing_if = "Option::is_none")]
|
||||
pub audio_urls: Option<Vec<String>>,
|
||||
#[serde(rename = "batchCount", skip_serializing_if = "Option::is_none")]
|
||||
pub batch_count: Option<u8>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod generate_video;
|
||||
pub mod poll_orders;
|
||||
pub mod prepare_image_upload;
|
||||
pub mod upload_image;
|
||||
pub mod prepare_file_upload;
|
||||
pub mod upload_file;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod prepare_file_upload;
|
||||
mod request_types;
|
||||
+39
-14
@@ -2,7 +2,7 @@ use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
|
||||
use crate::requests::prepare_image_upload::request_types::*;
|
||||
use crate::requests::prepare_file_upload::request_types::*;
|
||||
use crate::utils::common_headers::FIREFOX_USER_AGENT;
|
||||
use chrono::Utc;
|
||||
use log::info;
|
||||
@@ -13,32 +13,35 @@ use wreq_util::Emulation;
|
||||
const SIGNED_UPLOAD_URL: &str = "https://seedance2-pro.com/api/trpc/uploads.signedUploadUrl?batch=1";
|
||||
|
||||
/// Generates a material path based on the current time.
|
||||
/// Format: `materials/YYYYMMDD/<unix_millis>-<random_hex>.png`
|
||||
fn generate_material_path() -> String {
|
||||
/// Format: `materials/YYYYMMDD/<unix_millis>-<random_hex>.<extension>`
|
||||
fn generate_material_path(extension: &str) -> String {
|
||||
let now = Utc::now();
|
||||
let date_part = now.format("%Y%m%d").to_string();
|
||||
let timestamp_millis = now.timestamp_millis();
|
||||
let random_hex: u32 = rand::rng().random();
|
||||
let hex_part = format!("{:08x}", random_hex);
|
||||
format!("materials/{}/{}-{}.png", date_part, timestamp_millis, hex_part)
|
||||
format!("materials/{}/{}-{}.{}", date_part, timestamp_millis, hex_part, extension)
|
||||
}
|
||||
|
||||
pub struct PrepareImageUploadArgs<'a> {
|
||||
pub struct PrepareFileUploadArgs<'a> {
|
||||
pub session: &'a Seedance2ProSession,
|
||||
|
||||
/// File extension without the dot, e.g. "png", "mp4", "mp3".
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
pub struct PrepareImageUploadResponse {
|
||||
/// The signed URL to upload the image to (Cloudflare R2 / S3-compatible).
|
||||
pub struct PrepareFileUploadResponse {
|
||||
/// The signed URL to upload the file to (Cloudflare R2 / S3-compatible).
|
||||
pub upload_url: String,
|
||||
|
||||
/// The material path that was generated for this upload.
|
||||
pub material_path: String,
|
||||
}
|
||||
|
||||
pub async fn prepare_image_upload(args: PrepareImageUploadArgs<'_>) -> Result<PrepareImageUploadResponse, Seedance2ProError> {
|
||||
let material_path = generate_material_path();
|
||||
pub async fn prepare_file_upload(args: PrepareFileUploadArgs<'_>) -> Result<PrepareFileUploadResponse, Seedance2ProError> {
|
||||
let material_path = generate_material_path(&args.extension);
|
||||
|
||||
info!("Preparing image upload with path: {}", material_path);
|
||||
info!("Preparing file upload with path: {}", material_path);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
@@ -103,7 +106,7 @@ pub async fn prepare_image_upload(args: PrepareImageUploadArgs<'_>) -> Result<Pr
|
||||
.data
|
||||
.json;
|
||||
|
||||
Ok(PrepareImageUploadResponse {
|
||||
Ok(PrepareFileUploadResponse {
|
||||
upload_url,
|
||||
material_path,
|
||||
})
|
||||
@@ -120,19 +123,41 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_prepare_image_upload() -> AnyhowResult<()> {
|
||||
async fn test_prepare_file_upload_png() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let cookies = get_test_cookies()?;
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
let args = PrepareImageUploadArgs {
|
||||
let args = PrepareFileUploadArgs {
|
||||
session: &session,
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let result = prepare_image_upload(args).await?;
|
||||
let result = prepare_file_upload(args).await?;
|
||||
println!("Upload URL: {}", result.upload_url);
|
||||
println!("Material path: {}", result.material_path);
|
||||
assert!(!result.upload_url.is_empty());
|
||||
assert!(result.upload_url.contains("cloudflarestorage.com"));
|
||||
assert!(result.material_path.starts_with("materials/"));
|
||||
assert!(result.material_path.ends_with(".png"));
|
||||
|
||||
assert_eq!(1, 2); // NB: Intentional failure to check the response.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies
|
||||
async fn test_prepare_file_upload_mp4() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
let cookies = get_test_cookies()?;
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
let args = PrepareFileUploadArgs {
|
||||
session: &session,
|
||||
extension: "mp4".to_string(),
|
||||
};
|
||||
let result = prepare_file_upload(args).await?;
|
||||
println!("Upload URL: {}", result.upload_url);
|
||||
println!("Material path: {}", result.material_path);
|
||||
assert!(!result.upload_url.is_empty());
|
||||
assert!(result.material_path.ends_with(".mp4"));
|
||||
|
||||
assert_eq!(1, 2); // NB: Intentional failure to check the response.
|
||||
Ok(())
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod prepare_image_upload;
|
||||
mod request_types;
|
||||
@@ -0,0 +1 @@
|
||||
pub mod upload_file;
|
||||
+54
-20
@@ -9,16 +9,16 @@ use wreq_util::Emulation;
|
||||
|
||||
const STATIC_BASE_URL: &str = "https://static.seedance2-pro.com";
|
||||
|
||||
pub struct UploadImageArgs {
|
||||
/// The signed upload URL returned by `prepare_image_upload`.
|
||||
pub struct UploadFileArgs {
|
||||
/// The signed upload URL returned by `prepare_file_upload`.
|
||||
pub upload_url: String,
|
||||
|
||||
/// The raw image bytes to upload.
|
||||
pub image_bytes: Vec<u8>,
|
||||
/// The raw file bytes to upload.
|
||||
pub file_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct UploadImageResponse {
|
||||
/// The public-facing URL for the uploaded image.
|
||||
pub struct UploadFileResponse {
|
||||
/// The public-facing URL for the uploaded file.
|
||||
/// e.g. `https://static.seedance2-pro.com/materials/20260219/1771463564512-b14bfe90.png`
|
||||
pub public_url: String,
|
||||
}
|
||||
@@ -33,8 +33,8 @@ fn build_public_url(upload_url: &str) -> Result<String, Seedance2ProError> {
|
||||
Ok(format!("{}{}", STATIC_BASE_URL, path))
|
||||
}
|
||||
|
||||
pub async fn upload_image(args: UploadImageArgs) -> Result<UploadImageResponse, Seedance2ProError> {
|
||||
info!("Uploading image to: {}", args.upload_url);
|
||||
pub async fn upload_file(args: UploadFileArgs) -> Result<UploadFileResponse, Seedance2ProError> {
|
||||
info!("Uploading file to: {}", args.upload_url);
|
||||
|
||||
let client = Client::builder()
|
||||
.emulation(Emulation::Firefox143)
|
||||
@@ -53,7 +53,7 @@ pub async fn upload_image(args: UploadImageArgs) -> Result<UploadImageResponse,
|
||||
.header("Sec-Fetch-Mode", "cors")
|
||||
.header("Sec-Fetch-Site", "cross-site")
|
||||
.header("Priority", "u=4")
|
||||
.body(args.image_bytes)
|
||||
.body(args.file_bytes)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| Seedance2ProGenericApiError::WreqError(err))?;
|
||||
@@ -77,7 +77,7 @@ pub async fn upload_image(args: UploadImageArgs) -> Result<UploadImageResponse,
|
||||
|
||||
info!("Public URL: {}", public_url);
|
||||
|
||||
Ok(UploadImageResponse { public_url })
|
||||
Ok(UploadFileResponse { public_url })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -86,8 +86,8 @@ mod tests {
|
||||
use crate::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use crate::test_utils::get_test_cookies::get_test_cookies;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
use crate::requests::prepare_image_upload::prepare_image_upload::{
|
||||
prepare_image_upload, PrepareImageUploadArgs,
|
||||
use crate::requests::prepare_file_upload::prepare_file_upload::{
|
||||
prepare_file_upload, PrepareFileUploadArgs,
|
||||
};
|
||||
use errors::AnyhowResult;
|
||||
use log::LevelFilter;
|
||||
@@ -95,28 +95,62 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies and a test image
|
||||
async fn test_upload_image() -> AnyhowResult<()> {
|
||||
async fn test_upload_image_file() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
|
||||
// Step 1: Get a signed upload URL
|
||||
let cookies = get_test_cookies()?;
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
let prepare_args = PrepareImageUploadArgs {
|
||||
let prepare_args = PrepareFileUploadArgs {
|
||||
session: &session,
|
||||
extension: "jpg".to_string(),
|
||||
};
|
||||
let prepare_result = prepare_image_upload(prepare_args).await?;
|
||||
let prepare_result = prepare_file_upload(prepare_args).await?;
|
||||
println!("Upload URL: {}", prepare_result.upload_url);
|
||||
|
||||
// Step 2: Read a test image
|
||||
let image_bytes = fs::read("/Users/bt/dev/storyteller/artcraft/test_data/image/juno.jpg")?;
|
||||
println!("Image size: {} bytes", image_bytes.len());
|
||||
let file_bytes = fs::read("/Users/bt/dev/storyteller/artcraft/test_data/image/juno.jpg")?;
|
||||
println!("File size: {} bytes", file_bytes.len());
|
||||
|
||||
// Step 3: Upload
|
||||
let upload_args = UploadImageArgs {
|
||||
let upload_args = UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
image_bytes,
|
||||
file_bytes,
|
||||
};
|
||||
let result = upload_image(upload_args).await?;
|
||||
let result = upload_file(upload_args).await?;
|
||||
println!("Public URL: {}", result.public_url);
|
||||
|
||||
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
|
||||
assert_eq!(1, 2); // NB: Intentional failure to check the response.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real cookies and a test image
|
||||
async fn test_upload_video_file_that_is_too_long() -> AnyhowResult<()> {
|
||||
setup_test_logging(LevelFilter::Trace);
|
||||
|
||||
// Step 1: Get a signed upload URL
|
||||
let cookies = get_test_cookies()?;
|
||||
let session = Seedance2ProSession::from_cookies_string(cookies);
|
||||
let prepare_args = PrepareFileUploadArgs {
|
||||
session: &session,
|
||||
extension: "mp4".to_string(),
|
||||
};
|
||||
let prepare_result = prepare_file_upload(prepare_args).await?;
|
||||
println!("Upload URL: {}", prepare_result.upload_url);
|
||||
|
||||
// Step 2: Read a test image
|
||||
let file_bytes = fs::read("/Users/bt/Videos/Artcraft/Artcraft Best/ArtCraft Seedance Knight.mp4")?;
|
||||
println!("File size: {} bytes", file_bytes.len());
|
||||
|
||||
// Step 3: Upload
|
||||
let upload_args = UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes,
|
||||
};
|
||||
let result = upload_file(upload_args).await?;
|
||||
println!("Public URL: {}", result.public_url);
|
||||
|
||||
assert!(result.public_url.starts_with("https://static.seedance2-pro.com/materials/"));
|
||||
@@ -1 +0,0 @@
|
||||
pub mod upload_image;
|
||||
@@ -17,9 +17,11 @@ path = "src/lib.rs"
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
# NB: DO NOT ALLOW ANY OTHER DEPS - THIS IS URL LOGIC ONLY
|
||||
# (None)
|
||||
|
||||
# External
|
||||
# NB: DO NOT ALLOW ANY OTHER DEPS - THIS IS URL LOGIC ONLY
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
|
||||
/// A file extension that can be either a compile-time constant or a runtime-parsed value.
|
||||
///
|
||||
/// ```
|
||||
/// use url_utils::extension::extension::Extension;
|
||||
///
|
||||
/// // Static constants (no allocation)
|
||||
/// const PNG: Extension = Extension::from_static("png", ".png");
|
||||
/// assert_eq!(PNG.without_period(), "png");
|
||||
/// assert_eq!(PNG.with_period(), ".png");
|
||||
///
|
||||
/// // Runtime parsing (allocates only if needed)
|
||||
/// let ext = Extension::new("mp4");
|
||||
/// assert_eq!(ext.without_period(), "mp4");
|
||||
/// assert_eq!(ext.with_period(), ".mp4");
|
||||
/// ```
|
||||
#[derive(Clone, Eq, Hash)]
|
||||
pub struct Extension {
|
||||
/// The extension without the leading period, e.g. "png", "mp4".
|
||||
value: Cow<'static, str>,
|
||||
|
||||
/// The extension with the leading period, e.g. ".png", ".mp4".
|
||||
dotted: Cow<'static, str>,
|
||||
}
|
||||
|
||||
impl Extension {
|
||||
/// Creates an `Extension` from static strings at compile time.
|
||||
/// Pass both the bare extension and the dotted form.
|
||||
///
|
||||
/// ```
|
||||
/// use url_utils::extension::extension::Extension;
|
||||
/// const MP4: Extension = Extension::from_static("mp4", ".mp4");
|
||||
/// ```
|
||||
pub const fn from_static(ext: &'static str, dotted: &'static str) -> Self {
|
||||
Self {
|
||||
value: Cow::Borrowed(ext),
|
||||
dotted: Cow::Borrowed(dotted),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an `Extension` from a `&str` at runtime.
|
||||
/// The input must NOT include a leading period.
|
||||
pub fn new(ext: &str) -> Self {
|
||||
let dotted = format!(".{}", ext);
|
||||
Self {
|
||||
value: Cow::Owned(ext.to_string()),
|
||||
dotted: Cow::Owned(dotted),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the extension without the leading period, e.g. `"png"`.
|
||||
pub fn without_period(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// Returns the extension with the leading period, e.g. `".png"`.
|
||||
pub fn with_period(&self) -> &str {
|
||||
&self.dotted
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Extension {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.value == other.value
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Extension {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Extension(\"{}\")", self.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Extension {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PNG: Extension = Extension::from_static("png", ".png");
|
||||
const MP4: Extension = Extension::from_static("mp4", ".mp4");
|
||||
const MP3: Extension = Extension::from_static("mp3", ".mp3");
|
||||
|
||||
#[test]
|
||||
fn static_without_period() {
|
||||
assert_eq!(PNG.without_period(), "png");
|
||||
assert_eq!(MP4.without_period(), "mp4");
|
||||
assert_eq!(MP3.without_period(), "mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_with_period() {
|
||||
assert_eq!(PNG.with_period(), ".png");
|
||||
assert_eq!(MP4.with_period(), ".mp4");
|
||||
assert_eq!(MP3.with_period(), ".mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_without_period() {
|
||||
let ext = Extension::new("webm");
|
||||
assert_eq!(ext.without_period(), "webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_with_period() {
|
||||
let ext = Extension::new("webm");
|
||||
assert_eq!(ext.with_period(), ".webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_extension() {
|
||||
let ext = Extension::new("foobar");
|
||||
assert_eq!(ext.without_period(), "foobar");
|
||||
assert_eq!(ext.with_period(), ".foobar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equality() {
|
||||
let a = Extension::new("png");
|
||||
assert_eq!(a, PNG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone() {
|
||||
let a = Extension::new("wav");
|
||||
let b = a.clone();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display() {
|
||||
assert_eq!(format!("{}", PNG), "png");
|
||||
assert_eq!(format!("{}", Extension::new("xyz")), "xyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug() {
|
||||
assert_eq!(format!("{:?}", PNG), "Extension(\"png\")");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::extension::extension::Extension;
|
||||
use url::Url;
|
||||
|
||||
// TODO: Lookups against these as lists will be slow
|
||||
|
||||
/// Known image extensions.
|
||||
const KNOWN_IMAGE_EXTENSIONS: &[Extension] = &[
|
||||
Extension::from_static("png", ".png"),
|
||||
Extension::from_static("jpg", ".jpg"),
|
||||
Extension::from_static("jpeg", ".jpeg"),
|
||||
Extension::from_static("webp", ".webp"),
|
||||
];
|
||||
|
||||
/// Known audio extensions.
|
||||
const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[
|
||||
Extension::from_static("wav", ".wav"),
|
||||
Extension::from_static("mp3", ".mp3"),
|
||||
];
|
||||
|
||||
/// Known video extensions.
|
||||
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[
|
||||
Extension::from_static("mp4", ".mp4"),
|
||||
Extension::from_static("webm", ".webm"),
|
||||
];
|
||||
|
||||
/// Controls which extensions are accepted when extracting from a URL.
|
||||
pub enum ExtractExtensions {
|
||||
/// Accept any extension-like string found in the URL path.
|
||||
All,
|
||||
|
||||
/// Accept only extensions that appear in the given set.
|
||||
Set(HashSet<Extension>),
|
||||
|
||||
/// Accept only known image extensions (png, jpg, jpeg, webp).
|
||||
KnownImage,
|
||||
|
||||
/// Accept only known audio extensions (wav, mp3).
|
||||
KnownAudio,
|
||||
|
||||
/// Accept only known video extensions (mp4, webm).
|
||||
KnownVideo,
|
||||
|
||||
/// Accept any known media extension (image, audio, or video).
|
||||
KnownMedia,
|
||||
}
|
||||
|
||||
impl ExtractExtensions {
|
||||
/// Creates a `Set` variant from a `Vec<Extension>`.
|
||||
pub fn from_vec(extensions: Vec<Extension>) -> Self {
|
||||
Self::Set(extensions.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a file extension from a URL string, filtered by `accept`.
|
||||
pub fn extract_extension_from_url_str(url: &str, accept: &ExtractExtensions) -> Option<Extension> {
|
||||
let parsed_url = Url::parse(url).ok()?;
|
||||
extract_extension_from_url(&parsed_url, accept)
|
||||
}
|
||||
|
||||
/// Extracts a file extension from a parsed `Url`, filtered by `accept`.
|
||||
pub fn extract_extension_from_url(url: &Url, accept: &ExtractExtensions) -> Option<Extension> {
|
||||
let path = url.path();
|
||||
let raw_ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())?;
|
||||
|
||||
let lower = raw_ext.to_lowercase();
|
||||
let candidate = Extension::new(&lower);
|
||||
|
||||
match accept {
|
||||
ExtractExtensions::All => Some(candidate),
|
||||
ExtractExtensions::Set(set) => {
|
||||
if set.contains(&candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownImage => {
|
||||
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownAudio => {
|
||||
if slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownVideo => {
|
||||
if slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate) { Some(candidate) } else { None }
|
||||
}
|
||||
ExtractExtensions::KnownMedia => {
|
||||
if slice_contains(KNOWN_IMAGE_EXTENSIONS, &candidate)
|
||||
|| slice_contains(KNOWN_AUDIO_EXTENSIONS, &candidate)
|
||||
|| slice_contains(KNOWN_VIDEO_EXTENSIONS, &candidate)
|
||||
{
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn slice_contains(extensions: &[Extension], candidate: &Extension) -> bool {
|
||||
extensions.iter().any(|e| e == candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_extracts_any_extension() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/file.png", &ExtractExtensions::All);
|
||||
assert_eq!(ext.unwrap().without_period(), "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_extracts_unknown_extension() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/file.foobar", &ExtractExtensions::All);
|
||||
assert_eq!(ext.unwrap().without_period(), "foobar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_extracts_unknown_extension_all_caps() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/file.FOOBAR", &ExtractExtensions::All);
|
||||
assert_eq!(ext.unwrap().without_period(), "foobar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_lowercases() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/file.PNG", &ExtractExtensions::All);
|
||||
assert_eq!(ext.unwrap().without_period(), "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_image_matches_png() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/photo.png", &ExtractExtensions::KnownImage);
|
||||
assert_eq!(ext.unwrap().without_period(), "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_image_rejects_mp4() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/video.mp4", &ExtractExtensions::KnownImage);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_audio_matches_wav() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/sound.wav", &ExtractExtensions::KnownAudio);
|
||||
assert_eq!(ext.unwrap().without_period(), "wav");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_audio_rejects_png() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/photo.png", &ExtractExtensions::KnownAudio);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_video_matches_mp4() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/clip.mp4", &ExtractExtensions::KnownVideo);
|
||||
assert_eq!(ext.unwrap().without_period(), "mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_video_rejects_mp3() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/song.mp3", &ExtractExtensions::KnownVideo);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_media_matches_image() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/photo.jpg", &ExtractExtensions::KnownMedia);
|
||||
assert_eq!(ext.unwrap().without_period(), "jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_media_matches_audio() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/song.mp3", &ExtractExtensions::KnownMedia);
|
||||
assert_eq!(ext.unwrap().without_period(), "mp3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_media_matches_video() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/clip.webm", &ExtractExtensions::KnownMedia);
|
||||
assert_eq!(ext.unwrap().without_period(), "webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_media_rejects_unknown() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/data.xyz", &ExtractExtensions::KnownMedia);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_matches_custom_list() {
|
||||
let accept = ExtractExtensions::from_vec(vec![
|
||||
Extension::from_static("csv", ".csv"),
|
||||
Extension::from_static("tsv", ".tsv"),
|
||||
]);
|
||||
let ext = extract_extension_from_url_str("https://example.com/data.csv", &accept);
|
||||
assert_eq!(ext.unwrap().without_period(), "csv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rejects_non_member() {
|
||||
let accept = ExtractExtensions::from_vec(vec![
|
||||
Extension::from_static("csv", ".csv"),
|
||||
]);
|
||||
let ext = extract_extension_from_url_str("https://example.com/data.png", &accept);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_extension_returns_none() {
|
||||
let ext = extract_extension_from_url_str("https://example.com/file", &ExtractExtensions::All);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_params_ignored() {
|
||||
let ext = extract_extension_from_url_str(
|
||||
"https://example.com/file.mp4?token=abc&format=hd",
|
||||
&ExtractExtensions::KnownVideo,
|
||||
);
|
||||
assert_eq!(ext.unwrap().without_period(), "mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn difficult_url_returns_none() {
|
||||
let url = "https://videos.youtube.com/foo/bar/files/00000000-f19a-1275-a092-caa60b38f8d2%2Fraw?se=2025-11-21T22%3A26%3A53Z&sp=r&sv=2024-08-09&sr=b";
|
||||
let ext = extract_extension_from_url_str(url, &ExtractExtensions::All);
|
||||
assert!(ext.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod download_extension;
|
||||
pub mod extract_download_extension_from_url;
|
||||
pub mod extension;
|
||||
pub mod extract_download_extension_from_url;
|
||||
pub mod extract_extension_from_url;
|
||||
+21
-1
@@ -6,6 +6,16 @@ use strum::EnumCount;
|
||||
use strum::EnumIter;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/*
|
||||
Current DB values - this is in conflict with API serializations. Need to fix!
|
||||
imgref
|
||||
imgsrc
|
||||
imgmask
|
||||
vid_start_frame
|
||||
vid_end_frame
|
||||
vidref
|
||||
*/
|
||||
|
||||
/// Used in the `prompt_context_items` table in a `VARCHAR(16)` field.
|
||||
///
|
||||
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
|
||||
@@ -34,6 +44,9 @@ pub enum PromptContextSemanticType {
|
||||
ImgrefCharacter,
|
||||
ImgrefStyle,
|
||||
ImgrefBg,
|
||||
|
||||
/// Audio reference (e.g. for audio-to-video generation)
|
||||
Audioref,
|
||||
}
|
||||
|
||||
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
|
||||
@@ -54,6 +67,7 @@ impl PromptContextSemanticType {
|
||||
Self::ImgrefCharacter => "imgref_character",
|
||||
Self::ImgrefStyle => "imgref_style",
|
||||
Self::ImgrefBg => "imgref_bg",
|
||||
Self::Audioref => "audioref",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +82,7 @@ impl PromptContextSemanticType {
|
||||
"imgref_character" => Ok(Self::ImgrefCharacter),
|
||||
"imgref_style" => Ok(Self::ImgrefStyle),
|
||||
"imgref_bg" => Ok(Self::ImgrefBg),
|
||||
"audioref" => Ok(Self::Audioref),
|
||||
_ => Err(format!("invalid value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
@@ -85,6 +100,7 @@ impl PromptContextSemanticType {
|
||||
Self::ImgrefCharacter,
|
||||
Self::ImgrefStyle,
|
||||
Self::ImgrefBg,
|
||||
Self::Audioref,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -108,6 +124,7 @@ mod tests {
|
||||
assert_serialization(PromptContextSemanticType::ImgrefCharacter, "imgref_character");
|
||||
assert_serialization(PromptContextSemanticType::ImgrefStyle, "imgref_style");
|
||||
assert_serialization(PromptContextSemanticType::ImgrefBg, "imgref_bg");
|
||||
assert_serialization(PromptContextSemanticType::Audioref, "audioref");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +142,7 @@ mod tests {
|
||||
assert_eq!(PromptContextSemanticType::ImgrefCharacter.to_str(), "imgref_character");
|
||||
assert_eq!(PromptContextSemanticType::ImgrefStyle.to_str(), "imgref_style");
|
||||
assert_eq!(PromptContextSemanticType::ImgrefBg.to_str(), "imgref_bg");
|
||||
assert_eq!(PromptContextSemanticType::Audioref.to_str(), "audioref");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -138,6 +156,7 @@ mod tests {
|
||||
assert_eq!(PromptContextSemanticType::from_str("imgref_character").unwrap(), PromptContextSemanticType::ImgrefCharacter);
|
||||
assert_eq!(PromptContextSemanticType::from_str("imgref_style").unwrap(), PromptContextSemanticType::ImgrefStyle);
|
||||
assert_eq!(PromptContextSemanticType::from_str("imgref_bg").unwrap(), PromptContextSemanticType::ImgrefBg);
|
||||
assert_eq!(PromptContextSemanticType::from_str("audioref").unwrap(), PromptContextSemanticType::Audioref);
|
||||
assert!(PromptContextSemanticType::from_str("foo").is_err());
|
||||
}
|
||||
}
|
||||
@@ -148,7 +167,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = PromptContextSemanticType::all_variants();
|
||||
assert_eq!(variants.len(), 9);
|
||||
assert_eq!(variants.len(), 10);
|
||||
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidStartFrame));
|
||||
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidEndFrame));
|
||||
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::VidRef));
|
||||
@@ -158,6 +177,7 @@ mod tests {
|
||||
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefCharacter));
|
||||
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefStyle));
|
||||
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::ImgrefBg));
|
||||
assert_eq!(variants.pop_first(), Some(PromptContextSemanticType::Audioref));
|
||||
assert_eq!(variants.pop_first(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ thumbnail_generator = { path = "../../../api_clients/thumbnail_generator" }
|
||||
tokens.workspace = true
|
||||
tts_common = { path = "../../../lib/tts_common" }
|
||||
url_config = { path = "../../../lib/url_config" }
|
||||
url_utils = { path = "../../../lib/url_utils" }
|
||||
user_input_common = { path = "../../../lib/user_input_common" }
|
||||
users.workspace = true
|
||||
videos = { path = "../../../lib/files/videos" }
|
||||
|
||||
+114
-48
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
|
||||
@@ -7,7 +8,7 @@ use crate::http_server::endpoints::generate::common::payments_error_test::paymen
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::http_download_url_to_bytes::http_download_url_to_bytes;
|
||||
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
|
||||
use crate::util::lookup::lookup_media_file_urls_as_map::lookup_media_file_urls_as_map;
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use artcraft_api_defs::generate::video::multi_function::seedance_2p0_multi_function_video_gen::{
|
||||
@@ -34,12 +35,15 @@ use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
|
||||
use seedance2pro::requests::generate_video::generate_video::{
|
||||
generate_video, BatchCount, GenerateVideoArgs, Resolution,
|
||||
};
|
||||
use seedance2pro::requests::prepare_image_upload::prepare_image_upload::{
|
||||
prepare_image_upload, PrepareImageUploadArgs,
|
||||
use seedance2pro::requests::prepare_file_upload::prepare_file_upload::{
|
||||
prepare_file_upload, PrepareFileUploadArgs,
|
||||
};
|
||||
use seedance2pro::requests::upload_image::upload_image::{upload_image, UploadImageArgs};
|
||||
use seedance2pro::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
|
||||
use sqlx::Acquire;
|
||||
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
use url_utils::extension::extract_extension_from_url::{extract_extension_from_url, ExtractExtensions};
|
||||
|
||||
/// Seedance 2.0 Multi-Function video generation (text-to-video, keyframe, and reference).
|
||||
#[utoipa::path(
|
||||
@@ -91,7 +95,8 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
|
||||
// --- Collect all media tokens to look up ---
|
||||
|
||||
let mut all_media_tokens = Vec::new();
|
||||
let mut all_media_tokens: Vec<MediaFileToken> = Vec::new();
|
||||
|
||||
if let Some(token) = request.start_frame_media_token.as_ref() {
|
||||
all_media_tokens.push(token.clone());
|
||||
}
|
||||
@@ -101,12 +106,18 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
if let Some(tokens) = request.reference_image_media_tokens.as_ref() {
|
||||
all_media_tokens.extend(tokens.iter().cloned());
|
||||
}
|
||||
if let Some(tokens) = request.reference_video_media_tokens.as_ref() {
|
||||
all_media_tokens.extend(tokens.iter().cloned());
|
||||
}
|
||||
if let Some(tokens) = request.reference_audio_media_tokens.as_ref() {
|
||||
all_media_tokens.extend(tokens.iter().cloned());
|
||||
}
|
||||
|
||||
let image_urls_by_token = if all_media_tokens.is_empty() {
|
||||
std::collections::HashMap::new()
|
||||
let file_urls_by_token = if all_media_tokens.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
info!("Looking up image media tokens: {:?}", all_media_tokens);
|
||||
lookup_image_urls_as_map(
|
||||
info!("Looking up media file tokens: {:?}", all_media_tokens);
|
||||
lookup_media_file_urls_as_map(
|
||||
&http_request,
|
||||
&mut mysql_connection,
|
||||
server_state.server_environment,
|
||||
@@ -129,11 +140,11 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
server_state.seedance2pro.cookies.clone()
|
||||
);
|
||||
|
||||
// --- Upload images to seedance2pro CDN ---
|
||||
// --- Upload files to seedance2pro CDN ---
|
||||
|
||||
let start_frame_url = match request.start_frame_media_token.as_ref() {
|
||||
None => None,
|
||||
Some(token) => match image_urls_by_token.get(token) {
|
||||
Some(token) => match file_urls_by_token.get(token) {
|
||||
None => return Err(CommonWebError::BadInputWithSimpleMessage("Start frame media not found.".to_string())),
|
||||
Some(url) => Some(upload_to_seedance2pro(&session, url).await?),
|
||||
}
|
||||
@@ -141,29 +152,32 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
|
||||
let end_frame_url = match request.end_frame_media_token.as_ref() {
|
||||
None => None,
|
||||
Some(token) => match image_urls_by_token.get(token) {
|
||||
Some(token) => match file_urls_by_token.get(token) {
|
||||
None => return Err(CommonWebError::BadInputWithSimpleMessage("End frame media not found.".to_string())),
|
||||
Some(url) => Some(upload_to_seedance2pro(&session, url).await?),
|
||||
}
|
||||
};
|
||||
|
||||
let reference_image_urls: Option<Vec<String>> = match request.reference_image_media_tokens.as_ref() {
|
||||
None => None,
|
||||
Some(tokens) if tokens.is_empty() => None,
|
||||
Some(tokens) => {
|
||||
let mut urls = Vec::with_capacity(tokens.len());
|
||||
for token in tokens {
|
||||
match image_urls_by_token.get(token) {
|
||||
None => return Err(CommonWebError::BadInputWithSimpleMessage("Reference image media not found.".to_string())),
|
||||
Some(url) => {
|
||||
let seedance_url = upload_to_seedance2pro(&session, url).await?;
|
||||
urls.push(seedance_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(urls)
|
||||
}
|
||||
};
|
||||
let reference_image_urls = upload_reference_tokens_to_seedance2pro(
|
||||
&session,
|
||||
&file_urls_by_token,
|
||||
request.reference_image_media_tokens.as_deref(),
|
||||
"Reference image",
|
||||
).await?;
|
||||
|
||||
let reference_video_urls = upload_reference_tokens_to_seedance2pro(
|
||||
&session,
|
||||
&file_urls_by_token,
|
||||
request.reference_video_media_tokens.as_deref(),
|
||||
"Reference video",
|
||||
).await?;
|
||||
|
||||
let reference_audio_urls = upload_reference_tokens_to_seedance2pro(
|
||||
&session,
|
||||
&file_urls_by_token,
|
||||
request.reference_audio_media_tokens.as_deref(),
|
||||
"Reference audio",
|
||||
).await?;
|
||||
|
||||
// --- Map request params to seedance2pro types ---
|
||||
|
||||
@@ -194,6 +208,9 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
start_frame_url,
|
||||
end_frame_url,
|
||||
reference_image_urls,
|
||||
reference_video_urls,
|
||||
reference_audio_urls,
|
||||
use_face_blur_hack: None,
|
||||
};
|
||||
|
||||
// --- Calculate cost and charge wallet ---
|
||||
@@ -277,18 +294,28 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
context_semantic_type: PromptContextSemanticType::VidStartFrame,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(media_token) = &request.end_frame_media_token {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::VidEndFrame,
|
||||
});
|
||||
}
|
||||
if let Some(ref_tokens) = &request.reference_image_media_tokens {
|
||||
for media_token in ref_tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: PromptContextSemanticType::VidRef,
|
||||
});
|
||||
|
||||
let ref_token_groups: [(Option<&[MediaFileToken]>, PromptContextSemanticType); 3] = [
|
||||
(request.reference_image_media_tokens.as_deref(), PromptContextSemanticType::Imgref),
|
||||
(request.reference_video_media_tokens.as_deref(), PromptContextSemanticType::VidRef),
|
||||
(request.reference_audio_media_tokens.as_deref(), PromptContextSemanticType::Audioref),
|
||||
];
|
||||
|
||||
for (maybe_tokens, semantic_type) in ref_token_groups {
|
||||
if let Some(tokens) = maybe_tokens {
|
||||
for media_token in tokens {
|
||||
context_items.push(PromptContextItem {
|
||||
media_token: media_token.clone(),
|
||||
context_semantic_type: semantic_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,11 +402,47 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Uploads a list of reference media tokens to seedance2pro, returning the resulting URLs.
|
||||
/// Returns `None` if the token list is absent or empty.
|
||||
async fn upload_reference_tokens_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
file_urls_by_token: &HashMap<MediaFileToken, Url>,
|
||||
maybe_tokens: Option<&[MediaFileToken]>,
|
||||
label: &str,
|
||||
) -> Result<Option<Vec<String>>, CommonWebError> {
|
||||
let tokens = match maybe_tokens {
|
||||
None => return Ok(None),
|
||||
Some(tokens) if tokens.is_empty() => return Ok(None),
|
||||
Some(tokens) => tokens,
|
||||
};
|
||||
|
||||
let mut urls = Vec::with_capacity(tokens.len());
|
||||
|
||||
for token in tokens {
|
||||
match file_urls_by_token.get(token) {
|
||||
None => return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("{} media not found: {:?}", label, token),
|
||||
)),
|
||||
Some(url) => {
|
||||
let seedance_url = upload_to_seedance2pro(session, url).await?;
|
||||
urls.push(seedance_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(urls))
|
||||
}
|
||||
|
||||
async fn upload_to_seedance2pro(
|
||||
session: &Seedance2ProSession,
|
||||
our_cdn_url: &str,
|
||||
our_cdn_url: &Url,
|
||||
) -> Result<String, CommonWebError> {
|
||||
let image_bytes = http_download_url_to_bytes(our_cdn_url)
|
||||
let extension = extract_extension_from_url(our_cdn_url, &ExtractExtensions::All)
|
||||
.map(|ext| ext.without_period().to_string())
|
||||
.unwrap_or_else(|| "png".to_string());
|
||||
|
||||
let cdn_url_str = our_cdn_url.as_str();
|
||||
|
||||
let file_bytes = http_download_url_to_bytes(cdn_url_str)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error downloading media file from CDN: {:?}", err);
|
||||
@@ -387,20 +450,23 @@ async fn upload_to_seedance2pro(
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
let prepare_result = prepare_image_upload(PrepareImageUploadArgs { session })
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error preparing seedance2pro image upload: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
let upload_result = upload_image(UploadImageArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
image_bytes,
|
||||
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
|
||||
session,
|
||||
extension,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error uploading image to seedance2pro: {:?}", err);
|
||||
warn!("Error preparing seedance2pro file upload: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
let upload_result = upload_file(UploadFileArgs {
|
||||
upload_url: prepare_result.upload_url,
|
||||
file_bytes,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Error uploading file to seedance2pro: {:?}", err);
|
||||
CommonWebError::ServerError
|
||||
})?;
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
|
||||
use crate::http_server::endpoints::media_files::helpers::get_media_domain::get_media_domain;
|
||||
use actix_web::HttpRequest;
|
||||
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
|
||||
use log::{error, warn};
|
||||
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::batch_get_media_files_by_tokens_with_connection;
|
||||
use server_environment::ServerEnvironment;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter::FromIterator;
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use url::Url;
|
||||
|
||||
pub async fn lookup_media_file_urls_as_map(
|
||||
http_request: &HttpRequest,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
server_environment: ServerEnvironment,
|
||||
tokens: &[MediaFileToken],
|
||||
) -> Result<HashMap<MediaFileToken, Url>, CommonWebError> {
|
||||
const CAN_SEE_DELETED: bool = false;
|
||||
|
||||
let result = batch_get_media_files_by_tokens_with_connection(
|
||||
mysql_connection,
|
||||
tokens,
|
||||
CAN_SEE_DELETED,
|
||||
).await;
|
||||
|
||||
let media_files = match result {
|
||||
Ok(files) => files,
|
||||
Err(err) => {
|
||||
error!("Error getting media files by tokens: {:?}", err);
|
||||
return Err(CommonWebError::ServerError);
|
||||
}
|
||||
};
|
||||
|
||||
if media_files.len() != tokens.len() {
|
||||
warn!("Wrong number of media files returned for tokens: {} found for {} tokens", media_files.len(), tokens.len());
|
||||
|
||||
let requested : HashSet<&MediaFileToken> = HashSet::from_iter(tokens.iter());
|
||||
let returned : HashSet<&MediaFileToken> = HashSet::from_iter(media_files.iter().map(|m| &m.token));
|
||||
|
||||
let diff = requested.difference(&returned)
|
||||
.cloned()
|
||||
.collect::<Vec<&MediaFileToken>>();
|
||||
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("Not all media files could be found. Media files found: {}, tokens provided: {}, in original: {:?}, req {:?}, ret {:?}",
|
||||
media_files.len(), tokens.len(), diff, requested, returned)));
|
||||
}
|
||||
|
||||
let media_domain = get_media_domain(&http_request);
|
||||
|
||||
let file_urls = media_files.into_iter()
|
||||
.map(|file| {
|
||||
let public_bucket_path = MediaFileBucketPath::from_object_hash(
|
||||
&file.public_bucket_directory_hash,
|
||||
file.maybe_public_bucket_prefix.as_deref(),
|
||||
file.maybe_public_bucket_extension.as_deref());
|
||||
|
||||
let media_links = MediaLinksBuilder::from_media_path_and_env(
|
||||
media_domain,
|
||||
server_environment,
|
||||
&public_bucket_path);
|
||||
|
||||
(file.token, media_links.cdn_url)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Ok(file_urls)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod fetch_all_required_media_files;
|
||||
pub mod lookup_image_urls_as_optional_list;
|
||||
pub mod lookup_image_urls_as_map;
|
||||
pub mod lookup_image_urls_as_map;
|
||||
pub mod lookup_media_file_urls_as_map;
|
||||
Reference in New Issue
Block a user