Compare commits

..
Author SHA1 Message Date
Tanish Manku df9654fed9 Merge remote-tracking branch 'services/main' into design/proofing 2026-09-14 15:26:26 -07:00
Tanish Manku 239ae4ada2 hero: click boost 2x -> 3x (tuner max raised to 4x)
Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 19:31:51 -07:00
Tanish Manku 33d50851b0 fix Firefox back-navigation dead page: bump Next 16.2.6 -> 16.2.12
Reproduced in a real Firefox (Playwright): returning to the page via
browser Back was NOT a bfcache restore (pageshow persisted=false,
navType back_forward) — it was a fresh load on which Next's client
router threw "Internal Next.js error: Router action dispatched before
initialization" three times, aborting hydration. No effects ran, so
data-ruler was never stamped, the galaxy canvas never mounted, and
data-intro never lifted (the wordmark stayed CSS-hidden at opacity 0)
— the whole page looked unmounted.

The 16.2.12 patch fixes the router-initialization race; the same repro
now comes back fully mounted. The Firefox-gated unload bfcache-buster
from the previous attempt is removed (wrong theory, and it cost
Firefox users instant-back site-wide); the pageshow persisted reload
guard stays as insurance for genuinely broken bfcache restores.
Production build verified on 16.2.12.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 13:55:50 -07:00
102 changed files with 300 additions and 1864 deletions

No files matched your search

