diff --git a/crates/lib/mysql_queries/src/queries/media_files/list/list_featured_media_files.rs b/crates/lib/mysql_queries/src/queries/media_files/list/list_featured_media_files.rs index 6822b7ec37..0e6e8e13bf 100644 --- a/crates/lib/mysql_queries/src/queries/media_files/list/list_featured_media_files.rs +++ b/crates/lib/mysql_queries/src/queries/media_files/list/list_featured_media_files.rs @@ -97,6 +97,7 @@ pub struct ListFeaturedMediaFilesArgs<'a> { pub maybe_filter_media_types: Option<&'a HashSet>, pub maybe_filter_media_classes: Option<&'a HashSet>, pub maybe_filter_engine_categories: Option<&'a HashSet>, + pub maybe_filter_product_categories: Option<&'a HashSet>, pub maybe_offset: Option, pub cursor_is_reversed: bool, pub sort_ascending: bool, @@ -110,6 +111,7 @@ pub async fn list_featured_media_files(args: ListFeaturedMediaFilesArgs<'_>) -> args.maybe_filter_media_types, args.maybe_filter_media_classes, args.maybe_filter_engine_categories, + args.maybe_filter_product_categories, args.limit, args.maybe_offset, args.cursor_is_reversed, @@ -185,6 +187,7 @@ fn query_builder<'a>( maybe_filter_media_types: Option<&HashSet>, maybe_filter_media_classes: Option<&HashSet>, maybe_filter_engine_categories: Option<&HashSet>, + maybe_filter_product_categories: Option<&HashSet>, limit: usize, maybe_offset: Option, cursor_is_reversed: bool, @@ -340,6 +343,28 @@ LEFT OUTER JOIN prompts } } + if let Some(product_categories) = maybe_filter_product_categories { + // NB: `WHERE IN` comma separated syntax will be wrong if list has zero length + // We'll skip the predicate if the list isn't empty. + if !product_categories.is_empty() { + if !first_predicate_added { + query_builder.push(" WHERE "); + first_predicate_added = true; + } else { + query_builder.push(" AND "); + } + query_builder.push(" m.origin_product_category IN ( "); + + let mut separated = query_builder.separated(", "); + + for product_category in product_categories.iter() { + separated.push_bind(product_category.to_str()); + } + + separated.push_unseparated(") "); + } + } + // TODO: Argument to control `is_intermediate_system_file` { if !first_predicate_added { diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 4ba01f1cdd..c692550a40 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -38,6 +38,7 @@ use tokens::tokens::user_bookmarks::*; use tokens::tokens::users::*; use tokens::tokens::zs_voice_datasets::*; +use crate::http_server::common_requests::auto_product_category::AutoProductCategory; use crate::http_server::common_requests::media_file_token_path_info::MediaFileTokenPathInfo; use crate::http_server::common_responses::media_file_cover_image_details::MediaFileCoverImageDetails; use crate::http_server::common_responses::media_file_cover_image_details::MediaFileDefaultCover; @@ -89,7 +90,6 @@ use crate::http_server::endpoints::media_files::upload::upload_audio_media_file_ use crate::http_server::endpoints::media_files::upload::upload_engine_asset::upload_engine_asset_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError; use crate::http_server::endpoints::media_files::upload::upload_generic::upload_media_file_handler::*; -use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_image_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_new_engine_asset_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_new_scene_media_file_handler::*; @@ -97,6 +97,7 @@ use crate::http_server::endpoints::media_files::upload::upload_new_video_media_f use crate::http_server::endpoints::media_files::upload::upload_pmx::upload_pmx_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_saved_scene_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_scene_snapshot_media_file_handler::*; +use crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::*; use crate::http_server::endpoints::media_files::upload::upload_video::upload_video_media_file_handler::*; use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asset::write_engine_asset_media_file_handler::*; use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError; @@ -129,8 +130,6 @@ use crate::http_server::endpoints::weights::list::list_available_weights_handler use crate::http_server::endpoints::weights::list::list_featured_weights_handler::*; use crate::http_server::endpoints::weights::list::list_pinned_weights_handler::*; use crate::http_server::endpoints::weights::list::list_weights_by_user_handler::*; -use crate::http_server::endpoints::weights::search::search_model_weights_http_get_handler::*; -use crate::http_server::endpoints::weights::search::search_model_weights_http_post_handler::*; use crate::http_server::endpoints::weights::search::search_model_weights_impl::*; use crate::http_server::endpoints::weights::update::set_model_weight_cover_image_handler::*; use crate::http_server::endpoints::weights::update::update_weight_handler::*; @@ -267,6 +266,9 @@ use crate::http_server::web_utils::response_success_helpers::*; WeightsCategory, WeightsType, + // Other common enums + AutoProductCategory, + // Common path info MediaFileTokenPathInfo, diff --git a/crates/service/web/storyteller_web/src/http_server/common_requests/auto_product_category.rs b/crates/service/web/storyteller_web/src/http_server/common_requests/auto_product_category.rs new file mode 100644 index 0000000000..cdd2172d18 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/common_requests/auto_product_category.rs @@ -0,0 +1,121 @@ +use std::collections::HashSet; + +use utoipa::ToSchema; + +use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; + +/// AutoProductCategory can be used in APIs to auto-expand to multiple product categories, +/// deprecated product categories, etc. +/// +/// For example, "voice" expands to ["tts", "zs_voice", "voice_conversion"] +/// +/// These can also alias bad product names, eg "live_portrait" is an alias for "face_mirror" +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Hash, PartialOrd, Ord, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum AutoProductCategory { + // ===== Synthetic product categories that alias or auto-expand to multiple ===== // + + /// Synthetic category that expands to ["tts", "zs_voice", "voice_conversion"] + Voice, + + // ===== Synthetic product categories that alias others ===== // + + /// Synthetic category that expands to ["face_animator"] + Lipsync, + + /// This is an alias for "face_mirror" + LivePortrait, + + // ===== Current Database Product Categories ===== // + + FaceAnimator, + + FaceMirror, + + #[serde(rename = "vst")] + VideoStyleTransfer, + + #[serde(rename = "studio")] + StorytellerStudio, + + #[serde(rename = "tts")] + TextToSpeech, + + VoiceConversion, + + #[serde(rename = "zs_voice")] + ZeroShotVoice, + + Mocap, + + #[serde(rename = "image_gen")] + ImageGeneration, + + // ===== Deprecated Database Product Categories ===== // + + #[deprecated] + VideoFilter, + + #[deprecated] + Workflow, +} + +impl AutoProductCategory { + pub fn expand_to_db_product_categories(&self) -> HashSet { + match self { + // Synthetic product categories that auto-expand to multiple + AutoProductCategory::Voice => HashSet::from([ + MediaFileOriginProductCategory::TextToSpeech, + MediaFileOriginProductCategory::ZeroShotVoice, + MediaFileOriginProductCategory::VoiceConversion, + ]), + + // Synthetic product categories that alias other product categories + AutoProductCategory::Lipsync => HashSet::from([MediaFileOriginProductCategory::FaceAnimator]), + AutoProductCategory::LivePortrait => HashSet::from([MediaFileOriginProductCategory::FaceMirror]), + + // Current database product categories + AutoProductCategory::FaceAnimator => HashSet::from([MediaFileOriginProductCategory::FaceAnimator]), + AutoProductCategory::FaceMirror => HashSet::from([MediaFileOriginProductCategory::FaceMirror]), + AutoProductCategory::VideoStyleTransfer => HashSet::from([MediaFileOriginProductCategory::VideoStyleTransfer]), + AutoProductCategory::StorytellerStudio => HashSet::from([MediaFileOriginProductCategory::StorytellerStudio]), + AutoProductCategory::TextToSpeech => HashSet::from([MediaFileOriginProductCategory::TextToSpeech]), + AutoProductCategory::VoiceConversion => HashSet::from([MediaFileOriginProductCategory::VoiceConversion]), + AutoProductCategory::ZeroShotVoice => HashSet::from([MediaFileOriginProductCategory::ZeroShotVoice]), + AutoProductCategory::Mocap => HashSet::from([MediaFileOriginProductCategory::Mocap]), + AutoProductCategory::ImageGeneration => HashSet::from([MediaFileOriginProductCategory::ImageGeneration]), + + // Deprecated database product categories + AutoProductCategory::VideoFilter => HashSet::from([MediaFileOriginProductCategory::VideoFilter]), + AutoProductCategory::Workflow => HashSet::from([MediaFileOriginProductCategory::Workflow]), + } + } + + pub fn from_str(value: &str) -> Result { + match value { + // Synthetic product categories that auto-expand to multiple + "voice" => Ok(Self::Voice), + + // Synthetic product categories that alias other product categories + "lipsync" => Ok(Self::Lipsync), + "live_portrait" => Ok(Self::LivePortrait), + + // Current database product categories + "face_animator" => Ok(Self::FaceAnimator), + "face_mirror" => Ok(Self::FaceMirror), + "vst" => Ok(Self::VideoStyleTransfer), + "studio" => Ok(Self::StorytellerStudio), + "tts" => Ok(Self::TextToSpeech), + "voice_conversion" => Ok(Self::VoiceConversion), + "zs_voice" => Ok(Self::ZeroShotVoice), + "mocap" => Ok(Self::Mocap), + "image_gen" => Ok(Self::ImageGeneration), + + // Deprecated database product categories + "video_filter" => Ok(Self::VideoFilter), + "workflow" => Ok(Self::Workflow), + + _ => Err(format!("Invalid AutoProductCategory: {}", value)), + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/common_requests/mod.rs b/crates/service/web/storyteller_web/src/http_server/common_requests/mod.rs index 8970719a88..7218512606 100644 --- a/crates/service/web/storyteller_web/src/http_server/common_requests/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/common_requests/mod.rs @@ -1 +1,2 @@ +pub mod auto_product_category; pub mod media_file_token_path_info; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/helpers/get_scoped_product_categories.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/helpers/get_scoped_product_categories.rs new file mode 100644 index 0000000000..c364acb2d1 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/helpers/get_scoped_product_categories.rs @@ -0,0 +1,99 @@ +use std::collections::HashSet; + +use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; + +use crate::http_server::common_requests::auto_product_category::AutoProductCategory; + +pub fn get_scoped_product_categories( + maybe_query_param: Option<&str> +) -> Option> { + + let categories = match maybe_query_param { + None => return None, + Some(categories) => categories, + }; + + // NB: This silently fails on invalid values. Probably not the best tactic. + let categories= categories.split(",") + .map(|c| AutoProductCategory::from_str(c)) + .flatten() + .fold(HashSet::new(), |mut acc, cat| { + acc.extend(cat.expand_to_db_product_categories()); + acc + }); + + if categories.is_empty() { + return None; + } + + Some(categories) +} + +#[cfg(test)] +mod test { + use std::collections::HashSet; + + use enums::by_table::media_files::media_file_origin_product_category::MediaFileOriginProductCategory; + + use crate::http_server::endpoints::media_files::helpers::get_scoped_product_categories::get_scoped_product_categories; + + #[test] + fn none() { + assert_eq!(get_scoped_product_categories(None), None) + } + + #[test] + fn empty() { + assert_eq!(get_scoped_product_categories(Some("")), None) + } + + #[test] + fn garbage() { + assert_eq!(get_scoped_product_categories(Some("foo,bar,baz")), None) + } + + #[test] + fn direct_map() { + assert_eq!( + get_scoped_product_categories(Some("mocap")), + Some(HashSet::from([ + MediaFileOriginProductCategory::Mocap, + ])) + ); + assert_eq!( + get_scoped_product_categories(Some("face_animator,face_mirror")), + Some(HashSet::from([ + MediaFileOriginProductCategory::FaceAnimator, + MediaFileOriginProductCategory::FaceMirror + ])) + ); + } + + #[test] + fn alias_map() { + assert_eq!( + get_scoped_product_categories(Some("lipsync")), + Some(HashSet::from([ + MediaFileOriginProductCategory::FaceAnimator, + ])) + ); + assert_eq!( + get_scoped_product_categories(Some("live_portrait")), + Some(HashSet::from([ + MediaFileOriginProductCategory::FaceMirror, + ])) + ); + } + + #[test] + fn multi_map() { + assert_eq!( + get_scoped_product_categories(Some("voice")), + Some(HashSet::from([ + MediaFileOriginProductCategory::TextToSpeech, + MediaFileOriginProductCategory::VoiceConversion, + MediaFileOriginProductCategory::ZeroShotVoice, + ])) + ); + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/helpers/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/helpers/mod.rs index 7787a3da1f..752e6ec8eb 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/helpers/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/helpers/mod.rs @@ -1,3 +1,4 @@ pub mod get_scoped_engine_categories; pub mod get_scoped_media_classes; -pub mod get_scoped_media_types; \ No newline at end of file +pub mod get_scoped_media_types; +pub mod get_scoped_product_categories; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/list/list_featured_media_files_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/list/list_featured_media_files_handler.rs index 45074e6c01..67d58b7d86 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/list/list_featured_media_files_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/list/list_featured_media_files_handler.rs @@ -31,6 +31,7 @@ use crate::http_server::common_responses::user_details_lite::UserDetailsLight; use crate::http_server::endpoints::media_files::helpers::get_scoped_engine_categories::get_scoped_engine_categories; use crate::http_server::endpoints::media_files::helpers::get_scoped_media_classes::get_scoped_media_classes; use crate::http_server::endpoints::media_files::helpers::get_scoped_media_types::get_scoped_media_types; +use crate::http_server::endpoints::media_files::helpers::get_scoped_product_categories::get_scoped_product_categories; use crate::http_server::web_utils::bucket_urls::bucket_url_string_from_media_path::bucket_url_string_from_media_path; use crate::state::server_state::ServerState; use crate::util::allowed_explore_media_access::allowed_explore_media_access; @@ -73,6 +74,19 @@ pub struct ListFeaturedMediaFilesQueryParams { /// - `?filter_engine_categories=animation,character,object` /// - etc. pub filter_engine_categories: Option, + + /// NB: This can be one (or more comma-separated values) from `AutoProductCategory`. + /// AutoProductCategory is not a db enum directly and can expand to multiple categories for some values. + /// + /// Usage: + /// - `?filter_products=voice` + /// - `?filter_products=tts,voice_conversion,zs_voice` + /// - `?filter_products=live_portrait` + /// - `?filter_products=lipsync` + /// - `?filter_products=vst` + /// - `?filter_products=studio` + /// - etc. + pub filter_products: Option, } #[derive(Serialize, ToSchema)] @@ -353,6 +367,7 @@ async fn database_lookup( let maybe_filter_media_types = get_scoped_media_types(query.filter_media_type.as_deref()); let maybe_filter_media_classes = get_scoped_media_classes(query.filter_media_classes.as_deref()); let maybe_filter_engine_categories = get_scoped_engine_categories(query.filter_engine_categories.as_deref()); + let maybe_filter_product_categories = get_scoped_product_categories(query.filter_engine_categories.as_deref()); // NB: No reason to show deleted or non-public featured items to mods or authors. // That's just confusing and wastes an extra query. @@ -367,6 +382,7 @@ async fn database_lookup( maybe_filter_media_types: maybe_filter_media_types.as_ref(), maybe_filter_media_classes: maybe_filter_media_classes.as_ref(), maybe_filter_engine_categories: maybe_filter_engine_categories.as_ref(), + maybe_filter_product_categories: maybe_filter_product_categories.as_ref(), mysql_pool: &server_state.mysql_pool, }).await.map_err(|err| { error!("DB error: {:?}", err);