diff --git a/src/commands/upload/dry_run.rs b/src/commands/upload/dry_run.rs index 20f883b..d1d7b9f 100644 --- a/src/commands/upload/dry_run.rs +++ b/src/commands/upload/dry_run.rs @@ -3,7 +3,6 @@ use std::time::Instant; use crate::auth; use crate::media::ffmpeg::get_media_duration; -use crate::media::metadata::extract_media_metadata; use crate::media::media_file_type::{is_audio_file, is_image_file, is_metadata_file}; use crate::media::transcode::{has_video_streams, is_mxf_file}; use crate::media::video_file_ext::has_video_ext; @@ -19,6 +18,7 @@ pub fn run_dry_run( auth_bearer: &Option, force_upload: bool, disable_description_generation: bool, + local_encoding: bool, ) -> Result<(), String> { let bearer_env = auth_bearer .clone() @@ -255,22 +255,18 @@ pub fn run_dry_run( .unwrap_or_default() .to_string_lossy() .to_string(); - let related_umids: Vec = extract_media_metadata(file_path) - .map(|m| m.file_package_umids.iter().map(|u| u.umid.clone()).collect()) - .unwrap_or_default(); let generate_time_based_media_description = !disable_description_generation; output::plain(format!(" file: {}", file_name)); + output::plain(" cutter_sensitivity: 0.2"); output::plain(format!( " generate_time_based_media_description: {}", generate_time_based_media_description )); - if related_umids.is_empty() { - output::plain(" related_umid_for_master_clip: null"); + output::plain(" override_entity_ids: omitted"); + if local_encoding { + output::plain(" generate_proxy: []"); } else { - output::plain(" related_umid_for_master_clip:"); - for umid in &related_umids { - output::plain(format!(" - {}", umid)); - } + output::plain(" generate_proxy: omitted"); } output::plain(""); } diff --git a/src/commands/upload/main.rs b/src/commands/upload/main.rs index 901d8b8..33ba94d 100644 --- a/src/commands/upload/main.rs +++ b/src/commands/upload/main.rs @@ -1,7 +1,7 @@ use clap::{Args, Subcommand}; use regex::Regex; -use std::fs::File; use std::collections::HashMap; +use std::fs::File; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; @@ -14,8 +14,8 @@ use walkdir::WalkDir; use crate::auth; use crate::commands::api_config; use crate::media::ffmpeg::ensure_ffmpeg_available; +use crate::media::media_file_type::{is_audio_file, is_image_file}; use crate::media::metadata::{extract_media_metadata, get_ffprobe_json}; -use crate::media::media_file_type::is_audio_file; use crate::media::transcode::{ convert_to_mp3, create_rendition, has_video_streams, is_mxf_file, normalize_audio_to_mp3, Preset, RenditionDefinition, @@ -30,9 +30,8 @@ use tokio::sync::mpsc as tokio_mpsc; use tellers_api_client::apis::accepts_api_key_api as api; use tellers_api_client::apis::configuration::Configuration; -use tellers_api_client::models::process_assets_request::GenerateProxy; use tellers_api_client::models::{ - AssetUploadRequest, AssetUploadResponse, CreateFolderRequest, ProcessAssetsRequest, + AssetUploadRequest, AssetUploadResponse, CreateFolderRequest, FileType, ProcessAssetsRequest, SourceFileInfo, }; @@ -95,10 +94,6 @@ pub struct UploadCmdArgs { #[arg(long, default_value_t = false)] pub dry_run: bool, - /// Proxy heights to request from the server after upload (e.g. 720, 1080). Used when --local-encoding is false (omit → default 720; use empty --generate-proxy for none). When --local-encoding is true, omit → no server proxies (qualities control local encoding). - #[arg(long, value_delimiter = ',', num_args = 0.., value_parser = parse_generate_proxy)] - pub generate_proxy: Option>, - #[arg(long, default_value_t = false)] pub disable_description_generation: bool, @@ -163,20 +158,6 @@ struct MachineAssetStatus { status: String, } -fn parse_generate_proxy(s: &str) -> Result { - match s.trim() { - "360" => Ok(GenerateProxy::Variant360), - "480" => Ok(GenerateProxy::Variant480), - "720" => Ok(GenerateProxy::Variant720), - "1080" => Ok(GenerateProxy::Variant1080), - "2160" => Ok(GenerateProxy::Variant2160), - _ => Err(format!( - "generate_proxy must be one of 360, 480, 720, 1080, 2160, got '{}'", - s - )), - } -} - fn has_extension(file_path: &PathBuf, extensions: &[String]) -> bool { if extensions.is_empty() { return true; @@ -288,19 +269,20 @@ fn run_recreate_filesystem(args: RecreateFilesystemArgs) -> Result<(), String> { let api_key = api_config::get_api_key(None)?; let bearer_header = api_config::get_bearer_header(None); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| format!("failed to start runtime: {}", e))?; + let rt = + tokio::runtime::Runtime::new().map_err(|e| format!("failed to start runtime: {}", e))?; for folder_path in in_app_paths { let mut req = CreateFolderRequest::new(); req.path = Some(Some(folder_path.clone())); - let response = rt.block_on(api::create_folder_asset_folder_post( - &cfg, - req, - Some(api_key.as_str()), - bearer_header.as_deref(), - )) - .map_err(|e| e.to_string())?; + let response = rt + .block_on(api::create_folder_asset_folder_post( + &cfg, + req, + Some(api_key.as_str()), + bearer_header.as_deref(), + )) + .map_err(|e| e.to_string())?; println!("{}", response.path); } Ok(()) @@ -385,8 +367,7 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { .iter() .map(|pattern| Regex::new(pattern)) .collect(); - let regex_patterns = regex_patterns - .map_err(|e| format!("invalid regex pattern: {}", e))?; + let regex_patterns = regex_patterns.map_err(|e| format!("invalid regex pattern: {}", e))?; let before_count = original_files.len(); original_files.retain(|file_path| matches_regex(file_path, ®ex_patterns)); @@ -414,6 +395,7 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { &args.auth_bearer, args.force_upload, args.disable_description_generation, + args.local_encoding, ); } @@ -424,8 +406,10 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { } let bearer_header_for_auth = api_config::get_bearer_header(args.auth_bearer.clone()); - let user_id = - auth::get_user_id_from_bearer_with_logging(bearer_header_for_auth.as_deref(), !args.machine_readable); + let user_id = auth::get_user_id_from_bearer_with_logging( + bearer_header_for_auth.as_deref(), + !args.machine_readable, + ); if !args.force_upload { let before = original_files.len(); @@ -488,12 +472,15 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { match wait_result { Ok(outcome) => { if args.machine_readable { - print_machine_readable_result(&outcome, Some(script_start.elapsed().as_secs())); + print_machine_readable_result( + &outcome, + Some(script_start.elapsed().as_secs()), + ); } if !outcome.success { - return Err(outcome - .error - .unwrap_or_else(|| "one or more assets failed watched tasks".to_string())); + return Err(outcome.error.unwrap_or_else(|| { + "one or more assets failed watched tasks".to_string() + })); } } Err(e) => { @@ -540,10 +527,6 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { }); } - // local_encoding false path: default generate_proxy to 720 when omitted - let effective_generate_proxy = - args.generate_proxy.clone().or_else(|| Some(vec![GenerateProxy::Variant720])); - let base_dir = base_dir.clone(); let in_app_path = args.in_app_path.clone(); @@ -560,11 +543,12 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { )?) }; let progress_handle = progress.as_ref().map(|p| p.clone_handle()); - let render_handle = if let (Some(p), Some(ph)) = (progress.as_mut(), progress_handle.as_ref()) { - Some(p.start_render_loop(ph.clone())) - } else { - None - }; + let render_handle = + if let (Some(p), Some(ph)) = (progress.as_mut(), progress_handle.as_ref()) { + Some(p.start_render_loop(ph.clone())) + } else { + None + }; if let Some(ph) = progress_handle.as_ref() { let _ = ph.add_info(format!( @@ -586,7 +570,6 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { &api_key, bearer_header.as_deref(), args.disable_description_generation, - effective_generate_proxy.as_ref(), ) .await; @@ -628,9 +611,9 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { ); } if !outcome.success { - return Err(outcome - .error - .unwrap_or_else(|| "one or more assets failed watched tasks".to_string())); + return Err(outcome.error.unwrap_or_else(|| { + "one or more assets failed watched tasks".to_string() + })); } } Err(e) => { @@ -660,9 +643,16 @@ fn run_upload(args: UploadCmdArgs) -> Result<(), String> { fn work_item_file_name(w: &DownscaleWork) -> String { let p = match w { - DownscaleWork::MxfVideo(p) | DownscaleWork::MxfAudio(p) | DownscaleWork::Video(p) | DownscaleWork::Audio(p) | DownscaleWork::Passthrough(p) => p, + DownscaleWork::MxfVideo(p) + | DownscaleWork::MxfAudio(p) + | DownscaleWork::Video(p) + | DownscaleWork::Audio(p) + | DownscaleWork::Passthrough(p) => p, }; - p.file_name().unwrap_or_default().to_string_lossy().to_string() + p.file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() } fn do_one_downscale( @@ -682,7 +672,10 @@ fn do_one_downscale( let mut progress_cb = |pct: f64| progress_handle.set_downscale_current_pct(Some(pct)); let info_cb = |msg: &str| progress_handle.add_info(msg); match create_rendition(&original_path, def, Some(&mut progress_cb), Some(&info_cb)) { - Ok(upload_path) => FileToUpload { upload_path, original_path }, + Ok(upload_path) => FileToUpload { + upload_path, + original_path, + }, Err(e) => { let _ = progress_handle.add_error(format!( "Downscale failed for {}: {}", @@ -697,7 +690,10 @@ fn do_one_downscale( let mut progress_cb = |pct: f64| progress_handle.set_downscale_current_pct(Some(pct)); let info_cb = |msg: &str| progress_handle.add_info(msg); match convert_to_mp3(&original_path, None, Some(&mut progress_cb), Some(&info_cb)) { - Ok(upload_path) => FileToUpload { upload_path, original_path }, + Ok(upload_path) => FileToUpload { + upload_path, + original_path, + }, Err(e) => { let _ = progress_handle.add_error(format!( "MXF to MP3 failed for {}: {}", @@ -708,46 +704,54 @@ fn do_one_downscale( } } } - DownscaleWork::Video(original_path) => { - let def = RenditionDefinition { - quality: Some(qualities[0]), - preset, - crf: None, - audio_bitrate: None, - }; - let mut progress_cb = |pct: f64| progress_handle.set_downscale_current_pct(Some(pct)); - let info_cb = |msg: &str| progress_handle.add_info(msg); - match create_rendition(&original_path, def, Some(&mut progress_cb), Some(&info_cb)) { - Ok(upload_path) => FileToUpload { upload_path, original_path }, - Err(e) => { - let _ = progress_handle.add_error(format!( - "Downscale failed for {}: {}", - original_path.display(), - e - )); - return Ok(None); - } - } + DownscaleWork::Video(original_path) => { + let def = RenditionDefinition { + quality: Some(qualities[0]), + preset, + crf: None, + audio_bitrate: None, + }; + let mut progress_cb = |pct: f64| progress_handle.set_downscale_current_pct(Some(pct)); + let info_cb = |msg: &str| progress_handle.add_info(msg); + match create_rendition(&original_path, def, Some(&mut progress_cb), Some(&info_cb)) { + Ok(upload_path) => FileToUpload { + upload_path, + original_path, + }, + Err(e) => { + let _ = progress_handle.add_error(format!( + "Downscale failed for {}: {}", + original_path.display(), + e + )); + return Ok(None); } - DownscaleWork::Audio(original_path) => { - let mut progress_cb = |pct: f64| progress_handle.set_downscale_current_pct(Some(pct)); - let info_cb = |msg: &str| progress_handle.add_info(msg); - match normalize_audio_to_mp3(&original_path, Some(192), Some(&mut progress_cb), Some(&info_cb)) { - Ok(upload_path) => FileToUpload { - upload_path, - original_path, - }, - Err(e) => { - let _ = progress_handle.add_error(format!( - "Audio normalization failed for {}: {}", - original_path.display(), - e - )); - return Ok(None); - } - } + } + } + DownscaleWork::Audio(original_path) => { + let mut progress_cb = |pct: f64| progress_handle.set_downscale_current_pct(Some(pct)); + let info_cb = |msg: &str| progress_handle.add_info(msg); + match normalize_audio_to_mp3( + &original_path, + Some(192), + Some(&mut progress_cb), + Some(&info_cb), + ) { + Ok(upload_path) => FileToUpload { + upload_path, + original_path, }, - DownscaleWork::Passthrough(original_path) => FileToUpload { + Err(e) => { + let _ = progress_handle.add_error(format!( + "Audio normalization failed for {}: {}", + original_path.display(), + e + )); + return Ok(None); + } + } + } + DownscaleWork::Passthrough(original_path) => FileToUpload { upload_path: original_path.clone(), original_path, }, @@ -793,15 +797,18 @@ fn run_two_queue_pipeline( ) -> Result, String> { let (upload_tx, mut upload_rx) = tokio_mpsc::channel::(64); - let mut progress = TwoQueueProgress::new()?; + let mut progress = if args.machine_readable { + TwoQueueProgress::without_terminal() + } else { + TwoQueueProgress::new()? + }; let progress_handle = progress.clone_handle(); progress_handle.set_downscale_queued(work_items.len()); - let downscale_pending_names: Vec = - work_items.iter().map(work_item_file_name).collect(); + let downscale_pending_names: Vec = work_items.iter().map(work_item_file_name).collect(); progress_handle.set_downscale_pending(downscale_pending_names); - let rt = tokio::runtime::Runtime::new() - .map_err(|e| format!("failed to start runtime: {}", e))?; + let rt = + tokio::runtime::Runtime::new().map_err(|e| format!("failed to start runtime: {}", e))?; let base_dir = base_dir.clone(); let qualities = args.qualities.clone(); @@ -815,15 +822,6 @@ fn run_two_queue_pipeline( let user_id = user_id.to_string(); let upload_request_id = upload_request_id.to_string(); let disable_description_generation = args.disable_description_generation; - // local_encoding true → use qualities (no server proxies when omit); local_encoding false → use generate_proxy (default 720 when omit) - let generate_proxy = args.generate_proxy.clone().or_else(|| { - if args.local_encoding { - Some(vec![]) // no server proxies; user relies on qualities - } else { - Some(vec![GenerateProxy::Variant720]) // server path: default 720 - } - }); - let block_result = rt.block_on(async move { // Start render loop inside runtime so tokio::spawn has a current runtime let render_handle = progress.start_render_loop(progress_handle.clone()); @@ -846,9 +844,10 @@ fn run_two_queue_pipeline( progress_producer.set_downscale_current(Some(name)); let ph = progress_producer.clone(); let qual = qualities.clone(); - let file = tokio::task::spawn_blocking(move || do_one_downscale(w, &ph, &qual, preset)) - .await - .map_err(|e| format!("downscale task join: {}", e))??; + let file = + tokio::task::spawn_blocking(move || do_one_downscale(w, &ph, &qual, preset)) + .await + .map_err(|e| format!("downscale task join: {}", e))??; progress_producer.set_downscale_current(None::<&str>); if let Some(f) = file { progress_producer.increment_upload_queued(); @@ -859,7 +858,10 @@ fn run_two_queue_pipeline( .to_string_lossy() .to_string(); progress_producer.push_upload_pending(upload_name); - upload_tx_producer.send(f).await.map_err(|_| "upload channel closed".to_string())?; + upload_tx_producer + .send(f) + .await + .map_err(|_| "upload channel closed".to_string())?; } } drop(upload_tx_producer); @@ -882,12 +884,8 @@ fn run_two_queue_pipeline( .to_string(); progress_handle.set_upload_current(Some(file_name.clone())); - let (req, _upload_id, in_app_path_str, file_related_umids) = - build_single_upload_request( - &file_info, - &base_dir_async, - &in_app_path, - )?; + let (req, _upload_id, in_app_path_str) = + build_single_upload_request(&file_info, &base_dir_async, &in_app_path)?; let responses = request_presigned_urls(&cfg, &vec![req], &api_key, bearer_header).await?; let upload_resp = responses @@ -921,10 +919,8 @@ fn run_two_queue_pipeline( &upload_resp.asset_id, &upload_request_id, ) { - let _ = progress_handle.add_warning(format!( - "Failed to record upload in tracking file: {}", - e - )); + let _ = progress_handle + .add_warning(format!("Failed to record upload in tracking file: {}", e)); } if let Ok(mut guard) = uploaded_asset_ids_consumer.lock() { guard.push(UploadedAssetInfo { @@ -938,27 +934,20 @@ fn run_two_queue_pipeline( vec![upload_resp.clone()], None::, ); + preproc_req.cutter_sensitivity = Some(0.2); preproc_req.generate_time_based_media_description = Some(!disable_description_generation); - // Effective generate_proxy: explicit value, or (local_encoding → none, else → default 720). - preproc_req.generate_proxy = generate_proxy.clone(); - // related_umid_for_master_clip removed in current API; use override_entity_ids if needed - if !file_related_umids.is_empty() { - preproc_req.override_entity_ids = Some(Some(file_related_umids)); - } + preproc_req.generate_proxy = Some(vec![]); let preproc_tasks = api::process_assets_users_assets_preprocess_post( &cfg, preproc_req, None, Some(&api_key), - bearer_header, ) .await .map_err(|e| format!("failed to trigger preprocess: {}", e))?; - let _ = progress_handle.add_success(format!( - "Preprocess tasks queued: {}", - preproc_tasks.len() - )); + let _ = progress_handle + .add_success(format!("Preprocess tasks queued: {}", preproc_tasks.len())); progress_handle.set_upload_current(None::<&str>); progress_handle.set_upload_current_pct(None); @@ -989,7 +978,7 @@ fn build_single_upload_request( file_info: &FileToUpload, base_dir: &PathBuf, in_app_path: &Option, -) -> Result<(AssetUploadRequest, String, String, Vec), String> { +) -> Result<(AssetUploadRequest, String, String), String> { let content_length = std::fs::metadata(&file_info.upload_path) .map_err(|e| format!("failed to stat {}: {}", file_info.upload_path.display(), e))? .len(); @@ -1019,10 +1008,6 @@ fn build_single_upload_request( None, vec![], ); - let related_umids: Vec = umid - .as_ref() - .map(|m| m.file_package_umids.iter().map(|u| u.umid.clone()).collect()) - .unwrap_or_default(); if let Some(metadata) = umid { if let Some(umid_value) = metadata.material_package_umid { source_info.capture_device_umid = Some(Some(umid_value)); @@ -1034,17 +1019,35 @@ fn build_single_upload_request( if is_mxf_file(&file_info.original_path) { if let Ok(Some(probe)) = get_ffprobe_json(&file_info.original_path) { if let serde_json::Value::Object(map) = probe { - source_info.original_ffprobe_metadata = - Some(Some(map.into_iter().collect())); + source_info.original_ffprobe_metadata = Some(Some(map.into_iter().collect())); } } } - let req = AssetUploadRequest::new( + let mut req = AssetUploadRequest::new( i32::try_from(content_length).unwrap_or(i32::MAX), upload_id.clone(), source_info, ); - Ok((req, upload_id, file_in_app_path, related_umids)) + req.file_type = Some(infer_file_type(&file_info.original_path)); + Ok((req, upload_id, file_in_app_path)) +} + +fn infer_file_type(path: &PathBuf) -> FileType { + let ext = path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + + if ext == "aaf" { + FileType::Aaf + } else if is_image_file(path) { + FileType::Image + } else if is_audio_file(path) { + FileType::Audio + } else { + FileType::Video + } } const UPLOAD_URLS_MAX_RETRIES: u32 = 3; @@ -1163,7 +1166,6 @@ async fn upload_with_per_file_presigned( api_key: &str, bearer_opt: Option<&str>, disable_description_generation: bool, - generate_proxy: Option<&Vec>, ) -> Result, String> { let http = Arc::new( reqwest::Client::builder() @@ -1183,7 +1185,6 @@ async fn upload_with_per_file_presigned( let in_app_path = in_app_path.clone(); let upload_request_id = upload_request_id.to_string(); let user_id = user_id.to_string(); - let generate_proxy = generate_proxy.cloned(); for (i, file_info) in files_to_upload.iter().enumerate() { let file_info = file_info.clone(); @@ -1197,7 +1198,6 @@ async fn upload_with_per_file_presigned( let in_app_path_clone = in_app_path.clone(); let upload_request_id_clone = upload_request_id.clone(); let user_id_clone = user_id.clone(); - let generate_proxy_clone = generate_proxy.clone(); let uploaded_asset_ids_clone = Arc::clone(&uploaded_asset_ids); let task_id = i; @@ -1207,7 +1207,7 @@ async fn upload_with_per_file_presigned( .await .map_err(|e| format!("failed to acquire semaphore: {}", e))?; - let (req, _upload_id, in_app_path_str, file_related_umids) = + let (req, _upload_id, in_app_path_str) = build_single_upload_request(&file_info, &base_dir_clone, &in_app_path_clone)?; let file_size = req.content_length.max(0) as u64; @@ -1267,20 +1267,14 @@ async fn upload_with_per_file_presigned( vec![upload_resp], None::, ); + preproc_req.cutter_sensitivity = Some(0.2); preproc_req.generate_time_based_media_description = Some(!disable_description_generation); - if let Some(ref proxy) = generate_proxy_clone { - preproc_req.generate_proxy = Some(proxy.clone()); - } - if !file_related_umids.is_empty() { - preproc_req.override_entity_ids = Some(Some(file_related_umids)); - } if let Err(e) = api::process_assets_users_assets_preprocess_post( &cfg_clone, preproc_req, None, Some(&api_key_clone), - bearer_clone.as_deref(), ) .await { @@ -1332,7 +1326,9 @@ fn is_terminal_status(status: &str) -> bool { fn needs_task_for_mode(mode: StatusWaitMode, task_type: &str) -> bool { match mode { - StatusWaitMode::Done => matches!(task_type, "analyze asset" | "downscaling" | "deep analyze"), + StatusWaitMode::Done => { + matches!(task_type, "analyze asset" | "downscaling" | "deep analyze") + } StatusWaitMode::Analysed => matches!(task_type, "analyze asset" | "deep analyze"), StatusWaitMode::Transcoded => task_type == "downscaling", } @@ -1340,10 +1336,17 @@ fn needs_task_for_mode(mode: StatusWaitMode, task_type: &str) -> bool { fn all_done_for_mode(mode: StatusWaitMode, progress: &AssetTaskProgress) -> bool { let check = |entry: &Option<(String, f64)>| -> bool { - entry.as_ref().map(|(status, _)| is_terminal_status(status)).unwrap_or(false) + entry + .as_ref() + .map(|(status, _)| is_terminal_status(status)) + .unwrap_or(false) }; match mode { - StatusWaitMode::Done => check(&progress.analyze_asset) && check(&progress.downscaling) && check(&progress.deep_analyze), + StatusWaitMode::Done => { + check(&progress.analyze_asset) + && check(&progress.downscaling) + && check(&progress.deep_analyze) + } StatusWaitMode::Analysed => check(&progress.analyze_asset) && check(&progress.deep_analyze), StatusWaitMode::Transcoded => check(&progress.downscaling), } @@ -1367,7 +1370,9 @@ fn has_error_for_mode(mode: StatusWaitMode, progress: &AssetTaskProgress) -> boo || has_error(&progress.downscaling) || has_error(&progress.deep_analyze) } - StatusWaitMode::Analysed => has_error(&progress.analyze_asset) || has_error(&progress.deep_analyze), + StatusWaitMode::Analysed => { + has_error(&progress.analyze_asset) || has_error(&progress.deep_analyze) + } StatusWaitMode::Transcoded => has_error(&progress.downscaling), } } @@ -1385,10 +1390,7 @@ fn task_progress_to_percent(progress: f64) -> f64 { } } -fn render_status_row( - asset_id: &str, - progress: &AssetTaskProgress, -) -> String { +fn render_status_row(asset_id: &str, progress: &AssetTaskProgress) -> String { let analyze = progress .analyze_asset .as_ref() @@ -1405,7 +1407,11 @@ fn render_status_row( .map(|(s, p)| format!("{}:{:.0}%", s, task_progress_to_percent(*p))) .unwrap_or_else(|| "pending".to_string()); let asset_display = if asset_id.len() > 20 { - format!("{}...{}", &asset_id[..8], &asset_id[asset_id.len().saturating_sub(8)..]) + format!( + "{}...{}", + &asset_id[..8], + &asset_id[asset_id.len().saturating_sub(8)..] + ) } else { asset_id.to_string() }; @@ -1482,7 +1488,8 @@ async fn wait_for_asset_processing_status( .iter() .map(|a| (a.asset_id.clone(), AssetTaskProgress::default())) .collect(); - let ordered_asset_ids: Vec = uploaded_assets.iter().map(|a| a.asset_id.clone()).collect(); + let ordered_asset_ids: Vec = + uploaded_assets.iter().map(|a| a.asset_id.clone()).collect(); if !machine_readable { output::info("Polling /users/tasks every 2s for processing status..."); @@ -1566,7 +1573,11 @@ async fn wait_for_asset_processing_status( sum += task_progress_to_percent(*p); count += 1.0; } - if count > 0.0 { sum / count } else { 0.0 } + if count > 0.0 { + sum / count + } else { + 0.0 + } } StatusWaitMode::Analysed => { let mut sum = 0.0; @@ -1579,7 +1590,11 @@ async fn wait_for_asset_processing_status( sum += task_progress_to_percent(*p); count += 1.0; } - if count > 0.0 { sum / count } else { 0.0 } + if count > 0.0 { + sum / count + } else { + 0.0 + } } StatusWaitMode::Transcoded => asset_progress .downscaling @@ -1767,61 +1782,61 @@ async fn upload_single_file( let mut f = File::open(file_path) .map_err(|e| format!("failed to open {}: {}", file_path.display(), e))?; - let mut buf = Vec::with_capacity(total_bytes as usize); - - const CHUNK_SIZE: usize = 1024 * 1024; // 1MB chunks - let mut uploaded = 0u64; - let mut chunk = vec![0u8; CHUNK_SIZE.min(total_bytes as usize)]; - - loop { - let n = f - .read(&mut chunk) - .map_err(|e| format!("failed to read {}: {}", file_path.display(), e))?; - if n == 0 { - break; - } - buf.extend_from_slice(&chunk[..n]); - uploaded += n as u64; - if let Some(ph) = progress_handle { - let _ = ph.update_task(task_id, uploaded); - } - } + let mut buf = Vec::with_capacity(total_bytes as usize); - let content_type = mime_guess::from_path(file_path) - .first_or_text_plain() - .essence_str() - .to_string(); - - let put_res = http - .put(upload_url.as_str()) - .header(reqwest::header::CONTENT_LENGTH, total_bytes) - .header(reqwest::header::CONTENT_TYPE, &content_type) - .body(buf) - .send() - .await - .map_err(|e| format!("upload failed for {}: {}", file_path.display(), e))?; + const CHUNK_SIZE: usize = 1024 * 1024; // 1MB chunks + let mut uploaded = 0u64; + let mut chunk = vec![0u8; CHUNK_SIZE.min(total_bytes as usize)]; + loop { + let n = f + .read(&mut chunk) + .map_err(|e| format!("failed to read {}: {}", file_path.display(), e))?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + uploaded += n as u64; if let Some(ph) = progress_handle { - let _ = ph.update_task(task_id, total_bytes); + let _ = ph.update_task(task_id, uploaded); } + } - if !put_res.status().is_success() { - let status = put_res.status(); - let body = put_res - .text() - .await - .unwrap_or_else(|_| "".to_string()); - let error_msg = format!( - "Upload failed for {}: HTTP {} - {}", - file_path.display(), - status, - body - ); - if let Some(ph) = progress_handle { - let _ = ph.add_error(error_msg.clone()); - } - return Err(error_msg); + let content_type = mime_guess::from_path(file_path) + .first_or_text_plain() + .essence_str() + .to_string(); + + let put_res = http + .put(upload_url.as_str()) + .header(reqwest::header::CONTENT_LENGTH, total_bytes) + .header(reqwest::header::CONTENT_TYPE, &content_type) + .body(buf) + .send() + .await + .map_err(|e| format!("upload failed for {}: {}", file_path.display(), e))?; + + if let Some(ph) = progress_handle { + let _ = ph.update_task(task_id, total_bytes); + } + + if !put_res.status().is_success() { + let status = put_res.status(); + let body = put_res + .text() + .await + .unwrap_or_else(|_| "".to_string()); + let error_msg = format!( + "Upload failed for {}: HTTP {} - {}", + file_path.display(), + status, + body + ); + if let Some(ph) = progress_handle { + let _ = ph.add_error(error_msg.clone()); } + return Err(error_msg); + } if let Err(e) = uploads_tracking::record_upload( user_id, diff --git a/src/tellers_api/openapi.tellers_public_api.yaml b/src/tellers_api/openapi.tellers_public_api.yaml index ff34276..8c6a8a5 100644 --- a/src/tellers_api/openapi.tellers_public_api.yaml +++ b/src/tellers_api/openapi.tellers_public_api.yaml @@ -21,12 +21,14 @@ paths: required: true schema: type: integer + exclusiveMinimum: 0 title: Limit - name: page in: query required: true schema: type: integer + minimum: 0 title: Page - name: folder_on_top in: query @@ -76,6 +78,166 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /users/get: + get: + tags: + - accepts-api-key + summary: Get User + operationId: get_user_users_get_get + deprecated: true + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ApiUser' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /users/me: + get: + tags: + - accepts-api-key + summary: Get Current User + description: Return the authenticated user's profile, credit balance, and subscription + snapshot. + operationId: get_current_user_users_me_get + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ApiUser' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + patch: + tags: + - accepts-api-key + summary: Update Current User + operationId: update_current_user_users_me_patch + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ApiUser' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /users/check_coupons: + get: + tags: + - accepts-api-key + summary: Check Coupons + operationId: check_coupons_users_check_coupons_get + parameters: + - name: coupon_code + in: query + required: true + schema: + type: string + description: Stripe promotion code to validate. + title: Coupon Code + description: Stripe promotion code to validate. + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CouponCheckResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /users/assets/multipart/complete: post: tags: @@ -215,12 +377,6 @@ paths: summary: Process Assets operationId: process_assets_users_assets_preprocess_post parameters: - - name: priority - in: query - required: false - schema: - $ref: '#/components/schemas/TaskPriority' - default: highest - name: x-api-key in: header required: false @@ -436,6 +592,7 @@ paths: required: false schema: type: integer + exclusiveMinimum: 0 default: 1 title: Limit - name: group_id @@ -556,33 +713,15 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /project/{project_id}/export: + /asset/download_urls: post: tags: - accepts-api-key - summary: Export Project - description: 'Start async export of project to MP4 at one or more resolutions. - Creates the export asset - - in the API, then enqueues a task that generates each rendition. The export - appears as a - - new video asset in the project''s export folder (project name + date + .mp4), - with multiple - - renditions (e.g. 480p, 720p, 1080p, 4k) attached to that asset. - - - Returns task_id and asset_id. Poll GET /task/{task_id} for completion; result - includes asset_id.' - operationId: export_project_project__project_id__export_post + summary: Get Asset Download Url Batch Json + description: Return download URL payloads for multiple assets using one shared + config. + operationId: get_asset_download_url_batch_json_asset_download_urls_post parameters: - - name: project_id - in: path - required: true - schema: - type: string - title: Project Id - name: x-api-key in: header required: false @@ -604,42 +743,28 @@ paths: content: application/json: schema: - type: array - items: - enum: - - 480p - - 720p - - 1080p - - 4k - type: string - description: List of resolutions to export (e.g. ["720p", "1080p"]) - title: Renditions + $ref: '#/components/schemas/AssetDownloadUrlBatchRequest' responses: '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/ExportProjectResponse' + $ref: '#/components/schemas/AssetDownloadUrlBatchResponse' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /agent/response/{chat_id}: - delete: + /asset/infos: + post: tags: - accepts-api-key - summary: Cancel Response - operationId: cancel_response_agent_response__chat_id__delete + summary: Get Asset Infos Batch Json + description: Return minimal source/path/duration info for multiple assets. + operationId: get_asset_infos_batch_json_asset_infos_post parameters: - - name: chat_id - in: path - required: true - schema: - type: string - title: Chat Id - name: x-api-key in: header required: false @@ -656,44 +781,40 @@ paths: - type: string - type: 'null' title: Authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AssetInfosBatchRequest' responses: '200': description: Successful Response content: application/json: schema: - type: string - title: Response Cancel Response Agent Response Chat Id Delete + $ref: '#/components/schemas/AssetInfosBatchResponse' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /asset/description/{description_id}/generation-metadata: get: tags: - accepts-api-key - summary: Stream Chat Messages - description: 'Stream new chat messages as they are added to the database. - - Returns SSE events with message data.' - operationId: stream_chat_messages_agent_response__chat_id__get + summary: Get Asset Description Generation Metadata + description: Return debug/provenance metadata saved with one generated chunk + description. + operationId: get_asset_description_generation_metadata_asset_description__description_id__generation_metadata_get parameters: - - name: chat_id + - name: description_id in: path required: true schema: type: string - title: Chat Id - - name: after - in: query - required: false - schema: - anyOf: - - type: string - format: date-time - - type: 'null' - title: After + title: Description Id - name: x-api-key in: header required: false @@ -715,20 +836,31 @@ paths: description: Successful Response content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/AssetDescriptionGenerationMetadataResponse' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /agent/available-llm-models: - get: + /asset/{asset_id}/duplicate: + post: tags: - accepts-api-key - summary: Get Available Llm Models - operationId: get_available_llm_models_agent_available_llm_models_get + summary: Duplicate Asset + description: 'Duplicate a project asset. Caller must have WRITE on the source. + + + Non-project assets return 400. Missing asset or no write access returns 404.' + operationId: duplicate_asset_asset__asset_id__duplicate_post parameters: + - name: asset_id + in: path + required: true + schema: + type: string + title: Asset Id - name: x-api-key in: header required: false @@ -746,29 +878,45 @@ paths: - type: 'null' title: Authorization responses: - '200': + '201': description: Successful Response content: application/json: schema: - type: array - items: - type: string - title: Response Get Available Llm Models Agent Available Llm Models - Get + $ref: '#/components/schemas/DuplicateAssetResponse' '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' - /agent/response: + /project/{project_id}/export: post: tags: - accepts-api-key - summary: Process Agent Message - operationId: process_agent_message_agent_response_post + summary: Export Project + description: 'Start async export of project to MP4 at one or more resolutions. + Creates the export asset + + in the API, then enqueues a task that generates each rendition. The export + appears as a + + new video asset in the project''s export folder (project name + date + .mp4), + with multiple + + renditions (e.g. 480p, 720p, 1080p, 4k) attached to that asset. + + + Returns task_id and asset_id. Poll GET /task/{task_id} for completion; result + includes asset_id.' + operationId: export_project_project__project_id__export_post parameters: + - name: project_id + in: path + required: true + schema: + type: string + title: Project Id - name: x-api-key in: header required: false @@ -790,13 +938,201 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AgentMessageRequest' - responses: - '200': - description: Successful Response - content: - application/json: - schema: {} + type: array + items: + enum: + - 480p + - 720p + - 1080p + - 4k + type: string + description: List of resolutions to export (e.g. ["720p", "1080p"]) + title: Renditions + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ExportProjectResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /billing/catalog: + get: + tags: + - accepts-api-key + summary: Get Billing Catalog + operationId: get_billing_catalog_billing_catalog_get + parameters: + - name: If-None-Match + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: If-None-Match + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCatalog' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent/response/{chat_id}: + delete: + tags: + - accepts-api-key + summary: Cancel Response + operationId: cancel_response_agent_response__chat_id__delete + parameters: + - name: chat_id + in: path + required: true + schema: + type: string + title: Chat Id + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: string + title: Response Cancel Response Agent Response Chat Id Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - accepts-api-key + summary: Stream Chat Messages + description: "Subscribe to a chat's agent SSE stream (Redis-backed).\n\nEmits\ + \ the exact same events as ``POST /agent/response`` \u2014 both sides read\n\ + from the per-chat Redis Stream written by the producer running the agent.\n\ + \nQuery params:\n - ``last_event_id``: resume from this Redis stream entry\ + \ id. Pass the\n ``id:`` of the last SSE event you received to avoid duplicates.\ + \ When\n omitted, the subscription starts from \"now\" \u2014 only events\ + \ produced\n after the GET request arrives are delivered, no history is\ + \ replayed.\n - ``after``: accepted for backward compatibility and currently\ + \ ignored.\n Use ``last_event_id`` instead." + operationId: stream_chat_messages_agent_response__chat_id__get + parameters: + - name: chat_id + in: path + required: true + schema: + type: string + title: Chat Id + - name: after + in: query + required: false + schema: + anyOf: + - type: string + format: date-time + - type: 'null' + title: After + - name: last_event_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Last Event Id + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /agent/response: + post: + tags: + - accepts-api-key + summary: Process Agent Message + operationId: process_agent_message_agent_response_post + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentMessageRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} '422': description: Validation Error content: @@ -905,6 +1241,7 @@ paths: required: false schema: type: integer + exclusiveMinimum: 0 default: 10 title: Limit - name: offset @@ -912,6 +1249,7 @@ paths: required: false schema: type: integer + minimum: 0 default: 0 title: Offset - name: before @@ -1110,6 +1448,7 @@ paths: required: false schema: type: integer + minimum: 0 default: 0 title: Offset - name: limit @@ -1117,6 +1456,7 @@ paths: required: false schema: type: integer + exclusiveMinimum: 0 default: 20 title: Limit - name: before @@ -1256,6 +1596,23 @@ paths: - accepts-api-key summary: Get Settings operationId: get_settings_settings_get + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization responses: '200': description: Successful Response @@ -1263,6 +1620,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AppSettings' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' components: schemas: AgentMessageRequest: @@ -1317,14 +1680,18 @@ components: llm_model: type: string enum: + - gpt-5.5-2026-04-23 + - gpt-5.5-pro-2026-04-23 - gpt-5.4-pro-2026-03-05 - - gpt-5.4-2026-03-05 + - gpt-5.4 + - gpt-5.4-mini + - gpt-5.4-nano - gpt-5.3-codex - gpt-5.2-2025-12-11 - gpt-5-mini-2025-08-07 title: Llm Model description: The model to use to generate the response. - default: gpt-5.4-2026-03-05 + default: gpt-5.5-2026-04-23 tools: items: type: string @@ -1440,14 +1807,18 @@ components: llm_model: type: string enum: + - gpt-5.5-2026-04-23 + - gpt-5.5-pro-2026-04-23 - gpt-5.4-pro-2026-03-05 - - gpt-5.4-2026-03-05 + - gpt-5.4 + - gpt-5.4-mini + - gpt-5.4-nano - gpt-5.3-codex - gpt-5.2-2025-12-11 - gpt-5-mini-2025-08-07 title: Llm Model description: The model to use to generate the response. - default: gpt-5.4-2026-03-05 + default: gpt-5.5-2026-04-23 tools: items: type: string @@ -1505,76 +1876,478 @@ components: required: - message title: AgentMessageRequestWithoutNoInteraction - AppContext: + ApiUser: properties: - current_playback_time: + firebase_uid: anyOf: - - type: number + - type: string - type: 'null' - title: Current Playback Time - description: The current playback time in the project when the response - is sent. - on_screen_asset: + title: Firebase Uid + email: + type: string + title: Email + company: anyOf: - type: string - type: 'null' - title: On Screen Asset - description: The id of the asset on screen when the response is sent. - on_screen_project: + title: Company + first_name: anyOf: - type: string - type: 'null' - title: On Screen Project - description: The id of the project on screen when the response is sent. - selected_clips: + title: First Name + job_title: anyOf: - - items: - type: string - type: array + - type: string - type: 'null' - title: Selected Clips - description: The ids of the clips selected when the response is sent. - selected_assets: + title: Job Title + last_name: anyOf: - - items: - type: string - type: array + - type: string - type: 'null' - title: Selected Assets - description: The ids of the assets selected when the response is sent. - selected_projects: + title: Last Name + profile_pic: anyOf: - - items: - type: string - type: array + - type: string - type: 'null' - title: Selected Projects - description: The ids of the projects selected when the response is sent. - additionalProperties: false + title: Profile Pic + created_at_str: + type: string + title: Created At Str + description: '``created_at`` as UTC ISO-8601 ending in Z (e.g. for debugging + / clients).' + last_seen_at_str: + type: string + title: Last Seen At Str + description: '``last_seen_at`` as UTC ISO-8601 ending in Z.' + updated_at_str: + type: string + title: Updated At Str + description: '``updated_at`` as UTC ISO-8601 ending in Z.' + credits: + type: number + title: Credits + description: Spendable balance from grants (`get_token`), not legacy columns. + storage_quota_gb: + type: integer + title: Storage Quota Gb + description: Total storage quota available to the user in GB. + subscription: + anyOf: + - $ref: '#/components/schemas/ApiUserSubscription' + - type: 'null' + role: + additionalProperties: + type: boolean + type: object + title: Role + description: Role flags for roles held by the user. Missing roles are omitted. + user_preferences: + $ref: '#/components/schemas/UserPreferences' + description: Persisted user preferences returned with the user profile. type: object - title: AppContext - AppSettings: + required: + - firebase_uid + - email + - company + - first_name + - job_title + - last_name + - profile_pic + - created_at_str + - last_seen_at_str + - updated_at_str + - credits + - storage_quota_gb + - subscription + - user_preferences + title: ApiUser + ApiUserSubscription: properties: - maintenance_mode: - type: boolean - title: Maintenance Mode - available_agent_tools: - items: - additionalProperties: true + type: + type: string + enum: + - Monthly + - Yearly + title: Type + description: User-facing subscription cadence label. + monthly_tokens: + type: number + title: Monthly Tokens + description: Subscription token amount available each month. + storage_quota_gb: + type: integer + title: Storage Quota Gb + description: Total storage quota available to the user in GB. + pricing: + items: + $ref: '#/components/schemas/ApiUserSubscriptionPricingLine' + type: array + title: Pricing + description: Current Stripe subscription price lines with price id and quantity. + cancel_at_period_end: + type: boolean + title: Cancel At Period End + description: True when Stripe will cancel the subscription at period end. + default: false + cancel_at: + anyOf: + - type: string + - type: 'null' + title: Cancel At + description: UTC ISO-8601 time when Stripe will cancel the subscription. + current_period_end: + anyOf: + - type: string + - type: 'null' + title: Current Period End + description: UTC ISO-8601 end of the current subscription period. + latest_payment_issue: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Latest Payment Issue + description: When set, an invoice needs payment or customer action (e.g. + 3DS). Fields may include invoice_id, invoice_status, hosted_invoice_url, + customer_action_url. + type: object + required: + - type + - monthly_tokens + - storage_quota_gb + title: ApiUserSubscription + ApiUserSubscriptionPricingLine: + properties: + id: + type: string + title: Id + description: Stripe price id for this subscription line. + quantity: + type: integer + title: Quantity + description: Quantity for this Stripe price line. + type: object + required: + - id + - quantity + title: ApiUserSubscriptionPricingLine + AppContext: + properties: + current_playback_time: + anyOf: + - type: number + - type: 'null' + title: Current Playback Time + description: The current playback time in the project when the response + is sent. + on_screen_asset: + anyOf: + - type: string + - type: 'null' + title: On Screen Asset + description: The id of the asset on screen when the response is sent. + on_screen_project: + anyOf: + - type: string + - type: 'null' + title: On Screen Project + description: The id of the project on screen when the response is sent. + selected_clips: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Selected Clips + description: The ids of the clips selected when the response is sent. + selected_assets: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Selected Assets + description: The ids of the assets selected when the response is sent. + selected_projects: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Selected Projects + description: The ids of the projects selected when the response is sent. + additionalProperties: false + type: object + title: AppContext + AppSettings: + properties: + maintenance_mode: + type: boolean + title: Maintenance Mode + available_agent_tools: + items: + additionalProperties: true type: object type: array title: Available Agent Tools available_llm_models: items: - type: string + $ref: '#/components/schemas/AvailableModel' type: array title: Available Llm Models + presets: + items: + $ref: '#/components/schemas/TellersPreset' + type: array + title: Presets type: object required: - maintenance_mode - available_agent_tools - available_llm_models + - presets title: AppSettings + AssetAnalysisSettings: + properties: + reasoning: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Reasoning + fps: + anyOf: + - type: number + maximum: 24.0 + minimum: 1.0 + - type: 'null' + title: Fps + entity_quality: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Entity Quality + asset_quality: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Asset Quality + prompt: + anyOf: + - type: string + - type: 'null' + title: Prompt + additionalProperties: false + type: object + title: AssetAnalysisSettings + AssetDescriptionGenerationMetadataResponse: + properties: + description_id: + type: string + title: Description Id + asset_id: + type: string + title: Asset Id + start_time_ms: + type: integer + title: Start Time Ms + end_time_ms: + type: integer + title: End Time Ms + generation_metadata: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Generation Metadata + type: object + required: + - description_id + - asset_id + - start_time_ms + - end_time_ms + - generation_metadata + title: AssetDescriptionGenerationMetadataResponse + AssetDownloadUrlBatchItem: + properties: + asset_id: + type: string + title: Asset Id + error: + type: boolean + title: Error + error_message: + anyOf: + - type: string + - type: 'null' + title: Error Message + download: + anyOf: + - $ref: '#/components/schemas/AssetDownloadUrlResponse' + - type: 'null' + type: object + required: + - asset_id + - error + title: AssetDownloadUrlBatchItem + description: Resolved download payload for one asset in a batch request. + AssetDownloadUrlBatchRequest: + properties: + asset_ids: + items: + type: string + type: array + minItems: 1 + title: Asset Ids + description: Asset IDs to resolve. + quality: + type: string + enum: + - 480p + - 720p + - 1080p + - original + - highest + - lowest + title: Quality + default: 720p + player_compatible: + type: boolean + title: Player Compatible + default: true + frame_time: + anyOf: + - type: number + - type: 'null' + title: Frame Time + request_id: + anyOf: + - type: string + - type: 'null' + title: Request Id + type: object + required: + - asset_ids + title: AssetDownloadUrlBatchRequest + description: Request body for resolving multiple asset download URLs. + AssetDownloadUrlBatchResponse: + properties: + items: + items: + $ref: '#/components/schemas/AssetDownloadUrlBatchItem' + type: array + title: Items + type: object + required: + - items + title: AssetDownloadUrlBatchResponse + AssetDownloadUrlResponse: + properties: + url: + type: string + title: Url + expiredIn: + type: integer + title: Expiredin + description: 'Seconds the URL is expected to work: matches S3 presign expiry + when applicable; for Pexels/CDN links this is a recommended refresh interval, + not a hard protocol expiry.' + current_time: + type: string + format: date-time + title: Current Time + description: The current time when the URL was generated + request_id: + anyOf: + - type: string + - type: 'null' + title: Request Id + description: The request ID when the URL was generated + type: object + required: + - url + - expiredIn + title: AssetDownloadUrlResponse + description: Media URL and how long it should remain valid (for refresh / UrlSource + lifecycle). + AssetInfosBatchItem: + properties: + asset_id: + type: string + title: Asset Id + error: + type: boolean + title: Error + error_message: + anyOf: + - type: string + - type: 'null' + title: Error Message + info: + anyOf: + - $ref: '#/components/schemas/AssetMinimalInfoResponse' + - type: 'null' + type: object + required: + - asset_id + - error + title: AssetInfosBatchItem + AssetInfosBatchRequest: + properties: + asset_ids: + items: + type: string + type: array + minItems: 1 + title: Asset Ids + description: Asset IDs to fetch minimal info for. + type: object + required: + - asset_ids + title: AssetInfosBatchRequest + AssetInfosBatchResponse: + properties: + items: + items: + $ref: '#/components/schemas/AssetInfosBatchItem' + type: array + title: Items + type: object + required: + - items + title: AssetInfosBatchResponse + AssetMinimalInfoResponse: + properties: + source_name: + type: string + title: Source Name + path: + type: string + title: Path + duration_second: + type: number + title: Duration Second + file_type: + $ref: '#/components/schemas/FileType' + type: object + required: + - source_name + - path + - duration_second + - file_type + title: AssetMinimalInfoResponse AssetReferenceRequest: properties: is_reference: @@ -1664,6 +2437,285 @@ components: required: - is_public_read title: AssetVisibilityRequest + AvailableModel: + properties: + name: + type: string + title: Name + codename: + type: string + title: Codename + compatible_models: + items: + type: string + type: array + title: Compatible Models + options: + $ref: '#/components/schemas/TellersAvailableOptions' + type: object + required: + - name + - codename + - compatible_models + - options + title: AvailableModel + BillingCatalog: + properties: + plans: + items: + oneOf: + - $ref: '#/components/schemas/CatalogPlanOneOff' + - $ref: '#/components/schemas/CatalogPlanSubscription' + discriminator: + propertyName: kind + mapping: + one_off_tokens: '#/components/schemas/CatalogPlanOneOff' + subscription: '#/components/schemas/CatalogPlanSubscription' + type: array + title: Plans + additionalProperties: false + type: object + required: + - plans + title: BillingCatalog + CatalogFeature: + properties: + text: + type: string + title: Text + description: User-facing feature/highlight text. + icon_key: + anyOf: + - type: string + - type: 'null' + title: Icon Key + description: Optional semantic icon key that frontend maps to an icon. + additionalProperties: false + type: object + required: + - text + title: CatalogFeature + description: Frontend-facing feature/highlight with optional semantic icon key. + CatalogHighlight: + properties: + number: + anyOf: + - type: number + - type: 'null' + title: Number + description: Optional numeric value to display prominently. + stripe_price_id: + anyOf: + - type: string + - type: 'null' + title: Stripe Price Id + description: Optional Stripe price id used by UI when refreshing highlight + values. + text: + type: string + title: Text + description: Label displayed under/next to the number. + additionalProperties: false + type: object + required: + - text + title: CatalogHighlight + description: Frontend-facing compact metric shown as number + label. + CatalogPlanOneOff: + properties: + tier_id: + type: string + title: Tier Id + display_name: + type: string + title: Display Name + description: Human-readable plan name for frontend display. + subtitle: + type: string + title: Subtitle + description: Optional short subtitle/description displayed under the plan + name. + default: '' + cta_text: + type: string + title: Cta Text + description: Optional call-to-action label shown on the plan button. + default: '' + highlights: + items: + $ref: '#/components/schemas/CatalogHighlight' + type: array + maxItems: 2 + title: Highlights + description: Up to two compact highlights for frontend display (number + + text). + features: + items: + $ref: '#/components/schemas/CatalogFeature' + type: array + title: Features + description: Human-readable feature bullets for this plan (frontend display). + enabled: + type: boolean + title: Enabled + default: true + default_items: + items: + $ref: '#/components/schemas/PlanLineItem' + type: array + title: Default Items + description: Suggested default cart rows (empty list when not applicable). + pricing: + $ref: '#/components/schemas/PlanPricing' + description: Priced lines used for preview and checkout. + kind: + type: string + const: one_off_tokens + title: Kind + default: one_off_tokens + additionalProperties: false + type: object + required: + - tier_id + - display_name + - default_items + - pricing + title: CatalogPlanOneOff + description: Prepaid token pack. + CatalogPlanSubscription: + properties: + tier_id: + type: string + title: Tier Id + display_name: + type: string + title: Display Name + description: Human-readable plan name for frontend display. + subtitle: + type: string + title: Subtitle + description: Optional short subtitle/description displayed under the plan + name. + default: '' + cta_text: + type: string + title: Cta Text + description: Optional call-to-action label shown on the plan button. + default: '' + highlights: + items: + $ref: '#/components/schemas/CatalogHighlight' + type: array + maxItems: 2 + title: Highlights + description: Up to two compact highlights for frontend display (number + + text). + features: + items: + $ref: '#/components/schemas/CatalogFeature' + type: array + title: Features + description: Human-readable feature bullets for this plan (frontend display). + enabled: + type: boolean + title: Enabled + default: true + default_items: + items: + $ref: '#/components/schemas/PlanLineItem' + type: array + title: Default Items + description: Suggested default cart rows (empty list when not applicable). + pricing: + $ref: '#/components/schemas/PlanPricing' + description: Priced lines used for preview and checkout. + kind: + type: string + const: subscription + title: Kind + default: subscription + interval: + anyOf: + - type: string + enum: + - month + - year + - type: 'null' + title: Interval + interval_upgrade_cta: + type: string + title: Interval Upgrade Cta + description: Optional call-to-action label for switching subscription interval, + e.g. monthly to yearly. + default: '' + quantity_update_cta: + type: string + title: Quantity Update Cta + description: Optional call-to-action label for changing subscription item + quantities. + default: '' + additionalProperties: false + type: object + required: + - tier_id + - display_name + - default_items + - pricing + title: CatalogPlanSubscription + CatalogPricingLine: + properties: + stripe_price_id: + type: string + title: Stripe Price Id + display_name: + type: string + title: Display Name + description: Human-readable label for this Stripe price line (frontend display). + default: '' + unit_amount_cents: + type: number + title: Unit Amount Cents + description: Effective unit price in cents per displayed unit (e.g. per + token). For Stripe prices configured per pack/group, this is normalized + using Stripe transform quantity metadata. + default: 0.0 + min_quantity: + anyOf: + - type: integer + minimum: 0.0 + - type: 'null' + title: Min Quantity + description: Checkout minimum quantity for this price (one-off packs). + max_quantity: + anyOf: + - type: integer + minimum: 0.0 + - type: 'null' + title: Max Quantity + description: Checkout maximum quantity for this price (one-off packs). + tier_pricing_from_stripe: + type: boolean + title: Tier Pricing From Stripe + description: If true, replace this price line with tier bands fetched from + Stripe (subscription only). + default: false + tiers_mode: + anyOf: + - type: string + enum: + - graduated + - volume + - type: 'null' + title: Tiers Mode + description: 'For Stripe tiered prices: `graduated` vs `volume` (Stripe + Price `tiers_mode`). Set on each band when `tier_pricing_from_stripe` + lines are expanded from Stripe.' + additionalProperties: false + type: object + required: + - stripe_price_id + title: CatalogPricingLine + description: One billable Stripe price on the plan. ChatHistoryResponse: properties: id: @@ -1767,6 +2819,31 @@ components: - context - created_at title: ChatMessageUserResponse + CouponCheckResponse: + properties: + coupon_code: + type: string + title: Coupon Code + is_valid: + type: boolean + title: Is Valid + name: + anyOf: + - type: string + - type: 'null' + title: Name + description: + anyOf: + - type: string + - type: 'null' + title: Description + type: object + required: + - coupon_code + - is_valid + - name + - description + title: CouponCheckResponse CreateFolderRequest: properties: path: @@ -1801,6 +2878,16 @@ components: - id - path title: CreateFolderResponse + DuplicateAssetResponse: + properties: + id: + type: string + title: Id + type: object + required: + - id + title: DuplicateAssetResponse + description: New asset id after a successful duplicate. ExportProjectResponse: properties: task_id: @@ -1919,6 +3006,54 @@ components: - multipart_upload_id - parts title: MultipartCompleteRequest + PlanLineItem: + properties: + price_id: + type: string + title: Price Id + quantity: + type: integer + minimum: 0.0 + title: Quantity + description: Default quantity for this price. + quantity_step: + type: integer + minimum: 1.0 + title: Quantity Step + description: Checkout quantity step for this item. + units_per_quantity: + type: number + exclusiveMinimum: 0.0 + title: Units Per Quantity + description: Multiplier to convert checkout quantity into displayed units + for this item. + default: 1 + additionalProperties: false + type: object + required: + - price_id + - quantity + - quantity_step + title: PlanLineItem + description: Reusable cart line item (used by checkout defaults). + PlanPricing: + properties: + lines: + items: + $ref: '#/components/schemas/CatalogPricingLine' + type: array + title: Lines + description: Priced Stripe lines (one for typical one-off; two for subscription). + additionalProperties: false + type: object + required: + - lines + title: PlanPricing + description: 'Pricing-only blob for Rust ``pricing_preview_json`` (``lines`` + only). + + + Default starter quantities live on the plan as ``default_items``.' ProcessAssetsRequest: properties: assets: @@ -2090,16 +3225,6 @@ components: - description - keywords title: SourceFileInfo - TaskPriority: - type: string - enum: - - highest - - high - - medium - - low - - lowest - title: TaskPriority - description: Task priority is used to prioritize tasks in the queue. TaskResponse: properties: id: @@ -2147,6 +3272,111 @@ components: - ended_at - error_message title: TaskResponse + TellersAvailableOptions: + properties: + verbosity: + items: + type: string + enum: + - low + - medium + - high + type: array + title: Verbosity + reasoning_effort: + items: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + type: array + title: Reasoning Effort + type: object + required: + - verbosity + - reasoning_effort + title: TellersAvailableOptions + TellersPreset: + properties: + name: + type: string + title: Name + model: + anyOf: + - type: string + enum: + - gpt-5.5-2026-04-23 + - gpt-5.5-pro-2026-04-23 + - gpt-5.4-pro-2026-03-05 + - gpt-5.4 + - gpt-5.4-mini + - gpt-5.4-nano + - gpt-5.3-codex + - gpt-5.2-2025-12-11 + - gpt-5-mini-2025-08-07 + - type: 'null' + title: Model + options: + $ref: '#/components/schemas/TellersPresetOptions' + type: object + required: + - name + - model + - options + title: TellersPreset + TellersPresetOptions: + properties: + reasoning_effort: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + title: Reasoning Effort + verbosity: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Verbosity + type: object + required: + - reasoning_effort + - verbosity + title: TellersPresetOptions + UpdateUserRequest: + properties: + first_name: + anyOf: + - type: string + - type: 'null' + title: First Name + last_name: + anyOf: + - type: string + - type: 'null' + title: Last Name + user_preferences: + anyOf: + - $ref: '#/components/schemas/UserPreferences' + - type: 'null' + additionalProperties: false + type: object + title: UpdateUserRequest UseAgentToolRequest: properties: tool_id: @@ -2163,6 +3393,15 @@ components: - tool_id - tool_arguments title: UseAgentToolRequest + UserPreferences: + properties: + analysis: + anyOf: + - $ref: '#/components/schemas/AssetAnalysisSettings' + - type: 'null' + additionalProperties: true + type: object + title: UserPreferences ValidationError: properties: loc: diff --git a/src/tui/two_queue_progress.rs b/src/tui/two_queue_progress.rs index a7a5903..75277ec 100644 --- a/src/tui/two_queue_progress.rs +++ b/src/tui/two_queue_progress.rs @@ -34,11 +34,7 @@ pub(crate) struct TwoQueueState { impl TwoQueueState { fn downscale_display(&self) -> String { - let current = self - .downscale_current - .as_deref() - .unwrap_or("—") - .to_string(); + let current = self.downscale_current.as_deref().unwrap_or("—").to_string(); let s = truncate_string(¤t, 35); if let Some(pct) = self.downscale_current_pct { format!("{} ({:.0}%)", s, pct) @@ -48,11 +44,7 @@ impl TwoQueueState { } fn upload_display(&self) -> String { - let current = self - .upload_current - .as_deref() - .unwrap_or("—") - .to_string(); + let current = self.upload_current.as_deref().unwrap_or("—").to_string(); let s = truncate_string(¤t, 35); if let Some(pct) = self.upload_current_pct { format!("{} ({:.0}%)", s, pct) @@ -62,11 +54,17 @@ impl TwoQueueState { } fn downscale_pending_next(&self) -> impl Iterator { - self.downscale_pending.iter().take(PENDING_DISPLAY).map(|s| s.as_str()) + self.downscale_pending + .iter() + .take(PENDING_DISPLAY) + .map(|s| s.as_str()) } fn upload_pending_next(&self) -> impl Iterator { - self.upload_pending.iter().take(PENDING_DISPLAY).map(|s| s.as_str()) + self.upload_pending + .iter() + .take(PENDING_DISPLAY) + .map(|s| s.as_str()) } } @@ -94,6 +92,16 @@ impl TwoQueueProgress { }) } + pub fn without_terminal() -> Self { + Self { + terminal: RefCell::new(None), + state: Arc::new(Mutex::new(TwoQueueState { + max_messages: 100, + ..Default::default() + })), + } + } + pub fn clone_handle(&self) -> TwoQueueProgressHandle { TwoQueueProgressHandle { state: Arc::clone(&self.state), @@ -127,9 +135,7 @@ impl TwoQueueProgress { }) } - pub async fn stop_render_loop( - render_handle: tokio::task::JoinHandle>, - ) { + pub async fn stop_render_loop(render_handle: tokio::task::JoinHandle>) { tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; render_handle.abort(); let _ = render_handle.await; @@ -273,26 +279,21 @@ pub fn draw_two_queue_ui(frame: &mut Frame, state: &TwoQueueState) { let has_messages = !state.recent_messages.is_empty(); let msg_space = if has_messages { 4 } else { 0 }; - let vertical = Layout::vertical([ - Constraint::Min(3), - Constraint::Length(msg_space), - ]) - .margin(1); + let vertical = Layout::vertical([Constraint::Min(3), Constraint::Length(msg_space)]).margin(1); let areas = vertical.split(area); let main_area = areas[0]; let bottom_area = areas[1]; // Two columns: each shows "Queue: N" and "Current: " - let cols = Layout::horizontal([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) - .split(main_area); + let cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(main_area); let downscale_block = Block::default().title(Span::styled( " Downscale ", - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), )); frame.render_widget(downscale_block, cols[0]); @@ -318,9 +319,15 @@ pub fn draw_two_queue_ui(frame: &mut Frame, state: &TwoQueueState) { ), ]), ]; - let pending_down: Vec = state.downscale_pending_next().map(|s| truncate_string(s, 32)).collect(); + let pending_down: Vec = state + .downscale_pending_next() + .map(|s| truncate_string(s, 32)) + .collect(); if !pending_down.is_empty() { - downscale_lines.push(Line::from(Span::styled("Next:", Style::default().fg(Color::DarkGray)))); + downscale_lines.push(Line::from(Span::styled( + "Next:", + Style::default().fg(Color::DarkGray), + ))); for name in &pending_down { downscale_lines.push(Line::from(Span::styled( format!(" {}", name), @@ -333,7 +340,9 @@ pub fn draw_two_queue_ui(frame: &mut Frame, state: &TwoQueueState) { let upload_block = Block::default().title(Span::styled( " Upload ", - Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), )); frame.render_widget(upload_block, cols[1]); @@ -359,9 +368,15 @@ pub fn draw_two_queue_ui(frame: &mut Frame, state: &TwoQueueState) { ), ]), ]; - let pending_up: Vec = state.upload_pending_next().map(|s| truncate_string(s, 32)).collect(); + let pending_up: Vec = state + .upload_pending_next() + .map(|s| truncate_string(s, 32)) + .collect(); if !pending_up.is_empty() { - upload_lines.push(Line::from(Span::styled("Next:", Style::default().fg(Color::DarkGray)))); + upload_lines.push(Line::from(Span::styled( + "Next:", + Style::default().fg(Color::DarkGray), + ))); for name in &pending_up { upload_lines.push(Line::from(Span::styled( format!(" {}", name),