@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO media_files\nSET\n token = ?,\n\n media_class = ?,\n media_type = ?,\n maybe_project_type = ?,\n\n origin_category = ?,\n origin_product_category = ?,\n\n maybe_engine_category = ?,\n maybe_animation_type = ?,\n\n maybe_mime_type = ?,\n file_size_bytes = ?,\n\n checksum_sha2 = ?,\n\n maybe_prompt_token = ?,\n maybe_batch_token = ?,\n\n maybe_title = ?,\n maybe_duration_millis = ?,\n maybe_frame_width = ?,\n maybe_frame_height = ?,\n\n public_bucket_directory_hash = ?,\n maybe_public_bucket_prefix = ?,\n maybe_public_bucket_extension = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n\n creator_set_visibility = ?,\n\n maybe_scene_source_media_file_token = ?,\n\n is_intermediate_system_file = ?,\n is_user_upload = ?,\n\n maybe_generation_provider = ?,\n\n maybe_creator_file_synthetic_id = ?,\n maybe_creator_category_synthetic_id = ?,\n\n maybe_origin_model_type = NULL,\n maybe_origin_model_token = NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 30
},
"nullable": []
},
"hash": "026a3ab39a1ba09a891cebd840ab1c956e1bcedd07006ee34473bd4f1d0ff67a"
}
Generated
-4
View File
@@ -1348,7 +1348,6 @@ dependencies = [
"hostname 0.4.0",
"log",
"server_environment",
"tempfile",
"workspace-build-acceleration",
]
@@ -3240,7 +3239,6 @@ dependencies = [
"ffprobe",
"log",
"tempdir",
"tempfile",
"test_utils",
"workspace-build-acceleration",
]
@@ -7921,12 +7919,10 @@ dependencies = [
"easyenv",
"enums",
"errors",
"ffmpeg_utils",
"hashing",
"jobs_common",
"kinovi_web_client",
"log",
"mimetypes",
"mysql_queries",
"pager",
"rand 0.9.2",
@@ -6,7 +6,6 @@ use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -80,11 +79,6 @@ pub struct OmniApiVideoGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -6,7 +6,6 @@ use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -56,11 +55,6 @@ pub struct OmniGenVideoCostAndGenerateRequest {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<CommonBitrate>,
/// Output container for Seedance 2.5 (including Ultra). Other models ignore it.
/// Use "mp4" or "mov" (QuickTime). Omitted or null defaults to MP4.
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<CommonVideoOutputFormat>,
/// The quality to use.
pub quality: Option<CommonQuality>,
@@ -2,7 +2,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_quality::CommonQuality;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::model_creator::ModelCreator;
use enums::common::generation_provider::GenerationProvider;
use serde_derive::{Deserialize, Serialize};
@@ -157,12 +156,6 @@ pub struct OmniGenVideoModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub bitrate_default: Option<CommonBitrate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_options: Option<Vec<CommonVideoOutputFormat>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format_default: Option<CommonVideoOutputFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
@@ -236,8 +229,6 @@ impl Default for OmniGenVideoModelDetails {
resolution_default: None,
bitrate_options: None,
bitrate_default: None,
output_format_options: None,
output_format_default: None,
quality_options: None,
default_quality: None,
duration_seconds_min: None,
@@ -41,7 +41,7 @@ pub async fn upload_media_file_from_file<P: AsRef<Path>>(
Err(err) => Err(StorytellerError::Api(err)),
}
}
Some(FileExtension::Mp4 | FileExtension::Mov) => {
Some(FileExtension::Mp4) => {
match upload_video_media_file_from_file(UploadVideoFromFileArgs {
api_host,
maybe_creds,
@@ -13,6 +13,5 @@ pub mod router_quality;
pub mod router_resolution;
pub mod router_splat_model;
pub mod router_video_model;
pub mod router_video_output_format;
pub mod video_list_ref;
pub mod video_ref;
@@ -1,10 +0,0 @@
use serde_derive::{Deserialize, Serialize};
/// Output video container. Supported by Seedance 2.5 on Kinovi and Artcraft.
/// Other models/providers ignore this option.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RouterVideoOutputFormat {
Mp4,
Mov,
}
@@ -4,7 +4,6 @@ use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
@@ -126,10 +125,6 @@ pub struct GenerateVideoRequestBuilder {
/// Not all models support this; models that don't simply ignore it.
pub bitrate: Option<RouterBitrate>,
/// Output container for Seedance 2.5 on Kinovi and Artcraft.
/// None keeps the provider default; unsupported models/providers ignore it.
pub maybe_output_format: Option<RouterVideoOutputFormat>,
/// How many seconds to generate.
pub duration_seconds: Option<u16>,
@@ -173,7 +168,6 @@ impl Default for GenerateVideoRequestBuilder {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -3,12 +3,10 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -57,13 +55,6 @@ pub fn build_artcraft_omni_video_request(
let duration_seconds = plan_duration(builder.duration_seconds.take(), strategy)?;
let bitrate = plan_bitrate(builder.bitrate.take());
let prompt = builder.prompt.take();
let supports_output_format = matches!(model, CommonVideoModelEnum::Seedance2p5 | CommonVideoModelEnum::Seedance2p5Ultra);
let maybe_output_format = builder.maybe_output_format
.filter(|_| supports_output_format)
.map(|format| match format {
RouterVideoOutputFormat::Mp4 => CommonVideoOutputFormat::Mp4,
RouterVideoOutputFormat::Mov => CommonVideoOutputFormat::Mov,
});
let start_frame = resolve_image_ref(builder.start_frame.take())?;
let end_frame = resolve_image_ref(builder.end_frame.take())?;
@@ -86,7 +77,6 @@ pub fn build_artcraft_omni_video_request(
resolution,
aspect_ratio,
bitrate,
maybe_output_format,
duration_seconds: duration_seconds.map(|d| d as u16),
video_batch_count: Some(batch_count),
negative_prompt: None,
@@ -327,71 +317,6 @@ pub fn plan_duration(
mod tests {
use super::*;
mod output_format {
use super::*;
use crate::api::router_provider::RouterProvider;
use crate::api::router_video_model::RouterVideoModel;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
#[test]
fn seedance_2p5_preserves_output_format_and_audio_over_http() {
for model in [RouterVideoModel::Seedance2p5, RouterVideoModel::Seedance2p5Ultra] {
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
for generate_audio in [None, Some(true), Some(false)] {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
maybe_output_format,
generate_audio,
..Default::default()
};
let request = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
let json = serde_json::to_value(&request).unwrap();
let expected = match maybe_output_format {
None => None,
Some(RouterVideoOutputFormat::Mp4) => Some("mp4"),
Some(RouterVideoOutputFormat::Mov) => Some("mov"),
};
assert_eq!(json.get("output_format").and_then(|v| v.as_str()), expected);
if expected.is_none() {
assert!(json.get("output_format").is_none());
}
let decoded: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json).unwrap();
assert_eq!(decoded.maybe_output_format, request.maybe_output_format);
assert_eq!(decoded.generate_audio, generate_audio);
}
}
}
}
#[test]
fn unsupported_models_ignore_output_format_even_with_error_strategy() {
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p5Preview] {
for format in [RouterVideoOutputFormat::Mp4, RouterVideoOutputFormat::Mov] {
let builder = GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
model,
maybe_output_format: Some(format),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..Default::default()
};
let request = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p0Fast(state)) => state.request,
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Preview(state)) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
assert!(serde_json::to_value(request).unwrap().get("output_format").is_none());
}
}
}
}
mod bitrate_translation {
use super::*;
@@ -59,7 +59,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -49,7 +49,6 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let generate_audio = builder.generate_audio.take();
let mut request = build_artcraft_omni_video_request(
builder,
@@ -59,7 +58,6 @@ pub fn build_artcraft_seedance_2p5(mut builder: GenerateVideoRequestBuilder) ->
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
request.generate_audio = generate_audio;
let state = ArtcraftSeedance2p5RequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5(state)))
@@ -49,7 +49,6 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
plan_batch_count(builder.video_batch_count.take(), strategy)?;
let total_input_seconds = builder.total_reference_video_input_seconds.take();
let generate_audio = builder.generate_audio.take();
let mut request = build_artcraft_omni_video_request(
builder,
@@ -59,7 +58,6 @@ pub fn build_artcraft_seedance_2p5_u(mut builder: GenerateVideoRequestBuilder) -
)?;
request.duration_seconds = duration_seconds;
request.video_batch_count = Some(1);
request.generate_audio = generate_audio;
let state = ArtcraftSeedance2p5UltraRequestState { request, total_input_seconds };
Ok(VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::ArtcraftSeedance2p5Ultra(state)))
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0(mut builder: GenerateVideoRequestBuilder) -> Res
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0CostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -277,7 +276,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
.calculate_enterprise_costs();
(costs.total_cost.kinovi_credits.round() as u64, costs.total_cost.usd_cents_rounded_up)
@@ -656,7 +654,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -28,8 +28,6 @@ pub struct KinoviSeedance2p0DraftState {
pub batch_count: KinoviSeedance2p0BatchCount,
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
pub maybe_generate_audio: Option<bool>,
// Pending types that need to be queried.
pub unhandled_request_state: Option<KinoviSeedance2p0RemainingItems>,
}
@@ -98,7 +96,6 @@ impl KinoviSeedance2p0DraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0RequestState { request })
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0_fast(mut builder: GenerateVideoRequestBuilder) -
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0FastCostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -451,7 +450,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -25,7 +25,6 @@ pub struct KinoviSeedance2p0FastDraftState {
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0FastBatchCount,
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p0FastRemainingItems>,
}
@@ -94,7 +93,6 @@ impl KinoviSeedance2p0FastDraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0FastRequestState { request })
@@ -47,7 +47,6 @@ fn do_build_kinovi_seedance_2p0_mini(mut builder: GenerateVideoRequestBuilder) -
duration_seconds,
bitrate,
prompt,
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -65,7 +65,6 @@ impl KinoviSeedance2p0MiniCostState {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -381,7 +380,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}
}
@@ -25,7 +25,6 @@ pub struct KinoviSeedance2p0MiniDraftState {
pub duration_seconds: u8,
pub batch_count: KinoviSeedance2p0MiniBatchCount,
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p0MiniRemainingItems>,
}
@@ -94,7 +93,6 @@ impl KinoviSeedance2p0MiniDraftState {
character_ids,
use_face_blur_hack: None,
bitrate: self.bitrate,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p0MiniRequestState { request })
@@ -1,14 +1,12 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
KinoviSeedance2p5AspectRatio as KinoviAspectRatio,
KinoviSeedance2p5Bitrate,
KinoviSeedance2p5OutputFormat,
KinoviSeedance2p5OutputResolution as KinoviOutputResolution,
};
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_bitrate::RouterBitrate;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_output_format::RouterVideoOutputFormat;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
@@ -88,11 +86,6 @@ fn do_build_kinovi_seedance_2p5(mut builder: GenerateVideoRequestBuilder) -> Res
prompt,
total_input_seconds,
maybe_bitrate,
maybe_output_format: builder.maybe_output_format.map(|format| match format {
RouterVideoOutputFormat::Mp4 => KinoviSeedance2p5OutputFormat::Mp4,
RouterVideoOutputFormat::Mov => KinoviSeedance2p5OutputFormat::Mov,
}),
maybe_generate_audio: builder.generate_audio,
unhandled_request_state: Some(unhandled_request_state),
})
}
@@ -282,7 +275,6 @@ mod tests {
use crate::generate::generate_video::video_generation_draft::VideoGenerationDraftRequest;
use crate::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use crate::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video::video_generation_request::VideoGenerationRequest;
use super::*;
@@ -319,42 +311,6 @@ mod tests {
}
}
#[tokio::test]
async fn output_options_survive_router_finalization() {
let client = RouterClient::KinoviWeb(RouterKinoviWebClient::new(
KinoviWebSession::from_cookies_string(String::new()),
));
let context = VideoGenerationDraftContext { client: Some(&client), ..Default::default() };
for model in [RouterVideoModel::Seedance2p0, RouterVideoModel::Seedance2p0Fast, RouterVideoModel::Seedance2p0Mini, RouterVideoModel::Seedance2p5] {
for generate_audio in [None, Some(false), Some(true)] {
for maybe_output_format in [None, Some(RouterVideoOutputFormat::Mp4), Some(RouterVideoOutputFormat::Mov)] {
let builder = GenerateVideoRequestBuilder {
model, generate_audio, maybe_output_format, ..base_builder()
};
let VideoGenerationDraftOrRequest::Draft(draft) = builder.build2().unwrap() else {
panic!("expected Kinovi draft");
};
let finalized = draft.finalize(context.clone()).await.unwrap();
let actual_audio = match finalized {
VideoGenerationRequest::KinoviSeedance2p0(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p0Fast(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p0Mini(state) => state.request.maybe_generate_audio,
VideoGenerationRequest::KinoviSeedance2p5(state) => {
let actual_format = state.request.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => RouterVideoOutputFormat::Mp4,
KinoviSeedance2p5OutputFormat::Mov => RouterVideoOutputFormat::Mov,
});
assert_eq!(actual_format, maybe_output_format);
state.request.maybe_generate_audio
}
_ => panic!("expected a Seedance request"),
};
assert_eq!(actual_audio, generate_audio);
}
}
}
}
mod materialized_field_conversions {
use super::*;
@@ -92,8 +92,6 @@ impl KinoviSeedance2p5CostState {
total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
};
// Enterprise tier: what generations actually cost us (our discounted
@@ -1,6 +1,6 @@
use kinovi_web_client::generate::video::generate_seedance_2p5::{
GenerateSeedance2p5Request, KinoviSeedance2p5AspectRatio, KinoviSeedance2p5Bitrate, KinoviSeedance2p5Modality,
KinoviSeedance2p5OutputFormat, KinoviSeedance2p5OutputResolution,
KinoviSeedance2p5OutputResolution,
};
use crate::api::audio_list_ref::AudioListRef;
@@ -33,8 +33,6 @@ pub struct KinoviSeedance2p5DraftState {
pub total_input_seconds: Option<u8>,
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
pub maybe_generate_audio: Option<bool>,
pub unhandled_request_state: Option<KinoviSeedance2p5RemainingItems>,
}
@@ -116,8 +114,6 @@ impl KinoviSeedance2p5DraftState {
total_input_seconds: self.total_input_seconds,
use_face_blur_hack: None,
maybe_bitrate: self.maybe_bitrate,
maybe_output_format: self.maybe_output_format,
maybe_generate_audio: self.maybe_generate_audio,
};
Ok(KinoviSeedance2p5RequestState { request })
@@ -19,7 +19,9 @@ pub(crate) async fn upload_to_kinovi_web(
source_url: &str,
maybe_local_path: Option<&Path>,
) -> Result<String, ArtcraftRouterError> {
let extension = upload_extension(source_url, maybe_local_path);
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 = match maybe_local_path {
Some(local_path) => match std::fs::read(local_path) {
@@ -56,25 +58,3 @@ pub(crate) async fn upload_to_kinovi_web(
Ok(upload_response.public_url)
}
/// Keep the reference container when uploading, including QuickTime/MOV.
fn upload_extension(source_url: &str, maybe_local_path: Option<&Path>) -> String {
extract_extension_from_url_str(source_url, &ExtractExtensions::All)
.map(|ext| ext.without_period().to_string())
.or_else(|| maybe_local_path
.and_then(Path::extension)
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase))
.unwrap_or_else(|| "jpg".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quicktime_upload_keeps_mov_extension() {
assert_eq!(upload_extension("https://example.com/reference.MOV?token=test", None), "mov");
assert_eq!(upload_extension("https://example.com/reference.mov", Some(Path::new("/tmp/cached.bin"))), "mov");
assert_eq!(upload_extension("https://example.com/reference", Some(Path::new("/tmp/reference.MOV"))), "mov");
}
}
@@ -352,7 +352,6 @@ pub fn base_video_request() -> GenerateVideoRequestBuilder {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
duration_seconds: None,
video_batch_count: None,
generate_audio: None,
@@ -130,8 +130,6 @@ pub async fn generate_happy_horse_1p0(
character_ids: None,
use_face_blur_hack: Some(false),
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0Request {
/// Output video bitrate. None defaults to "standard"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0Bitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -223,8 +221,6 @@ pub async fn generate_seedance_2p0(
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -732,7 +728,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -805,7 +800,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v default — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -837,7 +831,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -872,7 +865,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -905,7 +897,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -942,7 +933,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -976,7 +966,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe start+end — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1014,7 +1003,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1050,7 +1038,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("4K image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1089,7 +1076,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("video ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1125,7 +1111,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("character ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1157,7 +1142,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("two characters — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0FastRequest {
/// Output video bitrate. None defaults to "standard"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0FastBitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -197,8 +195,6 @@ pub async fn generate_seedance_2p0_fast(
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
};
let raw_response = workflow_run_task(WorkflowRunTaskArgs {
@@ -652,7 +648,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -717,7 +712,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v fast default — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -749,7 +743,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v fast 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -784,7 +777,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast t2v 21:9 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -817,7 +809,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast keyframe 21:9 — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -854,7 +845,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast keyframe — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -892,7 +882,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast image ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -930,7 +919,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast video ref — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -966,7 +954,6 @@ mod tests {
character_ids: Some(vec![MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast character — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -998,7 +985,6 @@ mod tests {
character_ids: Some(vec![STEAMPUNK_CLOWN_ID.to_string(), MOCHI_ID.to_string()]),
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("fast two characters — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -38,8 +38,6 @@ pub struct GenerateSeedance2p0MiniRequest {
/// Output video bitrate. None defaults to "normal"; `High` requests a
/// higher bitrate. Does not affect cost.
pub bitrate: Option<KinoviSeedance2p0MiniBitrate>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Enums ──
@@ -224,8 +222,6 @@ fn to_raw_request(req: GenerateSeedance2p0MiniRequest) -> WorkflowRunTaskRequest
character_ids: req.character_ids,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.bitrate),
maybe_output_format: None,
maybe_generate_audio: req.maybe_generate_audio,
}
}
@@ -646,7 +642,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
@@ -700,7 +695,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: Some(KinoviSeedance2p0MiniBitrate::High),
maybe_generate_audio: None,
}
}
@@ -775,7 +769,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
}
}
}
@@ -812,7 +805,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini t2v 720p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -844,7 +836,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -877,7 +868,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini reference-to-video 720p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -910,7 +900,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
}).await?;
println!("mini video ref 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -7,7 +7,7 @@ use crate::pricing::kinovi_pricing_tier::KinoviPricingTier;
use crate::requests::kinovi_host::KinoviHost;
use crate::requests::workflow_run_task::workflow_run_task::{
workflow_run_task, KinoviAspectRatioRaw, KinoviBatchCountRaw, KinoviBitrateRaw, KinoviModelTypeRaw,
KinoviOutputFormatRaw, KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
KinoviOutputResolutionRaw, WorkflowRunTaskArgs, WorkflowRunTaskRequest,
};
// ── Constants ──
@@ -64,12 +64,6 @@ pub struct GenerateSeedance2p5Request {
/// Output video bitrate. None keeps the standard bitrate; `High` requests
/// a higher bitrate. This does not select a container or lossless codec.
pub maybe_bitrate: Option<KinoviSeedance2p5Bitrate>,
/// Output container. None leaves the provider default unchanged.
pub maybe_output_format: Option<KinoviSeedance2p5OutputFormat>,
/// Whether to generate audio. None leaves the provider default unchanged.
pub maybe_generate_audio: Option<bool>,
}
// ── Modality ──
@@ -130,13 +124,6 @@ pub enum KinoviSeedance2p5Bitrate {
High,
}
/// Output container for Seedance 2.5.
#[derive(Debug, Clone, Copy)]
pub enum KinoviSeedance2p5OutputFormat {
Mp4,
Mov,
}
// ── Pricing ──
//
// Seedance 2.5 credit pricing (credits per billed second). Without video
@@ -318,11 +305,6 @@ fn to_raw_request(req: GenerateSeedance2p5Request) -> WorkflowRunTaskRequest {
character_ids: None,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: map_bitrate(req.maybe_bitrate),
maybe_output_format: req.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => KinoviOutputFormatRaw::Mp4,
KinoviSeedance2p5OutputFormat::Mov => KinoviOutputFormatRaw::Mov,
}),
maybe_generate_audio: req.maybe_generate_audio,
}
}
@@ -783,8 +765,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -812,8 +792,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -874,8 +852,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.model_type, KinoviModelTypeRaw::Seedance2p5));
@@ -902,14 +878,10 @@ mod tests {
total_input_seconds: Some(7),
use_face_blur_hack: None,
maybe_bitrate: Some(KinoviSeedance2p5Bitrate::High),
maybe_output_format: Some(KinoviSeedance2p5OutputFormat::Mov),
maybe_generate_audio: Some(false),
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::UltraWide21x9));
assert!(matches!(raw.bitrate, Some(KinoviBitrateRaw::High)));
assert!(matches!(raw.maybe_output_format, Some(KinoviOutputFormatRaw::Mov)));
assert_eq!(raw.maybe_generate_audio, Some(false));
assert!(raw.start_frame_url.is_none());
assert!(raw.end_frame_url.is_none());
assert_eq!(raw.reference_image_urls.as_deref().map(|urls| urls.len()), Some(1));
@@ -932,8 +904,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.output_resolution, Some(KinoviOutputResolutionRaw::TenEightyP)));
@@ -954,8 +924,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
});
assert!(matches!(raw.aspect_ratio, KinoviAspectRatioRaw::Landscape16x9));
@@ -986,8 +954,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1020,8 +986,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("t2v 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1050,8 +1014,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 1080p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1080,8 +1042,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("keyframe 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -1115,8 +1075,6 @@ mod tests {
total_input_seconds: None,
use_face_blur_hack: None,
maybe_bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}).await?;
println!("reference 480p — task_id={}, order_id={}", result.task_id, result.order_id);
@@ -148,8 +148,6 @@ fn to_raw_request(req: GenerateSeedance2p5PreviewRequest) -> WorkflowRunTaskRequ
character_ids: None,
use_face_blur_hack: req.use_face_blur_hack,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -68,10 +68,6 @@ pub (super) struct ApiParams {
pub batch_count: Option<u8>,
#[serde(rename = "bitrate_mode", skip_serializing_if = "Option::is_none")]
pub bitrate_mode: Option<&'static str>,
#[serde(rename = "output_format", skip_serializing_if = "Option::is_none")]
pub maybe_output_format: Option<&'static str>,
#[serde(rename = "generate_audio", skip_serializing_if = "Option::is_none")]
pub maybe_generate_audio: Option<bool>,
}
#[derive(Deserialize, Debug)]
@@ -74,12 +74,6 @@ pub struct WorkflowRunTaskRequest {
/// Output video bitrate. None defaults to "standard" (the field is omitted);
/// `High` sends `bitrate_mode: "high"`. Does not affect cost.
pub bitrate: Option<KinoviBitrateRaw>,
/// Output container, supported only by Seedance 2.5. None omits the field.
pub maybe_output_format: Option<KinoviOutputFormatRaw>,
/// Audio generation for Seedance 2.0 and 2.5. None keeps the provider default.
pub maybe_generate_audio: Option<bool>,
}
impl std::fmt::Debug for WorkflowRunTaskRequest {
@@ -99,8 +93,6 @@ impl std::fmt::Debug for WorkflowRunTaskRequest {
.field("output_resolution", &self.output_resolution)
.field("use_face_blur_hack", &self.use_face_blur_hack)
.field("bitrate", &self.bitrate)
.field("maybe_output_format", &self.maybe_output_format)
.field("maybe_generate_audio", &self.maybe_generate_audio)
.finish()
}
}
@@ -116,22 +108,6 @@ impl std::fmt::Debug for WorkflowRunTaskArgs<'_> {
// --- Public enums ---
/// Output container for Seedance 2.5.
#[derive(Debug, Clone, Copy)]
pub enum KinoviOutputFormatRaw {
Mp4,
Mov,
}
impl KinoviOutputFormatRaw {
fn as_api_str(self) -> &'static str {
match self {
Self::Mp4 => "mp4",
Self::Mov => "mov",
}
}
}
/// Video resolution / aspect ratio.
#[derive(Debug, Clone, Copy)]
pub enum KinoviAspectRatioRaw {
@@ -613,15 +589,6 @@ fn build_batch_request(req: WorkflowRunTaskRequest) -> BatchRequest {
audio_urls,
batch_count,
bitrate_mode: req.bitrate.and_then(|bitrate| bitrate.as_api_str()),
maybe_output_format: match req.model_type {
KinoviModelTypeRaw::Seedance2p5 => req.maybe_output_format.map(KinoviOutputFormatRaw::as_api_str),
_ => None,
},
maybe_generate_audio: match req.model_type {
KinoviModelTypeRaw::Seedance2Pro | KinoviModelTypeRaw::Seedance2Fast
| KinoviModelTypeRaw::Seedance2Mini | KinoviModelTypeRaw::Seedance2p5 => req.maybe_generate_audio,
_ => None,
},
},
},
},
@@ -683,8 +650,6 @@ mod tests {
audio_urls: None,
batch_count: None,
bitrate_mode,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
}
@@ -736,8 +701,6 @@ mod tests {
audio_urls: None,
batch_count: None,
bitrate_mode: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
}
@@ -772,8 +735,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -881,8 +842,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -988,51 +947,6 @@ mod tests {
mod seedance_2p5_shape_tests {
use super::*;
#[test]
fn optional_output_settings_preserve_omission_and_explicit_values() {
for (format, expected) in [(None, None), (Some(KinoviOutputFormatRaw::Mp4), Some("mp4")), (Some(KinoviOutputFormatRaw::Mov), Some("mov"))] {
for audio in [None, Some(false), Some(true)] {
let mut request = base_request("A boat on a lake", 4);
request.maybe_output_format = format;
request.maybe_generate_audio = audio;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert_eq!(params.get("output_format"), expected.map(serde_json::Value::from).as_ref());
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
}
}
}
#[test]
fn audio_setting_applies_to_seedance_2p0_without_output_format() {
for model in [KinoviModelTypeRaw::Seedance2Pro, KinoviModelTypeRaw::Seedance2Fast, KinoviModelTypeRaw::Seedance2Mini] {
for audio in [None, Some(false), Some(true)] {
let mut request = base_request("A boat on a lake", 4);
request.model_type = model;
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
request.maybe_generate_audio = audio;
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert!(params.get("output_format").is_none());
assert_eq!(params.get("generate_audio"), audio.map(serde_json::Value::from).as_ref());
}
}
}
#[test]
fn unrelated_models_omit_unsupported_output_settings() {
for model in [KinoviModelTypeRaw::HappyHorse1p0, KinoviModelTypeRaw::Seedance2p5Preview] {
let mut request = base_request("A boat on a lake", 4);
request.model_type = model;
request.maybe_output_format = Some(KinoviOutputFormatRaw::Mov);
request.maybe_generate_audio = Some(false);
let body = serde_json::to_value(build_batch_request(request)).unwrap();
let params = &body["0"]["json"]["apiParams"];
assert!(params.get("output_format").is_none());
assert!(params.get("generate_audio").is_none());
}
}
fn base_request(prompt: &str, duration_seconds: u8) -> WorkflowRunTaskRequest {
WorkflowRunTaskRequest {
model_type: KinoviModelTypeRaw::Seedance2p5,
@@ -1049,8 +963,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1203,8 +1115,6 @@ mod tests {
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
}
}
@@ -1295,8 +1205,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1331,8 +1239,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1369,8 +1275,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1406,8 +1310,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1445,8 +1347,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1501,8 +1401,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1556,8 +1454,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1611,8 +1507,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1675,8 +1569,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1732,8 +1624,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1774,8 +1664,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1810,8 +1698,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1848,8 +1734,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1887,8 +1771,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1927,8 +1809,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -1981,8 +1861,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -2035,8 +1913,6 @@ mod tests {
use_face_blur_hack: Some(false),
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::TenEightyP),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -2081,8 +1957,6 @@ mod tests {
output_resolution,
use_face_blur_hack: None,
bitrate: None,
maybe_output_format: None,
maybe_generate_audio: None,
},
}
}
@@ -2189,8 +2063,6 @@ mod tests {
use_face_blur_hack: None,
bitrate: None,
output_resolution: Some(KinoviOutputResolutionRaw::FourK),
maybe_output_format: None,
maybe_generate_audio: None,
},
};
let result = workflow_run_task(args).await?;
@@ -4,8 +4,7 @@ use serde_json::{json, Value};
#[derive(Debug)]
pub enum ThumbnailTaskInputMimeType {
MP4,
MOV,
MP4
}
pub enum ThumbnailTaskOutputMimeType {
@@ -18,7 +17,6 @@ impl ThumbnailTaskInputMimeType {
fn as_str(&self) -> &str {
match self {
ThumbnailTaskInputMimeType::MP4 => "video/mp4",
ThumbnailTaskInputMimeType::MOV => "video/quicktime",
}
}
@@ -28,7 +26,7 @@ impl ThumbnailTaskInputMimeType {
fn to_output_mimetypes(&self) -> Vec<ThumbnailTaskOutputMimeType> {
match self {
ThumbnailTaskInputMimeType::MP4 | ThumbnailTaskInputMimeType::MOV => vec![
ThumbnailTaskInputMimeType::MP4 => vec![
ThumbnailTaskOutputMimeType::JPEG,
ThumbnailTaskOutputMimeType::GIF,
],
@@ -213,21 +211,3 @@ impl ThumbnailTask{
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quicktime_thumbnail_uses_correct_source_mime_type() {
let task = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MOV)
.with_bucket("test-bucket")
.with_path("video.mov")
.with_event_id("test-event")
.build(ThumbnailTaskOutputMimeType::JPEG)
.unwrap();
let json = task.request_json();
assert_eq!(json["input"]["type"], "video/quicktime");
assert_eq!(json["input"]["path"], "video.mov");
}
}
@@ -120,7 +120,6 @@ pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Resu
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(duration),
video_batch_count: None,
@@ -428,7 +428,6 @@ async fn generate(
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
session,
host_override: None,
@@ -459,7 +458,6 @@ async fn generate(
character_ids: None,
use_face_blur_hack: None,
bitrate: None,
maybe_generate_audio: None,
},
session,
host_override: None,
+1 -1
View File
@@ -31,4 +31,4 @@ hostname.workspace = true
log = "0.4.14"
[dev-dependencies]
tempfile.workspace = true
# None yet
+3 -20
View File
@@ -5,8 +5,6 @@ use log::{info, warn};
use errors::{anyhow, AnyhowResult};
use server_environment::ServerEnvironment;
use crate::config_search_directories::{config_search_directories, find_repository_root};
pub struct BootstrapArgs<'a, P: AsRef<Path>> {
/// The name of the application or service
pub app_name: &'a str,
@@ -16,7 +14,6 @@ pub struct BootstrapArgs<'a, P: AsRef<Path>> {
pub default_logging_override: Option<&'a str>,
/// Where to look for env conf files.
/// Relative paths are also searched from an enclosing artcraft or artcraft-services checkout.
pub config_search_directories: &'a [P],
/// If true, ignore the root '.env' file.
@@ -116,16 +113,12 @@ pub fn bootstrap<P: AsRef<Path>>(args: BootstrapArgs<'_, P>) -> AnyhowResult<Con
}
fn load_env_config_files<P: AsRef<Path>>(server_environment: ServerEnvironment, args: &BootstrapArgs<'_, P>) -> AnyhowResult<()> {
let maybe_current_directory = std::env::current_dir().ok();
let maybe_repository_root = maybe_current_directory.as_deref().and_then(find_repository_root);
let search_directories = config_search_directories(args.config_search_directories, maybe_repository_root);
let secrets_filename = format!("{}.development-secrets.env", &args.app_name);
let env_config_file_names = match server_environment {
ServerEnvironment::Development => vec![
format!("{}.common.env", &args.app_name),
format!("{}.development.env", &args.app_name),
secrets_filename.clone(), // NB: .gitignore these files
format!("{}.development-secrets.env", &args.app_name), // NB: .gitignore these files
],
ServerEnvironment::Production => vec![
format!("{}.common.env", &args.app_name),
@@ -136,19 +129,9 @@ fn load_env_config_files<P: AsRef<Path>>(server_environment: ServerEnvironment,
for env_config_file in env_config_file_names.into_iter() {
info!("Loading environment variable config file: {}", &env_config_file);
let mut was_read = easyenv::maybe_read_env_config_from_filename_and_paths(
let was_read = easyenv::maybe_read_env_config_from_filename_and_paths(
&env_config_file,
&search_directories)?;
// Development backups fill missing variables, even when a local config file exists.
// dotenv preserves values already set in the environment or earlier config files.
if server_environment == ServerEnvironment::Development && env_config_file == secrets_filename {
if let Some(root) = maybe_repository_root {
was_read |= easyenv::maybe_read_env_config_from_filename_and_paths(
&env_config_file,
&[root.join("secrets/artcraft-backup")])?;
}
}
args.config_search_directories)?;
if was_read {
info!("Environment config file `{}` was read.", &env_config_file);
@@ -1,33 +0,0 @@
use std::path::{Path, PathBuf};
/// Keep caller-supplied paths first, then resolve relative paths from this checkout.
pub(crate) fn config_search_directories<P: AsRef<Path>>(
paths: &[P],
maybe_repository_root: Option<&Path>,
) -> Vec<PathBuf> {
let mut directories = paths.iter()
.map(|path| path.as_ref().to_path_buf())
.collect::<Vec<_>>();
if let Some(root) = maybe_repository_root {
for path in paths {
if path.as_ref().is_relative() {
let directory = root.join(path);
if !directories.contains(&directory) {
directories.push(directory);
}
}
}
}
directories
}
/// Use the enclosing checkout, never a sibling repository's configuration.
pub(crate) fn find_repository_root(current_directory: &Path) -> Option<&Path> {
current_directory.ancestors().find(|directory| {
matches!(directory.file_name().and_then(|name| name.to_str()), Some("artcraft" | "artcraft-services"))
&& directory.join("Cargo.toml").is_file()
&& directory.join("crates").is_dir()
})
}
+1 -3
View File
@@ -18,6 +18,4 @@
#![allow(dead_code)]
#![allow(non_snake_case)]
pub mod bootstrap;
mod config_search_directories;
pub mod bootstrap;
@@ -1,131 +0,0 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use bootstrap::bootstrap::{bootstrap, BootstrapArgs};
use tempfile::TempDir;
const CONFIG_DIRECTORY: &str = "crates/service/job/test_job/config";
const BACKUP_DIRECTORY: &str = "secrets/artcraft-backup";
const CHILD_PROCESS: &str = "BOOTSTRAP_CONFIG_TEST_CHILD";
const VALUE: &str = "BOOTSTRAP_CONFIG_TEST_VALUE";
const MISSING_VALUE: &str = "BOOTSTRAP_CONFIG_TEST_MISSING_VALUE";
const COMMON_VALUE: &str = "BOOTSTRAP_CONFIG_TEST_COMMON_VALUE";
mod development {
use super::*;
#[test]
fn loads_checkout_configs_and_backup_secrets_from_roots_and_subdirectories() {
for name in ["artcraft", "artcraft-services"] {
let (_temporary, root) = checkout(name);
write_config(&root, CONFIG_DIRECTORY, "common", &format!("{COMMON_VALUE}=common\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{VALUE}=backup\n"));
// The other checkout must not supply this process's secrets.
let sibling_name = if name == "artcraft" { "artcraft-services" } else { "artcraft" };
let sibling = root.parent().unwrap().join(sibling_name);
write_config(&sibling, BACKUP_DIRECTORY, "development-secrets", &format!("{VALUE}=wrong-checkout\n"));
// Nested directories can themselves be named artcraft without being the repo root.
let nested = root.join("crates/api_clients/artcraft/test_client");
fs::create_dir_all(&nested).unwrap();
for directory in [&root, &root.join(CONFIG_DIRECTORY), &nested] {
run_bootstrap(directory, "development", "backup", "common", false);
}
}
}
#[test]
fn environment_and_local_configs_take_precedence_but_backup_fills_missing_values() {
let (_temporary, root) = checkout("artcraft-services");
write_config(&root, CONFIG_DIRECTORY, "development-secrets", &format!("{VALUE}=local\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets",
&format!("{VALUE}=backup\n{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root, "development", "local", "", true);
run_bootstrap(&root, "development", "environment", "", true);
}
}
mod deployment {
use super::*;
#[test]
fn production_does_not_read_development_secrets() {
let (_temporary, root) = checkout("artcraft-services");
write_config(&root, CONFIG_DIRECTORY, "production", &format!("{VALUE}=production\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root.join(CONFIG_DIRECTORY), "production", "production", "", false);
}
#[test]
fn configured_paths_still_work_outside_named_checkouts() {
let temporary = TempDir::new().unwrap();
let root = temporary.path().join("deployment");
write_config(&root, "config", "development-secrets", &format!("{VALUE}=deployment\n"));
write_config(&root, BACKUP_DIRECTORY, "development-secrets", &format!("{MISSING_VALUE}=backup-only\n"));
run_bootstrap(&root, "development", "deployment", "", false);
}
}
// Run bootstrap in a subprocess so environment and logger changes cannot affect other tests.
#[test]
fn bootstrap_child_process() {
if env::var_os(CHILD_PROCESS).is_none() {
return;
}
bootstrap(BootstrapArgs {
app_name: "bootstrap-test",
default_logging_override: None,
config_search_directories: &[".", "./config", CONFIG_DIRECTORY],
ignore_legacy_dot_env_file: true,
}).unwrap();
assert_eq!(env::var(VALUE).unwrap(), env::var("EXPECTED_VALUE").unwrap());
assert_eq!(env::var(COMMON_VALUE).unwrap_or_default(), env::var("EXPECTED_COMMON_VALUE").unwrap());
if env::var("EXPECT_BACKUP_VALUE").unwrap() == "true" {
assert_eq!(env::var(MISSING_VALUE).unwrap(), "backup-only");
} else {
assert!(env::var_os(MISSING_VALUE).is_none());
}
}
fn run_bootstrap(directory: &Path, environment: &str, expected: &str, expected_common: &str, expect_backup: bool) {
let mut command = Command::new(env::current_exe().unwrap());
command.args(["--exact", "bootstrap_child_process", "--nocapture"])
.current_dir(directory)
.env_clear()
.env(CHILD_PROCESS, "1")
.env("RUST_LOG", "error")
.env("SERVER_ENVIRONMENT", environment)
.env("EXPECTED_VALUE", expected)
.env("EXPECTED_COMMON_VALUE", expected_common)
.env("EXPECT_BACKUP_VALUE", expect_backup.to_string());
if expected == "environment" {
command.env(VALUE, "environment");
}
let output = command.output().unwrap();
assert!(output.status.success(), "bootstrap failed in {directory:?}:\n{}\n{}",
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
}
fn checkout(name: &str) -> (TempDir, PathBuf) {
let temporary = TempDir::new().unwrap();
let root = temporary.path().join(name);
fs::create_dir_all(root.join(CONFIG_DIRECTORY)).unwrap();
fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
(temporary, root)
}
fn write_config(root: &Path, directory: &str, suffix: &str, contents: &str) {
let directory = root.join(directory);
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join(format!("bootstrap-test.{suffix}.env")), contents).unwrap();
}
-1
View File
@@ -27,7 +27,6 @@ errors.workspace = true
anyhow.workspace = true
ffprobe = "0.4.0"
log.workspace = true
tempfile.workspace = true
[dev-dependencies]
tempdir.workspace = true
@@ -1,25 +1,18 @@
use std::io::Write;
use errors::AnyhowResult;
use std::path::Path;
use std::str::FromStr;
use errors::AnyhowResult;
use ffprobe::FfProbe;
use tempfile::NamedTempFile;
#[derive(Debug, Default)]
pub struct VideoInfo {
pub dimensions: Option<VideoDimensions>,
pub duration: Option<VideoDuration>,
pub frame_rate: Option<VideoFrameRate>,
}
#[derive(Debug)]
pub struct VideoDimensions {
pub width: u64,
pub height: u64,
}
#[derive(Debug)]
pub struct VideoDuration {
/// We have to convert ffprobe's seconds (with decimal) into milliseconds.
/// We use a u32 as this can hold 49 days of milliseconds.
@@ -29,7 +22,6 @@ pub struct VideoDuration {
pub seconds_original: String,
}
#[derive(Debug)]
pub struct VideoFrameRate {
pub fps: f32,
/// This is the original value returned by ffprobe (for debugging).
@@ -41,41 +33,56 @@ pub fn ffprobe_get_info(
) -> AnyhowResult<VideoInfo>
{
let result = ffprobe::ffprobe(video_path)?;
Ok(info_from_probe(result))
}
/// Probe downloaded/uploaded bytes without changing their container or audio.
/// A seekable temporary file lets ffprobe inspect MOV metadata at the end of the file.
pub fn ffprobe_get_info_from_bytes(bytes: &[u8]) -> AnyhowResult<VideoInfo> {
let mut file = NamedTempFile::new()?;
file.write_all(bytes)?;
ffprobe_get_info(file.path())
}
let mut maybe_dimensions = None;
let mut maybe_duration = None;
let mut maybe_frame_rate = None;
fn info_from_probe(result: FfProbe) -> VideoInfo {
let Some(stream) = result.streams.iter().find(|stream| {
stream.codec_type.as_deref() == Some("video") && stream.disposition.attached_pic == 0
}) else {
return VideoInfo::default();
let video_streams = result.streams.iter()
.filter(|stream| stream.codec_type.as_deref() == Some("video"));
for stream in video_streams {
// Grab the first video stream with dimensions and duration.
if let (Some(width), Some(height), Some(duration)) =
(stream.width, stream.height, stream.duration.as_deref())
{
maybe_dimensions = Some(VideoDimensions {
width: width.unsigned_abs(),
height: height.unsigned_abs(),
});
maybe_duration = Some(duration.to_string());
// avg_frame_rate vs r_frame_rate: https://github.com/eugeneware/ffprobe/issues/7
maybe_frame_rate = Some(stream.avg_frame_rate.to_string());
}
}
let maybe_duration = match maybe_duration {
None => None,
Some(duration) => {
let millis = parse_seconds(&duration)?;
Some(VideoDuration {
millis,
seconds_original: duration,
})
}
};
// MOV files can carry duration only at the container level. Keep dimensions
// even when the stream-level duration is absent.
let dimensions = stream.width.zip(stream.height)
.filter(|(width, height)| *width > 0 && *height > 0)
.map(|(width, height)| VideoDimensions { width: width as u64, height: height as u64 });
let duration = [stream.duration.as_deref(), result.format.duration.as_deref()]
.into_iter().flatten()
.find_map(|seconds| parse_seconds(seconds).ok().map(|millis| VideoDuration {
millis,
seconds_original: seconds.to_string(),
}));
let frame_rate = [&stream.avg_frame_rate, &stream.r_frame_rate].into_iter()
.find_map(|rate| parse_fps(rate).ok()
.filter(|fps| fps.is_finite() && *fps > 0.0)
.map(|fps| VideoFrameRate { fps, fps_original: rate.to_string() }));
let maybe_frame_rate = match maybe_frame_rate {
None => None,
Some(frame_rate) => {
let fps = parse_fps(&frame_rate)?;
Some(VideoFrameRate {
fps,
fps_original: frame_rate,
})
}
};
VideoInfo { dimensions, duration, frame_rate }
Ok(VideoInfo {
dimensions: maybe_dimensions,
duration: maybe_duration,
frame_rate: maybe_frame_rate,
})
}
fn parse_seconds(ffprobe_seconds: &str) -> AnyhowResult<u32> {
@@ -125,56 +132,6 @@ pub mod tests {
assert_eq!(info.frame_rate.unwrap().fps, 30.0);
}
mod quicktime {
use super::super::{ffprobe_get_info_from_bytes, info_from_probe};
use ffprobe::{FfProbe, Format, Stream};
use std::process::Command;
use tempdir::TempDir;
#[test]
fn container_duration_does_not_discard_dimensions() {
let info = info_from_probe(FfProbe {
streams: vec![Stream {
codec_type: Some("video".into()),
width: Some(320),
height: Some(180),
duration: None,
avg_frame_rate: "0/0".into(),
r_frame_rate: "24/1".into(),
..Default::default()
}],
format: Format { duration: Some("1.250".into()), ..Default::default() },
});
let dimensions = info.dimensions.unwrap();
assert_eq!((dimensions.width, dimensions.height), (320, 180));
assert_eq!(info.duration.unwrap().millis, 1250);
assert_eq!(info.frame_rate.unwrap().fps, 24.0);
}
#[test]
fn probes_mov_with_pcm_audio_without_modifying_bytes() {
let dir = TempDir::new("quicktime-metadata-test").unwrap();
let path = dir.path().join("reference.mov");
let output = Command::new("ffmpeg").args([
"-hide_banner", "-loglevel", "error", "-nostdin",
"-f", "lavfi", "-i", "color=c=blue:s=160x90:r=24",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", "pcm_s16le", "-ac", "2",
]).arg(&path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let bytes = std::fs::read(&path).unwrap();
let info = ffprobe_get_info_from_bytes(&bytes).unwrap();
let dimensions = info.dimensions.unwrap();
assert_eq!((dimensions.width, dimensions.height), (160, 90));
assert_eq!(info.duration.unwrap().millis, 1000);
assert_eq!(info.frame_rate.unwrap().fps, 24.0);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let probe = ffprobe::ffprobe(&path).unwrap();
assert!(probe.streams.iter().any(|s| s.codec_name.as_deref() == Some("pcm_s16le")));
}
}
mod parse_seconds {
use super::*;
@@ -22,7 +22,6 @@ const KNOWN_AUDIO_EXTENSIONS: &[Extension] = &[
/// Known video extensions.
const KNOWN_VIDEO_EXTENSIONS: &[Extension] = &[
Extension::from_static("mp4", ".mp4"),
Extension::from_static("mov", ".mov"),
Extension::from_static("webm", ".webm"),
];
@@ -40,7 +39,7 @@ pub enum ExtractExtensions {
/// Accept only known audio extensions (wav, mp3).
KnownAudio,
/// Accept only known video extensions (mp4, mov, webm).
/// Accept only known video extensions (mp4, webm).
KnownVideo,
/// Accept any known media extension (image, audio, or video).
@@ -159,14 +158,6 @@ mod tests {
assert_eq!(ext.unwrap().without_period(), "mp4");
}
#[test]
fn quicktime_is_a_known_video_and_media_extension() {
for accept in [ExtractExtensions::KnownVideo, ExtractExtensions::KnownMedia] {
let ext = extract_extension_from_url_str("https://example.com/clip.MOV?token=test", &accept);
assert_eq!(ext.unwrap().without_period(), "mov");
}
}
#[test]
fn known_video_rejects_mp3() {
let ext = extract_extension_from_url_str("https://example.com/song.mp3", &ExtractExtensions::KnownVideo);
@@ -62,8 +62,6 @@ pub struct InsertMediaFileFromUploadArgs<'a> {
pub maybe_title: Option<&'a str>,
pub maybe_duration_millis: Option<u64>,
pub maybe_frame_width: Option<u32>,
pub maybe_frame_height: Option<u32>,
pub maybe_scene_source_media_file_token: Option<&'a MediaFileToken>,
pub is_intermediate_system_file: bool,
@@ -157,8 +155,6 @@ SET
maybe_title = ?,
maybe_duration_millis = ?,
maybe_frame_width = ?,
maybe_frame_height = ?,
public_bucket_directory_hash = ?,
maybe_public_bucket_prefix = ?,
@@ -209,8 +205,6 @@ SET
args.maybe_title,
args.maybe_duration_millis,
args.maybe_frame_width,
args.maybe_frame_height,
args.public_bucket_directory_hash,
args.maybe_public_bucket_prefix,
@@ -57,10 +57,6 @@ pub enum UserFeatureFlag {
/// Access to Minimax with debugging priority (admins)
#[serde(rename = "minimax_p")]
MinimaxPriority,
/// Access to QuickTime video output options
#[serde(rename = "use_qt")]
CanUseQuicktime,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -83,7 +79,6 @@ impl UserFeatureFlag {
Self::ApiKey => "api_key",
Self::Minimax => "minimax",
Self::MinimaxPriority => "minimax_p",
Self::CanUseQuicktime => "use_qt",
}
}
@@ -100,7 +95,6 @@ impl UserFeatureFlag {
"api_key" => Ok(Self::ApiKey),
"minimax" => Ok(Self::Minimax),
"minimax_p" => Ok(Self::MinimaxPriority),
"use_qt" => Ok(Self::CanUseQuicktime),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -118,7 +112,6 @@ impl UserFeatureFlag {
Self::ApiKey => "API Keys",
Self::Minimax => "Minimax",
Self::MinimaxPriority => "Minimax (Debugging Priority)",
Self::CanUseQuicktime => "QuickTime Video Output",
}
}
@@ -135,7 +128,6 @@ impl UserFeatureFlag {
Self::ApiKey => "Access to API key creation",
Self::Minimax => "Access to Minimax",
Self::MinimaxPriority => "Access to Minimax with debugging priority (admins)",
Self::CanUseQuicktime => "Access to QuickTime video output options for Seedance 2.5",
}
}
@@ -154,7 +146,6 @@ impl UserFeatureFlag {
Self::ApiKey,
Self::Minimax,
Self::MinimaxPriority,
Self::CanUseQuicktime,
])
}
}
@@ -180,7 +171,6 @@ mod tests {
assert_serialization(UserFeatureFlag::ApiKey, "api_key");
assert_serialization(UserFeatureFlag::Minimax, "minimax");
assert_serialization(UserFeatureFlag::MinimaxPriority, "minimax_p");
assert_serialization(UserFeatureFlag::CanUseQuicktime, "use_qt");
}
#[test]
@@ -196,7 +186,6 @@ mod tests {
assert_eq!(UserFeatureFlag::ApiKey.to_str(), "api_key");
assert_eq!(UserFeatureFlag::Minimax.to_str(), "minimax");
assert_eq!(UserFeatureFlag::MinimaxPriority.to_str(), "minimax_p");
assert_eq!(UserFeatureFlag::CanUseQuicktime.to_str(), "use_qt");
}
#[test]
@@ -212,14 +201,13 @@ mod tests {
assert_eq!(UserFeatureFlag::from_str("api_key").unwrap(), UserFeatureFlag::ApiKey);
assert_eq!(UserFeatureFlag::from_str("minimax").unwrap(), UserFeatureFlag::Minimax);
assert_eq!(UserFeatureFlag::from_str("minimax_p").unwrap(), UserFeatureFlag::MinimaxPriority);
assert_eq!(UserFeatureFlag::from_str("use_qt").unwrap(), UserFeatureFlag::CanUseQuicktime);
assert!(UserFeatureFlag::from_str("foo").is_err());
}
#[test]
fn all_variants() {
let mut variants = UserFeatureFlag::all_variants();
assert_eq!(variants.len(), 12);
assert_eq!(variants.len(), 11);
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ExploreMedia));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Studio));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Upload3d));
@@ -231,7 +219,6 @@ mod tests {
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::ApiKey));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::Minimax));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::MinimaxPriority));
assert_eq!(variants.pop_first(), Some(UserFeatureFlag::CanUseQuicktime));
assert_eq!(variants.pop_first(), None);
}
}
@@ -1,162 +0,0 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
use crate::error::enum_error::EnumError;
/// Maximum serialized string length for database storage.
pub const MAX_LENGTH: usize = 16;
/// Output video containers for generation. Unset options retain the provider default.
/// Models without output-format support ignore this option.
///
/// NB: Keep the max serialized length to 16 characters.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CommonVideoOutputFormat {
#[serde(rename = "mp4")]
Mp4,
#[serde(rename = "mov")]
Mov,
}
impl_enum_display_and_debug_using_to_str!(CommonVideoOutputFormat);
impl_mysql_enum_coders!(CommonVideoOutputFormat);
impl_mysql_from_row!(CommonVideoOutputFormat);
impl CommonVideoOutputFormat {
pub fn to_str(&self) -> &'static str {
match self {
Self::Mp4 => "mp4",
Self::Mov => "mov",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"mp4" => Ok(Self::Mp4),
"mov" => Ok(Self::Mov),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::Mp4,
Self::Mov,
])
}
}
#[cfg(test)]
mod tests {
use crate::common::generation::common_video_output_format::CommonVideoOutputFormat;
use crate::common::generation::common_video_output_format::MAX_LENGTH;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(CommonVideoOutputFormat::Mp4, "mp4");
assert_serialization(CommonVideoOutputFormat::Mov, "mov");
}
#[test]
fn to_str() {
assert_eq!(CommonVideoOutputFormat::Mp4.to_str(), "mp4");
assert_eq!(CommonVideoOutputFormat::Mov.to_str(), "mov");
}
#[test]
fn from_str() {
assert_eq!(CommonVideoOutputFormat::from_str("mp4").unwrap(), CommonVideoOutputFormat::Mp4);
assert_eq!(CommonVideoOutputFormat::from_str("mov").unwrap(), CommonVideoOutputFormat::Mov);
}
#[test]
fn from_str_err() {
let result = CommonVideoOutputFormat::from_str("invalid");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "invalid");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = CommonVideoOutputFormat::all_variants();
assert_eq!(variants.len(), 2);
assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mp4));
assert_eq!(variants.pop_first(), Some(CommonVideoOutputFormat::Mov));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(CommonVideoOutputFormat::all_variants().len(), CommonVideoOutputFormat::iter().len());
}
#[test]
fn round_trip() {
for variant in CommonVideoOutputFormat::all_variants() {
assert_eq!(variant, CommonVideoOutputFormat::from_str(variant.to_str()).unwrap());
assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, CommonVideoOutputFormat::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
for variant in CommonVideoOutputFormat::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant);
}
for variant in CommonVideoOutputFormat::all_variants() {
let json = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json);
}
}
#[test]
fn serialized_names_must_not_contain_dots() {
for variant in CommonVideoOutputFormat::all_variants() {
let to_str_value = variant.to_str();
assert!(!to_str_value.contains('.'), "to_str() for {:?} contains a dot: {:?}", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(!json_value.contains('.'), "JSON serialization for {:?} contains a dot: {:?}", variant, json_value);
}
}
#[test]
fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() {
let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap();
for variant in CommonVideoOutputFormat::all_variants() {
let to_str_value = variant.to_str();
assert!(valid_pattern.is_match(to_str_value),
"to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value);
let json_value = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(valid_pattern.is_match(&json_value),
"JSON serialization for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, json_value);
}
}
}
}
@@ -15,4 +15,3 @@ pub mod model_creator;
pub mod common_resolution;
pub mod common_splat_model;
pub mod common_video_model;
pub mod common_video_output_format;
@@ -27,8 +27,6 @@ cloud_storage.workspace = true
concurrency.workspace = true
easyenv.workspace = true
enums.workspace = true
mimetypes.workspace = true
ffmpeg_utils.workspace = true
errors.workspace = true
hashing = { path = "../../../lib/files/hashing" }
jobs_common = { path = "../../../lib/jobs_common" }
@@ -9,8 +9,6 @@ use enums::by_table::media_files::media_file_origin_product_category::MediaFileO
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation_provider::GenerationProvider;
use errors::AnyhowResult;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use mysql_queries::queries::generic_inference::api_providers::kinovi_web::list_pending_kinovi_web_video_jobs::PendingKinoviWebJob;
use mysql_queries::queries::generic_inference::job::select_inference_job_status_for_update::select_inference_job_status_for_update;
@@ -23,6 +21,7 @@ use crate::job_dependencies::JobDependencies;
use crate::jobs::order_processing_job::is_job_status_terminal::is_job_status_terminal;
const PREFIX: &str = "artcraft_";
const SUFFIX: &str = ".mp4";
/// Download the completed video, upload to bucket, create media file record, and mark job done.
pub async fn process_successful_video_job(
@@ -53,7 +52,7 @@ pub async fn process_successful_video_job(
);
// Download the video bytes.
let video_bytes: Vec<u8> = match deps.download_client.get(video_url).send().await.and_then(|resp| resp.error_for_status()) {
let video_bytes: Vec<u8> = match deps.download_client.get(video_url).send().await {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => bytes.to_vec(),
Err(err) => {
@@ -83,22 +82,12 @@ pub async fn process_successful_video_job(
order.order_id
);
let (suffix, mimetype, media_file_type) = video_metadata(&video_bytes)?;
let (video_bytes, probe_result) = tokio::task::spawn_blocking(move || {
let info = ffprobe_get_info_from_bytes(&video_bytes);
(video_bytes, info)
}).await?;
let video_info = probe_result.unwrap_or_else(|err| {
warn!("Could not probe video for order {}: {:?}", order.order_id, err);
VideoInfo::default()
});
// Hash the video.
let checksum = sha256_hash_bytes(&video_bytes)
.map_err(|err| anyhow!("error hashing video: {:?}", err))?;
// Build the bucket path.
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(suffix));
let bucket_path = MediaFileBucketPath::generate_new(Some(PREFIX), Some(SUFFIX));
let object_path = bucket_path.get_full_object_path_str();
@@ -110,7 +99,7 @@ pub async fn process_successful_video_job(
// Upload to public bucket.
let upload_result = deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &video_bytes, mimetype)
.upload_file_with_content_type_process(object_path, &video_bytes, "video/mp4")
.await;
if let Err(err) = upload_result {
@@ -128,15 +117,10 @@ pub async fn process_successful_video_job(
order.order_id
);
// Prefer the downloaded file's dimensions; provider fields are a fallback.
let maybe_result = order.results.iter().find(|result| result.url == video_url);
let maybe_frame_width = video_info.dimensions.as_ref()
.and_then(|d| u32::try_from(d.width).ok())
.or_else(|| maybe_result.and_then(|result| result.maybe_width));
let maybe_frame_height = video_info.dimensions.as_ref()
.and_then(|d| u32::try_from(d.height).ok())
.or_else(|| maybe_result.and_then(|result| result.maybe_height));
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
// Optionally extract frame dimensions from the order results.
// Persist the integer dimensions when the API provides them, NULL otherwise.
let maybe_frame_width = order.results.first().and_then(|r| r.maybe_width);
let maybe_frame_height = order.results.first().and_then(|r| r.maybe_height);
// Insert media file record.
let media_file_result = MediaFileInsertBuilder::new()
@@ -146,7 +130,6 @@ pub async fn process_successful_video_job(
.file_size_bytes(video_bytes.len() as u64)
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_duration_millis(maybe_duration_millis)
.maybe_frame_height(maybe_frame_height)
.maybe_frame_width(maybe_frame_width)
.maybe_generation_provider(Some(GenerationProvider::Artcraft))
@@ -156,8 +139,8 @@ pub async fn process_successful_video_job(
.media_file_class(MediaFileClass::Video)
.media_file_origin_category(MediaFileOriginCategory::Inference)
.media_file_origin_product_category(MediaFileOriginProductCategory::VideoGeneration)
.media_file_type(media_file_type)
.mime_type(mimetype)
.media_file_type(MediaFileType::Mp4)
.mime_type("video/mp4")
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await;
@@ -263,34 +246,3 @@ async fn finalize_success(
Ok(())
}
/// Detect the actual container instead of labeling every provider result as MP4.
fn video_metadata(bytes: &[u8]) -> AnyhowResult<(&'static str, &'static str, MediaFileType)> {
match get_mimetype_for_bytes(bytes) {
Some("video/quicktime") => Ok((".mov", "video/quicktime", MediaFileType::Mov)),
Some("video/mp4") => Ok((".mp4", "video/mp4", MediaFileType::Mp4)),
other => Err(anyhow!("Unsupported Kinovi video result MIME type: {:?}", other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_quicktime_result() {
let bytes = b"\x00\x00\x00\x14ftypqt \x00\x00\x02\x00qt ";
assert_eq!(video_metadata(bytes).unwrap(), (".mov", "video/quicktime", MediaFileType::Mov));
}
#[test]
fn detects_mp4_result() {
let bytes = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00isomiso2avc1mp41";
assert_eq!(video_metadata(bytes).unwrap(), (".mp4", "video/mp4", MediaFileType::Mp4));
}
#[test]
fn rejects_error_page_as_video() {
assert!(video_metadata(b"<html>Download failed</html>").is_err());
}
}
@@ -3,7 +3,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::model_creator::ModelCreator;
/// The Seedance 2.5 family of video models.
@@ -49,8 +48,6 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
output_format_options: Some(vec![CommonVideoOutputFormat::Mp4, CommonVideoOutputFormat::Mov]),
output_format_default: Some(CommonVideoOutputFormat::Mp4),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
@@ -98,11 +95,6 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
CommonBitrate::High,
]),
bitrate_default: Some(CommonBitrate::Normal),
output_format_options: Some(vec![
CommonVideoOutputFormat::Mp4,
CommonVideoOutputFormat::Mov
]),
output_format_default: Some(CommonVideoOutputFormat::Mp4),
batch_size_options: Some(vec![1]),
batch_size_default: Some(1),
duration_seconds_min: Some(4),
@@ -159,23 +151,3 @@ pub fn seedance_2p5_video_models() -> Vec<OmniGenVideoModelDetails> {
models
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn output_format_config_matches_supported_models() {
for model in seedance_2p5_video_models() {
let json = serde_json::to_value(&model).unwrap();
if matches!(model.model, CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra) {
assert_eq!(json["output_format_options"], json!(["mp4", "mov"]));
assert_eq!(json["output_format_default"], "mp4");
} else {
assert!(json.get("output_format_options").is_none());
assert!(json.get("output_format_default").is_none());
}
}
}
}
@@ -310,7 +310,6 @@ use enums::common::generation::common_model_class::CommonModelClass;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_bitrate::CommonBitrate;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
@@ -817,7 +816,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
CommonBitrate,
CommonGenerationMode,
CommonResolution,
CommonVideoOutputFormat,
PromptContextSemanticType,
PromptType,
MediaFileOriginModelType,
@@ -52,7 +52,6 @@ pub async fn estimate_video_cost_handler(
resolution: router_resolution,
aspect_ratio: router_aspect_ratio,
bitrate: None,
maybe_output_format: None,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
@@ -258,8 +258,6 @@ pub async fn upload_snapshot_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: Some("Snapshot"),
maybe_scene_source_media_file_token: None,
@@ -153,8 +153,6 @@ pub async fn save_new_project(args: SaveNewProjectArgs<'_>) -> Result<MediaFileT
maybe_mime_type: Some(PROJECT_MIMETYPE),
file_size_bytes: file_bytes.len() as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &sha256_checksum,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -332,8 +332,6 @@ pub async fn upload_audio_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::sync::Arc;
@@ -12,7 +11,6 @@ use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCat
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use http_server_common::request::get_request_ip::get_request_ip;
use media::decode_basic_audio_info::decode_basic_audio_bytes_info;
use mimetypes::mimetype_for_bytes::get_mimetype_for_bytes;
@@ -196,7 +194,7 @@ pub async fn process_upload_media_file(
let do_audio_decode = match mimetype {
// TODO: Revisit when Safari can send us this metadata consistently
"audio/mp4" | "video/mp4" | "video/quicktime" => false,
"audio/mp4" | "video/mp4" => false,
"audio/opus" => {
// TODO/FIXME(bt, 2023-05-19): Symphonia is currently broken for Firefox's opus.
// We're on an off-master branch that may resolve the problem in the future, but for now
@@ -282,18 +280,6 @@ pub async fn process_upload_media_file(
};
let media_file_class = media_file_type.to_media_class();
let video_info = if media_file_class == MediaFileClass::Video {
ffprobe_get_info_from_bytes(&bytes).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
})
} else {
VideoInfo::default()
};
if let Some(duration) = &video_info.duration {
maybe_duration_millis = Some(u64::from(duration.millis));
}
let upload_type = match upload_media_request.media_source {
MediaFileUploadSource::Unknown => UploadType::Filesystem,
@@ -363,8 +349,6 @@ pub async fn process_upload_media_file(
.mime_type(mime_type)
.file_size_bytes(file_size_bytes as u64)
.maybe_duration_millis(maybe_duration_millis)
.maybe_frame_width(video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok()))
.maybe_frame_height(video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok()))
.checksum_sha2(&hash)
.maybe_title(upload_media_request.title.as_deref())
.maybe_origin_filename(upload_media_request.file_name.as_deref())
@@ -33,7 +33,7 @@ static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
// Video
"video/mp4",
"video/webm",
"video/quicktime",
//"video/quicktime",
// Image
"image/gif",
"image/jpeg",
@@ -304,8 +304,6 @@ pub async fn upload_image_media_file_handler(
maybe_batch_token: maybe_batch_token.as_ref(),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -224,8 +224,6 @@ pub async fn upload_new_engine_asset_media_file_handler(
maybe_mime_type: Some(file_info.mimetype),
file_size_bytes: file_info.file_size_bytes as u64,
maybe_duration_millis: file_info.maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &file_info.sha256_checksum,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false, // NB: is_user_upload = TRUE
@@ -240,8 +240,6 @@ pub async fn upload_new_scene_media_file_handler(
maybe_mime_type: Some(MIMETYPE),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -271,8 +271,6 @@ pub async fn upload_pmx_media_file_handler(
maybe_mime_type: Some("application/octet-stream"),
file_size_bytes: pmx_details.file_size_bytes,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &pmx_details.sha256_checksum,
maybe_scene_source_media_file_token: None,
is_intermediate_system_file: false, // NB: is_user_upload = TRUE
@@ -233,8 +233,6 @@ pub async fn upload_scene_snapshot_media_file_handler(
maybe_mime_type: Some(MIMETYPE),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: maybe_scene_source_media_file_token.as_ref(),
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
@@ -18,8 +17,9 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info, VideoInfo};
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::file_read_bytes::file_read_bytes;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
@@ -35,7 +35,6 @@ use tokens::tokens::prompts::PromptToken;
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
@@ -120,8 +119,14 @@ pub struct UploadNewVideoMediaFileSuccessResponse {
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/mp4",
"video/quicktime", // Preserve MOV video and audio without transcoding.
"video/mp4", // NB: Only mp4 for now.
])
});
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/quicktime", // .qt quicktime files
])
});
@@ -231,7 +236,9 @@ pub async fn upload_new_video_media_file_handler(
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
})?;
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
// NB: Don't let our error message inject malicious strings
let filtered_mimetype = mimetype
.chars()
@@ -241,24 +248,10 @@ pub async fn upload_new_video_media_file_handler(
return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype)));
}
if mimetype == "video/quicktime" {
let maybe_feature_flags = match session_auth.maybe_header_session.as_ref() {
Some(session) => session.maybe_feature_flags.as_deref(),
None => session_auth.maybe_cookie_session.as_ref()
.and_then(|session| session.maybe_feature_flags.as_deref()),
};
let user_feature_flags = UserSessionFeatureFlags::new(maybe_feature_flags);
if !user_feature_flags.can_use_quicktime() {
return Err(MediaFileUploadError::NotAuthorizedVerbose(
"QuickTime uploads are not enabled for this account".to_string(),
));
}
}
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
// Hold on to the user's original file (path + mimetype + extension) so we
// can upload it as a `_original.{ext}` sibling later if we end up
// trimming or resampling it.
// transcoding and/or resampling it.
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
let original_mimetype: String = mimetype.clone();
@@ -274,10 +267,49 @@ pub async fn upload_new_video_media_file_handler(
MediaFileUploadError::ServerError
})?;
// Preserve the source container and audio unless the caller requests an edit.
let mut final_upload_file_path = form.file.file.path().to_path_buf();
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
// distribution format we want for the media file record. Transcode them to
// mp4 in a tempdir so we can resample / upload mp4 instead.
// Tempdirs we need to keep alive across the upload step. They get dropped at
// function exit which cleans up the on-disk files.
let mut _transcode_tempdir_ref = None;
let mut _resample_tempdir_ref = None;
let mut final_upload_file_path = form.file.file.path().to_path_buf();
if needs_transcode {
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
.map_err(|err| {
error!("Problem creating transcode temp dir: {:?}", err);
MediaFileUploadError::ServerError
})?;
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
info!("Transcoding {} → mp4", &original_mimetype);
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
video_input_path: form.file.file.path(),
video_output_path: &transcoded_path,
}).map_err(|err| {
error!("Problem transcoding video to mp4: {:?}", err);
MediaFileUploadError::ServerError
})?;
// The canonical file is now the transcoded mp4. Read its bytes and pin
// its mimetype to mp4 so all the downstream metadata reflects the
// transcoded file rather than the user's original upload.
file_bytes = file_read_bytes(&transcoded_path)
.map_err(|e| {
error!("Problem reading transcoded mp4: {:?}", e);
MediaFileUploadError::ServerError
})?;
mimetype = "video/mp4".to_string();
final_upload_file_path = transcoded_path;
_transcode_tempdir_ref = Some(transcode_tempdir);
}
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
let should_resample = form.maybe_resample_fps.is_some()
@@ -298,7 +330,8 @@ pub async fn upload_new_video_media_file_handler(
let maybe_start_offset = form.maybe_trim_start_millis.map(|millis| Duration::from_millis(millis.0));
let maybe_end_offset = form.maybe_trim_end_millis.map(|millis| Duration::from_millis(millis.0));
// Explicit trimming/resampling produces MP4; the original is retained below.
// NB: Resample reads from `final_upload_file_path` (which is the transcoded
// mp4 if we transcoded above, or the user's original upload otherwise).
ffmpeg_trim_and_resample(Args {
video_input_path: &final_upload_file_path,
video_output_path: &video_output_path,
@@ -327,18 +360,23 @@ pub async fn upload_new_video_media_file_handler(
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
}
// Retain the unmodified original when the caller requests an edit.
let should_upload_original = should_resample;
// True if we touched the user's file in any way (transcoded, resampled, or both).
// We'll upload their unmodified original alongside the canonical file when so.
let should_upload_original = needs_transcode || should_resample;
// ==================== OTHER FILE METADATA ==================== //
let video_info = ffprobe_get_info(&final_upload_file_path).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
});
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
let maybe_frame_width = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok());
let maybe_frame_height = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok());
let mut maybe_duration_millis = None;
match ffprobe_get_info(&final_upload_file_path) {
Ok(video_info) => {
maybe_duration_millis = video_info.duration
.map(|duration| duration.millis as u64);
}
Err(error) => {
warn!("Error reading video dimensions with ffprobe: {:?}", error);
}
}
let maybe_filename = form.file.file_name.as_deref()
.as_deref()
@@ -385,7 +423,7 @@ pub async fn upload_new_video_media_file_handler(
})?;
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
// If we trimmed or resampled the user's upload, also upload the
// If we transcoded and/or resampled the user's upload, also upload the
// unmodified original alongside the canonical file as `_original.{ext}`
// (sharing the same object hash / directory). The media file record points
// at the canonical file, not this sibling.
@@ -469,8 +507,6 @@ pub async fn upload_new_video_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width,
maybe_frame_height,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token,
@@ -489,12 +525,7 @@ pub async fn upload_new_video_media_file_handler(
info!("new media file id: {} token: {:?}", record_id, &token);
let thumbnail_mimetype = if mimetype == "video/quicktime" {
ThumbnailTaskInputMimeType::MOV
} else {
ThumbnailTaskInputMimeType::MP4
};
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(thumbnail_mimetype)
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
.with_output_suffix("thumb")
@@ -236,8 +236,6 @@ pub async fn upload_video_media_file_handler(
maybe_mime_type: Some(mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: Some(mp4_info.duration_millis as u64),
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: upload_media_request.title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -128,7 +128,6 @@ fn to_omni_gen_request(request: OmniApiVideoGenerateRequest) -> OmniGenVideoCost
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
bitrate: request.bitrate,
maybe_output_format: request.maybe_output_format,
quality: request.quality,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
@@ -1,5 +1,3 @@
use std::convert::TryFrom;
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info_from_bytes, VideoInfo};
use std::collections::HashSet;
use log::{info, warn};
@@ -73,15 +71,6 @@ pub async fn upload_url_to_media_file(
format!("Unpermitted mime type for URL {}: {}", url, filtered_mimetype)));
}
let video_info = if matches!(kind, MediaUploadKind::Video) {
ffprobe_get_info_from_bytes(&file_bytes).unwrap_or_else(|err| {
warn!("Could not probe uploaded video URL: {:?}", err);
VideoInfo::default()
})
} else {
VideoInfo::default()
};
// ==================== OTHER FILE METADATA ==================== //
let extension = mimetype_to_extension(&mimetype)
@@ -136,9 +125,7 @@ pub async fn upload_url_to_media_file(
maybe_batch_token: None,
file_size_bytes,
// NB: Duration would require probing the file on disk; URL inputs skip it.
maybe_duration_millis: video_info.duration.map(|duration| u64::from(duration.millis)),
maybe_frame_width: video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok()),
maybe_frame_height: video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok()),
maybe_duration_millis: None,
sha256_checksum: &hash,
maybe_title: None,
maybe_scene_source_media_file_token: None,
@@ -172,7 +159,6 @@ static IMAGE_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
static VIDEO_MIME_TYPES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
"video/mp4",
"video/quicktime",
])
});
@@ -88,7 +88,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -70,7 +70,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -296,8 +296,6 @@ pub async fn omni_upload_audio_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -282,8 +282,6 @@ pub async fn omni_upload_image_media_file_handler(
maybe_batch_token: maybe_batch_token.as_ref(),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis: None,
maybe_frame_width: None,
maybe_frame_height: None,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token: None,
@@ -1,4 +1,3 @@
use std::convert::TryFrom;
use std::collections::HashSet;
use std::io::Read;
use std::path::PathBuf;
@@ -19,8 +18,9 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::visibility::Visibility;
use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args};
use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args};
use ffmpeg_utils::ffprobe::ffprobe_get_info::{ffprobe_get_info, VideoInfo};
use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info;
use filesys::file_read_bytes::file_read_bytes;
use filesys::path_to_string::path_to_string;
use hashing::sha256::sha256_hash_bytes::sha256_hash_bytes;
@@ -108,8 +108,14 @@ pub struct OmniUploadVideoMediaFileForm {
static ALLOWED_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/mp4",
"video/quicktime", // Preserve MOV video and audio without transcoding.
"video/mp4", // NB: Only mp4 for now.
])
});
static TRANSCODE_MIME_TYPES : Lazy<HashSet<&'static str>> = Lazy::new(|| {
HashSet::from([
// Video
"video/quicktime", // .qt quicktime files
])
});
@@ -212,7 +218,9 @@ pub async fn omni_upload_video_media_file_handler(
MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string())
})?;
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) {
let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str());
if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode {
// NB: Don't let our error message inject malicious strings
let filtered_mimetype = mimetype
.chars()
@@ -225,7 +233,7 @@ pub async fn omni_upload_video_media_file_handler(
// ==================== ORIGINAL FILE BOOKKEEPING ==================== //
// Hold on to the user's original file (path + mimetype + extension) so we
// can upload it as a `_original.{ext}` sibling later if we end up
// trimming or resampling it.
// transcoding and/or resampling it.
let original_file_path: PathBuf = form.file.file.path().to_path_buf();
let original_mimetype: String = mimetype.clone();
@@ -241,10 +249,44 @@ pub async fn omni_upload_video_media_file_handler(
MediaFileUploadError::ServerError
})?;
// Preserve the source container and audio unless the caller requests an edit.
let mut final_upload_file_path = form.file.file.path().to_path_buf();
// ==================== OPTIONAL TRANSCODE TO MP4 ==================== //
// Some accepted source mime types (e.g. QuickTime .mov) are not the canonical
// distribution format we want for the media file record. Transcode them to
// mp4 in a tempdir so we can resample / upload mp4 instead.
let mut _transcode_tempdir_ref = None;
let mut _resample_tempdir_ref = None;
let mut final_upload_file_path = form.file.file.path().to_path_buf();
if needs_transcode {
let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode")
.map_err(|err| {
error!("Problem creating transcode temp dir: {:?}", err);
MediaFileUploadError::ServerError
})?;
let transcoded_path = transcode_tempdir.path().join("transcoded.mp4");
info!("Transcoding {} → mp4", &original_mimetype);
ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args {
video_input_path: form.file.file.path(),
video_output_path: &transcoded_path,
}).map_err(|err| {
error!("Problem transcoding video to mp4: {:?}", err);
MediaFileUploadError::ServerError
})?;
file_bytes = file_read_bytes(&transcoded_path)
.map_err(|e| {
error!("Problem reading transcoded mp4: {:?}", e);
MediaFileUploadError::ServerError
})?;
mimetype = "video/mp4".to_string();
final_upload_file_path = transcoded_path;
_transcode_tempdir_ref = Some(transcode_tempdir);
}
// ==================== OPTIONAL VIDEO RESAMPLE ==================== //
let should_resample = form.maybe_resample_fps.is_some()
@@ -292,18 +334,22 @@ pub async fn omni_upload_video_media_file_handler(
_resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope
}
// Retain the unmodified original when the caller requests an edit.
let should_upload_original = should_resample;
// True if we touched the user's file in any way (transcoded, resampled, or both).
let should_upload_original = needs_transcode || should_resample;
// ==================== OTHER FILE METADATA ==================== //
let video_info = ffprobe_get_info(&final_upload_file_path).unwrap_or_else(|err| {
warn!("Could not probe uploaded video: {:?}", err);
VideoInfo::default()
});
let maybe_duration_millis = video_info.duration.map(|duration| u64::from(duration.millis));
let maybe_frame_width = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.width).ok());
let maybe_frame_height = video_info.dimensions.as_ref().and_then(|d| u32::try_from(d.height).ok());
let mut maybe_duration_millis = None;
match ffprobe_get_info(&final_upload_file_path) {
Ok(video_info) => {
maybe_duration_millis = video_info.duration
.map(|duration| duration.millis as u64);
}
Err(error) => {
warn!("Error reading video dimensions with ffprobe: {:?}", error);
}
}
let maybe_filename = form.file.file_name.as_deref()
.as_deref()
@@ -350,7 +396,7 @@ pub async fn omni_upload_video_media_file_handler(
})?;
// ==================== UPLOAD ORIGINAL SIBLING ==================== //
// If we trimmed or resampled the user's upload, also upload the
// If we transcoded and/or resampled the user's upload, also upload the
// unmodified original alongside the canonical file as `_original.{ext}`.
if should_upload_original {
@@ -431,8 +477,6 @@ pub async fn omni_upload_video_media_file_handler(
maybe_mime_type: Some(&mimetype),
file_size_bytes: file_size_bytes as u64,
maybe_duration_millis,
maybe_frame_width,
maybe_frame_height,
sha256_checksum: &hash,
maybe_title: maybe_title.as_deref(),
maybe_scene_source_media_file_token,
@@ -451,12 +495,7 @@ pub async fn omni_upload_video_media_file_handler(
info!("new media file id: {} token: {:?}", record_id, &token);
let thumbnail_mimetype = if mimetype == "video/quicktime" {
ThumbnailTaskInputMimeType::MOV
} else {
ThumbnailTaskInputMimeType::MP4
};
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(thumbnail_mimetype)
let thumbnail_task_result = ThumbnailTaskBuilder::new_for_source_mimetype(ThumbnailTaskInputMimeType::MP4)
.with_bucket(server_state.public_bucket_client.bucket_name().as_str())
.with_path(&*path_to_string(public_upload_path.to_full_object_pathbuf()))
.with_output_suffix("thumb")
@@ -271,7 +271,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -6,7 +6,6 @@ use artcraft_router::api::router_aspect_ratio::RouterAspectRatio;
use artcraft_router::api::router_bitrate::RouterBitrate;
use artcraft_router::api::router_resolution::RouterResolution;
use artcraft_router::api::router_video_model::RouterVideoModel;
use artcraft_router::api::router_video_output_format::RouterVideoOutputFormat;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::image_ref::ImageRef;
use artcraft_router::api::router_provider::RouterProvider;
@@ -17,7 +16,6 @@ use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonA
use enums::common::generation::common_bitrate::CommonBitrate as CommonBitrateEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
pub fn hydrate_to_router_request(
request: &OmniGenVideoCostAndGenerateRequest,
@@ -65,14 +63,6 @@ pub fn hydrate_to_router_request(
resolution,
aspect_ratio,
bitrate,
maybe_output_format: if matches!(model, RouterVideoModel::Seedance2p5 | RouterVideoModel::Seedance2p5Ultra) {
Some(match request.maybe_output_format.unwrap_or(CommonVideoOutputFormat::Mp4) {
CommonVideoOutputFormat::Mp4 => RouterVideoOutputFormat::Mp4,
CommonVideoOutputFormat::Mov => RouterVideoOutputFormat::Mov,
})
} else {
None
},
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
generate_audio: request.generate_audio,
@@ -132,84 +122,6 @@ fn convert_bitrate(
mod tests {
use super::*;
mod output_format {
use super::*;
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_kinovi_web_client::RouterKinoviWebClient;
use artcraft_router::generate::generate_video::video_generation_draft_context::VideoGenerationDraftContext;
use artcraft_router::generate::generate_video::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use artcraft_router::generate::generate_video::video_generation_request::VideoGenerationRequest;
use kinovi_web_client::creds::kinovi_web_session::KinoviWebSession;
use kinovi_web_client::generate::video::generate_seedance_2p5::KinoviSeedance2p5OutputFormat;
use serde_json::{json, Value};
#[tokio::test]
async fn artcraft_http_request_reaches_kinovi_with_format_and_audio() {
// No media inputs or send: finalization needs a client but makes no HTTP calls.
let client = RouterClient::KinoviWeb(RouterKinoviWebClient::new(
KinoviWebSession::from_cookies_string(String::new()),
));
let context = VideoGenerationDraftContext { client: Some(&client), ..Default::default() };
for model in ["seedance_2p5", "seedance_2p5_u"] {
for output_format in [None, Some(Value::Null), Some(json!("mp4")), Some(json!("mov"))] {
for generate_audio in [None, Some(true), Some(false)] {
let mut body = json!({
"model": model,
"generate_audio": generate_audio,
});
if let Some(format) = &output_format {
body["output_format"] = format.clone();
}
let request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(body).unwrap();
let mut builder = hydrate_to_router_request(&request).unwrap();
builder.provider = RouterProvider::KinoviWeb;
// Mirror run_pipeline_v2: Ultra executes as the base model on
// its own account, while billing retains the original model.
if matches!(builder.model, RouterVideoModel::Seedance2p5Ultra) {
builder.model = RouterVideoModel::Seedance2p5;
}
let draft = match builder.build2().unwrap() {
VideoGenerationDraftOrRequest::Draft(draft) => draft,
other => panic!("Unexpected request: {:?}", other),
};
let finalized = draft.finalize(context.clone()).await.unwrap();
let request = match finalized {
VideoGenerationRequest::KinoviSeedance2p5(state) => state.request,
other => panic!("Unexpected request: {:?}", other),
};
let actual_format = request.maybe_output_format.map(|format| match format {
KinoviSeedance2p5OutputFormat::Mp4 => "mp4",
KinoviSeedance2p5OutputFormat::Mov => "mov",
});
let expected_format = output_format.as_ref().and_then(Value::as_str).unwrap_or("mp4");
assert_eq!(actual_format, Some(expected_format), "model: {model}");
assert_eq!(request.maybe_generate_audio, generate_audio);
}
}
}
}
#[test]
fn unsupported_model_ignores_format() {
for model in ["seedance_2p0", "seedance_2p5_preview"] {
let request: OmniGenVideoCostAndGenerateRequest = serde_json::from_value(json!({
"model": model, "output_format": "mov"
})).unwrap();
assert!(hydrate_to_router_request(&request).unwrap().maybe_output_format.is_none());
}
}
#[test]
fn invalid_format_is_rejected() {
for output_format in [json!("webm"), json!("MP4"), json!(42)] {
let request = serde_json::from_value::<OmniGenVideoCostAndGenerateRequest>(json!({
"model": "seedance_2p5", "output_format": output_format,
}));
assert!(request.is_err());
}
}
}
mod bitrate_hydration {
use super::*;
@@ -255,7 +167,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -29,5 +29,3 @@ mod mcp_session_tests;
mod omni_api_parity_tests;
mod seedance_2p0;
mod seedance_2p5;
mod quicktime_tests;
@@ -1,178 +0,0 @@
use std::process::Command;
use actix_web::cookie::Cookie;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use enums::by_table::media_files::media_file_class::MediaFileClass;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation::common_video_output_format::CommonVideoOutputFormat;
use mysql_queries::queries::media_files::get::get_media_file::get_media_file;
use mysql_queries::queries::users::user::update::set_user_feature_flags::{set_user_feature_flags, SetUserFeatureFlagArgs};
use mysql_testing::fixtures::users::{create_test_user, TestUser};
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::endpoints::media_files::upload::upload_generic::upload_media_file_handler::upload_media_file_handler;
use crate::http_server::endpoints::media_files::upload::upload_video_new::upload_new_video_media_file_handler::upload_new_video_media_file_handler;
use crate::http_server::endpoints::omni_api::upload::omni_upload_video_media_file_handler::omni_upload_video_media_file_handler;
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{base_generate_request, stub_traffic, TestHarness};
const BOUNDARY: &str = "artcraft-quicktime-test-boundary";
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn quicktime_uploads_are_video_references_with_mp4_or_mov_output() {
let harness = TestHarness::create().await;
let user = harness.create_funded_user(10_000).await;
enable_quicktime(&harness, &user).await;
let api_key = harness.create_api_key(&user).await;
let bytes = quicktime_fixture();
let app = test::init_service(App::new()
.app_data(web::Data::new(harness.server_state.clone()))
.route("/new-video", web::post().to(upload_new_video_media_file_handler))
.route("/omni-upload", web::post().to(omni_upload_video_media_file_handler))
.route("/generic-upload", web::post().to(upload_media_file_handler))
).await;
for route in ["/new-video", "/omni-upload", "/generic-upload"] {
let idempotency = base_generate_request(CommonVideoModel::Seedance2p5).idempotency_token.unwrap();
let body = multipart_body(&idempotency, &bytes);
let request = test::TestRequest::post().uri(route)
.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful())))
.insert_header(("Content-Type", format!("multipart/form-data; boundary={BOUNDARY}")))
.peer_addr("127.0.0.1:9999".parse().unwrap())
.set_payload(body).to_request();
let response = test::call_service(&app, request).await;
let status = response.status();
let body = test::read_body(response).await;
assert!(status.is_success(), "{}: {}", route, String::from_utf8_lossy(&body));
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let token = MediaFileToken::new_from_str(json["media_file_token"].as_str().unwrap());
let media = get_media_file(&token, false, &harness.pool).await.unwrap().unwrap();
assert_eq!(media.media_class, MediaFileClass::Video);
assert_eq!(media.media_type, MediaFileType::Mov);
assert_eq!(media.maybe_mime_type.as_deref(), Some("video/quicktime"));
assert_eq!(media.maybe_public_bucket_extension.as_deref(), Some(".mov"));
assert_eq!(media.maybe_duration_millis, Some(1000));
for (format, expected) in [(None, "mp4"), (Some(CommonVideoOutputFormat::Mp4), "mp4"), (Some(CommonVideoOutputFormat::Mov), "mov")] {
let mut request = base_generate_request(CommonVideoModel::Seedance2p5);
let prompt = format!("QuickTime reference test {}", request.idempotency_token.as_deref().unwrap());
request.prompt = Some(prompt.clone());
request.reference_video_media_tokens = Some(vec![token.clone()]);
request.maybe_output_format = format;
request.generate_audio = Some(true);
request.duration_seconds = Some(4);
request.resolution = Some(CommonResolution::FourEightyP);
let response = harness.post_generate(&user, request).await.expect("submit MOV video reference");
assert!(response.success);
let traffic = stub_traffic().lock().unwrap();
let params = traffic.workflows.iter()
.map(|request| &request["0"]["json"]["apiParams"])
.find(|params| params["prompt"].as_str() == Some(&prompt))
.expect("captured Kinovi request");
assert_eq!(params["output_format"], expected);
assert_eq!(params["generate_audio"], true);
assert_eq!(params["mode"], "reference");
assert!(params["videoUrls"][0].as_str().is_some_and(|url| url.ends_with(".mov")), "{}", params);
assert_eq!(traffic.uploads.get("fixture.mov").unwrap(), &bytes);
}
}
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn new_video_quicktime_uploads_require_feature_flag_for_every_auth_type() {
let harness = TestHarness::create().await;
let mov_bytes = quicktime_fixture();
let mp4_bytes = video_fixture("mp4", "aac");
let app = test::init_service(App::new()
.app_data(web::Data::new(harness.server_state.clone()))
.route("/new-video", web::post().to(upload_new_video_media_file_handler))
).await;
for enabled in [false, true] {
let user = create_test_user(&harness.pool).await.unwrap();
if enabled {
enable_quicktime(&harness, &user).await;
}
let api_key = harness.create_api_key(&user).await;
let mcp_session = harness.create_mcp_session(&user).await;
let cookie = harness.server_state.session_cookie_manager
.create_cookie(&user.session_token, &user.user_token).unwrap();
for auth in ["anonymous", "cookie", "api_key", "mcp"] {
for (is_quicktime, bytes) in [(true, &mov_bytes), (false, &mp4_bytes)] {
let idempotency = base_generate_request(CommonVideoModel::Seedance2p5).idempotency_token.unwrap();
// Even MOV bytes claim to be MP4: authorization must use the detected
// container, not the filename or multipart Content-Type.
let body = multipart_body_for_file(&idempotency, bytes, "reference.mp4", "video/mp4");
let mut request = test::TestRequest::post().uri("/new-video")
.insert_header(("Content-Type", format!("multipart/form-data; boundary={BOUNDARY}")))
.peer_addr("127.0.0.1:9999".parse().unwrap())
.set_payload(body);
request = match auth {
"cookie" => request.cookie(Cookie::new("session", cookie.value().to_string())),
"api_key" => request.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful()))),
"mcp" => request.insert_header(("Authorization", format!("Bearer {}", mcp_session.as_str()))),
_ => request,
};
let response = test::call_service(&app, request.to_request()).await;
let status = response.status();
let body = test::read_body(response).await;
let expected = if is_quicktime && (!enabled || auth == "anonymous") {
StatusCode::UNAUTHORIZED
} else {
StatusCode::OK
};
assert_eq!(status, expected,
"enabled={}, auth={}, quicktime={}: {}", enabled, auth, is_quicktime, String::from_utf8_lossy(&body));
}
}
}
}
async fn enable_quicktime(harness: &TestHarness, user: &TestUser) {
set_user_feature_flags(SetUserFeatureFlagArgs {
subject_user_token: &user.user_token,
maybe_feature_flags: Some("use_qt"),
ip_address: "127.0.0.1",
maybe_mod_user_token: None,
mysql_pool: &harness.pool,
}).await.unwrap();
}
fn quicktime_fixture() -> Vec<u8> {
video_fixture("mov", "pcm_s16le")
}
fn video_fixture(extension: &str, audio_codec: &str) -> Vec<u8> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(format!("reference.{extension}"));
let output = Command::new("ffmpeg").args([
"-hide_banner", "-loglevel", "error", "-nostdin",
"-f", "lavfi", "-i", "color=c=blue:s=160x90:r=24",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p",
"-c:a", audio_codec, "-ac", "2",
]).arg(&path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
std::fs::read(path).unwrap()
}
fn multipart_body(idempotency_token: &str, bytes: &[u8]) -> Vec<u8> {
multipart_body_for_file(idempotency_token, bytes, "reference.mov", "video/quicktime")
}
fn multipart_body_for_file(idempotency_token: &str, bytes: &[u8], filename: &str, mimetype: &str) -> Vec<u8> {
let mut body = format!(
"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"uuid_idempotency_token\"\r\n\r\n{idempotency_token}\r\n\
--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mimetype}\r\n\r\n"
).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes());
body
}
@@ -3,10 +3,10 @@
//! ever leave the process), and helpers for driving the generate handler
//! with dummy Actix HTTP requests as a fixture user.
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::convert::TryFrom;
use std::marker::PhantomData;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::Arc;
use std::time::Duration;
use actix_web::cookie::Cookie;
@@ -15,7 +15,6 @@ use actix_web::web::{Data, Json};
use actix_web::HttpRequest;
use chrono::Utc;
use sqlx::MySqlPool;
use serde_json::Value;
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
@@ -324,7 +323,6 @@ pub fn base_generate_request(model: CommonVideoModel) -> OmniGenVideoCostAndGene
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: None,
video_batch_count: None,
@@ -350,29 +348,16 @@ fn uuid_v4_like() -> String {
/// BOTH the Kinovi host-override and media-CDN-override env vars exactly
/// once, before returning.
fn process_stub_kinovi_base_url() -> &'static String {
use std::sync::OnceLock;
static STUB: OnceLock<String> = OnceLock::new();
STUB.get_or_init(|| {
let base_url = spawn_stub_server();
std::env::set_var(ENV_KINOVI_CUSTOM_API_HOST, &base_url);
std::env::set_var(ENV_KINOVI_CUSTOM_CDN_HOST, &base_url);
std::env::set_var("THUMBNAIL_GENERATOR_API_URL", format!("{base_url}/thumbnails"));
std::env::set_var("THUMBNAIL_GENERATOR_API_BASIC_AUTH", "");
base_url
})
}
/// Captured local stub traffic for assertions about provider payloads and exact upload bytes.
#[derive(Default)]
pub struct StubTraffic {
pub uploads: HashMap<String, Vec<u8>>,
pub workflows: Vec<Value>,
}
pub fn stub_traffic() -> &'static Mutex<StubTraffic> {
static TRAFFIC: OnceLock<Mutex<StubTraffic>> = OnceLock::new();
TRAFFIC.get_or_init(Default::default)
}
/// A stub for BOTH the Kinovi API and the media CDN. Runs on plain OS
/// threads so it is independent of any tokio runtime and serves connections
/// until the test process exits.
@@ -419,15 +404,8 @@ fn spawn_stub_server() -> String {
match stream.read(&mut buffer[read_total..]) {
Ok(n) if n > 0 => {
read_total += n;
if let Some(header_end) = buffer[..read_total].windows(4).position(|w| w == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&buffer[..header_end]);
let content_length = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::<usize>().ok()).flatten()
}).unwrap_or(0);
if read_total >= header_end + 4 + content_length {
break;
}
if buffer[..read_total].windows(4).any(|w| w == b"\r\n\r\n") && read_total > 4 {
break;
}
}
_ => break,
@@ -435,19 +413,9 @@ fn spawn_stub_server() -> String {
}
let head = String::from_utf8_lossy(&buffer[..read_total]).to_string();
let request_line = head.lines().next().unwrap_or_default().to_string();
let body_start = buffer[..read_total].windows(4).position(|w| w == b"\r\n\r\n")
.map(|index| index + 4).unwrap_or(read_total);
let request_body = &buffer[body_start..read_total];
let filename = request_line.split_whitespace().nth(1).unwrap_or("")
.split('?').next().unwrap_or("").rsplit('/').next().unwrap_or("").to_string();
let maybe_uploaded = stub_traffic().lock().unwrap().uploads.get(&filename).cloned();
let (status, content_type, body): (&str, &str, Vec<u8>) =
if request_line.starts_with("GET") && maybe_uploaded.is_some() {
let mime = if filename.ends_with(".mov") { "video/quicktime" } else { "video/mp4" };
("200 OK", mime, maybe_uploaded.unwrap())
} else if request_line.starts_with("GET") {
if request_line.starts_with("GET") {
match extract_fixture_duration_millis(&request_line) {
Some(millis) => ("200 OK", "video/mp4", fixture_video_bytes(millis)),
// URL-ingested uploads land under the `video_` bucket prefix
@@ -460,17 +428,12 @@ fn spawn_stub_server() -> String {
None => ("404 Not Found", "text/plain", b"not found".to_vec()),
}
} else if request_line.starts_with("PUT") {
stub_traffic().lock().unwrap().uploads.insert(filename, request_body.to_vec());
("200 OK", "text/plain", Vec::new())
} else if request_line.contains("uploads.signedUploadUrl") {
let extension = if head.contains("mov") { "mov" } else { "mp4" };
let upload_url = format!("{base_url}/upload-target/materials/test/fixture.{extension}");
let upload_url = format!("{base_url}/upload-target/materials/test/fixture.mp4");
let json = format!(r#"[{{"result":{{"data":{{"json":"{upload_url}"}}}}}}]"#);
("200 OK", "application/json", json.into_bytes())
} else {
if let Ok(payload) = serde_json::from_slice(request_body) {
stub_traffic().lock().unwrap().workflows.push(payload);
}
let order_number = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed);
let json = format!(
r#"[{{"result":{{"data":{{"json":{{"taskId":"task_test_{process_prefix:016x}_{order_number:04}","orderId":"ord_test_{process_prefix:016x}_{order_number:04}","violationWarning":false}}}}}}}}]"#,
@@ -582,6 +545,7 @@ fn build_test_server_state(pool: MySqlPool, media_cdn_override_url: String) -> S
auto_gc_bucket_client: LegacyBucketClient,
redis_rate_limiters: crate::state::server_state::RedisRateLimiters,
}
use std::sync::OnceLock;
static DUMMIES: OnceLock<ExpensiveDummies> = OnceLock::new();
let dummies = DUMMIES.get_or_init(|| retry_keychain_flake(|| {
// Bucket uploads (e.g. URL-input ingestion) PUT to the stub, which
@@ -1024,7 +988,6 @@ pub fn to_omni_api_request(request: OmniGenVideoCostAndGenerateRequest) -> OmniA
resolution: request.resolution,
aspect_ratio: request.aspect_ratio,
bitrate: request.bitrate,
maybe_output_format: request.maybe_output_format,
quality: request.quality,
duration_seconds: request.duration_seconds,
video_batch_count: request.video_batch_count,
@@ -42,15 +42,6 @@ pub async fn omni_gen_video_models_handler(
remove_models(&mut response, &MINIMAX_GATED_MODELS);
}
if !can_use_quicktime(&http_request, &server_state).await? {
for model in &mut response.models {
if matches!(model.model, CommonVideoModel::Seedance2p5 | CommonVideoModel::Seedance2p5Ultra) {
model.output_format_options = None;
model.output_format_default = None;
}
}
}
Ok(Json(response))
}
@@ -86,34 +77,6 @@ pub async fn can_see_minimax(
Ok(can_use_minimax || can_use_minimax_priority)
}
/// Whether the (optional) logged-in user holds the QuickTime feature flag.
/// Anonymous requests do not expose the output-format options.
pub async fn can_use_quicktime(
http_request: &HttpRequest,
server_state: &ServerState,
) -> Result<bool, CommonWebError> {
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let maybe_user_session = server_state
.session_checker
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
CommonWebError::from(e)
})?;
let user_session = match maybe_user_session {
Some(session) => session,
None => return Ok(false),
};
let user_feature_flags =
UserSessionFeatureFlags::new(user_session.maybe_feature_flags.as_deref());
Ok(user_feature_flags.can_use_quicktime())
}
/// Strip the given models from both the top-level model list and every
/// provider's model list.
fn remove_models(response: &mut OmniGenVideoModelsResponse, hidden_models: &[CommonVideoModel]) {
@@ -77,7 +77,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -57,7 +57,6 @@ mod tests {
resolution: None,
aspect_ratio: None,
bitrate: None,
maybe_output_format: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
@@ -109,10 +109,6 @@ impl UserSessionFeatureFlags {
pub fn can_use_minimax_priority(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::MinimaxPriority)
}
pub fn can_use_quicktime(&self) -> bool {
self.feature_flags.contains(&UserFeatureFlag::CanUseQuicktime)
}
}
#[cfg(test)]
+1 -9
View File
@@ -35,19 +35,11 @@ environment, install dependencies, and free the dev port first):
./script/website/unix_frontend_webapp_dev.sh # artcraft-webapp (user dashboard), port 4201
```
Run the webapp from **artcraft-services**, not the separate `artcraft` desktop
checkout. The webapp launcher also works from another directory when invoked
by its full or relative path:
```bash
../artcraft-services/script/website/unix_frontend_webapp_dev.sh
```
Or directly from this directory:
```bash
nx dev artcraft-website
npx nx dev @frontend/artcraft-webapp
nx dev artcraft-webapp
```
## Building
@@ -14,9 +14,6 @@ import {
AUDIO_FILE_ACCEPT,
AUDIO_FILE_TYPE_ERROR,
isAudioFile,
getVideoFileAccept,
getVideoFileTypeError,
isVideoFile,
} from "@storyteller/ui-promptbox";
import { toast } from "../toast/toast";
import { AddButton } from "./ImagePromptRow";
@@ -35,7 +32,6 @@ interface MediaReferenceRowProps {
onReferenceVideosChange: (videos: RefVideo[]) => void;
maxVideoCount: number;
maxVideoRefDuration: number;
allowQuicktimeUploads?: boolean;
onPickVideoFromLibrary?: () => void;
referenceAudios: RefAudio[];
onReferenceAudiosChange: (audios: RefAudio[]) => void;
@@ -52,7 +48,6 @@ export const MediaReferenceRow = ({
onReferenceVideosChange,
maxVideoCount,
maxVideoRefDuration,
allowQuicktimeUploads = false,
onPickVideoFromLibrary,
referenceAudios,
onReferenceAudiosChange,
@@ -93,10 +88,6 @@ export const MediaReferenceRow = ({
const baseVideos = [...referenceVideos];
const file = files[0];
if (!isVideoFile(file, allowQuicktimeUploads)) {
toast.error(getVideoFileTypeError(allowQuicktimeUploads));
return;
}
const duration = await getVideoDuration(file);
if (duration <= 0) {
@@ -219,7 +210,7 @@ export const MediaReferenceRow = ({
type="file"
ref={videoInputRef}
className="hidden"
accept={getVideoFileAccept(allowQuicktimeUploads)}
accept="video/*"
onChange={handleVideoUpload}
/>
<input
@@ -95,7 +95,6 @@ interface PromptBoxProps {
onReferenceVideosChange?: (videos: RefVideo[]) => void;
maxVideoCount?: number;
maxVideoRefDuration?: number;
allowQuicktimeUploads?: boolean;
audioRefsSupported?: boolean;
referenceAudios?: RefAudio[];
onReferenceAudiosChange?: (audios: RefAudio[]) => void;
@@ -163,7 +162,6 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
onReferenceVideosChange,
maxVideoCount = 3,
maxVideoRefDuration = 30,
allowQuicktimeUploads = false,
audioRefsSupported,
referenceAudios = [],
onReferenceAudiosChange,
@@ -223,7 +221,6 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
setReferenceVideos: onReferenceVideosChange,
maxVideos: maxVideoCount,
maxVideoTotalSec: maxVideoRefDuration,
allowQuicktimeUploads,
referenceAudios,
setReferenceAudios: onReferenceAudiosChange,
maxAudios: maxAudioCount,
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { ImagesIcon, MusicIcon, VideoIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { isVideoFile } from "@storyteller/ui-promptbox";
import { toast } from "../toast/toast";
// ── Types ───────────────────────────────────────────────────────────────
@@ -141,7 +140,7 @@ export function usePromptBoxDrop({
const rejectedKinds = new Set<DropKind>();
let unknownCount = 0;
for (const file of files) {
const kind = kindOfMime(file.type) ?? (isVideoFile(file, true) ? "video" : null);
const kind = kindOfMime(file.type);
if (kind === null) {
unknownCount++;
} else if (!acceptsKind(kind)) {
@@ -34,7 +34,6 @@ export type VideoUiState = {
duration: number | null;
resolution: string | null;
bitrate: string | null;
outputFormat: string | null;
generateWithSound: boolean;
inputMode: VideoInputMode;
numVideos: number;
@@ -80,7 +79,6 @@ const DEFAULT_UI: VideoUiState = {
duration: null,
resolution: null,
bitrate: null,
outputFormat: null,
generateWithSound: false,
inputMode: "reference",
numVideos: 1,
@@ -6,7 +6,7 @@ import {
InfoIcon,
SparklesIcon,
} from "lucide-react";
import { CharactersApi, FilterMediaClasses, USER_FEATURE_FLAGS } from "@storyteller/api";
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
import type { OmniGenVideoModelInfo } from "@storyteller/api";
import { Button, ToggleButton } from "@storyteller/ui-button";
import {
@@ -147,11 +147,6 @@ const LABEL_TO_BITRATE: Record<string, string> = Object.fromEntries(
Object.entries(BITRATE_LABELS).map(([k, v]) => [v, k]),
);
const OUTPUT_FORMAT_LABELS: Record<string, string> = {
mp4: "MP4",
mov: "MOV",
};
// ── Model lookup ─────────────────────────────────────────────────────────
let _modelLookup = new Map<string, OmniGenVideoModelInfo>();
@@ -284,9 +279,6 @@ function resolveDurationForModel(
export default function CreateVideo() {
const { user, authChecked } = useAuthCheck();
const canUseQuicktime = !!user?.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.CAN_USE_QUICKTIME,
);
const { loggedIn, openSignupCta } = useSignupCta();
const openInsufficientCredits = useInsufficientCredits();
const { promptBoxRef, promptHeight } = usePromptHeight();
@@ -371,11 +363,6 @@ export default function CreateVideo() {
(v: string | null) => setUi({ bitrate: v }),
[setUi],
);
const outputFormat = resolveModelOption(
ui.outputFormat ?? undefined,
selectedModel?.output_format_options,
selectedModel?.output_format_default,
);
const generateWithSound = ui.generateWithSound;
const numVideos = resolveModelCount(
ui.numVideos,
@@ -499,8 +486,6 @@ export default function CreateVideo() {
const hasResolutionOptions =
(selectedModel?.resolution_options?.length ?? 0) > 0;
const hasBitrateOptions = (selectedModel?.bitrate_options?.length ?? 0) > 0;
const hasOutputFormatOptions =
(selectedModel?.output_format_options?.length ?? 0) > 0;
const hasSound = !!selectedModel?.show_generate_with_sound_toggle;
const supportsImagePrompts =
!!selectedModel?.starting_keyframe_supported ||
@@ -762,17 +747,6 @@ export default function CreateVideo() {
: null,
[selectedModel, bitrate],
);
const outputFormatItems = useMemo(
(): PopoverItem[] | null =>
selectedModel?.output_format_options?.length
? selectedModel.output_format_options.map((format) => ({
label: OUTPUT_FORMAT_LABELS[format] ?? format,
selected: format === outputFormat,
action: format,
}))
: null,
[selectedModel?.output_format_options, outputFormat],
);
const inputModeItems = useMemo(
(): PopoverItem[] | null =>
supportsRefMode
@@ -1028,13 +1002,6 @@ export default function CreateVideo() {
[setBitrate],
);
const handleOutputFormatChange = useCallback(
(item: PopoverItem) => {
if (item.action) setUi({ outputFormat: item.action });
},
[setUi],
);
const handleInputModeChange = useCallback(
(item: PopoverItem) => {
const mode = item.label === "Omni Reference" ? "reference" : "keyframe";
@@ -1358,7 +1325,6 @@ export default function CreateVideo() {
bitrate: hasBitrateOptions
? (bitrate ?? selectedModel.bitrate_default ?? undefined)
: undefined,
outputFormat: hasOutputFormatOptions ? outputFormat : undefined,
generateAudio: hasSound ? generateWithSound : undefined,
startFrameImageMediaToken: startFrameToken?.length
? startFrameToken
@@ -1461,10 +1427,8 @@ export default function CreateVideo() {
resolution,
bitrate,
generateWithSound,
outputFormat,
hasResolutionOptions,
hasBitrateOptions,
hasOutputFormatOptions,
hasSound,
supportsImagePrompts,
hasEndFrame,
@@ -1490,7 +1454,6 @@ export default function CreateVideo() {
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={maxVideoRefs}
maxVideoRefDuration={maxVideoRefDuration}
allowQuicktimeUploads={canUseQuicktime}
onPickVideoFromLibrary={() => setIsVideoRefPickerOpen(true)}
referenceAudios={referenceAudios}
onReferenceAudiosChange={setReferenceAudios}
@@ -1506,12 +1469,10 @@ export default function CreateVideo() {
const activeResolutionLabel = resolutionItems?.find((i) => i.selected)?.label;
const activeBitrateLabel = bitrateItems?.find((i) => i.selected)?.label;
const activeOutputFormatLabel = outputFormatItems?.find((i) => i.selected)?.label;
const outputSummary = [
`${effectiveDuration}s`,
activeResolutionLabel,
activeBitrateLabel,
activeOutputFormatLabel,
]
.filter(Boolean)
.join(" · ");
@@ -1621,7 +1582,7 @@ export default function CreateVideo() {
/>
</div>
)}
{(durationRange || resolutionItems || bitrateItems || outputFormatItems) && (
{(durationRange || resolutionItems || bitrateItems) && (
<>
<MobileFieldButton
label="Output"
@@ -1676,14 +1637,6 @@ export default function CreateVideo() {
/>
</DrawerSection>
)}
{outputFormatItems && (
<DrawerSection label="Output Format">
<DrawerOptionList
items={outputFormatItems}
onSelect={handleOutputFormatChange}
/>
</DrawerSection>
)}
</SettingsDrawer>
</>
)}
@@ -1852,7 +1805,6 @@ export default function CreateVideo() {
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={maxVideoRefs}
maxVideoRefDuration={maxVideoRefDuration}
allowQuicktimeUploads={canUseQuicktime}
onPickVideoFromLibrary={
supportsVideoRefs
? () => setIsVideoRefPickerOpen(true)
@@ -1934,21 +1886,6 @@ export default function CreateVideo() {
/>
</Tooltip>
)}
{outputFormatItems && (
<Tooltip
content="Output Format"
position="top"
className="z-50"
closeOnClick
>
<PopoverMenu
items={outputFormatItems}
onSelect={handleOutputFormatChange}
mode="toggle"
panelTitle="Output Format"
/>
</Tooltip>
)}
{durationRange && (
<Tooltip content="Duration" position="top" className="z-50">
<PopoverMenu
@@ -1,46 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { enqueueVideoGeneration } from "./generate-video-api";
const { generateVideo } = vi.hoisted(() => ({ generateVideo: vi.fn() }));
vi.mock("@storyteller/api", () => ({
OmniGenApi: class {
generateVideo = generateVideo;
},
JobsApi: class {},
HttpApiError: class extends Error {},
}));
describe("enqueueVideoGeneration output format", () => {
beforeEach(() => {
generateVideo.mockReset().mockResolvedValue({
success: true,
inference_job_token: "jinf_video",
});
});
it.each(["seedance_2p5", "seedance_2p5_u"])(
"%s sends the selected output_format",
async (model) => {
for (const outputFormat of ["mp4", "mov"]) {
const result = await enqueueVideoGeneration({
model,
prompt: "A running corgi",
outputFormat,
});
expect(result).toEqual({ success: true, jobToken: "jinf_video" });
expect(generateVideo).toHaveBeenLastCalledWith(expect.objectContaining({
model,
output_format: outputFormat,
}));
}
},
);
it("sends null when no output format is selected", async () => {
await enqueueVideoGeneration({ model: "seedance_2p0", prompt: "A running corgi" });
expect(generateVideo).toHaveBeenCalledWith(expect.objectContaining({
output_format: null,
}));
});
});
@@ -12,7 +12,6 @@ export interface GenerateVideoParams {
duration?: number;
resolution?: string;
bitrate?: string;
outputFormat?: string;
generateAudio?: boolean;
startFrameImageMediaToken?: string;
endFrameImageMediaToken?: string;
@@ -39,7 +38,6 @@ export async function enqueueVideoGeneration(
aspect_ratio: params.aspectRatio ?? null,
resolution: params.resolution ?? null,
bitrate: params.bitrate ?? null,
output_format: params.outputFormat ?? null,
duration_seconds: params.duration ?? null,
generate_audio: params.generateAudio ?? null,
video_batch_count: params.numVideos ?? 1,
+52 -40
View File
@@ -15,7 +15,7 @@
"gsap": "3.15.0",
"lenis": "1.3.23",
"lucide-react": "^1.31.0",
"next": "16.2.6",
"next": "^16.2.12",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "^3.6.0",
@@ -608,15 +608,15 @@
}
},
"node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz",
"integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz",
"integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==",
"cpu": [
"arm64"
],
@@ -630,9 +630,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz",
"integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==",
"cpu": [
"x64"
],
@@ -646,12 +646,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz",
"integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -662,12 +665,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz",
"integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -678,12 +684,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz",
"integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -694,12 +703,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz",
"integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -710,9 +722,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz",
"integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==",
"cpu": [
"arm64"
],
@@ -726,9 +738,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz",
"integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==",
"cpu": [
"x64"
],
@@ -2209,12 +2221,12 @@
}
},
"node_modules/next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
"version": "16.2.12",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz",
"integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.6",
"@next/env": "16.2.12",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -2228,14 +2240,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.6",
"@next/swc-darwin-x64": "16.2.6",
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-arm64-musl": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6",
"@next/swc-linux-x64-musl": "16.2.6",
"@next/swc-win32-arm64-msvc": "16.2.6",
"@next/swc-win32-x64-msvc": "16.2.6",
"@next/swc-darwin-arm64": "16.2.12",
"@next/swc-darwin-x64": "16.2.12",
"@next/swc-linux-arm64-gnu": "16.2.12",
"@next/swc-linux-arm64-musl": "16.2.12",
"@next/swc-linux-x64-gnu": "16.2.12",
"@next/swc-linux-x64-musl": "16.2.12",
"@next/swc-win32-arm64-msvc": "16.2.12",
"@next/swc-win32-x64-msvc": "16.2.12",
"sharp": "^0.34.5"
},
"peerDependencies": {
@@ -19,7 +19,7 @@
"gsap": "3.15.0",
"lenis": "1.3.23",
"lucide-react": "^1.31.0",
"next": "16.2.6",
"next": "^16.2.12",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "^3.6.0",
@@ -101,11 +101,13 @@ const JSON_LD = {
// full word flashes for the frames between paint and hydration, then
// snaps into the logo-only formation start.
//
// Also reloads on back/forward-cache restores: Firefox freezes the JS heap
// and discards the WebGL context, so a restored page shows a dead galaxy
// canvas and stalled instruments. The listener is attached here (pre-paint)
// so it lives inside the frozen page and fires on the restore; the intro is
// seen-flagged by then, so the reload comes back settled.
// Also reloads on back/forward-cache restores (pageshow with persisted):
// a bfcache-restored page thaws with its WebGL context discarded in some
// browsers, leaving a dead galaxy canvas — a reload swaps it for a clean
// load, and the intro is seen-flagged by then so it comes back settled.
// (The Firefox back-navigation hydration failure was a different bug:
// Next's router crashed on back_forward loads — "Router action dispatched
// before initialization" — fixed by the Next 16.2.12 patch bump.)
const THEME_SCRIPT = `(function(){try{var t=localStorage.getItem("artcraft-theme");if(t==="light"||t==="dark"){document.documentElement.setAttribute("data-theme",t);}}catch(e){}try{if(!matchMedia("(prefers-reduced-motion: reduce)").matches){document.documentElement.setAttribute("data-intro","");}}catch(e){}addEventListener("pageshow",function(e){if(e.persisted)location.reload();});})();`;
export default function RootLayout({
@@ -362,9 +362,9 @@ export const galaxyPointerTuner = defineTunables(
boostScale: {
label: "Boost scale ×",
min: 1.2,
max: 3,
max: 4,
step: 0.1,
default: 2,
default: 3,
info: "Scale multiplier a click toggles onto the held card. Allowed to overlap neighbors; scales back down on leaving the card, and the next hold needs a fresh click.",
},
boostTau: {
-3
View File
@@ -55,7 +55,6 @@ export interface OmniGenVideoRequest {
resolution?: string | null;
quality?: string | null;
bitrate?: string | null;
output_format?: string | null;
duration_seconds?: number | null;
video_batch_count?: number | null;
generate_audio?: boolean | null;
@@ -225,8 +224,6 @@ export interface OmniGenVideoModelInfo {
default_quality: string | null;
bitrate_options: string[] | null;
bitrate_default: string | null;
output_format_options?: string[] | null;
output_format_default?: string | null;
duration_seconds_options: number[] | null;
duration_seconds_default: number | null;
duration_seconds_min: number | null;
@@ -1,6 +1,5 @@
export enum USER_FEATURE_FLAGS {
API_KEY = "api_key",
CAN_USE_QUICKTIME = "use_qt",
EXPLORE_MEDIA = "explore_media",
REFERRALS = "referrals",
STUDIO = "studio",
@@ -1,6 +1,5 @@
export * from "./lib/common/AspectRatioIcon";
export * from "./lib/common/audioFiles";
export * from "./lib/common/videoFiles";
export * from "./lib/common/imagePreview";
export * from "./lib/PromptBox2D";
export * from "./lib/PromptBox3D";
@@ -1,27 +0,0 @@
import { isAudioFile } from "./audioFiles";
const MP4_FILE_ACCEPT = "video/mp4,.mp4";
const QUICKTIME_FILE_ACCEPT = "video/quicktime,.mov,.qt";
export function getVideoFileAccept(allowQuicktime: boolean): string {
return allowQuicktime
? `${MP4_FILE_ACCEPT},${QUICKTIME_FILE_ACCEPT}`
: MP4_FILE_ACCEPT;
}
export function getVideoFileTypeError(allowQuicktime: boolean): string {
return allowQuicktime
? "Please choose an MP4 or QuickTime (MOV) video."
: "Please choose an MP4 video.";
}
// File pickers and drops can supply an empty or generic MIME type for MOV.
// The server verifies the container from the file bytes before accepting it.
export function isVideoFile(file: File, allowQuicktime: boolean): boolean {
if (isAudioFile(file)) return false;
const extension = file.name.split(".").pop()?.toLowerCase();
if (file.type === "video/quicktime" || extension === "mov" || extension === "qt") {
return allowQuicktime;
}
return file.type === "video/mp4" || extension === "mp4";
}
@@ -1,105 +0,0 @@
import { act, cleanup, fireEvent, render, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "@storyteller/ui-toaster";
import { useDeckMedia } from "./useDeckMedia";
vi.mock("@storyteller/ui-gallery-modal", () => ({ GalleryModal: () => null }));
vi.mock("@storyteller/api", () => ({ downloadFileFromUrl: vi.fn() }));
vi.mock("@storyteller/ui-toaster", () => ({ toast: { error: vi.fn() } }));
describe("QuickTime reference uploads", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: vi.fn(() => "blob:video") });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: vi.fn() });
vi.spyOn(HTMLMediaElement.prototype, "duration", "get").mockReturnValue(5);
vi.spyOn(HTMLMediaElement.prototype, "src", "set").mockImplementation(function (this: HTMLMediaElement) {
queueMicrotask(() => this.dispatchEvent(new Event("loadedmetadata")));
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it.each([false, true])("sets both picker filters when QuickTime is enabled=%s", (enabled) => {
const { result } = setup(enabled);
const { container } = render(<>{result.current.fileInputs}</>);
const videoInputs = Array.from(container.querySelectorAll<HTMLInputElement>("input"))
.filter((input) => input.accept.includes("video/mp4"));
expect(videoInputs).toHaveLength(2);
for (const input of videoInputs) {
expect(input.accept.includes("video/quicktime")).toBe(enabled);
expect(input.accept.includes(".mov")).toBe(enabled);
}
});
it("blocks dropped MOV files by default, including when mislabeled as MP4", async () => {
const { result, uploadVideo } = setup();
for (const type of ["video/quicktime", "", "video/mp4"]) {
await act(async () => {
await result.current.processVideoFiles([new File(["video"], "reference.MOV", { type })]);
});
}
expect(uploadVideo).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("Please choose an MP4 video.", { id: "video-ref-type" });
});
it.each(["video/quicktime", "", "application/octet-stream"])(
"uploads MOV files with MIME %s when enabled",
async (type) => {
const { result, uploadVideo } = setup(true);
const file = new File(["video"], "reference.MOV", { type });
await act(async () => {
await result.current.processVideoFiles([file]);
});
expect(uploadVideo).toHaveBeenCalledWith(expect.objectContaining({ assetFile: file }));
},
);
it("routes a MOV without a MIME type through the combined picker", async () => {
const { result, uploadVideo } = setup(true);
const { container } = render(<>{result.current.fileInputs}</>);
const input = container.querySelector<HTMLInputElement>('input[accept^="image/*,video/mp4"]')!;
const file = new File(["video"], "reference.mov");
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(uploadVideo).toHaveBeenCalledWith(expect.objectContaining({ assetFile: file }));
});
});
it("keeps MP4 uploads available without the feature", async () => {
const { result, uploadVideo } = setup();
const file = new File(["video"], "reference.mp4", { type: "video/mp4" });
await act(async () => {
await result.current.processVideoFiles([file]);
});
expect(uploadVideo).toHaveBeenCalledWith(expect.objectContaining({ assetFile: file }));
});
it("still enforces video duration limits for QuickTime", async () => {
vi.spyOn(HTMLMediaElement.prototype, "duration", "get").mockReturnValue(31);
const { result, uploadVideo } = setup(true);
await act(async () => {
await result.current.processVideoFiles([new File(["video"], "reference.mov", { type: "video/quicktime" })]);
});
expect(uploadVideo).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("Total video duration cannot exceed 30s", { id: "video-ref-limit" });
});
});
function setup(allowQuicktimeUploads?: boolean) {
const uploadVideo = vi.fn(async () => {});
const hook = renderHook(() => useDeckMedia({
referenceImages: [],
setReferenceImages: vi.fn(),
maxImages: 3,
referenceVideos: [],
setReferenceVideos: vi.fn(),
maxVideoTotalSec: 30,
allowQuicktimeUploads,
uploadVideo,
}));
return { ...hook, uploadVideo };
}
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more