From 1e37e362014348e7f2a0022d1a7c47897201edd2 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 30 May 2026 16:06:57 +0000 Subject: [PATCH 01/14] feat(samples): discovery UX with variant grouping and faceted search Surface the growing sample-pipeline catalog as grouped scenario cards with a variant selector and faceted/fuzzy search instead of a flat list of near-duplicate templates. Discovery metadata (group/variant/category/tags) is optional in sample YAML; when omitted the server derives best-effort values from node kinds, the client section, and filename patterns, so existing samples need no edits. Explicit YAML values win per-field; derived tags union with curated ones. The TemplateSelector (used by both Convert and Stream views) collapses a variant family (e.g. the colorbars codec variants) into one card with radio pills, and adds category/capability/needs-hardware facet chips alongside the existing origin filter and search. Signed-off-by: streamkit-devin --- apps/skit/src/lib.rs | 1 + apps/skit/src/main.rs | 1 + apps/skit/src/sample_discovery.rs | 429 +++++++++++++++ apps/skit/src/samples.rs | 217 ++++++-- crates/api/src/lib.rs | 13 + crates/api/src/yaml/compiler.rs | 16 +- crates/api/src/yaml/mod.rs | 20 + .../pipelines/dynamic/video_moq_colorbars.yml | 6 + samples/pipelines/oneshot/speech_to_text.yml | 3 + samples/pipelines/oneshot/vad-demo.yml | 4 + .../converter/TemplateSelector.styles.ts | 320 +++++++++++ .../converter/TemplateSelector.test.tsx | 99 ++++ .../components/converter/TemplateSelector.tsx | 513 +++++++++--------- ui/src/types/generated/api-types.ts | 19 +- ui/src/utils/samplePipelineOrdering.test.ts | 113 ++++ ui/src/utils/samplePipelineOrdering.ts | 149 ++++- 16 files changed, 1607 insertions(+), 316 deletions(-) create mode 100644 apps/skit/src/sample_discovery.rs create mode 100644 ui/src/components/converter/TemplateSelector.styles.ts diff --git a/apps/skit/src/lib.rs b/apps/skit/src/lib.rs index dab5e0e62..5082c19f3 100644 --- a/apps/skit/src/lib.rs +++ b/apps/skit/src/lib.rs @@ -24,6 +24,7 @@ pub mod plugin_records; pub mod plugins; pub mod profiling; pub mod role_extractor; +pub mod sample_discovery; pub mod samples; pub mod server; pub mod session; diff --git a/apps/skit/src/main.rs b/apps/skit/src/main.rs index c9a20a94e..c4f05277c 100644 --- a/apps/skit/src/main.rs +++ b/apps/skit/src/main.rs @@ -45,6 +45,7 @@ mod plugin_records; mod plugins; mod profiling; mod role_extractor; +mod sample_discovery; mod samples; mod server; mod session; diff --git a/apps/skit/src/sample_discovery.rs b/apps/skit/src/sample_discovery.rs new file mode 100644 index 000000000..8b6824f5d --- /dev/null +++ b/apps/skit/src/sample_discovery.rs @@ -0,0 +1,429 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Best-effort discovery metadata for sample pipelines. +//! +//! The Convert and Stream views group near-duplicate samples (e.g. the +//! h264/av1 colorbars family) into a single card with a variant selector, and +//! expose faceted/fuzzy search over capability tags and categories. Authors may +//! set `group`/`variant`/`category`/`tags` explicitly in a sample's YAML, but +//! most samples omit them — so this module derives sensible defaults from the +//! node kinds, the client section, and the filename. Explicit YAML values +//! always win; derived `tags` are unioned with any curated ones. + +/// Explicit discovery fields parsed from a sample's YAML (all optional). +#[derive(Debug, Default, Clone)] +pub struct ExplicitDiscovery { + pub group: Option, + pub variant: Option, + pub category: Option, + pub tags: Vec, +} + +/// Resolved discovery metadata after merging explicit values with derivation. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Discovery { + pub group: Option, + pub variant: Option, + pub category: Option, + pub tags: Vec, +} + +/// Filename tokens that distinguish variants of the same scenario, paired with +/// a human label. Compound tokens are matched before single ones so that e.g. +/// `vulkan_video` does not collapse into a stray `video` group token. +const COMPOUND_TOKENS: &[(&str, &str)] = + &[("vulkan_video", "Vulkan Video"), ("svt_av1", "SVT-AV1")]; + +const SINGLE_TOKENS: &[(&str, &str)] = &[ + ("h264", "H.264"), + ("h265", "HEVC"), + ("hevc", "HEVC"), + ("av1", "AV1"), + ("vp9", "VP9"), + ("aac", "AAC"), + ("opus", "Opus"), + ("vaapi", "VA-API"), + ("nvidia", "NVIDIA"), + ("nvenc", "NVIDIA"), + ("nv", "NVIDIA"), + ("vulkan", "Vulkan"), + ("dav1d", "dav1d"), + ("svt", "SVT"), + ("openh264", "OpenH264"), + ("helsinki", "Helsinki"), + ("nllb", "NLLB"), +]; + +const LANGUAGE_TOKENS: &[(&str, &str)] = &[ + ("en", "English"), + ("es", "Spanish"), + ("fr", "French"), + ("de", "German"), + ("it", "Italian"), + ("pt", "Portuguese"), + ("zh", "Chinese"), + ("ja", "Japanese"), + ("ko", "Korean"), +]; + +fn label_for(table: &[(&'static str, &'static str)], token: &str) -> Option<&'static str> { + table.iter().find(|(t, _)| *t == token).map(|(_, label)| *label) +} + +/// Splits a filename base into its base-scenario group key and a variant label, +/// stripping codec/hardware/language tokens that distinguish near-duplicates. +fn group_and_variant_from_filename(filename_base: &str) -> (String, Option) { + let mut normalized = filename_base.to_lowercase().replace('-', "_"); + + let mut variant_parts: Vec = Vec::new(); + + for (token, label) in COMPOUND_TOKENS { + let needle = format!("_{token}_"); + let padded = format!("_{normalized}_"); + if padded.contains(&needle) { + normalized = padded.replace(&needle, "_").trim_matches('_').to_string(); + variant_parts.push((*label).to_string()); + } + } + + let mut group_tokens: Vec = Vec::new(); + let mut languages: Vec<&'static str> = Vec::new(); + + for token in normalized.split('_').filter(|t| !t.is_empty()) { + if let Some(lang) = label_for(LANGUAGE_TOKENS, token) { + languages.push(lang); + } else if let Some(label) = label_for(SINGLE_TOKENS, token) { + variant_parts.push(label.to_string()); + } else { + group_tokens.push(token.to_string()); + } + } + + if languages.len() >= 2 { + variant_parts.push(format!("{} → {}", languages[0], languages[1])); + } else if let Some(lang) = languages.first() { + variant_parts.push((*lang).to_string()); + } + + group_tokens.dedup(); + let group_key = if group_tokens.is_empty() { + filename_base.to_lowercase().replace('_', "-") + } else { + group_tokens.join("-") + }; + + let variant = if variant_parts.is_empty() { None } else { Some(variant_parts.join(" ")) }; + + (group_key, variant) +} + +fn tags_for_kind(kind: &str) -> Vec<&'static str> { + let k = kind.to_lowercase(); + let mut tags: Vec<&'static str> = Vec::new(); + + if k.contains("whisper") || k.contains("parakeet") || k.contains("sensevoice") { + tags.push("speech-to-text"); + } + if k.contains("kokoro") + || k.contains("piper") + || k.contains("matcha") + || k.contains("supertonic") + || k.contains("pocket") + { + tags.push("text-to-speech"); + } + if k.contains("nllb") || k.contains("helsinki") { + tags.push("translation"); + } + if k.ends_with("::vad") { + tags.push("voice-activity-detection"); + } + if k.starts_with("video::") && k.contains("encoder") { + tags.push("video-encoding"); + } + if k.starts_with("video::") && k.contains("decoder") { + tags.push("video-decoding"); + } + if k == "video::compositor" { + tags.push("compositing"); + } + if k == "video::colorbars" { + tags.push("colorbars"); + } + if k.starts_with("transport::moq") { + tags.push("moq"); + } + if k == "transport::http::mse" { + tags.push("mse"); + } + if k.starts_with("transport::rtmp") { + tags.push("rtmp"); + } + if k.starts_with("containers::mp4") { + tags.push("mp4"); + } + if k.starts_with("containers::webm") { + tags.push("webm"); + } + if k == "audio::mixer" { + tags.push("mixing"); + } + + if k.contains("vaapi") { + tags.push("hardware:vaapi"); + } + if k.starts_with("video::nv::") || k.contains("nvenc") { + tags.push("hardware:nvidia"); + } + if k.contains("vulkan") { + tags.push("hardware:vulkan"); + } + + tags +} + +fn capability_tags( + node_kinds: &[String], + client_input: Option<&str>, + client_output: Option<&str>, +) -> Vec { + let mut tags: Vec = Vec::new(); + + for kind in node_kinds { + for tag in tags_for_kind(kind) { + tags.push(tag.to_string()); + } + } + + match client_output { + Some("transcription") => tags.push("speech-to-text".to_string()), + Some("audio") => tags.push("audio-output".to_string()), + Some("video") => tags.push("video-output".to_string()), + _ => {}, + } + match client_input { + Some("file_upload") => tags.push("file-input".to_string()), + Some("text") => tags.push("text-input".to_string()), + _ => {}, + } + + tags.sort_unstable(); + tags.dedup(); + tags +} + +/// Picks a single faceting bucket. Composition and encoding outrank the speech +/// stack so that a streaming pipeline that happens to transcribe still reads as +/// a video scenario; translation outranks STT/TTS for speech-translate samples. +fn category_from_tags(tags: &[String]) -> Option { + const PRIORITY: &[(&str, &str)] = &[ + ("compositing", "Video Compositing"), + ("video-encoding", "Video Encoding"), + ("translation", "Translation"), + ("speech-to-text", "Speech to Text"), + ("text-to-speech", "Text to Speech"), + ("video-decoding", "Video Processing"), + ("moq", "Streaming"), + ("mse", "Streaming"), + ("rtmp", "Streaming"), + ("mixing", "Audio Processing"), + ]; + + PRIORITY + .iter() + .find(|(tag, _)| tags.iter().any(|t| t == tag)) + .map(|(_, category)| (*category).to_string()) +} + +/// Derives discovery metadata, with explicit YAML values taking precedence. +pub fn derive( + filename_base: &str, + node_kinds: &[String], + client_input: Option<&str>, + client_output: Option<&str>, + explicit: ExplicitDiscovery, +) -> Discovery { + let (derived_group, derived_variant) = group_and_variant_from_filename(filename_base); + let derived_tags = capability_tags(node_kinds, client_input, client_output); + + let mut tags = explicit.tags; + for tag in derived_tags { + if !tags.contains(&tag) { + tags.push(tag); + } + } + tags.sort_unstable(); + tags.dedup(); + + let category = explicit.category.or_else(|| category_from_tags(&tags)); + + Discovery { + group: explicit.group.or(Some(derived_group)), + variant: explicit.variant.or(derived_variant), + category, + tags, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(list: &[&str]) -> Vec { + list.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn colorbars_family_shares_a_group_with_distinct_variants() { + let (g_plain, v_plain) = group_and_variant_from_filename("video_moq_colorbars"); + let (g_h264, v_h264) = group_and_variant_from_filename("video_moq_h264_colorbars"); + let (g_vaapi, v_vaapi) = group_and_variant_from_filename("video_moq_vaapi_h264_colorbars"); + let (g_nv, v_nv) = group_and_variant_from_filename("video_moq_nv_av1_colorbars"); + let (g_vk, v_vk) = group_and_variant_from_filename("video_moq_vulkan_video_h264_colorbars"); + + assert_eq!(g_plain, "video-moq-colorbars"); + assert_eq!(g_h264, "video-moq-colorbars"); + assert_eq!(g_vaapi, "video-moq-colorbars"); + assert_eq!(g_nv, "video-moq-colorbars"); + assert_eq!(g_vk, "video-moq-colorbars"); + + assert_eq!(v_plain, None); + assert_eq!(v_h264.as_deref(), Some("H.264")); + assert_eq!(v_vaapi.as_deref(), Some("VA-API H.264")); + assert_eq!(v_nv.as_deref(), Some("NVIDIA AV1")); + assert_eq!(v_vk.as_deref(), Some("Vulkan Video H.264")); + } + + #[test] + fn svt_av1_compound_does_not_leak_into_group_key() { + let (group, variant) = group_and_variant_from_filename("video_svt_av1_compositor_demo"); + assert_eq!(group, "video-compositor-demo"); + assert_eq!(variant.as_deref(), Some("SVT-AV1")); + + let (plain_group, plain_variant) = group_and_variant_from_filename("video_compositor_demo"); + assert_eq!(plain_group, "video-compositor-demo"); + assert_eq!(plain_variant, None); + } + + #[test] + fn language_pairs_become_directional_variants() { + let (g_en_es, v_en_es) = group_and_variant_from_filename("speech-translate-en-es"); + let (g_es_en, v_es_en) = group_and_variant_from_filename("speech-translate-es-en"); + let (g_hel, v_hel) = group_and_variant_from_filename("speech-translate-helsinki-en-es"); + + assert_eq!(g_en_es, "speech-translate"); + assert_eq!(g_es_en, "speech-translate"); + assert_eq!(g_hel, "speech-translate"); + assert_eq!(v_en_es.as_deref(), Some("English → Spanish")); + assert_eq!(v_es_en.as_deref(), Some("Spanish → English")); + assert_eq!(v_hel.as_deref(), Some("Helsinki English → Spanish")); + } + + #[test] + fn unrelated_samples_do_not_collide() { + let (g_audio, _) = group_and_variant_from_filename("mp4_mux_audio"); + let (g_video, _) = group_and_variant_from_filename("mp4_mux_video"); + let (g_av, _) = group_and_variant_from_filename("mp4_mux_aac_h264"); + assert_ne!(g_audio, g_video); + assert_ne!(g_audio, g_av); + assert_ne!(g_video, g_av); + } + + #[test] + fn derives_capability_tags_from_node_kinds() { + let tags = capability_tags( + &kinds(&[ + "streamkit::http_input", + "audio::resampler", + "plugin::native::whisper", + "plugin::native::nllb", + "plugin::native::piper", + ]), + None, + None, + ); + assert!(tags.contains(&"speech-to-text".to_string())); + assert!(tags.contains(&"translation".to_string())); + assert!(tags.contains(&"text-to-speech".to_string())); + } + + #[test] + fn flags_hardware_encoders_with_a_facet() { + let vaapi = capability_tags(&kinds(&["video::vaapi::h264_encoder"]), None, None); + assert!(vaapi.contains(&"video-encoding".to_string())); + assert!(vaapi.contains(&"hardware:vaapi".to_string())); + + let nv = capability_tags(&kinds(&["video::nv::av1_encoder"]), None, None); + assert!(nv.contains(&"hardware:nvidia".to_string())); + + let vulkan = capability_tags(&kinds(&["video::vulkan_video::h264_encoder"]), None, None); + assert!(vulkan.contains(&"hardware:vulkan".to_string())); + + let software = capability_tags(&kinds(&["video::svt_av1::encoder"]), None, None); + assert!(software.contains(&"video-encoding".to_string())); + assert!(!software.iter().any(|t| t.starts_with("hardware:"))); + } + + #[test] + fn aac_encoder_is_not_video_encoding() { + let tags = capability_tags(&kinds(&["plugin::native::aac_encoder"]), None, None); + assert!(!tags.contains(&"video-encoding".to_string())); + } + + #[test] + fn category_prefers_compositing_then_encoding() { + assert_eq!( + category_from_tags(&["compositing".into(), "video-encoding".into(), "moq".into()]), + Some("Video Compositing".to_string()) + ); + assert_eq!( + category_from_tags(&["video-encoding".into(), "moq".into()]), + Some("Video Encoding".to_string()) + ); + assert_eq!( + category_from_tags(&["translation".into(), "speech-to-text".into()]), + Some("Translation".to_string()) + ); + assert_eq!(category_from_tags(&[]), None); + } + + #[test] + fn explicit_values_override_derivation_and_tags_union() { + let discovery = derive( + "video_moq_vaapi_h264_colorbars", + &kinds(&["video::vaapi::h264_encoder", "transport::moq::publisher"]), + None, + Some("video"), + ExplicitDiscovery { + group: Some("custom-group".to_string()), + variant: Some("Custom".to_string()), + category: Some("Demos".to_string()), + tags: vec!["curated".to_string()], + }, + ); + + assert_eq!(discovery.group.as_deref(), Some("custom-group")); + assert_eq!(discovery.variant.as_deref(), Some("Custom")); + assert_eq!(discovery.category.as_deref(), Some("Demos")); + assert!(discovery.tags.contains(&"curated".to_string())); + assert!(discovery.tags.contains(&"video-encoding".to_string())); + assert!(discovery.tags.contains(&"hardware:vaapi".to_string())); + } + + #[test] + fn derivation_fills_in_when_explicit_absent() { + let discovery = derive( + "video_moq_nv_av1_colorbars", + &kinds(&["video::nv::av1_encoder", "transport::moq::publisher"]), + None, + Some("video"), + ExplicitDiscovery::default(), + ); + assert_eq!(discovery.group.as_deref(), Some("video-moq-colorbars")); + assert_eq!(discovery.variant.as_deref(), Some("NVIDIA AV1")); + assert_eq!(discovery.category.as_deref(), Some("Video Encoding")); + assert!(discovery.tags.contains(&"hardware:nvidia".to_string())); + } +} diff --git a/apps/skit/src/samples.rs b/apps/skit/src/samples.rs index ed07eaefa..1850ab6f0 100644 --- a/apps/skit/src/samples.rs +++ b/apps/skit/src/samples.rs @@ -183,24 +183,116 @@ fn has_yaml_extension(filename: &str) -> bool { .is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")) } -/// Parse pipeline YAML and extract metadata (name, description, mode) -fn parse_pipeline_metadata( - yaml: &str, - path: &std::path::Path, -) -> (Option, Option, streamkit_api::EngineMode) { - streamkit_api::yaml::parse_yaml(yaml).map_or_else( - |e| { +/// Metadata extracted from a pipeline YAML, including the raw inputs the +/// discovery derivation needs (node kinds + client I/O types). +#[derive(Default)] +struct PipelineMetadata { + name: Option, + description: Option, + mode: streamkit_api::EngineMode, + explicit: crate::sample_discovery::ExplicitDiscovery, + node_kinds: Vec, + client_input: Option, + client_output: Option, +} + +const fn input_type_str(input_type: streamkit_api::yaml::InputType) -> &'static str { + use streamkit_api::yaml::InputType; + match input_type { + InputType::FileUpload => "file_upload", + InputType::Text => "text", + InputType::Trigger => "trigger", + InputType::None => "none", + } +} + +const fn output_type_str(output_type: streamkit_api::yaml::OutputType) -> &'static str { + use streamkit_api::yaml::OutputType; + match output_type { + OutputType::Transcription => "transcription", + OutputType::Json => "json", + OutputType::Audio => "audio", + OutputType::Video => "video", + } +} + +fn client_io_types( + client: Option<&streamkit_api::yaml::ClientSection>, +) -> (Option, Option) { + let input = + client.and_then(|c| c.input.as_ref()).map(|i| input_type_str(i.input_type).to_string()); + let output = + client.and_then(|c| c.output.as_ref()).map(|o| output_type_str(o.output_type).to_string()); + (input, output) +} + +/// Parse pipeline YAML and extract metadata used for listing and discovery. +fn parse_pipeline_metadata(yaml: &str, path: &std::path::Path) -> PipelineMetadata { + use streamkit_api::yaml::UserPipeline; + + let user_pipeline = match streamkit_api::yaml::parse_yaml(yaml) { + Ok(p) => p, + Err(e) => { warn!("Failed to parse pipeline metadata from {}: {}", path.display(), e); - (None, None, streamkit_api::EngineMode::default()) + return PipelineMetadata::default(); }, - |user_pipeline| { - use streamkit_api::yaml::UserPipeline; - match user_pipeline { - UserPipeline::Steps { name, description, mode, .. } - | UserPipeline::Dag { name, description, mode, .. } => (name, description, mode), + }; + + let explicit = |group, variant, category, tags| crate::sample_discovery::ExplicitDiscovery { + group, + variant, + category, + tags, + }; + + match user_pipeline { + UserPipeline::Steps { + name, + description, + mode, + group, + variant, + category, + tags, + steps, + client, + } => { + let node_kinds = steps.into_iter().map(|s| s.kind).collect(); + let (client_input, client_output) = client_io_types(client.as_ref()); + PipelineMetadata { + name, + description, + mode, + explicit: explicit(group, variant, category, tags), + node_kinds, + client_input, + client_output, } }, - ) + UserPipeline::Dag { + name, + description, + mode, + group, + variant, + category, + tags, + nodes, + client, + } => { + let node_kinds = nodes.into_values().map(|n| n.kind).collect(); + let (client_input, client_output) = client_io_types(client.as_ref()); + PipelineMetadata { + name, + description, + mode, + explicit: explicit(group, variant, category, tags), + node_kinds, + client_input, + client_output, + } + }, + } } fn mode_to_string(mode: streamkit_api::EngineMode) -> String { @@ -240,13 +332,21 @@ async fn load_samples_from_dir( match fs::read_to_string(&path).await { Ok(yaml) => { - let (name, description, mode) = parse_pipeline_metadata(&yaml, &path); + let meta = parse_pipeline_metadata(&yaml, &path); let base_filename = filename.trim_end_matches(".yml").trim_end_matches(".yaml"); let id = format!("{subdir}/{base_filename}"); - let name = name.unwrap_or_else(|| filename_to_name(filename)); - let description = description.unwrap_or_default(); + let discovery = crate::sample_discovery::derive( + base_filename, + &meta.node_kinds, + meta.client_input.as_deref(), + meta.client_output.as_deref(), + meta.explicit, + ); + + let name = meta.name.unwrap_or_else(|| filename_to_name(filename)); + let description = meta.description.unwrap_or_default(); let is_fragment = name == filename_to_name(filename) && description.is_empty(); samples.push(SamplePipeline { @@ -255,8 +355,12 @@ async fn load_samples_from_dir( description, yaml, is_system, - mode: mode_to_string(mode), + mode: mode_to_string(meta.mode), is_fragment, + group: discovery.group, + variant: discovery.variant, + category: discovery.category, + tags: discovery.tags, }); }, Err(e) => { @@ -362,11 +466,19 @@ pub async fn get_sample( let yaml = fs::read_to_string(&path).await?; - let (name, description, mode) = parse_pipeline_metadata(&yaml, &path); + let meta = parse_pipeline_metadata(&yaml, &path); + let mode_str = mode_to_string(meta.mode); - let name = name.unwrap_or_else(|| filename_to_name(&filename)); - let description = description.unwrap_or_default(); - let mode_str = mode_to_string(mode); + let discovery = crate::sample_discovery::derive( + filename_base, + &meta.node_kinds, + meta.client_input.as_deref(), + meta.client_output.as_deref(), + meta.explicit, + ); + + let name = meta.name.unwrap_or_else(|| filename_to_name(&filename)); + let description = meta.description.unwrap_or_default(); let relative_path = path.strip_prefix(&base_path).unwrap_or(&path).to_string_lossy().to_string(); @@ -385,6 +497,10 @@ pub async fn get_sample( is_system, mode: mode_str, is_fragment, + group: discovery.group, + variant: discovery.variant, + category: discovery.category, + tags: discovery.tags, }); } } @@ -494,8 +610,16 @@ async fn save_sample( // Always prefix user pipelines with "user/" let id = format!("user/{base_filename}"); - let (_, _, mode) = parse_pipeline_metadata(&yaml_with_metadata, &path); - let mode_str = mode_to_string(mode); + let meta = parse_pipeline_metadata(&yaml_with_metadata, &path); + let mode_str = mode_to_string(meta.mode); + + let discovery = crate::sample_discovery::derive( + base_filename, + &meta.node_kinds, + meta.client_input.as_deref(), + meta.client_output.as_deref(), + meta.explicit, + ); Ok(SamplePipeline { id, @@ -505,6 +629,10 @@ async fn save_sample( is_system: false, mode: mode_str, is_fragment: request.is_fragment, + group: discovery.group, + variant: discovery.variant, + category: discovery.category, + tags: discovery.tags, }) } @@ -836,11 +964,11 @@ nodes: needs: input "; let path = std::path::Path::new("test.yml"); - let (name, description, mode) = parse_pipeline_metadata(yaml, path); + let meta = parse_pipeline_metadata(yaml, path); - assert_eq!(name.as_deref(), Some("My Sample")); - assert_eq!(description.as_deref(), Some("A nice description")); - assert_eq!(mode, EngineMode::OneShot); + assert_eq!(meta.name.as_deref(), Some("My Sample")); + assert_eq!(meta.description.as_deref(), Some("A nice description")); + assert_eq!(meta.mode, EngineMode::OneShot); } #[test] @@ -854,11 +982,12 @@ steps: - kind: streamkit::http_output "; let path = std::path::Path::new("steps.yml"); - let (name, description, mode) = parse_pipeline_metadata(yaml, path); + let meta = parse_pipeline_metadata(yaml, path); - assert_eq!(name.as_deref(), Some("Linear")); - assert_eq!(description.as_deref(), Some("Steps form")); - assert_eq!(mode, EngineMode::Dynamic); + assert_eq!(meta.name.as_deref(), Some("Linear")); + assert_eq!(meta.description.as_deref(), Some("Steps form")); + assert_eq!(meta.mode, EngineMode::Dynamic); + assert_eq!(meta.node_kinds, vec!["streamkit::http_input", "streamkit::http_output"]); } #[test] @@ -871,12 +1000,12 @@ nodes: kind: streamkit::http_input "; let path = std::path::Path::new("anon.yml"); - let (name, description, mode) = parse_pipeline_metadata(yaml, path); + let meta = parse_pipeline_metadata(yaml, path); - assert!(name.is_none(), "expected no name, got {name:?}"); - assert!(description.is_none(), "expected no description, got {description:?}"); - assert_eq!(mode, EngineMode::default()); - assert_eq!(mode, EngineMode::Dynamic, "Dynamic must be the documented default"); + assert!(meta.name.is_none(), "expected no name, got {:?}", meta.name); + assert!(meta.description.is_none(), "expected no description, got {:?}", meta.description); + assert_eq!(meta.mode, EngineMode::default()); + assert_eq!(meta.mode, EngineMode::Dynamic, "Dynamic must be the documented default"); } #[test] @@ -888,10 +1017,10 @@ nodes: kind: streamkit::http_input "; let path = std::path::Path::new("no-mode.yml"); - let (name, _, mode) = parse_pipeline_metadata(yaml, path); + let meta = parse_pipeline_metadata(yaml, path); - assert_eq!(name.as_deref(), Some("No mode")); - assert_eq!(mode, EngineMode::default()); + assert_eq!(meta.name.as_deref(), Some("No mode")); + assert_eq!(meta.mode, EngineMode::default()); } #[test] @@ -902,11 +1031,11 @@ nodes: // best-effort enrichment. let yaml = "this: is: not: valid: yaml: at: all: ["; let path = std::path::Path::new("broken.yml"); - let (name, description, mode) = parse_pipeline_metadata(yaml, path); + let meta = parse_pipeline_metadata(yaml, path); - assert!(name.is_none()); - assert!(description.is_none()); - assert_eq!(mode, EngineMode::default()); + assert!(meta.name.is_none()); + assert!(meta.description.is_none()); + assert_eq!(meta.mode, EngineMode::default()); } } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 82ee1e3e2..13570117c 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -391,6 +391,19 @@ pub struct SamplePipeline { pub mode: String, #[serde(default)] pub is_fragment: bool, + /// Base-scenario key shared by variants; collapses near-duplicate samples + /// into a single card with a variant selector in the UI. + #[serde(default)] + pub group: Option, + /// Human label distinguishing this variant within its `group`. + #[serde(default)] + pub variant: Option, + /// Top-level bucket used for faceted filtering. + #[serde(default)] + pub category: Option, + /// Capability keywords powering fuzzy search and facet chips. + #[serde(default)] + pub tags: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, TS)] diff --git a/crates/api/src/yaml/compiler.rs b/crates/api/src/yaml/compiler.rs index 85046a9c7..80edc60b5 100644 --- a/crates/api/src/yaml/compiler.rs +++ b/crates/api/src/yaml/compiler.rs @@ -8,10 +8,10 @@ use indexmap::IndexMap; pub fn compile(pipeline: UserPipeline) -> Result { match pipeline { - UserPipeline::Steps { name, description, mode, steps, client } => { + UserPipeline::Steps { name, description, mode, steps, client, .. } => { Ok(compile_steps(name, description, mode, steps, client)) }, - UserPipeline::Dag { name, description, mode, nodes, client } => { + UserPipeline::Dag { name, description, mode, nodes, client, .. } => { compile_dag(name, description, mode, nodes, client) }, } @@ -296,7 +296,17 @@ mod tests { } fn dag_pipeline(nodes: IndexMap, mode: EngineMode) -> UserPipeline { - UserPipeline::Dag { name: None, description: None, mode, nodes, client: None } + UserPipeline::Dag { + name: None, + description: None, + mode, + group: None, + variant: None, + category: None, + tags: Vec::new(), + nodes, + client: None, + } } fn user_node(kind: &str, needs: Needs) -> UserNode { diff --git a/crates/api/src/yaml/mod.rs b/crates/api/src/yaml/mod.rs index 0fada506f..8351957fc 100644 --- a/crates/api/src/yaml/mod.rs +++ b/crates/api/src/yaml/mod.rs @@ -269,6 +269,10 @@ pub struct ControlConfig { pub options: Option>, } +/// Optional top-level discovery fields are repeated across both `UserPipeline` +/// arms (faceted search and variant grouping). They are overrides: when omitted +/// the server derives best-effort values from node kinds, the client section, +/// and filename patterns. See `apps/skit/src/sample_discovery.rs`. #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum UserPipeline { @@ -279,6 +283,14 @@ pub enum UserPipeline { description: Option, #[serde(default)] mode: EngineMode, + #[serde(default)] + group: Option, + #[serde(default)] + variant: Option, + #[serde(default)] + category: Option, + #[serde(default)] + tags: Vec, steps: Vec, client: Option, }, @@ -289,6 +301,14 @@ pub enum UserPipeline { description: Option, #[serde(default)] mode: EngineMode, + #[serde(default)] + group: Option, + #[serde(default)] + variant: Option, + #[serde(default)] + category: Option, + #[serde(default)] + tags: Vec, nodes: IndexMap, client: Option, }, diff --git a/samples/pipelines/dynamic/video_moq_colorbars.yml b/samples/pipelines/dynamic/video_moq_colorbars.yml index eb18cf847..5bdbb3ae6 100644 --- a/samples/pipelines/dynamic/video_moq_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_colorbars.yml @@ -5,6 +5,12 @@ name: Video Color Bars (MoQ Stream) description: Continuously generates SMPTE color bars and streams via MoQ mode: dynamic +variant: Software +category: Streaming +tags: + - colorbars + - smpte + - test-pattern client: gateway_path: /moq/video watch: diff --git a/samples/pipelines/oneshot/speech_to_text.yml b/samples/pipelines/oneshot/speech_to_text.yml index 8d797f5f2..76041408b 100644 --- a/samples/pipelines/oneshot/speech_to_text.yml +++ b/samples/pipelines/oneshot/speech_to_text.yml @@ -1,6 +1,9 @@ name: Speech-to-Text (Whisper) description: Transcribes speech to text using Whisper mode: oneshot +tags: + - transcription + - subtitles client: input: type: file_upload diff --git a/samples/pipelines/oneshot/vad-demo.yml b/samples/pipelines/oneshot/vad-demo.yml index dad8056ba..c68f3d585 100644 --- a/samples/pipelines/oneshot/vad-demo.yml +++ b/samples/pipelines/oneshot/vad-demo.yml @@ -5,6 +5,10 @@ name: Voice Activity Detection description: Detects voice activity and outputs events as JSON mode: oneshot +category: Voice Activity +tags: + - vad + - voice-activity-detection client: input: type: file_upload diff --git a/ui/src/components/converter/TemplateSelector.styles.ts b/ui/src/components/converter/TemplateSelector.styles.ts new file mode 100644 index 000000000..4f1a4d8aa --- /dev/null +++ b/ui/src/components/converter/TemplateSelector.styles.ts @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +/** + * Styled components for TemplateSelector. + * + * Extracted to a separate file to keep the main component under the + * file-length lint budget. + */ + +import styled from '@emotion/styled'; +import * as RadixRadioGroup from '@radix-ui/react-radio-group'; + +import { RadioLabel } from '@/components/ui/RadioGroup'; + +export const SelectorContainer = styled.div` + width: 100%; +`; + +export const Controls = styled.div` + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 12px; +`; + +export const SearchInput = styled.input` + flex: 1; + min-width: 220px; + padding: 10px 12px; + font-size: 14px; + background: var(--sk-bg); + color: var(--sk-text); + border: 1px solid var(--sk-border); + border-radius: 8px; + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--sk-primary); + } + + &::placeholder { + color: var(--sk-text-muted); + } +`; + +export const FilterGroup = styled.div` + display: inline-flex; + border: 1px solid var(--sk-border); + border-radius: 8px; + overflow: hidden; + background: var(--sk-panel-bg); +`; + +export const FilterButton = styled.button<{ active?: boolean }>` + padding: 8px 12px; + font-size: 13px; + font-weight: 700; + border: none; + cursor: pointer; + transition: none; + background: ${(props) => (props.active ? 'var(--sk-primary)' : 'transparent')}; + color: ${(props) => (props.active ? 'var(--sk-primary-contrast)' : 'var(--sk-text)')}; + + &:hover { + background: ${(props) => (props.active ? 'var(--sk-primary-hover)' : 'var(--sk-hover-bg)')}; + } + + &:focus-visible { + outline: 2px solid var(--sk-primary); + outline-offset: -2px; + } +`; + +export const HiddenSelectionHint = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + margin-bottom: 12px; + border: 1px solid var(--sk-border); + border-radius: 8px; + background: var(--sk-panel-bg); + color: var(--sk-text-muted); + font-size: 13px; +`; + +export const HintButton = styled.button` + border: none; + background: none; + color: var(--sk-primary); + font-weight: 700; + cursor: pointer; + padding: 0; + + &:hover { + color: var(--sk-primary-hover); + } + + &:focus-visible { + outline: 2px solid var(--sk-primary); + outline-offset: 2px; + border-radius: 4px; + } +`; + +export const Section = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 18px; +`; + +export const SectionHeader = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + color: var(--sk-text-muted); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +`; + +export const SectionCount = styled.span` + font-weight: 700; + letter-spacing: 0.02em; + text-transform: none; +`; + +export const EmptyState = styled.div` + padding: 16px; + border: 1px solid var(--sk-border); + border-radius: 8px; + background: var(--sk-panel-bg); + color: var(--sk-text-muted); + font-size: 14px; +`; + +export const TemplateGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 16px; +`; + +export const TemplateCard = styled(RadioLabel)` + padding: 20px; + background: var(--sk-panel-bg); + border: 2px solid var(--sk-border); + border-radius: 8px; + cursor: pointer; + text-align: left; + display: flex; + gap: 12px; + transition: none; + align-items: flex-start; + + &:hover { + border-color: var(--sk-border-strong); + background: var(--sk-hover-bg); + } + + &:has([data-state='checked']) { + background: var(--sk-primary); + color: var(--sk-primary-contrast); + border-color: var(--sk-primary); + } + + &:has([data-state='checked']):hover { + background: var(--sk-primary-hover); + border-color: var(--sk-primary-hover); + } +`; + +export const TemplateContent = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +`; + +export const TemplateHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const TemplateName = styled.div` + font-weight: 600; + font-size: 16px; +`; + +export const TemplateBadge = styled.span<{ variant: 'system' | 'user' }>` + font-size: 11px; + font-weight: 700; + padding: 3px 10px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + background: ${(props) => (props.variant === 'system' ? '#4caf50' : '#2196f3')}; + color: #ffffff; + + /* Adjust for selected state - use high contrast */ + [data-state='checked'] & { + background: rgba(0, 0, 0, 0.3); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.6); + padding: 2px 9px; /* Account for border */ + } +`; + +export const TemplateDescription = styled.div` + font-size: 13px; + line-height: 1.4; + color: inherit; + opacity: 0.9; +`; + +export const FacetBar = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +`; + +export const FacetRow = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +`; + +export const FacetRowLabel = styled.span` + color: var(--sk-text-muted); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-right: 4px; +`; + +export const FacetChip = styled.button<{ active?: boolean }>` + padding: 5px 12px; + font-size: 13px; + font-weight: 600; + border-radius: 999px; + cursor: pointer; + transition: none; + background: ${(props) => (props.active ? 'var(--sk-primary)' : 'var(--sk-panel-bg)')}; + color: ${(props) => (props.active ? 'var(--sk-primary-contrast)' : 'var(--sk-text)')}; + border: 1px solid ${(props) => (props.active ? 'var(--sk-primary)' : 'var(--sk-border)')}; + + &:hover { + border-color: ${(props) => + props.active ? 'var(--sk-primary-hover)' : 'var(--sk-border-strong)'}; + background: ${(props) => (props.active ? 'var(--sk-primary-hover)' : 'var(--sk-hover-bg)')}; + } + + &:focus-visible { + outline: 2px solid var(--sk-primary); + outline-offset: 2px; + } +`; + +// Plain div twin of TemplateCard for multi-variant groups: the card itself is +// not a single radio target, so selection happens through the variant pills. +export const GroupCard = styled.div` + padding: 20px; + background: var(--sk-panel-bg); + border: 2px solid var(--sk-border); + border-radius: 8px; + text-align: left; + display: flex; + flex-direction: column; + gap: 12px; + + &[data-selected] { + border-color: var(--sk-primary); + } +`; + +export const VariantSelector = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; +`; + +export const VariantOption = styled(RadixRadioGroup.Item)` + padding: 5px 12px; + font-size: 13px; + font-weight: 600; + border-radius: 999px; + cursor: pointer; + background: var(--sk-bg); + color: var(--sk-text); + border: 1px solid var(--sk-border); + + &:hover { + border-color: var(--sk-border-strong); + background: var(--sk-hover-bg); + } + + &[data-state='checked'] { + background: var(--sk-primary); + color: var(--sk-primary-contrast); + border-color: var(--sk-primary); + } + + &:focus-visible { + outline: 2px solid var(--sk-primary); + outline-offset: 2px; + } +`; diff --git a/ui/src/components/converter/TemplateSelector.test.tsx b/ui/src/components/converter/TemplateSelector.test.tsx index 2b1fa5aa2..f3efc1169 100644 --- a/ui/src/components/converter/TemplateSelector.test.tsx +++ b/ui/src/components/converter/TemplateSelector.test.tsx @@ -18,6 +18,10 @@ function makePipeline(overrides: Partial = {}): SamplePipeline { is_system: true, mode: 'oneshot', is_fragment: false, + group: null, + variant: null, + category: null, + tags: [], ...overrides, }; } @@ -137,3 +141,98 @@ describe('TemplateSelector', () => { expect(screen.getByRole('group', { name: 'Filter templates by origin' })).toBeInTheDocument(); }); }); + +describe('TemplateSelector variant grouping', () => { + const colorbars = makePipeline({ + id: 'd/colorbars', + name: 'Colorbars', + group: 'video-moq-colorbars', + }); + const h264 = makePipeline({ + id: 'd/h264-colorbars', + name: 'H.264 Colorbars', + group: 'video-moq-colorbars', + variant: 'H.264', + }); + const vaapi = makePipeline({ + id: 'd/vaapi-colorbars', + name: 'VA-API Colorbars', + group: 'video-moq-colorbars', + variant: 'VA-API H.264', + }); + + it('collapses a variant family into a single card with a variant selector', () => { + render( + + ); + + const systemHeader = screen.getByText('System Pipelines').closest('div')!; + expect(within(systemHeader).getByText('1')).toBeInTheDocument(); + + expect(screen.getByRole('group', { name: /Colorbars variants/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Colorbars (default)' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'H.264' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'VA-API H.264' })).toBeInTheDocument(); + }); + + it('selecting a variant loads that variant id', () => { + const onSelect = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('radio', { name: 'VA-API H.264' })); + expect(onSelect).toHaveBeenCalledWith('d/vaapi-colorbars'); + }); +}); + +describe('TemplateSelector facets', () => { + const encode = makePipeline({ + id: 'd/encode', + name: 'VA-API Encode', + category: 'Video Encoding', + tags: ['video-encoding', 'hardware:vaapi'], + }); + const transcribe = makePipeline({ + id: 'o/transcribe', + name: 'Transcribe', + category: 'Speech to Text', + tags: ['speech-to-text'], + }); + + it('filters by category facet chip', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Speech to Text' })); + expect(screen.getByText('Transcribe')).toBeInTheDocument(); + expect(screen.queryByText('VA-API Encode')).not.toBeInTheDocument(); + }); + + it('filters to hardware-requiring samples via the requirements facet', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Needs hardware' })); + expect(screen.getByText('VA-API Encode')).toBeInTheDocument(); + expect(screen.queryByText('Transcribe')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 9706b0684..98df5b24b 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -2,231 +2,189 @@ // // SPDX-License-Identifier: MPL-2.0 -import styled from '@emotion/styled'; import React from 'react'; -import { RadioGroupRoot, RadioItem, RadioIndicator, RadioLabel } from '@/components/ui/RadioGroup'; +import { RadioGroupRoot, RadioItem, RadioIndicator } from '@/components/ui/RadioGroup'; import type { SamplePipeline } from '@/types/generated/api-types'; +import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { + collectSampleFacets, compareSamplePipelinesByName, + groupSamplePipelinesByScenario, matchesSamplePipelineQuery, + sampleNeedsHardware, } from '@/utils/samplePipelineOrdering'; -const SelectorContainer = styled.div` - width: 100%; -`; - -const Controls = styled.div` - display: flex; - gap: 12px; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - margin-bottom: 12px; -`; - -const SearchInput = styled.input` - flex: 1; - min-width: 220px; - padding: 10px 12px; - font-size: 14px; - background: var(--sk-bg); - color: var(--sk-text); - border: 1px solid var(--sk-border); - border-radius: 8px; - font-family: inherit; - - &:focus { - outline: none; - border-color: var(--sk-primary); - } - - &::placeholder { - color: var(--sk-text-muted); - } -`; - -const FilterGroup = styled.div` - display: inline-flex; - border: 1px solid var(--sk-border); - border-radius: 8px; - overflow: hidden; - background: var(--sk-panel-bg); -`; - -const FilterButton = styled.button<{ active?: boolean }>` - padding: 8px 12px; - font-size: 13px; - font-weight: 700; - border: none; - cursor: pointer; - transition: none; - background: ${(props) => (props.active ? 'var(--sk-primary)' : 'transparent')}; - color: ${(props) => (props.active ? 'var(--sk-primary-contrast)' : 'var(--sk-text)')}; - - &:hover { - background: ${(props) => (props.active ? 'var(--sk-primary-hover)' : 'var(--sk-hover-bg)')}; - } +import { + Controls, + EmptyState, + FacetBar, + FacetChip, + FacetRow, + FacetRowLabel, + FilterButton, + FilterGroup, + GroupCard, + HiddenSelectionHint, + HintButton, + SearchInput, + Section, + SectionCount, + SectionHeader, + SelectorContainer, + TemplateBadge, + TemplateCard, + TemplateContent, + TemplateDescription, + TemplateGrid, + TemplateHeader, + TemplateName, + VariantOption, + VariantSelector, +} from './TemplateSelector.styles'; + +function formatTagLabel(tag: string): string { + return tag + .split('-') + .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); +} - &:focus-visible { - outline: 2px solid var(--sk-primary); - outline-offset: -2px; - } -`; - -const HiddenSelectionHint = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - margin-bottom: 12px; - border: 1px solid var(--sk-border); - border-radius: 8px; - background: var(--sk-panel-bg); - color: var(--sk-text-muted); - font-size: 13px; -`; - -const HintButton = styled.button` - border: none; - background: none; - color: var(--sk-primary); - font-weight: 700; - cursor: pointer; - padding: 0; - - &:hover { - color: var(--sk-primary-hover); - } +interface TemplateSelectorProps { + templates: SamplePipeline[]; + selectedTemplateId: string; + onTemplateSelect: (templateId: string) => void; +} - &:focus-visible { - outline: 2px solid var(--sk-primary); - outline-offset: 2px; - border-radius: 4px; - } -`; - -const Section = styled.div` - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 18px; -`; - -const SectionHeader = styled.div` - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - color: var(--sk-text-muted); - font-size: 12px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -`; - -const SectionCount = styled.span` - font-weight: 700; - letter-spacing: 0.02em; - text-transform: none; -`; - -const EmptyState = styled.div` - padding: 16px; - border: 1px solid var(--sk-border); - border-radius: 8px; - background: var(--sk-panel-bg); - color: var(--sk-text-muted); - font-size: 14px; -`; - -const TemplateGrid = styled.div` - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 16px; -`; - -const TemplateCard = styled(RadioLabel)` - padding: 20px; - background: var(--sk-panel-bg); - border: 2px solid var(--sk-border); - border-radius: 8px; - cursor: pointer; - text-align: left; - display: flex; - gap: 12px; - transition: none; - align-items: flex-start; - - &:hover { - border-color: var(--sk-border-strong); - background: var(--sk-hover-bg); +const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string }> = ({ + group, + selectedTemplateId, +}) => { + const { base, variants } = group; + + if (variants.length === 1) { + return ( + + + + + + + {base.name} + + {base.is_system ? 'System' : 'User'} + + + {base.description && {base.description}} + + + ); } - &:has([data-state='checked']) { - background: var(--sk-primary); - color: var(--sk-primary-contrast); - border-color: var(--sk-primary); - } + const selectedInGroup = variants.some((variant) => variant.id === selectedTemplateId); - &:has([data-state='checked']):hover { - background: var(--sk-primary-hover); - border-color: var(--sk-primary-hover); - } -`; - -const TemplateContent = styled.div` - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; -`; - -const TemplateHeader = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; - -const TemplateName = styled.div` - font-weight: 600; - font-size: 16px; -`; - -const TemplateBadge = styled.span<{ variant: 'system' | 'user' }>` - font-size: 11px; - font-weight: 700; - padding: 3px 10px; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.5px; - white-space: nowrap; - background: ${(props) => (props.variant === 'system' ? '#4caf50' : '#2196f3')}; - color: #ffffff; - - /* Adjust for selected state - use high contrast */ - [data-state='checked'] & { - background: rgba(0, 0, 0, 0.3); - color: #ffffff; - border: 1px solid rgba(255, 255, 255, 0.6); - padding: 2px 9px; /* Account for border */ - } -`; + return ( + + + + {base.name} + + {base.is_system ? 'System' : 'User'} + + + {base.description && {base.description}} + + + {variants.map((variant) => ( + + {variant.variant ?? 'Default'} + + ))} + + + ); +}; -const TemplateDescription = styled.div` - font-size: 13px; - line-height: 1.4; - color: inherit; - opacity: 0.9; -`; +function toScenarioGroups(samples: SamplePipeline[]): ScenarioGroup[] { + return groupSamplePipelinesByScenario(samples) + .slice() + .sort((a, b) => compareSamplePipelinesByName(a.base, b.base)); +} -interface TemplateSelectorProps { - templates: SamplePipeline[]; - selectedTemplateId: string; - onTemplateSelect: (templateId: string) => void; +interface FacetFiltersProps { + facets: SampleFacets; + categoryFilter: string | null; + capabilityFilter: string | null; + hardwareOnly: boolean; + onToggleCategory: (category: string) => void; + onToggleCapability: (capability: string) => void; + onToggleHardware: () => void; } +const FacetFilters: React.FC = ({ + facets, + categoryFilter, + capabilityFilter, + hardwareOnly, + onToggleCategory, + onToggleCapability, + onToggleHardware, +}) => ( + + {facets.categories.length > 0 && ( + + Category + {facets.categories.map((category) => ( + onToggleCategory(category)} + > + {category} + + ))} + + )} + + {facets.capabilities.length > 0 && ( + + Capability + {facets.capabilities.map((capability) => ( + onToggleCapability(capability)} + > + {formatTagLabel(capability)} + + ))} + + )} + + {facets.hasHardware && ( + + Requirements + + Needs hardware + + + )} + +); + export const TemplateSelector: React.FC = ({ templates, selectedTemplateId, @@ -234,33 +192,58 @@ export const TemplateSelector: React.FC = ({ }) => { const [query, setQuery] = React.useState(''); const [originFilter, setOriginFilter] = React.useState<'all' | 'system' | 'user'>('all'); + const [categoryFilter, setCategoryFilter] = React.useState(null); + const [capabilityFilter, setCapabilityFilter] = React.useState(null); + const [hardwareOnly, setHardwareOnly] = React.useState(false); + + const facets = React.useMemo(() => collectSampleFacets(templates), [templates]); + + const facetFiltersActive = React.useMemo( + () => Boolean(categoryFilter || capabilityFilter || hardwareOnly), + [categoryFilter, capabilityFilter, hardwareOnly] + ); const resetFilters = React.useCallback(() => { setQuery(''); setOriginFilter('all'); + setCategoryFilter(null); + setCapabilityFilter(null); + setHardwareOnly(false); }, []); + const toggleCategory = React.useCallback( + (category: string) => setCategoryFilter((current) => (current === category ? null : category)), + [] + ); + + const toggleCapability = React.useCallback( + (capability: string) => + setCapabilityFilter((current) => (current === capability ? null : capability)), + [] + ); + + const toggleHardware = React.useCallback(() => setHardwareOnly((current) => !current), []); + const filteredTemplates = React.useMemo(() => { return templates.filter((template) => { if (originFilter === 'system' && !template.is_system) return false; if (originFilter === 'user' && template.is_system) return false; + if (categoryFilter && template.category !== categoryFilter) return false; + if (capabilityFilter && !(template.tags ?? []).includes(capabilityFilter)) return false; + if (hardwareOnly && !sampleNeedsHardware(template)) return false; return matchesSamplePipelineQuery(template, query); }); - }, [templates, originFilter, query]); - - const systemTemplates = React.useMemo(() => { - return filteredTemplates - .filter((template) => template.is_system) - .slice() - .sort(compareSamplePipelinesByName); - }, [filteredTemplates]); - - const userTemplates = React.useMemo(() => { - return filteredTemplates - .filter((template) => !template.is_system) - .slice() - .sort(compareSamplePipelinesByName); - }, [filteredTemplates]); + }, [templates, originFilter, categoryFilter, capabilityFilter, hardwareOnly, query]); + + const systemGroups = React.useMemo( + () => toScenarioGroups(filteredTemplates.filter((template) => template.is_system)), + [filteredTemplates] + ); + + const userGroups = React.useMemo( + () => toScenarioGroups(filteredTemplates.filter((template) => !template.is_system)), + [filteredTemplates] + ); const selectedExists = React.useMemo(() => { return templates.some((template) => template.id === selectedTemplateId); @@ -270,11 +253,21 @@ export const TemplateSelector: React.FC = ({ return filteredTemplates.some((template) => template.id === selectedTemplateId); }, [filteredTemplates, selectedTemplateId]); - const showHiddenSelectionHint = - selectedTemplateId && - selectedExists && - !selectedVisible && - (query.trim() || originFilter !== 'all'); + const showHiddenSelectionHint = React.useMemo( + () => + Boolean( + selectedTemplateId && + selectedExists && + !selectedVisible && + (query.trim() || originFilter !== 'all' || facetFiltersActive) + ), + [selectedTemplateId, selectedExists, selectedVisible, query, originFilter, facetFiltersActive] + ); + + const showFacetBar = React.useMemo( + () => facets.categories.length > 0 || facets.capabilities.length > 0 || facets.hasHardware, + [facets] + ); return ( @@ -310,6 +303,18 @@ export const TemplateSelector: React.FC = ({ + {showFacetBar && ( + + )} + {showHiddenSelectionHint && (
Selected template is hidden by your filters.
@@ -324,59 +329,41 @@ export const TemplateSelector: React.FC = ({ onValueChange={onTemplateSelect} aria-label="Pipeline template selection" > - {systemTemplates.length === 0 && userTemplates.length === 0 && ( + {systemGroups.length === 0 && userGroups.length === 0 && ( No pipelines match your filters. )} - {systemTemplates.length > 0 && ( + {systemGroups.length > 0 && (
System Pipelines - {systemTemplates.length} + {systemGroups.length} - {systemTemplates.map((template) => ( - - - - - - - {template.name} - - {template.is_system ? 'System' : 'User'} - - - {template.description} - - + {systemGroups.map((group) => ( + ))}
)} - {userTemplates.length > 0 && ( + {userGroups.length > 0 && (
User Pipelines - {userTemplates.length} + {userGroups.length} - {userTemplates.map((template) => ( - - - - - - - {template.name} - - {template.is_system ? 'System' : 'User'} - - - {template.description} - - + {userGroups.map((group) => ( + ))}
diff --git a/ui/src/types/generated/api-types.ts b/ui/src/types/generated/api-types.ts index 781e5f112..2aafa6a76 100644 --- a/ui/src/types/generated/api-types.ts +++ b/ui/src/types/generated/api-types.ts @@ -373,7 +373,24 @@ export type SamplePipeline = { id: string, name: string, description: string, ya /** * Whether this is a reusable fragment (partial pipeline) vs a complete pipeline */ -is_fragment: boolean, }; +is_fragment: boolean, +/** + * Base-scenario key shared by variants; collapses near-duplicate samples + * into a single card with a variant selector in the UI. + */ +group: string | null, +/** + * Human label distinguishing this variant within its `group`. + */ +variant: string | null, +/** + * Top-level bucket used for faceted filtering. + */ +category: string | null, +/** + * Capability keywords powering fuzzy search and facet chips. + */ +tags: Array, }; export type SavePipelineRequest = { name: string, description: string, yaml: string, overwrite: boolean, /** diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index 2d66bb4c3..f31ec7bfc 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -7,9 +7,12 @@ import { describe, expect, it } from 'vitest'; import type { SamplePipeline } from '@/types/generated/api-types'; import { + collectSampleFacets, compareSamplePipelinesByName, + groupSamplePipelinesByScenario, matchesSamplePipelineQuery, orderSamplePipelinesSystemFirst, + sampleNeedsHardware, } from './samplePipelineOrdering'; function makePipeline(partial: Partial & { id: string }): SamplePipeline { @@ -21,6 +24,10 @@ function makePipeline(partial: Partial & { id: string }): Sample is_system: partial.is_system ?? false, mode: partial.mode ?? 'dynamic', is_fragment: partial.is_fragment ?? false, + group: partial.group ?? null, + variant: partial.variant ?? null, + category: partial.category ?? null, + tags: partial.tags ?? [], }; } @@ -128,4 +135,110 @@ describe('matchesSamplePipelineQuery', () => { expect(matchesSamplePipelineQuery(sparse, 'x')).toBe(true); expect(matchesSamplePipelineQuery(sparse, 'absent')).toBe(false); }); + + it('matches via tags and category', () => { + const sample = makePipeline({ + id: 'whisper-transcribe', + name: 'Live Transcription', + category: 'Speech to Text', + tags: ['speech-to-text', 'voice-activity-detection'], + }); + expect(matchesSamplePipelineQuery(sample, 'speech')).toBe(true); + expect(matchesSamplePipelineQuery(sample, 'voice activity')).toBe(true); + }); + + it('expands synonyms so abbreviations find derived tags', () => { + const stt = makePipeline({ id: 'stt', name: 'Whisper', tags: ['speech-to-text'] }); + const tts = makePipeline({ id: 'tts', name: 'Kokoro', tags: ['text-to-speech'] }); + expect(matchesSamplePipelineQuery(stt, 'stt')).toBe(true); + expect(matchesSamplePipelineQuery(stt, 'transcribe')).toBe(true); + expect(matchesSamplePipelineQuery(tts, 'tts')).toBe(true); + expect(matchesSamplePipelineQuery(tts, 'speech synthesis')).toBe(true); + expect(matchesSamplePipelineQuery(tts, 'stt')).toBe(false); + }); + + it('requires every query term to match (AND semantics)', () => { + const sample = makePipeline({ + id: 'hw-encode', + name: 'VA-API H.264 Colorbars', + tags: ['video-encoding', 'hardware:vaapi'], + }); + expect(matchesSamplePipelineQuery(sample, 'vaapi encode')).toBe(true); + expect(matchesSamplePipelineQuery(sample, 'vaapi audio')).toBe(false); + }); +}); + +describe('groupSamplePipelinesByScenario', () => { + const plain = makePipeline({ + id: 'd/colorbars', + name: 'Colorbars', + group: 'video-moq-colorbars', + }); + const h264 = makePipeline({ + id: 'd/h264-colorbars', + name: 'H.264 Colorbars', + group: 'video-moq-colorbars', + variant: 'H.264', + }); + const vaapi = makePipeline({ + id: 'd/vaapi-colorbars', + name: 'VA-API Colorbars', + group: 'video-moq-colorbars', + variant: 'VA-API H.264', + }); + + it('collapses same-group samples into one entry with sorted variants', () => { + const groups = groupSamplePipelinesByScenario([h264, plain, vaapi]); + expect(groups).toHaveLength(1); + expect(groups[0].key).toBe('video-moq-colorbars'); + // Canonical (no variant) is the base and comes first. + expect(groups[0].base).toBe(plain); + expect(groups[0].variants[0]).toBe(plain); + expect(groups[0].variants.map((v) => v.variant)).toEqual([null, 'H.264', 'VA-API H.264']); + }); + + it('treats samples without a group as singletons keyed by id', () => { + const a = makePipeline({ id: 'oneshot/tts', name: 'TTS' }); + const b = makePipeline({ id: 'oneshot/stt', name: 'STT' }); + const groups = groupSamplePipelinesByScenario([a, b]); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.key)).toEqual(['oneshot/tts', 'oneshot/stt']); + }); + + it('preserves first-appearance order of groups', () => { + const groups = groupSamplePipelinesByScenario([ + makePipeline({ id: 'b', group: 'beta' }), + makePipeline({ id: 'a', group: 'alpha' }), + makePipeline({ id: 'b2', group: 'beta' }), + ]); + expect(groups.map((g) => g.key)).toEqual(['beta', 'alpha']); + }); +}); + +describe('sampleNeedsHardware', () => { + it('detects hardware facet tags', () => { + expect(sampleNeedsHardware(makePipeline({ id: 'a', tags: ['hardware:vaapi'] }))).toBe(true); + expect(sampleNeedsHardware(makePipeline({ id: 'b', tags: ['video-encoding'] }))).toBe(false); + }); +}); + +describe('collectSampleFacets', () => { + it('aggregates sorted categories and capabilities, excluding hardware tags', () => { + const facets = collectSampleFacets([ + makePipeline({ + id: 'a', + category: 'Video Encoding', + tags: ['video-encoding', 'hardware:vaapi'], + }), + makePipeline({ id: 'b', category: 'Speech to Text', tags: ['speech-to-text'] }), + ]); + expect(facets.categories).toEqual(['Speech to Text', 'Video Encoding']); + expect(facets.capabilities).toEqual(['speech-to-text', 'video-encoding']); + expect(facets.hasHardware).toBe(true); + }); + + it('reports no hardware when no hardware tags are present', () => { + const facets = collectSampleFacets([makePipeline({ id: 'a', tags: ['mp4'] })]); + expect(facets.hasHardware).toBe(false); + }); }); diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 5507f57bb..611a6413a 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -37,14 +37,153 @@ export function orderSamplePipelinesSystemFirst(pipelines: SamplePipeline[]): Sa return [...system, ...user]; } -export function matchesSamplePipelineQuery(pipeline: SamplePipeline, query: string): boolean { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return true; +/** + * Groups of interchangeable search terms. A query term matches a pipeline if any + * term in the same group appears in the searchable text, so "stt", "transcribe" + * and the derived `speech-to-text` tag are all mutually findable. + */ +const SYNONYM_GROUPS: string[][] = [ + ['stt', 'speech-to-text', 'speech to text', 'transcribe', 'transcription', 'transcript', 'asr'], + ['tts', 'text-to-speech', 'text to speech', 'speech synthesis', 'synthesize', 'voice'], + ['translate', 'translation', 'translator'], + ['vad', 'voice-activity-detection', 'voice activity'], + ['encode', 'encoding', 'encoder', 'video-encoding', 'transcode'], + ['decode', 'decoding', 'decoder', 'video-decoding'], + ['compositor', 'compositing', 'composite', 'overlay'], + ['webcam', 'camera', 'cam'], + ['mic', 'microphone'], + ['moq', 'media over quic', 'stream', 'streaming'], + ['hw', 'hardware', 'gpu', 'accelerated'], + ['pip', 'picture-in-picture', 'picture in picture'], + ['av1', 'aom'], + ['h264', 'avc', 'h.264'], + ['hevc', 'h265', 'h.265'], +]; - const haystack = [pipeline.name, pipeline.description, pipeline.id] +function expandTerm(term: string): string[] { + const expanded = new Set([term]); + for (const group of SYNONYM_GROUPS) { + if (group.some((entry) => entry === term || entry.includes(term) || term.includes(entry))) { + for (const entry of group) expanded.add(entry); + } + } + return [...expanded]; +} + +function searchableText(pipeline: SamplePipeline): string { + return [ + pipeline.name, + pipeline.description, + pipeline.id, + pipeline.category, + pipeline.variant, + pipeline.group, + ...(pipeline.tags ?? []), + ] .filter(Boolean) .join(' ') .toLowerCase(); +} + +/** + * Token + synonym match: every whitespace-separated query term must match the + * pipeline's searchable text (name, description, id, category, variant, group, + * tags), where a term matches if it — or any of its synonyms — is a substring. + */ +export function matchesSamplePipelineQuery(pipeline: SamplePipeline, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return true; + + const haystack = searchableText(pipeline); + const terms = normalizedQuery.split(/\s+/).filter(Boolean); + + return terms.every((term) => expandTerm(term).some((candidate) => haystack.includes(candidate))); +} + +export interface ScenarioGroup { + /** Stable key shared by all variants (the pipeline `group`, or the id). */ + key: string; + /** Representative sample used for the card title/description. */ + base: SamplePipeline; + /** All samples in the group, canonical (no explicit variant) first. */ + variants: SamplePipeline[]; +} + +function variantSortKey(sample: SamplePipeline): string { + return (sample.variant ?? '').toLowerCase(); +} + +/** + * Collapses samples that share a `group` into a single entry with a variant + * list, so near-duplicate cards (e.g. the colorbars codec/hardware family) + * render once with a variant selector. Samples without a group are singletons. + * Input order of first appearance is preserved. + */ +export function groupSamplePipelinesByScenario(samples: SamplePipeline[]): ScenarioGroup[] { + const order: string[] = []; + const byKey = new Map(); + + for (const sample of samples) { + const key = sample.group && sample.group.length > 0 ? sample.group : sample.id; + const existing = byKey.get(key); + if (existing) { + existing.push(sample); + } else { + byKey.set(key, [sample]); + order.push(key); + } + } + + return order.map((key) => { + const members = byKey.get(key) ?? []; + const variants = members.slice().sort((a, b) => { + const aCanonical = a.variant ? 1 : 0; + const bCanonical = b.variant ? 1 : 0; + if (aCanonical !== bCanonical) return aCanonical - bCanonical; + const variantCompare = getCollator().compare(variantSortKey(a), variantSortKey(b)); + if (variantCompare !== 0) return variantCompare; + return compareSamplePipelinesByName(a, b); + }); + const base = variants.find((v) => !v.variant) ?? variants[0]; + return { key, base, variants }; + }); +} + +const HARDWARE_TAG_PREFIX = 'hardware:'; + +export function sampleNeedsHardware(sample: SamplePipeline): boolean { + return (sample.tags ?? []).some((tag) => tag.startsWith(HARDWARE_TAG_PREFIX)); +} + +export interface SampleFacets { + categories: string[]; + capabilities: string[]; + hasHardware: boolean; +} + +/** Capability tags exclude the `hardware:*` facets, which surface as a toggle. */ +export function collectSampleFacets(samples: SamplePipeline[]): SampleFacets { + const categories = new Set(); + const capabilities = new Set(); + let hasHardware = false; + + for (const sample of samples) { + if (sample.category) categories.add(sample.category); + for (const tag of sample.tags ?? []) { + if (tag.startsWith(HARDWARE_TAG_PREFIX)) { + hasHardware = true; + } else { + capabilities.add(tag); + } + } + } + + const sortStrings = (values: Set): string[] => + [...values].sort((a, b) => getCollator().compare(a, b)); - return haystack.includes(normalizedQuery); + return { + categories: sortStrings(categories), + capabilities: sortStrings(capabilities), + hasHardware, + }; } From ccee3afdf904e73b663902b9aba857d3e6553b9c Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 30 May 2026 16:19:32 +0000 Subject: [PATCH 02/14] test(ui): add discovery fields to SamplePipeline test fixtures The extended SamplePipeline type makes group/variant/category/tags required; update the two inline mocks in the samples/fragments service tests to match. Signed-off-by: streamkit-devin --- ui/src/services/fragments.test.ts | 4 ++++ ui/src/services/samples.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/ui/src/services/fragments.test.ts b/ui/src/services/fragments.test.ts index b77290554..f551a400c 100644 --- a/ui/src/services/fragments.test.ts +++ b/ui/src/services/fragments.test.ts @@ -30,6 +30,10 @@ const FRAGMENT_SAMPLE: SamplePipeline = { is_system: false, mode: 'oneshot', is_fragment: true, + group: null, + variant: null, + category: null, + tags: [], }; beforeEach(() => { diff --git a/ui/src/services/samples.test.ts b/ui/src/services/samples.test.ts index 915f6fb0a..b8cf7338b 100644 --- a/ui/src/services/samples.test.ts +++ b/ui/src/services/samples.test.ts @@ -60,6 +60,10 @@ const SAMPLE: SamplePipeline = { is_system: false, mode: 'oneshot', is_fragment: false, + group: null, + variant: null, + category: null, + tags: [], }; beforeEach(() => { From ecae7da02c8aab5beda4374dbf48f5e123baea6f Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 30 May 2026 16:42:16 +0000 Subject: [PATCH 03/14] fix(samples): make grouped variant pills selectable by full name in E2E Grouped scenario cards render each variant as a radio pill whose visible label is the short variant name, so E2E specs that clicked a sample by its full name could no longer select it (colorbars and webcam-PiP families). Set each variant pill's accessible name to the full sample name and add a selectPipelineTemplate() helper that selects via the radio role for grouped samples and falls back to the name text for ungrouped cards. Signed-off-by: streamkit-devin --- e2e/tests/convert.spec.ts | 19 +++----------- e2e/tests/stream.spec.ts | 25 ++++--------------- e2e/tests/test-helpers.ts | 18 ++++++++++++- .../converter/TemplateSelector.test.tsx | 8 +++--- .../components/converter/TemplateSelector.tsx | 6 +---- 5 files changed, 31 insertions(+), 45 deletions(-) diff --git a/e2e/tests/convert.spec.ts b/e2e/tests/convert.spec.ts index 49673350c..945ef4b35 100644 --- a/e2e/tests/convert.spec.ts +++ b/e2e/tests/convert.spec.ts @@ -11,6 +11,7 @@ import { ensureLoggedIn, getAuthHeaders } from './auth-helpers'; import { type ConsoleErrorCollector, createConsoleErrorCollector, + selectPipelineTemplate, verifyAudioPlayback, verifyVideoPlayback, } from './test-helpers'; @@ -105,11 +106,7 @@ test.describe('Convert View - Audio Mixing Pipeline', () => { }) => { await expect(page.getByText('1. Select Pipeline Template')).toBeVisible(); - const templateCard = page.getByText('Audio Mixing (Upload + Music Track)', { - exact: true, - }); - await expect(templateCard).toBeVisible({ timeout: 10_000 }); - await templateCard.click(); + await selectPipelineTemplate(page, 'Audio Mixing (Upload + Music Track)'); await expect(page.locator('input[type="file"]').first()).toBeAttached(); await page.locator('input[type="file"]').first().setInputFiles(sampleOggPath); @@ -137,11 +134,7 @@ test.describe('Convert View - Audio Mixing Pipeline', () => { }) => { await expect(page.getByText('1. Select Pipeline Template')).toBeVisible(); - const templateCard = page.getByText('Audio Mixing (Upload + Music Track)', { - exact: true, - }); - await expect(templateCard).toBeVisible({ timeout: 10_000 }); - await templateCard.click(); + await selectPipelineTemplate(page, 'Audio Mixing (Upload + Music Track)'); const assetModeButton = page.getByRole('button', { name: /Select Existing Asset/i, @@ -192,11 +185,7 @@ test.describe('Convert View - Video Color Bars Pipeline', () => { await expect(page.getByText('1. Select Pipeline Template')).toBeVisible(); - const templateCard = page.getByText('Video Color Bars (VP9/WebM)', { - exact: true, - }); - await expect(templateCard).toBeVisible({ timeout: 10_000 }); - await templateCard.click(); + await selectPipelineTemplate(page, 'Video Color Bars (VP9/WebM)'); // This is a no-input (generator) pipeline, so the button says "Generate". const generateButton = page.getByRole('button', { name: /Generate/i }); diff --git a/e2e/tests/stream.spec.ts b/e2e/tests/stream.spec.ts index 6a8f94b31..32abeff7d 100644 --- a/e2e/tests/stream.spec.ts +++ b/e2e/tests/stream.spec.ts @@ -10,6 +10,7 @@ import { MOQ_BENIGN_PATTERNS, createConsoleErrorCollector, installAudioContextTracker, + selectPipelineTemplate, verifyAudioContextActive, verifyCanvasRendering, } from './test-helpers'; @@ -37,11 +38,7 @@ test.describe('Stream View - Dynamic Pipeline', () => { const pipelineHeading = page.getByText('Pipeline Selection'); await expect(pipelineHeading).toBeVisible({ timeout: 15_000 }); - const templateCard = page.getByText('MoQ Peer Transcoder (Gateway)', { - exact: true, - }); - await expect(templateCard).toBeVisible({ timeout: 10_000 }); - await templateCard.click(); + await selectPipelineTemplate(page, 'MoQ Peer Transcoder (Gateway)'); const createButton = page.getByRole('button', { name: /Create Session/i }); await expect(createButton).toBeEnabled({ timeout: 5_000 }); @@ -93,11 +90,7 @@ test.describe('Stream View - Dynamic Pipeline', () => { } } - const templateCard = page.getByText('MoQ Peer Transcoder (Gateway)', { - exact: true, - }); - await expect(templateCard).toBeVisible({ timeout: 10_000 }); - await templateCard.click(); + await selectPipelineTemplate(page, 'MoQ Peer Transcoder (Gateway)'); const createButton = page.getByRole('button', { name: /Create Session/i }); await expect(createButton).toBeEnabled({ timeout: 5_000 }); @@ -233,11 +226,7 @@ test.describe('Stream View - Video MoQ Color Bars Pipeline', () => { } // Select the video colorbars MoQ template. - const templateCard = page.getByText('Video Color Bars (MoQ Stream)', { - exact: true, - }); - await expect(templateCard).toBeVisible({ timeout: 10_000 }); - await templateCard.click(); + await selectPipelineTemplate(page, 'Video Color Bars (MoQ Stream)'); // Create session. const createButton = page.getByRole('button', { name: /Create Session/i }); @@ -375,11 +364,7 @@ test.describe('Stream View - Webcam PiP Pipeline', () => { } // Select the webcam PiP template. - const templateCard = page.getByText('Webcam PiP (MoQ Stream)', { - exact: true, - }); - await expect(templateCard).toBeVisible({ timeout: 10_000 }); - await templateCard.click(); + await selectPipelineTemplate(page, 'Webcam PiP (MoQ Stream)'); // Create session. const createButton = page.getByRole('button', { name: /Create Session/i }); diff --git a/e2e/tests/test-helpers.ts b/e2e/tests/test-helpers.ts index 2660095b2..c2cf2aad4 100644 --- a/e2e/tests/test-helpers.ts +++ b/e2e/tests/test-helpers.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MPL-2.0 -import type { Page } from '@playwright/test'; +import { expect, type Page } from '@playwright/test'; // Console error substrings that are always benign regardless of test context. // ResizeObserver loop errors are a Chromium quirk; WebSocket reconnect chatter @@ -32,6 +32,22 @@ export interface ConsoleErrorCollector { getUnexpected(extraBenignPatterns?: string[]): string[]; } +/** + * Selects a sample pipeline in the TemplateSelector by its full name, working + * for both ungrouped cards (clickable name text) and grouped scenarios where + * the sample is a variant pill whose accessible name is the full sample name. + */ +export async function selectPipelineTemplate(page: Page, name: string): Promise { + const variantPill = page.getByRole('radio', { name, exact: true }); + const flatCard = page.getByText(name, { exact: true }); + await expect(variantPill.or(flatCard).first()).toBeVisible({ timeout: 10_000 }); + if ((await variantPill.count()) > 0) { + await variantPill.first().click(); + } else { + await flatCard.click(); + } +} + /** * Attach a console-error listener to `page` and return a collector object. * diff --git a/ui/src/components/converter/TemplateSelector.test.tsx b/ui/src/components/converter/TemplateSelector.test.tsx index f3efc1169..c2f5e08ea 100644 --- a/ui/src/components/converter/TemplateSelector.test.tsx +++ b/ui/src/components/converter/TemplateSelector.test.tsx @@ -174,9 +174,9 @@ describe('TemplateSelector variant grouping', () => { expect(within(systemHeader).getByText('1')).toBeInTheDocument(); expect(screen.getByRole('group', { name: /Colorbars variants/i })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'Colorbars (default)' })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'H.264' })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'VA-API H.264' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Colorbars' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'H.264 Colorbars' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'VA-API Colorbars' })).toBeInTheDocument(); }); it('selecting a variant loads that variant id', () => { @@ -189,7 +189,7 @@ describe('TemplateSelector variant grouping', () => { /> ); - fireEvent.click(screen.getByRole('radio', { name: 'VA-API H.264' })); + fireEvent.click(screen.getByRole('radio', { name: 'VA-API Colorbars' })); expect(onSelect).toHaveBeenCalledWith('d/vaapi-colorbars'); }); }); diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 98df5b24b..97471fde5 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -96,11 +96,7 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string {variants.map((variant) => ( - + {variant.variant ?? 'Default'} ))} From f26f2b07b74c0bab0fcffa97c1a4d6fd5a94ccfd Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sat, 30 May 2026 20:11:27 +0000 Subject: [PATCH 04/14] refactor(samples): address discovery UX review feedback - Drop variant/category override from video_moq_colorbars so the software sample stays the canonical group representative and shares the derived Video Encoding category with its codec siblings. - expandTerm: match synonyms only on exact or >=3-char prefix, dropping the reverse-substring branch that leaked short tokens (mic/cam) into unrelated queries (dynamic, scam). - Give flat-card radios an explicit sample-name accessible label so the E2E helper selects grouped and ungrouped cards through one role lookup. - Pass typed Input/OutputType into discovery derivation, removing the hand-rolled snake_case glue and dead match arms. - De-duplicate the Steps/Dag arms in parse_pipeline_metadata. - Drop the no-op group_tokens.dedup(); reuse labelFromKey for capability chips; remove redundant slice; extract ScenarioHeader. Signed-off-by: streamkit-devin --- apps/skit/src/sample_discovery.rs | 29 ++-- apps/skit/src/samples.rs | 143 ++++++++---------- e2e/tests/test-helpers.ts | 18 +-- .../pipelines/dynamic/video_moq_colorbars.yml | 6 - .../components/converter/TemplateSelector.tsx | 50 +++--- ui/src/utils/jsonSchema.ts | 2 +- ui/src/utils/samplePipelineOrdering.test.ts | 11 ++ ui/src/utils/samplePipelineOrdering.ts | 7 +- 8 files changed, 122 insertions(+), 144 deletions(-) diff --git a/apps/skit/src/sample_discovery.rs b/apps/skit/src/sample_discovery.rs index 8b6824f5d..29eb87964 100644 --- a/apps/skit/src/sample_discovery.rs +++ b/apps/skit/src/sample_discovery.rs @@ -12,6 +12,8 @@ //! node kinds, the client section, and the filename. Explicit YAML values //! always win; derived `tags` are unioned with any curated ones. +use streamkit_api::yaml::{InputType, OutputType}; + /// Explicit discovery fields parsed from a sample's YAML (all optional). #[derive(Debug, Default, Clone)] pub struct ExplicitDiscovery { @@ -107,7 +109,6 @@ fn group_and_variant_from_filename(filename_base: &str) -> (String, Option Vec<&'static str> { fn capability_tags( node_kinds: &[String], - client_input: Option<&str>, - client_output: Option<&str>, + client_input: Option, + client_output: Option, ) -> Vec { let mut tags: Vec = Vec::new(); @@ -198,15 +199,15 @@ fn capability_tags( } match client_output { - Some("transcription") => tags.push("speech-to-text".to_string()), - Some("audio") => tags.push("audio-output".to_string()), - Some("video") => tags.push("video-output".to_string()), - _ => {}, + Some(OutputType::Transcription) => tags.push("speech-to-text".to_string()), + Some(OutputType::Audio) => tags.push("audio-output".to_string()), + Some(OutputType::Video) => tags.push("video-output".to_string()), + Some(OutputType::Json) | None => {}, } match client_input { - Some("file_upload") => tags.push("file-input".to_string()), - Some("text") => tags.push("text-input".to_string()), - _ => {}, + Some(InputType::FileUpload) => tags.push("file-input".to_string()), + Some(InputType::Text) => tags.push("text-input".to_string()), + Some(InputType::Trigger | InputType::None) | None => {}, } tags.sort_unstable(); @@ -241,8 +242,8 @@ fn category_from_tags(tags: &[String]) -> Option { pub fn derive( filename_base: &str, node_kinds: &[String], - client_input: Option<&str>, - client_output: Option<&str>, + client_input: Option, + client_output: Option, explicit: ExplicitDiscovery, ) -> Discovery { let (derived_group, derived_variant) = group_and_variant_from_filename(filename_base); @@ -395,7 +396,7 @@ mod tests { "video_moq_vaapi_h264_colorbars", &kinds(&["video::vaapi::h264_encoder", "transport::moq::publisher"]), None, - Some("video"), + Some(OutputType::Video), ExplicitDiscovery { group: Some("custom-group".to_string()), variant: Some("Custom".to_string()), @@ -418,7 +419,7 @@ mod tests { "video_moq_nv_av1_colorbars", &kinds(&["video::nv::av1_encoder", "transport::moq::publisher"]), None, - Some("video"), + Some(OutputType::Video), ExplicitDiscovery::default(), ); assert_eq!(discovery.group.as_deref(), Some("video-moq-colorbars")); diff --git a/apps/skit/src/samples.rs b/apps/skit/src/samples.rs index 1850ab6f0..661c37559 100644 --- a/apps/skit/src/samples.rs +++ b/apps/skit/src/samples.rs @@ -192,37 +192,15 @@ struct PipelineMetadata { mode: streamkit_api::EngineMode, explicit: crate::sample_discovery::ExplicitDiscovery, node_kinds: Vec, - client_input: Option, - client_output: Option, -} - -const fn input_type_str(input_type: streamkit_api::yaml::InputType) -> &'static str { - use streamkit_api::yaml::InputType; - match input_type { - InputType::FileUpload => "file_upload", - InputType::Text => "text", - InputType::Trigger => "trigger", - InputType::None => "none", - } -} - -const fn output_type_str(output_type: streamkit_api::yaml::OutputType) -> &'static str { - use streamkit_api::yaml::OutputType; - match output_type { - OutputType::Transcription => "transcription", - OutputType::Json => "json", - OutputType::Audio => "audio", - OutputType::Video => "video", - } + client_input: Option, + client_output: Option, } fn client_io_types( client: Option<&streamkit_api::yaml::ClientSection>, -) -> (Option, Option) { - let input = - client.and_then(|c| c.input.as_ref()).map(|i| input_type_str(i.input_type).to_string()); - let output = - client.and_then(|c| c.output.as_ref()).map(|o| output_type_str(o.output_type).to_string()); +) -> (Option, Option) { + let input = client.and_then(|c| c.input.as_ref()).map(|i| i.input_type); + let output = client.and_then(|c| c.output.as_ref()).map(|o| o.output_type); (input, output) } @@ -238,60 +216,63 @@ fn parse_pipeline_metadata(yaml: &str, path: &std::path::Path) -> PipelineMetada }, }; - let explicit = |group, variant, category, tags| crate::sample_discovery::ExplicitDiscovery { - group, - variant, - category, - tags, - }; - - match user_pipeline { - UserPipeline::Steps { - name, - description, - mode, - group, - variant, - category, - tags, - steps, - client, - } => { - let node_kinds = steps.into_iter().map(|s| s.kind).collect(); - let (client_input, client_output) = client_io_types(client.as_ref()); - PipelineMetadata { + // The Steps and Dag arms differ only in how node kinds are collected; the + // discovery fields are shared, so destructure them once here. + let (name, description, mode, group, variant, category, tags, node_kinds, client) = + match user_pipeline { + UserPipeline::Steps { name, description, mode, - explicit: explicit(group, variant, category, tags), - node_kinds, - client_input, - client_output, - } - }, - UserPipeline::Dag { - name, - description, - mode, - group, - variant, - category, - tags, - nodes, - client, - } => { - let node_kinds = nodes.into_values().map(|n| n.kind).collect(); - let (client_input, client_output) = client_io_types(client.as_ref()); - PipelineMetadata { + group, + variant, + category, + tags, + steps, + client, + } => ( name, description, mode, - explicit: explicit(group, variant, category, tags), - node_kinds, - client_input, - client_output, - } - }, + group, + variant, + category, + tags, + steps.into_iter().map(|s| s.kind).collect::>(), + client, + ), + UserPipeline::Dag { + name, + description, + mode, + group, + variant, + category, + tags, + nodes, + client, + } => ( + name, + description, + mode, + group, + variant, + category, + tags, + nodes.into_values().map(|n| n.kind).collect::>(), + client, + ), + }; + + let (client_input, client_output) = client_io_types(client.as_ref()); + PipelineMetadata { + name, + description, + mode, + explicit: crate::sample_discovery::ExplicitDiscovery { group, variant, category, tags }, + node_kinds, + client_input, + client_output, } } @@ -340,8 +321,8 @@ async fn load_samples_from_dir( let discovery = crate::sample_discovery::derive( base_filename, &meta.node_kinds, - meta.client_input.as_deref(), - meta.client_output.as_deref(), + meta.client_input, + meta.client_output, meta.explicit, ); @@ -472,8 +453,8 @@ pub async fn get_sample( let discovery = crate::sample_discovery::derive( filename_base, &meta.node_kinds, - meta.client_input.as_deref(), - meta.client_output.as_deref(), + meta.client_input, + meta.client_output, meta.explicit, ); @@ -616,8 +597,8 @@ async fn save_sample( let discovery = crate::sample_discovery::derive( base_filename, &meta.node_kinds, - meta.client_input.as_deref(), - meta.client_output.as_deref(), + meta.client_input, + meta.client_output, meta.explicit, ); diff --git a/e2e/tests/test-helpers.ts b/e2e/tests/test-helpers.ts index c2cf2aad4..e63f2ad25 100644 --- a/e2e/tests/test-helpers.ts +++ b/e2e/tests/test-helpers.ts @@ -33,19 +33,15 @@ export interface ConsoleErrorCollector { } /** - * Selects a sample pipeline in the TemplateSelector by its full name, working - * for both ungrouped cards (clickable name text) and grouped scenarios where - * the sample is a variant pill whose accessible name is the full sample name. + * Selects a sample pipeline in the TemplateSelector by its full name. Both + * ungrouped cards and grouped scenario variant pills expose the radio with the + * sample name as its accessible name, so a single role-based lookup selects + * either one. */ export async function selectPipelineTemplate(page: Page, name: string): Promise { - const variantPill = page.getByRole('radio', { name, exact: true }); - const flatCard = page.getByText(name, { exact: true }); - await expect(variantPill.or(flatCard).first()).toBeVisible({ timeout: 10_000 }); - if ((await variantPill.count()) > 0) { - await variantPill.first().click(); - } else { - await flatCard.click(); - } + const radio = page.getByRole('radio', { name, exact: true }); + await expect(radio.first()).toBeVisible({ timeout: 10_000 }); + await radio.first().click(); } /** diff --git a/samples/pipelines/dynamic/video_moq_colorbars.yml b/samples/pipelines/dynamic/video_moq_colorbars.yml index 5bdbb3ae6..eb18cf847 100644 --- a/samples/pipelines/dynamic/video_moq_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_colorbars.yml @@ -5,12 +5,6 @@ name: Video Color Bars (MoQ Stream) description: Continuously generates SMPTE color bars and streams via MoQ mode: dynamic -variant: Software -category: Streaming -tags: - - colorbars - - smpte - - test-pattern client: gateway_path: /moq/video watch: diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 97471fde5..71eadb36f 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { RadioGroupRoot, RadioItem, RadioIndicator } from '@/components/ui/RadioGroup'; import type { SamplePipeline } from '@/types/generated/api-types'; +import { labelFromKey } from '@/utils/jsonSchema'; import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { collectSampleFacets, @@ -43,19 +44,24 @@ import { VariantSelector, } from './TemplateSelector.styles'; -function formatTagLabel(tag: string): string { - return tag - .split('-') - .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) - .join(' '); -} - interface TemplateSelectorProps { templates: SamplePipeline[]; selectedTemplateId: string; onTemplateSelect: (templateId: string) => void; } +const ScenarioHeader: React.FC<{ sample: SamplePipeline }> = ({ sample }) => ( + + + {sample.name} + + {sample.is_system ? 'System' : 'User'} + + + {sample.description && {sample.description}} + +); + const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string }> = ({ group, selectedTemplateId, @@ -65,18 +71,10 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string if (variants.length === 1) { return ( - + - - - {base.name} - - {base.is_system ? 'System' : 'User'} - - - {base.description && {base.description}} - + ); } @@ -85,15 +83,7 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string return ( - - - {base.name} - - {base.is_system ? 'System' : 'User'} - - - {base.description && {base.description}} - + {variants.map((variant) => ( @@ -106,9 +96,9 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string }; function toScenarioGroups(samples: SamplePipeline[]): ScenarioGroup[] { - return groupSamplePipelinesByScenario(samples) - .slice() - .sort((a, b) => compareSamplePipelinesByName(a.base, b.base)); + return groupSamplePipelinesByScenario(samples).sort((a, b) => + compareSamplePipelinesByName(a.base, b.base) + ); } interface FacetFiltersProps { @@ -159,7 +149,7 @@ const FacetFilters: React.FC = ({ aria-pressed={capabilityFilter === capability} onClick={() => onToggleCapability(capability)} > - {formatTagLabel(capability)} + {labelFromKey(capability)} ))} diff --git a/ui/src/utils/jsonSchema.ts b/ui/src/utils/jsonSchema.ts index 69024bf07..ae33046f0 100644 --- a/ui/src/utils/jsonSchema.ts +++ b/ui/src/utils/jsonSchema.ts @@ -256,7 +256,7 @@ export const extractTextConfigs = (schema: JsonSchema | undefined): TextConfig[] // Schema → ControlConfig conversion /** Derive a human-readable label: "clock_running" → "Clock Running". */ -function labelFromKey(key: string): string { +export function labelFromKey(key: string): string { return key.replace(/[_-]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index f31ec7bfc..ec8b90cd9 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -157,6 +157,17 @@ describe('matchesSamplePipelineQuery', () => { expect(matchesSamplePipelineQuery(tts, 'stt')).toBe(false); }); + it('does not let a query that merely contains a short synonym leak in its group', () => { + const mic = makePipeline({ id: 'mic', name: 'Microphone Capture', tags: ['microphone'] }); + const cam = makePipeline({ id: 'cam', name: 'Webcam PiP', tags: ['webcam'] }); + // "dynamic" contains "mic" and "scam" contains "cam"; neither should expand + // the microphone/webcam synonym groups. + expect(matchesSamplePipelineQuery(mic, 'dynamic')).toBe(false); + expect(matchesSamplePipelineQuery(cam, 'scam')).toBe(false); + // Genuine prefixes still expand their group. + expect(matchesSamplePipelineQuery(cam, 'camera')).toBe(true); + }); + it('requires every query term to match (AND semantics)', () => { const sample = makePipeline({ id: 'hw-encode', diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 611a6413a..ce4200f2a 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -60,10 +60,15 @@ const SYNONYM_GROUPS: string[][] = [ ['hevc', 'h265', 'h.265'], ]; +// A query term joins a synonym group on an exact match, or when it is a prefix +// fragment of an entry (>=3 chars, so "transcrib" finds "transcribe"). The +// reverse direction (entry being a substring of the term) is deliberately +// excluded: short entries like "mic"/"cam" would otherwise pull whole groups +// into unrelated queries ("dynamic" → microphone, "scam" → webcam). function expandTerm(term: string): string[] { const expanded = new Set([term]); for (const group of SYNONYM_GROUPS) { - if (group.some((entry) => entry === term || entry.includes(term) || term.includes(entry))) { + if (group.some((entry) => entry === term || (term.length >= 3 && entry.includes(term)))) { for (const entry of group) expanded.add(entry); } } From 2b2e461f9e00b3de5fd0ff59c3fbaaa20a09c097 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 07:34:11 +0000 Subject: [PATCH 05/14] docs(ui): clarify expandTerm synonym match is substring not prefix Signed-off-by: streamkit-devin --- ui/src/utils/samplePipelineOrdering.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index ce4200f2a..4e860e10b 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -60,8 +60,8 @@ const SYNONYM_GROUPS: string[][] = [ ['hevc', 'h265', 'h.265'], ]; -// A query term joins a synonym group on an exact match, or when it is a prefix -// fragment of an entry (>=3 chars, so "transcrib" finds "transcribe"). The +// A query term joins a synonym group on an exact match, or when it is a +// substring of an entry (>=3 chars, so "transcrib" finds "transcribe"). The // reverse direction (entry being a substring of the term) is deliberately // excluded: short entries like "mic"/"cam" would otherwise pull whole groups // into unrelated queries ("dynamic" → microphone, "scam" → webcam). From f9a38a4d0a0988278063b7f77cbe7cbb593b4de7 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 07:51:42 +0000 Subject: [PATCH 06/14] feat(ui): polish sample discovery facets and variant cards - Label the no-variant base member 'Software' instead of the opaque 'Default'. - Acronym-aware capability labels (MoQ/MP4/MSE/RTMP/WebM/VP9) via formatCapabilityLabel. - Give GroupCard hover + a filled selected state matching flat cards. - Square off filter chips so they read distinctly from the rounded variant pills. - Add a persistent 'Clear all filters' affordance plus empty-state recovery. - Drop capability chips already covered by a shown Category facet to remove the redundant facet rows. Signed-off-by: streamkit-devin --- .../converter/TemplateSelector.styles.ts | 41 ++++++++++++++++- .../components/converter/TemplateSelector.tsx | 26 +++++++++-- ui/src/utils/samplePipelineOrdering.test.ts | 44 ++++++++++++++++--- ui/src/utils/samplePipelineOrdering.ts | 43 ++++++++++++++++++ 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/ui/src/components/converter/TemplateSelector.styles.ts b/ui/src/components/converter/TemplateSelector.styles.ts index 4f1a4d8aa..f3389566f 100644 --- a/ui/src/components/converter/TemplateSelector.styles.ts +++ b/ui/src/components/converter/TemplateSelector.styles.ts @@ -135,6 +135,10 @@ export const SectionCount = styled.span` `; export const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; padding: 16px; border: 1px solid var(--sk-border); border-radius: 8px; @@ -246,11 +250,13 @@ export const FacetRowLabel = styled.span` margin-right: 4px; `; +// Filter chips use a squared-off shape so they read as filters, distinct from +// the fully-rounded variant pills (which are a selection control, not a filter). export const FacetChip = styled.button<{ active?: boolean }>` padding: 5px 12px; font-size: 13px; font-weight: 600; - border-radius: 999px; + border-radius: 6px; cursor: pointer; transition: none; background: ${(props) => (props.active ? 'var(--sk-primary)' : 'var(--sk-panel-bg)')}; @@ -281,8 +287,41 @@ export const GroupCard = styled.div` flex-direction: column; gap: 12px; + &:hover { + border-color: var(--sk-border-strong); + background: var(--sk-hover-bg); + } + &[data-selected] { border-color: var(--sk-primary); + background: color-mix(in srgb, var(--sk-primary) 8%, var(--sk-panel-bg)); + box-shadow: inset 0 0 0 1px var(--sk-primary); + } + + &[data-selected]:hover { + background: color-mix(in srgb, var(--sk-primary) 14%, var(--sk-panel-bg)); + } +`; + +// Ghost button for clearing all active filters (facet bar + empty state). +export const ClearAllButton = styled.button` + border: 1px solid var(--sk-border); + background: var(--sk-panel-bg); + color: var(--sk-primary); + font-size: 13px; + font-weight: 700; + padding: 5px 12px; + border-radius: 6px; + cursor: pointer; + + &:hover { + border-color: var(--sk-primary); + background: var(--sk-hover-bg); + } + + &:focus-visible { + outline: 2px solid var(--sk-primary); + outline-offset: 2px; } `; diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 71eadb36f..7104a299b 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -6,17 +6,18 @@ import React from 'react'; import { RadioGroupRoot, RadioItem, RadioIndicator } from '@/components/ui/RadioGroup'; import type { SamplePipeline } from '@/types/generated/api-types'; -import { labelFromKey } from '@/utils/jsonSchema'; import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { collectSampleFacets, compareSamplePipelinesByName, + formatCapabilityLabel, groupSamplePipelinesByScenario, matchesSamplePipelineQuery, sampleNeedsHardware, } from '@/utils/samplePipelineOrdering'; import { + ClearAllButton, Controls, EmptyState, FacetBar, @@ -87,7 +88,7 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string {variants.map((variant) => ( - {variant.variant ?? 'Default'} + {variant.variant ?? 'Software'} ))} @@ -149,7 +150,7 @@ const FacetFilters: React.FC = ({ aria-pressed={capabilityFilter === capability} onClick={() => onToggleCapability(capability)} > - {labelFromKey(capability)} + {formatCapabilityLabel(capability)} ))} @@ -189,6 +190,11 @@ export const TemplateSelector: React.FC = ({ [categoryFilter, capabilityFilter, hardwareOnly] ); + const anyFilterActive = React.useMemo( + () => Boolean(query.trim() || originFilter !== 'all' || facetFiltersActive), + [query, originFilter, facetFiltersActive] + ); + const resetFilters = React.useCallback(() => { setQuery(''); setOriginFilter('all'); @@ -287,6 +293,11 @@ export const TemplateSelector: React.FC = ({ User + {anyFilterActive && ( + + Clear all filters + + )} {showFacetBar && ( @@ -316,7 +327,14 @@ export const TemplateSelector: React.FC = ({ aria-label="Pipeline template selection" > {systemGroups.length === 0 && userGroups.length === 0 && ( - No pipelines match your filters. + +
No pipelines match your filters.
+ {anyFilterActive && ( + + Clear all filters + + )} +
)} {systemGroups.length > 0 && ( diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index ec8b90cd9..a85d7a637 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -9,6 +9,7 @@ import type { SamplePipeline } from '@/types/generated/api-types'; import { collectSampleFacets, compareSamplePipelinesByName, + formatCapabilityLabel, groupSamplePipelinesByScenario, matchesSamplePipelineQuery, orderSamplePipelinesSystemFirst, @@ -235,17 +236,34 @@ describe('sampleNeedsHardware', () => { describe('collectSampleFacets', () => { it('aggregates sorted categories and capabilities, excluding hardware tags', () => { + const facets = collectSampleFacets([ + makePipeline({ id: 'a', category: 'Video Encoding', tags: ['hardware:vaapi', 'colorbars'] }), + makePipeline({ id: 'b', category: 'Speech to Text', tags: ['mp4'] }), + ]); + expect(facets.categories).toEqual(['Speech to Text', 'Video Encoding']); + expect(facets.capabilities).toEqual(['colorbars', 'mp4']); + expect(facets.hasHardware).toBe(true); + }); + + it('drops capability tags already represented by a shown category', () => { const facets = collectSampleFacets([ makePipeline({ id: 'a', category: 'Video Encoding', - tags: ['video-encoding', 'hardware:vaapi'], + tags: ['video-encoding', 'moq', 'vp9'], }), - makePipeline({ id: 'b', category: 'Speech to Text', tags: ['speech-to-text'] }), + makePipeline({ id: 'b', category: 'Streaming', tags: ['moq'] }), ]); - expect(facets.categories).toEqual(['Speech to Text', 'Video Encoding']); - expect(facets.capabilities).toEqual(['speech-to-text', 'video-encoding']); - expect(facets.hasHardware).toBe(true); + // `video-encoding` and `moq` map to the shown `Video Encoding` / `Streaming` + // categories, so only the cross-cutting `vp9` capability remains. + expect(facets.categories).toEqual(['Streaming', 'Video Encoding']); + expect(facets.capabilities).toEqual(['vp9']); + }); + + it('keeps a category-source tag as a capability when its category is absent', () => { + const facets = collectSampleFacets([makePipeline({ id: 'a', tags: ['moq'] })]); + expect(facets.categories).toEqual([]); + expect(facets.capabilities).toEqual(['moq']); }); it('reports no hardware when no hardware tags are present', () => { @@ -253,3 +271,19 @@ describe('collectSampleFacets', () => { expect(facets.hasHardware).toBe(false); }); }); + +describe('formatCapabilityLabel', () => { + it('uses curated acronym casing for known tags', () => { + expect(formatCapabilityLabel('moq')).toBe('MoQ'); + expect(formatCapabilityLabel('mp4')).toBe('MP4'); + expect(formatCapabilityLabel('mse')).toBe('MSE'); + expect(formatCapabilityLabel('rtmp')).toBe('RTMP'); + expect(formatCapabilityLabel('webm')).toBe('WebM'); + expect(formatCapabilityLabel('vp9')).toBe('VP9'); + }); + + it('falls back to title-casing for other tags', () => { + expect(formatCapabilityLabel('voice-activity-detection')).toBe('Voice Activity Detection'); + expect(formatCapabilityLabel('colorbars')).toBe('Colorbars'); + }); +}); diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 4e860e10b..3eaac4f8a 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -4,6 +4,8 @@ import type { SamplePipeline } from '@/types/generated/api-types'; +import { labelFromKey } from './jsonSchema'; + let collator: Intl.Collator | null = null; function getCollator(): Intl.Collator { @@ -160,6 +162,40 @@ export function sampleNeedsHardware(sample: SamplePipeline): boolean { return (sample.tags ?? []).some((tag) => tag.startsWith(HARDWARE_TAG_PREFIX)); } +// Acronyms / mixed-case names that the generic title-caser would mangle +// ("Moq", "Mp4"). Anything not listed falls back to labelFromKey. +const CAPABILITY_LABEL_OVERRIDES: Record = { + moq: 'MoQ', + mp4: 'MP4', + mse: 'MSE', + rtmp: 'RTMP', + webm: 'WebM', + vp9: 'VP9', + av1: 'AV1', +}; + +export function formatCapabilityLabel(tag: string): string { + return CAPABILITY_LABEL_OVERRIDES[tag] ?? labelFromKey(tag); +} + +// Tags that the server also collapses into a `category` bucket (mirrors the +// priority list in apps/skit/src/sample_discovery.rs::category_from_tags). When +// a tag's category is already shown as a Category chip, surfacing the same tag +// again as a Capability chip is redundant, so we drop it from the capability +// facet and let the broader Category chip cover it. +const CATEGORY_SOURCE_TAGS: Record = { + compositing: 'Video Compositing', + 'video-encoding': 'Video Encoding', + translation: 'Translation', + 'speech-to-text': 'Speech to Text', + 'text-to-speech': 'Text to Speech', + 'video-decoding': 'Video Processing', + moq: 'Streaming', + mse: 'Streaming', + rtmp: 'Streaming', + mixing: 'Audio Processing', +}; + export interface SampleFacets { categories: string[]; capabilities: string[]; @@ -183,6 +219,13 @@ export function collectSampleFacets(samples: SamplePipeline[]): SampleFacets { } } + for (const tag of [...capabilities]) { + const sourcedCategory = CATEGORY_SOURCE_TAGS[tag]; + if (sourcedCategory && categories.has(sourcedCategory)) { + capabilities.delete(tag); + } + } + const sortStrings = (values: Set): string[] => [...values].sort((a, b) => getCollator().compare(a, b)); From 0a16be6919268d79e073e3180332393eae66c788 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 07:57:49 +0000 Subject: [PATCH 07/14] fix(ui): render VAD acronym and drop redundant vad tag Add 'vad' to the capability acronym map so it renders 'VAD' not 'Vad', and remove the explicit 'vad' tag from the vad-demo exemplar since the vad node already derives 'voice-activity-detection' (avoids a duplicate facet chip). Signed-off-by: streamkit-devin --- samples/pipelines/oneshot/vad-demo.yml | 1 - ui/src/utils/samplePipelineOrdering.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/pipelines/oneshot/vad-demo.yml b/samples/pipelines/oneshot/vad-demo.yml index c68f3d585..ac4dcad7e 100644 --- a/samples/pipelines/oneshot/vad-demo.yml +++ b/samples/pipelines/oneshot/vad-demo.yml @@ -7,7 +7,6 @@ description: Detects voice activity and outputs events as JSON mode: oneshot category: Voice Activity tags: - - vad - voice-activity-detection client: input: diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 3eaac4f8a..359579e84 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -172,6 +172,7 @@ const CAPABILITY_LABEL_OVERRIDES: Record = { webm: 'WebM', vp9: 'VP9', av1: 'AV1', + vad: 'VAD', }; export function formatCapabilityLabel(tag: string): string { From a79862a4631d7f55d6f64abbc0b5728777530ccd Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 08:38:30 +0000 Subject: [PATCH 08/14] fix(ui): undo lossy facet dedup, rename Needs GPU, unify clear control - Revert the Category/Capability dedup: category is a single priority-picked bucket while tags are multi-valued, so dropping a capability chip whose category is shown removed a cross-cutting filter (e.g. compositor demos that also encode were unreachable from the encoding filter). - Rename the 'Needs hardware' facet to 'Needs GPU' (the underlying tags are all GPU accel APIs: vaapi/nvidia/vulkan). - Collapse the duplicate clear affordances to the single persistent 'Clear all filters' control; drop the empty-state and hidden-hint buttons. Signed-off-by: streamkit-devin --- .../converter/TemplateSelector.styles.ts | 27 ------------------- .../converter/TemplateSelector.test.tsx | 7 +++-- .../components/converter/TemplateSelector.tsx | 19 +++---------- ui/src/utils/samplePipelineOrdering.test.ts | 25 +++++++---------- ui/src/utils/samplePipelineOrdering.ts | 25 ----------------- 5 files changed, 16 insertions(+), 87 deletions(-) diff --git a/ui/src/components/converter/TemplateSelector.styles.ts b/ui/src/components/converter/TemplateSelector.styles.ts index f3389566f..4ec75de82 100644 --- a/ui/src/components/converter/TemplateSelector.styles.ts +++ b/ui/src/components/converter/TemplateSelector.styles.ts @@ -77,10 +77,6 @@ export const FilterButton = styled.button<{ active?: boolean }>` `; export const HiddenSelectionHint = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; padding: 10px 12px; margin-bottom: 12px; border: 1px solid var(--sk-border); @@ -90,25 +86,6 @@ export const HiddenSelectionHint = styled.div` font-size: 13px; `; -export const HintButton = styled.button` - border: none; - background: none; - color: var(--sk-primary); - font-weight: 700; - cursor: pointer; - padding: 0; - - &:hover { - color: var(--sk-primary-hover); - } - - &:focus-visible { - outline: 2px solid var(--sk-primary); - outline-offset: 2px; - border-radius: 4px; - } -`; - export const Section = styled.div` display: flex; flex-direction: column; @@ -135,10 +112,6 @@ export const SectionCount = styled.span` `; export const EmptyState = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; padding: 16px; border: 1px solid var(--sk-border); border-radius: 8px; diff --git a/ui/src/components/converter/TemplateSelector.test.tsx b/ui/src/components/converter/TemplateSelector.test.tsx index c2f5e08ea..76330a15c 100644 --- a/ui/src/components/converter/TemplateSelector.test.tsx +++ b/ui/src/components/converter/TemplateSelector.test.tsx @@ -99,14 +99,13 @@ describe('TemplateSelector', () => { expect(screen.getByText('Selected template is hidden by your filters.')).toBeInTheDocument(); }); - it('clears filters when hint button is clicked', () => { + it('clears filters via the persistent Clear all filters control', () => { render(); const systemButton = screen.getByRole('button', { name: 'System' }); fireEvent.click(systemButton); - const clearButton = screen.getByText('Clear filters'); - fireEvent.click(clearButton); + fireEvent.click(screen.getByRole('button', { name: 'Clear all filters' })); expect(screen.getByText('Transcribe Audio')).toBeInTheDocument(); expect(screen.getByText('My Custom Pipeline')).toBeInTheDocument(); @@ -231,7 +230,7 @@ describe('TemplateSelector facets', () => { /> ); - fireEvent.click(screen.getByRole('button', { name: 'Needs hardware' })); + fireEvent.click(screen.getByRole('button', { name: 'Needs GPU' })); expect(screen.getByText('VA-API Encode')).toBeInTheDocument(); expect(screen.queryByText('Transcribe')).not.toBeInTheDocument(); }); diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 7104a299b..f7d4e3f72 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -28,7 +28,6 @@ import { FilterGroup, GroupCard, HiddenSelectionHint, - HintButton, SearchInput, Section, SectionCount, @@ -165,7 +164,7 @@ const FacetFilters: React.FC = ({ aria-pressed={hardwareOnly} onClick={onToggleHardware} > - Needs hardware + Needs GPU )} @@ -313,12 +312,7 @@ export const TemplateSelector: React.FC = ({ )} {showHiddenSelectionHint && ( - -
Selected template is hidden by your filters.
- - Clear filters - -
+ Selected template is hidden by your filters. )} = ({ aria-label="Pipeline template selection" > {systemGroups.length === 0 && userGroups.length === 0 && ( - -
No pipelines match your filters.
- {anyFilterActive && ( - - Clear all filters - - )} -
+ No pipelines match your filters. )} {systemGroups.length > 0 && ( diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index a85d7a637..b97d9354a 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -245,25 +245,20 @@ describe('collectSampleFacets', () => { expect(facets.hasHardware).toBe(true); }); - it('drops capability tags already represented by a shown category', () => { + it('keeps tags as capabilities even when a same-named category is shown', () => { + // category is a single priority-picked bucket while tags are multi-valued, + // so a capability chip must survive as a cross-cutting filter (e.g. a + // sample bucketed as `Video Compositing` may still carry `video-encoding`). const facets = collectSampleFacets([ + makePipeline({ id: 'a', category: 'Video Encoding', tags: ['video-encoding', 'vp9'] }), makePipeline({ - id: 'a', - category: 'Video Encoding', - tags: ['video-encoding', 'moq', 'vp9'], + id: 'b', + category: 'Video Compositing', + tags: ['compositing', 'video-encoding'], }), - makePipeline({ id: 'b', category: 'Streaming', tags: ['moq'] }), ]); - // `video-encoding` and `moq` map to the shown `Video Encoding` / `Streaming` - // categories, so only the cross-cutting `vp9` capability remains. - expect(facets.categories).toEqual(['Streaming', 'Video Encoding']); - expect(facets.capabilities).toEqual(['vp9']); - }); - - it('keeps a category-source tag as a capability when its category is absent', () => { - const facets = collectSampleFacets([makePipeline({ id: 'a', tags: ['moq'] })]); - expect(facets.categories).toEqual([]); - expect(facets.capabilities).toEqual(['moq']); + expect(facets.categories).toEqual(['Video Compositing', 'Video Encoding']); + expect(facets.capabilities).toEqual(['compositing', 'video-encoding', 'vp9']); }); it('reports no hardware when no hardware tags are present', () => { diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 359579e84..87a3621e4 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -179,24 +179,6 @@ export function formatCapabilityLabel(tag: string): string { return CAPABILITY_LABEL_OVERRIDES[tag] ?? labelFromKey(tag); } -// Tags that the server also collapses into a `category` bucket (mirrors the -// priority list in apps/skit/src/sample_discovery.rs::category_from_tags). When -// a tag's category is already shown as a Category chip, surfacing the same tag -// again as a Capability chip is redundant, so we drop it from the capability -// facet and let the broader Category chip cover it. -const CATEGORY_SOURCE_TAGS: Record = { - compositing: 'Video Compositing', - 'video-encoding': 'Video Encoding', - translation: 'Translation', - 'speech-to-text': 'Speech to Text', - 'text-to-speech': 'Text to Speech', - 'video-decoding': 'Video Processing', - moq: 'Streaming', - mse: 'Streaming', - rtmp: 'Streaming', - mixing: 'Audio Processing', -}; - export interface SampleFacets { categories: string[]; capabilities: string[]; @@ -220,13 +202,6 @@ export function collectSampleFacets(samples: SamplePipeline[]): SampleFacets { } } - for (const tag of [...capabilities]) { - const sourcedCategory = CATEGORY_SOURCE_TAGS[tag]; - if (sourcedCategory && categories.has(sourcedCategory)) { - capabilities.delete(tag); - } - } - const sortStrings = (values: Set): string[] => [...values].sort((a, b) => getCollator().compare(a, b)); From 5630252db5a6d98ab694a4a7ca1e2db08c1b9103 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 12:31:45 +0000 Subject: [PATCH 09/14] refactor(samples): typed codec derivation + discovery precision fixes - Derive variant codecs from typed VideoCodec/AudioCodec enums (single source) instead of string-sniffing; TS uses exhaustive Record maps so a new codec is a compile error until a label is added. - Only auto-derive group for system samples so unrelated user saves with colliding name slugs no longer collapse into one card. - Guard two-letter language tokens to a translation context. - Drop codec/format/transport tags from capability facets (codec is the variant-pill axis); derive the no-variant base pill from its codec tag. - Tighten expandTerm so a shared token cannot pull both video families; precompile per-query synonym expansion once instead of per pipeline. - Collapse duplicated Steps/Dag metadata arms; merge the identical ExplicitDiscovery/Discovery structs; extract GroupSection; drop trivial useMemo over primitives. Signed-off-by: streamkit-devin --- apps/skit/src/sample_discovery.rs | 153 ++++++++++++++---- apps/skit/src/samples.rs | 76 ++++----- .../components/converter/TemplateSelector.tsx | 106 ++++++------ ui/src/utils/samplePipelineOrdering.test.ts | 49 +++++- ui/src/utils/samplePipelineOrdering.ts | 105 +++++++++--- 5 files changed, 325 insertions(+), 164 deletions(-) diff --git a/apps/skit/src/sample_discovery.rs b/apps/skit/src/sample_discovery.rs index 29eb87964..205a31a5f 100644 --- a/apps/skit/src/sample_discovery.rs +++ b/apps/skit/src/sample_discovery.rs @@ -13,18 +13,37 @@ //! always win; derived `tags` are unioned with any curated ones. use streamkit_api::yaml::{InputType, OutputType}; +use streamkit_core::types::{AudioCodec, VideoCodec}; + +/// Output codec a video encoder node produces, or `None` for non-encoder kinds. +/// +/// Node kinds embed the codec's canonical name (`video::vp9::encoder`, +/// `video::vaapi::av1_encoder`, `video::vulkan_video::h264_encoder`, ...), so we +/// match against [`VideoCodec::as_c_name`] rather than re-spelling codec strings +/// — adding a codec variant is then a single edit on the enum. +fn video_codec_for_kind(kind: &str) -> Option { + let k = kind.to_lowercase(); + if !k.contains("encoder") { + return None; + } + [VideoCodec::Vp9, VideoCodec::H264, VideoCodec::Av1] + .into_iter() + .find(|codec| k.contains(codec.as_c_name())) +} -/// Explicit discovery fields parsed from a sample's YAML (all optional). -#[derive(Debug, Default, Clone)] -pub struct ExplicitDiscovery { - pub group: Option, - pub variant: Option, - pub category: Option, - pub tags: Vec, +/// Output codec an audio encoder node produces, or `None` for non-encoder kinds. +fn audio_codec_for_kind(kind: &str) -> Option { + let k = kind.to_lowercase(); + if !k.contains("encoder") { + return None; + } + [AudioCodec::Opus, AudioCodec::Aac].into_iter().find(|codec| k.contains(codec.as_c_name())) } -/// Resolved discovery metadata after merging explicit values with derivation. -#[derive(Debug, Default, Clone, PartialEq, Eq)] +/// Discovery metadata for a sample. Used both for the explicit values parsed +/// from a sample's YAML (all optional) and for the resolved values after +/// merging those with the derived defaults. +#[derive(Debug, Default, Clone)] pub struct Discovery { pub group: Option, pub variant: Option, @@ -76,7 +95,14 @@ fn label_for(table: &[(&'static str, &'static str)], token: &str) -> Option<&'st /// Splits a filename base into its base-scenario group key and a variant label, /// stripping codec/hardware/language tokens that distinguish near-duplicates. -fn group_and_variant_from_filename(filename_base: &str) -> (String, Option) { +/// +/// Language tokens are only honoured when `is_translation` is set; the two-letter +/// codes (`de`, `it`, `pt`, ...) collide with common filename fragments, so +/// outside a translation pipeline they stay part of the group key. +fn group_and_variant_from_filename( + filename_base: &str, + is_translation: bool, +) -> (String, Option) { let mut normalized = filename_base.to_lowercase().replace('-', "_"); let mut variant_parts: Vec = Vec::new(); @@ -94,7 +120,7 @@ fn group_and_variant_from_filename(filename_base: &str) -> (String, Option = Vec::new(); for token in normalized.split('_').filter(|t| !t.is_empty()) { - if let Some(lang) = label_for(LANGUAGE_TOKENS, token) { + if let Some(lang) = is_translation.then(|| label_for(LANGUAGE_TOKENS, token)).flatten() { languages.push(lang); } else if let Some(label) = label_for(SINGLE_TOKENS, token) { variant_parts.push(label.to_string()); @@ -196,6 +222,14 @@ fn capability_tags( for tag in tags_for_kind(kind) { tags.push(tag.to_string()); } + // Output codec, surfaced as the variant axis (pills) and a search term + // but excluded from the capability facets (see collectSampleFacets). + let codec = video_codec_for_kind(kind) + .map(VideoCodec::as_c_name) + .or_else(|| audio_codec_for_kind(kind).map(AudioCodec::as_c_name)); + if let Some(name) = codec { + tags.push(format!("codec:{name}")); + } } match client_output { @@ -239,14 +273,18 @@ fn category_from_tags(tags: &[String]) -> Option { } /// Derives discovery metadata, with explicit YAML values taking precedence. +/// +/// Grouping is only auto-derived for curated system samples; user pipelines +/// group solely via an explicit `group`, so two unrelated saves whose names +/// happen to share a group token are never silently collapsed into one card. pub fn derive( filename_base: &str, node_kinds: &[String], client_input: Option, client_output: Option, - explicit: ExplicitDiscovery, + is_system: bool, + explicit: Discovery, ) -> Discovery { - let (derived_group, derived_variant) = group_and_variant_from_filename(filename_base); let derived_tags = capability_tags(node_kinds, client_input, client_output); let mut tags = explicit.tags; @@ -258,10 +296,14 @@ pub fn derive( tags.sort_unstable(); tags.dedup(); + let is_translation = tags.iter().any(|t| t == "translation"); + let (derived_group, derived_variant) = + group_and_variant_from_filename(filename_base, is_translation); + let category = explicit.category.or_else(|| category_from_tags(&tags)); Discovery { - group: explicit.group.or(Some(derived_group)), + group: explicit.group.or(if is_system { Some(derived_group) } else { None }), variant: explicit.variant.or(derived_variant), category, tags, @@ -278,11 +320,13 @@ mod tests { #[test] fn colorbars_family_shares_a_group_with_distinct_variants() { - let (g_plain, v_plain) = group_and_variant_from_filename("video_moq_colorbars"); - let (g_h264, v_h264) = group_and_variant_from_filename("video_moq_h264_colorbars"); - let (g_vaapi, v_vaapi) = group_and_variant_from_filename("video_moq_vaapi_h264_colorbars"); - let (g_nv, v_nv) = group_and_variant_from_filename("video_moq_nv_av1_colorbars"); - let (g_vk, v_vk) = group_and_variant_from_filename("video_moq_vulkan_video_h264_colorbars"); + let (g_plain, v_plain) = group_and_variant_from_filename("video_moq_colorbars", false); + let (g_h264, v_h264) = group_and_variant_from_filename("video_moq_h264_colorbars", false); + let (g_vaapi, v_vaapi) = + group_and_variant_from_filename("video_moq_vaapi_h264_colorbars", false); + let (g_nv, v_nv) = group_and_variant_from_filename("video_moq_nv_av1_colorbars", false); + let (g_vk, v_vk) = + group_and_variant_from_filename("video_moq_vulkan_video_h264_colorbars", false); assert_eq!(g_plain, "video-moq-colorbars"); assert_eq!(g_h264, "video-moq-colorbars"); @@ -299,20 +343,23 @@ mod tests { #[test] fn svt_av1_compound_does_not_leak_into_group_key() { - let (group, variant) = group_and_variant_from_filename("video_svt_av1_compositor_demo"); + let (group, variant) = + group_and_variant_from_filename("video_svt_av1_compositor_demo", false); assert_eq!(group, "video-compositor-demo"); assert_eq!(variant.as_deref(), Some("SVT-AV1")); - let (plain_group, plain_variant) = group_and_variant_from_filename("video_compositor_demo"); + let (plain_group, plain_variant) = + group_and_variant_from_filename("video_compositor_demo", false); assert_eq!(plain_group, "video-compositor-demo"); assert_eq!(plain_variant, None); } #[test] fn language_pairs_become_directional_variants() { - let (g_en_es, v_en_es) = group_and_variant_from_filename("speech-translate-en-es"); - let (g_es_en, v_es_en) = group_and_variant_from_filename("speech-translate-es-en"); - let (g_hel, v_hel) = group_and_variant_from_filename("speech-translate-helsinki-en-es"); + let (g_en_es, v_en_es) = group_and_variant_from_filename("speech-translate-en-es", true); + let (g_es_en, v_es_en) = group_and_variant_from_filename("speech-translate-es-en", true); + let (g_hel, v_hel) = + group_and_variant_from_filename("speech-translate-helsinki-en-es", true); assert_eq!(g_en_es, "speech-translate"); assert_eq!(g_es_en, "speech-translate"); @@ -322,11 +369,35 @@ mod tests { assert_eq!(v_hel.as_deref(), Some("Helsinki English → Spanish")); } + #[test] + fn language_tokens_are_ignored_outside_a_translation_context() { + let (group, variant) = group_and_variant_from_filename("video_de_interlace_demo", false); + assert_eq!(group, "video-de-interlace-demo"); + assert_eq!(variant, None); + } + + #[test] + fn user_pipelines_are_not_auto_grouped() { + let derive_for = |is_system| { + derive( + "encode_h264", + &kinds(&["video::openh264::encoder"]), + None, + Some(OutputType::Video), + is_system, + Discovery::default(), + ) + .group + }; + assert_eq!(derive_for(true).as_deref(), Some("encode")); + assert_eq!(derive_for(false), None); + } + #[test] fn unrelated_samples_do_not_collide() { - let (g_audio, _) = group_and_variant_from_filename("mp4_mux_audio"); - let (g_video, _) = group_and_variant_from_filename("mp4_mux_video"); - let (g_av, _) = group_and_variant_from_filename("mp4_mux_aac_h264"); + let (g_audio, _) = group_and_variant_from_filename("mp4_mux_audio", false); + let (g_video, _) = group_and_variant_from_filename("mp4_mux_video", false); + let (g_av, _) = group_and_variant_from_filename("mp4_mux_aac_h264", false); assert_ne!(g_audio, g_video); assert_ne!(g_audio, g_av); assert_ne!(g_video, g_av); @@ -373,6 +444,28 @@ mod tests { assert!(!tags.contains(&"video-encoding".to_string())); } + #[test] + fn encoders_emit_the_output_codec_tag() { + // The no-token base of a grouped family (e.g. the software colorbars or + // the Opus mixer) carries no filename variant, so the UI labels its pill + // from this codec tag instead of a hardcoded fallback. + let vp9 = capability_tags(&kinds(&["video::vp9::encoder"]), None, None); + assert!(vp9.contains(&"codec:vp9".to_string())); + + let opus = capability_tags(&kinds(&["audio::opus::encoder"]), None, None); + assert!(opus.contains(&"codec:opus".to_string())); + + let h264 = capability_tags(&kinds(&["video::openh264::encoder"]), None, None); + assert!(h264.contains(&"codec:h264".to_string())); + + let aac = capability_tags(&kinds(&["plugin::native::aac_encoder"]), None, None); + assert!(aac.contains(&"codec:aac".to_string())); + + // Decoders are not the output codec. + let decoder = capability_tags(&kinds(&["audio::opus::decoder"]), None, None); + assert!(!decoder.iter().any(|t| t.starts_with("codec:"))); + } + #[test] fn category_prefers_compositing_then_encoding() { assert_eq!( @@ -397,7 +490,8 @@ mod tests { &kinds(&["video::vaapi::h264_encoder", "transport::moq::publisher"]), None, Some(OutputType::Video), - ExplicitDiscovery { + true, + Discovery { group: Some("custom-group".to_string()), variant: Some("Custom".to_string()), category: Some("Demos".to_string()), @@ -420,7 +514,8 @@ mod tests { &kinds(&["video::nv::av1_encoder", "transport::moq::publisher"]), None, Some(OutputType::Video), - ExplicitDiscovery::default(), + true, + Discovery::default(), ); assert_eq!(discovery.group.as_deref(), Some("video-moq-colorbars")); assert_eq!(discovery.variant.as_deref(), Some("NVIDIA AV1")); diff --git a/apps/skit/src/samples.rs b/apps/skit/src/samples.rs index 661c37559..77bd280db 100644 --- a/apps/skit/src/samples.rs +++ b/apps/skit/src/samples.rs @@ -190,7 +190,7 @@ struct PipelineMetadata { name: Option, description: Option, mode: streamkit_api::EngineMode, - explicit: crate::sample_discovery::ExplicitDiscovery, + explicit: crate::sample_discovery::Discovery, node_kinds: Vec, client_input: Option, client_output: Option, @@ -216,60 +216,35 @@ fn parse_pipeline_metadata(yaml: &str, path: &std::path::Path) -> PipelineMetada }, }; - // The Steps and Dag arms differ only in how node kinds are collected; the - // discovery fields are shared, so destructure them once here. - let (name, description, mode, group, variant, category, tags, node_kinds, client) = - match user_pipeline { - UserPipeline::Steps { - name, - description, - mode, - group, - variant, - category, - tags, - steps, - client, - } => ( - name, - description, - mode, - group, - variant, - category, - tags, - steps.into_iter().map(|s| s.kind).collect::>(), - client, - ), - UserPipeline::Dag { - name, - description, - mode, - group, - variant, - category, - tags, - nodes, - client, - } => ( - name, - description, - mode, - group, - variant, - category, - tags, - nodes.into_values().map(|n| n.kind).collect::>(), - client, - ), - }; + // The Steps and Dag arms differ only in how node kinds are collected, so + // pull those out first and bind the shared discovery fields with one + // irrefutable or-pattern. + let node_kinds: Vec = match &user_pipeline { + UserPipeline::Steps { steps, .. } => steps.iter().map(|s| s.kind.clone()).collect(), + UserPipeline::Dag { nodes, .. } => nodes.values().map(|n| n.kind.clone()).collect(), + }; + + let (UserPipeline::Steps { + name, + description, + mode, + group, + variant, + category, + tags, + client, + .. + } + | UserPipeline::Dag { + name, description, mode, group, variant, category, tags, client, .. + }) = user_pipeline; let (client_input, client_output) = client_io_types(client.as_ref()); PipelineMetadata { name, description, mode, - explicit: crate::sample_discovery::ExplicitDiscovery { group, variant, category, tags }, + explicit: crate::sample_discovery::Discovery { group, variant, category, tags }, node_kinds, client_input, client_output, @@ -323,6 +298,7 @@ async fn load_samples_from_dir( &meta.node_kinds, meta.client_input, meta.client_output, + is_system, meta.explicit, ); @@ -455,6 +431,7 @@ pub async fn get_sample( &meta.node_kinds, meta.client_input, meta.client_output, + is_system, meta.explicit, ); @@ -599,6 +576,7 @@ async fn save_sample( &meta.node_kinds, meta.client_input, meta.client_output, + false, meta.explicit, ); diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index f7d4e3f72..8fee9798e 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -8,11 +8,13 @@ import { RadioGroupRoot, RadioItem, RadioIndicator } from '@/components/ui/Radio import type { SamplePipeline } from '@/types/generated/api-types'; import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { + baseVariantLabel, collectSampleFacets, compareSamplePipelinesByName, + expandQueryTerms, formatCapabilityLabel, groupSamplePipelinesByScenario, - matchesSamplePipelineQuery, + matchesExpandedQuery, sampleNeedsHardware, } from '@/utils/samplePipelineOrdering'; @@ -87,7 +89,7 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string {variants.map((variant) => ( - {variant.variant ?? 'Software'} + {variant.variant ?? baseVariantLabel(variant) ?? 'Default'} ))} @@ -101,6 +103,27 @@ function toScenarioGroups(samples: SamplePipeline[]): ScenarioGroup[] { ); } +const GroupSection: React.FC<{ + title: string; + groups: ScenarioGroup[]; + selectedTemplateId: string; +}> = ({ title, groups, selectedTemplateId }) => { + if (groups.length === 0) return null; + return ( +
+ + {title} + {groups.length} + + + {groups.map((group) => ( + + ))} + +
+ ); +}; + interface FacetFiltersProps { facets: SampleFacets; categoryFilter: string | null; @@ -184,15 +207,8 @@ export const TemplateSelector: React.FC = ({ const facets = React.useMemo(() => collectSampleFacets(templates), [templates]); - const facetFiltersActive = React.useMemo( - () => Boolean(categoryFilter || capabilityFilter || hardwareOnly), - [categoryFilter, capabilityFilter, hardwareOnly] - ); - - const anyFilterActive = React.useMemo( - () => Boolean(query.trim() || originFilter !== 'all' || facetFiltersActive), - [query, originFilter, facetFiltersActive] - ); + const facetFiltersActive = Boolean(categoryFilter || capabilityFilter || hardwareOnly); + const anyFilterActive = Boolean(query.trim() || originFilter !== 'all' || facetFiltersActive); const resetFilters = React.useCallback(() => { setQuery(''); @@ -215,6 +231,8 @@ export const TemplateSelector: React.FC = ({ const toggleHardware = React.useCallback(() => setHardwareOnly((current) => !current), []); + const expandedQuery = React.useMemo(() => expandQueryTerms(query), [query]); + const filteredTemplates = React.useMemo(() => { return templates.filter((template) => { if (originFilter === 'system' && !template.is_system) return false; @@ -222,9 +240,9 @@ export const TemplateSelector: React.FC = ({ if (categoryFilter && template.category !== categoryFilter) return false; if (capabilityFilter && !(template.tags ?? []).includes(capabilityFilter)) return false; if (hardwareOnly && !sampleNeedsHardware(template)) return false; - return matchesSamplePipelineQuery(template, query); + return matchesExpandedQuery(template, expandedQuery); }); - }, [templates, originFilter, categoryFilter, capabilityFilter, hardwareOnly, query]); + }, [templates, originFilter, categoryFilter, capabilityFilter, hardwareOnly, expandedQuery]); const systemGroups = React.useMemo( () => toScenarioGroups(filteredTemplates.filter((template) => template.is_system)), @@ -244,21 +262,12 @@ export const TemplateSelector: React.FC = ({ return filteredTemplates.some((template) => template.id === selectedTemplateId); }, [filteredTemplates, selectedTemplateId]); - const showHiddenSelectionHint = React.useMemo( - () => - Boolean( - selectedTemplateId && - selectedExists && - !selectedVisible && - (query.trim() || originFilter !== 'all' || facetFiltersActive) - ), - [selectedTemplateId, selectedExists, selectedVisible, query, originFilter, facetFiltersActive] + const showHiddenSelectionHint = Boolean( + selectedTemplateId && selectedExists && !selectedVisible && anyFilterActive ); - const showFacetBar = React.useMemo( - () => facets.categories.length > 0 || facets.capabilities.length > 0 || facets.hasHardware, - [facets] - ); + const showFacetBar = + facets.categories.length > 0 || facets.capabilities.length > 0 || facets.hasHardware; return ( @@ -324,41 +333,16 @@ export const TemplateSelector: React.FC = ({ No pipelines match your filters. )} - {systemGroups.length > 0 && ( -
- - System Pipelines - {systemGroups.length} - - - {systemGroups.map((group) => ( - - ))} - -
- )} - - {userGroups.length > 0 && ( -
- - User Pipelines - {userGroups.length} - - - {userGroups.map((group) => ( - - ))} - -
- )} + +
); diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index b97d9354a..b1d0ba819 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import type { SamplePipeline } from '@/types/generated/api-types'; import { + baseVariantLabel, collectSampleFacets, compareSamplePipelinesByName, formatCapabilityLabel, @@ -158,6 +159,19 @@ describe('matchesSamplePipelineQuery', () => { expect(matchesSamplePipelineQuery(tts, 'stt')).toBe(false); }); + it('expands a whole-token prefix of a multi-word synonym', () => { + const tts = makePipeline({ id: 'tts', name: 'Kokoro', tags: ['text-to-speech'] }); + expect(matchesSamplePipelineQuery(tts, 'synthesis')).toBe(true); + }); + + it('does not expand both video families from a shared token', () => { + // "video" is a token of the hyphenated derived tags video-encoding and + // video-decoding, but those are expansion targets only, so querying "video" + // must not pull a decoder pipeline in via the encode group's synonyms. + const decoder = makePipeline({ id: 'dec', name: 'AV1 Decode', tags: ['video-decoding'] }); + expect(matchesSamplePipelineQuery(decoder, 'encoder')).toBe(false); + }); + it('does not let a query that merely contains a short synonym leak in its group', () => { const mic = makePipeline({ id: 'mic', name: 'Microphone Capture', tags: ['microphone'] }); const cam = makePipeline({ id: 'cam', name: 'Webcam PiP', tags: ['webcam'] }); @@ -235,13 +249,18 @@ describe('sampleNeedsHardware', () => { }); describe('collectSampleFacets', () => { - it('aggregates sorted categories and capabilities, excluding hardware tags', () => { + it('aggregates sorted categories and capabilities, excluding hardware and format tags', () => { const facets = collectSampleFacets([ - makePipeline({ id: 'a', category: 'Video Encoding', tags: ['hardware:vaapi', 'colorbars'] }), + makePipeline({ + id: 'a', + category: 'Video Encoding', + tags: ['hardware:vaapi', 'colorbars', 'codec:vp9'], + }), makePipeline({ id: 'b', category: 'Speech to Text', tags: ['mp4'] }), ]); expect(facets.categories).toEqual(['Speech to Text', 'Video Encoding']); - expect(facets.capabilities).toEqual(['colorbars', 'mp4']); + // codec:* and container/transport tags are the variant axis, not facets. + expect(facets.capabilities).toEqual(['colorbars']); expect(facets.hasHardware).toBe(true); }); @@ -250,7 +269,7 @@ describe('collectSampleFacets', () => { // so a capability chip must survive as a cross-cutting filter (e.g. a // sample bucketed as `Video Compositing` may still carry `video-encoding`). const facets = collectSampleFacets([ - makePipeline({ id: 'a', category: 'Video Encoding', tags: ['video-encoding', 'vp9'] }), + makePipeline({ id: 'a', category: 'Video Encoding', tags: ['video-encoding', 'codec:vp9'] }), makePipeline({ id: 'b', category: 'Video Compositing', @@ -258,7 +277,7 @@ describe('collectSampleFacets', () => { }), ]); expect(facets.categories).toEqual(['Video Compositing', 'Video Encoding']); - expect(facets.capabilities).toEqual(['compositing', 'video-encoding', 'vp9']); + expect(facets.capabilities).toEqual(['compositing', 'video-encoding']); }); it('reports no hardware when no hardware tags are present', () => { @@ -274,7 +293,7 @@ describe('formatCapabilityLabel', () => { expect(formatCapabilityLabel('mse')).toBe('MSE'); expect(formatCapabilityLabel('rtmp')).toBe('RTMP'); expect(formatCapabilityLabel('webm')).toBe('WebM'); - expect(formatCapabilityLabel('vp9')).toBe('VP9'); + expect(formatCapabilityLabel('vad')).toBe('VAD'); }); it('falls back to title-casing for other tags', () => { @@ -282,3 +301,21 @@ describe('formatCapabilityLabel', () => { expect(formatCapabilityLabel('colorbars')).toBe('Colorbars'); }); }); + +describe('baseVariantLabel', () => { + it('derives the base label from the output codec tag', () => { + const colorbars = makePipeline({ id: 'cb', tags: ['codec:vp9', 'moq'] }); + const mixer = makePipeline({ id: 'mix', tags: ['codec:opus', 'mixing'] }); + expect(baseVariantLabel(colorbars)).toBe('VP9'); + expect(baseVariantLabel(mixer)).toBe('Opus'); + }); + + it('prefers the video codec when a sample carries both', () => { + const pip = makePipeline({ id: 'pip', tags: ['codec:opus', 'codec:h264'] }); + expect(baseVariantLabel(pip)).toBe('H.264'); + }); + + it('returns null when there is no codec tag to distinguish the base', () => { + expect(baseVariantLabel(makePipeline({ id: 'x', tags: ['mixing'] }))).toBeNull(); + }); +}); diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 87a3621e4..4cc3f5ca1 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MPL-2.0 -import type { SamplePipeline } from '@/types/generated/api-types'; +import type { AudioCodec, SamplePipeline, VideoCodec } from '@/types/generated/api-types'; import { labelFromKey } from './jsonSchema'; @@ -62,21 +62,42 @@ const SYNONYM_GROUPS: string[][] = [ ['hevc', 'h265', 'h.265'], ]; -// A query term joins a synonym group on an exact match, or when it is a -// substring of an entry (>=3 chars, so "transcrib" finds "transcribe"). The -// reverse direction (entry being a substring of the term) is deliberately -// excluded: short entries like "mic"/"cam" would otherwise pull whole groups -// into unrelated queries ("dynamic" → microphone, "scam" → webcam). +// Whether a query term joins a synonym group via one of its entries. Exact +// matches always count. Otherwise the term must be a >=3 char prefix of a whole +// token of a non-hyphenated entry: this lets "transcrib" find "transcribe" and +// "synthesis" find "speech synthesis", while keeping hyphenated derived tags +// (`video-encoding`, `video-decoding`) as expansion targets only — so a shared +// token like "video" no longer pulls both the encode and decode groups. The +// reverse direction (entry being a substring of the term) is excluded too, so +// "dynamic" never reaches "mic". +function termJoinsEntry(entry: string, term: string): boolean { + if (entry === term) return true; + if (entry.includes('-')) return false; + return entry + .split(/\s+/) + .some((token) => token === term || (term.length >= 3 && token.startsWith(term))); +} + function expandTerm(term: string): string[] { const expanded = new Set([term]); for (const group of SYNONYM_GROUPS) { - if (group.some((entry) => entry === term || (term.length >= 3 && entry.includes(term)))) { + if (group.some((entry) => termJoinsEntry(entry, term))) { for (const entry of group) expanded.add(entry); } } return [...expanded]; } +/** + * Expands a query into its per-term synonym candidate lists once, so callers + * filtering a list of pipelines do not re-scan the synonym groups per pipeline. + */ +export function expandQueryTerms(query: string): string[][] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return []; + return normalizedQuery.split(/\s+/).filter(Boolean).map(expandTerm); +} + function searchableText(pipeline: SamplePipeline): string { return [ pipeline.name, @@ -93,18 +114,20 @@ function searchableText(pipeline: SamplePipeline): string { } /** - * Token + synonym match: every whitespace-separated query term must match the + * Whether a pipeline matches an already-expanded query (see `expandQueryTerms`). + * Every term must match — it or one of its synonyms being a substring of the * pipeline's searchable text (name, description, id, category, variant, group, - * tags), where a term matches if it — or any of its synonyms — is a substring. + * tags). An empty term list matches everything. */ -export function matchesSamplePipelineQuery(pipeline: SamplePipeline, query: string): boolean { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return true; - +export function matchesExpandedQuery(pipeline: SamplePipeline, expandedTerms: string[][]): boolean { + if (expandedTerms.length === 0) return true; const haystack = searchableText(pipeline); - const terms = normalizedQuery.split(/\s+/).filter(Boolean); + return expandedTerms.every((candidates) => candidates.some((c) => haystack.includes(c))); +} - return terms.every((term) => expandTerm(term).some((candidate) => haystack.includes(candidate))); +/** Convenience wrapper that expands `query` and matches a single pipeline. */ +export function matchesSamplePipelineQuery(pipeline: SamplePipeline, query: string): boolean { + return matchesExpandedQuery(pipeline, expandQueryTerms(query)); } export interface ScenarioGroup { @@ -157,21 +180,51 @@ export function groupSamplePipelinesByScenario(samples: SamplePipeline[]): Scena } const HARDWARE_TAG_PREFIX = 'hardware:'; +const CODEC_TAG_PREFIX = 'codec:'; + +// Codec/container/transport tags are surfaced as the variant axis (pills) and +// remain searchable, but are noise in the capability facets — codec is already +// the pill dimension and the transport is implied by the Streaming category. +const FORMAT_FACET_TAGS = new Set(['moq', 'mse', 'rtmp', 'mp4', 'webm']); + +// Display labels keyed on the generated codec enums, so adding a codec variant +// in Rust is a TypeScript compile error here until a label is supplied (rather +// than silently falling back to a mangled title-case like "H264"). +const VIDEO_CODEC_LABELS: Record = { + vp9: 'VP9', + h264: 'H.264', + av1: 'AV1', +}; +const AUDIO_CODEC_LABELS: Record = { + opus: 'Opus', + aac: 'AAC', +}; +const CODEC_LABELS: Record = { ...VIDEO_CODEC_LABELS, ...AUDIO_CODEC_LABELS }; + +// A group's no-variant base shows the codec its siblings vary on; video codecs +// win over audio when a sample carries both (e.g. webcam PiP encodes both). +const BASE_CODEC_PRIORITY: string[] = [ + ...Object.keys(VIDEO_CODEC_LABELS), + ...Object.keys(AUDIO_CODEC_LABELS), +]; + +function isFormatFacetTag(tag: string): boolean { + return tag.startsWith(CODEC_TAG_PREFIX) || FORMAT_FACET_TAGS.has(tag); +} export function sampleNeedsHardware(sample: SamplePipeline): boolean { return (sample.tags ?? []).some((tag) => tag.startsWith(HARDWARE_TAG_PREFIX)); } // Acronyms / mixed-case names that the generic title-caser would mangle -// ("Moq", "Mp4"). Anything not listed falls back to labelFromKey. +// ("Moq", "Mp4"). Anything not listed falls back to labelFromKey. Codec labels +// live in the typed CODEC_LABELS maps above, not here. const CAPABILITY_LABEL_OVERRIDES: Record = { moq: 'MoQ', mp4: 'MP4', mse: 'MSE', rtmp: 'RTMP', webm: 'WebM', - vp9: 'VP9', - av1: 'AV1', vad: 'VAD', }; @@ -179,6 +232,20 @@ export function formatCapabilityLabel(tag: string): string { return CAPABILITY_LABEL_OVERRIDES[tag] ?? labelFromKey(tag); } +/** + * Pill label for a group's no-variant base, derived from its output codec tag + * (e.g. the software colorbars base reads `VP9`, the Opus mixer base `Opus`), + * rather than a hardcoded fallback that misreads non-encoding groups. + */ +export function baseVariantLabel(sample: SamplePipeline): string | null { + const codecs = (sample.tags ?? []) + .filter((tag) => tag.startsWith(CODEC_TAG_PREFIX)) + .map((tag) => tag.slice(CODEC_TAG_PREFIX.length)); + if (codecs.length === 0) return null; + const pick = BASE_CODEC_PRIORITY.find((codec) => codecs.includes(codec)) ?? codecs[0]; + return CODEC_LABELS[pick] ?? pick.toUpperCase(); +} + export interface SampleFacets { categories: string[]; capabilities: string[]; @@ -196,7 +263,7 @@ export function collectSampleFacets(samples: SamplePipeline[]): SampleFacets { for (const tag of sample.tags ?? []) { if (tag.startsWith(HARDWARE_TAG_PREFIX)) { hasHardware = true; - } else { + } else if (!isFormatFacetTag(tag)) { capabilities.add(tag); } } From f7d6db094b33e2e398faf37da5d41cade7b9f4b1 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 12:31:55 +0000 Subject: [PATCH 10/14] feat(convert): add "Open in Design view" handoff from Customize step Carries the current Customize-editor YAML (with any edits) into the visual Design editor via router state, which DesignView imports once node definitions are loaded. Signed-off-by: streamkit-devin --- ui/src/views/ConvertView.tsx | 51 ++++++++++++++++++++++++++++++++++++ ui/src/views/DesignView.tsx | 16 ++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/ui/src/views/ConvertView.tsx b/ui/src/views/ConvertView.tsx index 3da95bd9b..db95f1f59 100644 --- a/ui/src/views/ConvertView.tsx +++ b/ui/src/views/ConvertView.tsx @@ -5,6 +5,7 @@ import styled from '@emotion/styled'; import { load as loadYaml } from 'js-yaml'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { AssetSelector } from '@/components/converter/AssetSelector'; import { ConversionProgress } from '@/components/converter/ConversionProgress'; @@ -145,6 +146,32 @@ const ConvertButtonContainer = styled.div` justify-content: center; `; +const CustomizeActions = styled.div` + display: flex; + justify-content: flex-end; +`; + +const OpenInDesignButton = styled.button` + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + color: var(--sk-text); + background: var(--sk-panel-bg); + border: 1px solid var(--sk-border); + border-radius: 6px; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--sk-primary); + color: var(--sk-primary); + } + + &:disabled { + color: var(--sk-text-muted); + cursor: not-allowed; + } +`; + const ConvertButton = styled.button<{ disabled: boolean; isProcessing?: boolean }>` padding: 14px 40px; font-size: 16px; @@ -755,6 +782,20 @@ const ConvertView: React.FC = () => { fetchSamples(); }, [setSamples, setSamplesLoading, setSamplesError, setSelectedTemplateId, setPipelineYaml]); + const navigate = useNavigate(); + + const handleOpenInDesign = useCallback(() => { + if (!pipelineYaml.trim()) return; + const sample = samples.find((s) => s.id === selectedTemplateId); + navigate('/design', { + state: { + importYaml: pipelineYaml, + name: sample?.name ?? '', + description: sample?.description ?? '', + }, + }); + }, [navigate, pipelineYaml, samples, selectedTemplateId]); + const handleTemplateSelect = (templateId: string) => { const sample = samples.find((s) => s.id === templateId); if (sample) { @@ -1239,6 +1280,16 @@ const ConvertView: React.FC = () => { onChange={setPipelineYaml} nodeDefinitions={nodeDefinitions} /> + + + Open in Design view + + diff --git a/ui/src/views/DesignView.tsx b/ui/src/views/DesignView.tsx index b7ded4578..b5215fa51 100644 --- a/ui/src/views/DesignView.tsx +++ b/ui/src/views/DesignView.tsx @@ -15,7 +15,7 @@ import { type OnConnectEnd, } from '@xyflow/react'; import React, { useState, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useShallow } from 'zustand/shallow'; import ConfirmModal from '@/components/ConfirmModal'; @@ -556,6 +556,20 @@ const DesignViewContent: React.FC = () => { getId, } = usePipeline(); + const location = useLocation(); + const handoffImportedRef = React.useRef(false); + React.useEffect(() => { + const handoff = location.state as { + importYaml?: string; + name?: string; + description?: string; + } | null; + if (handoffImportedRef.current || !handoff?.importYaml || nodeDefinitions.length === 0) return; + handoffImportedRef.current = true; + handleImportYaml(handoff.importYaml, handoff.description ?? '', handoff.name ?? ''); + navigate('.', { replace: true, state: null }); + }, [location.state, nodeDefinitions, handleImportYaml, navigate]); + const cachesRef = React.useRef>>({}); React.useEffect(() => { From 9be84225e0b2792151e221cf504993a5511a60fe Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 14:18:05 +0000 Subject: [PATCH 11/14] refactor(samples): drop dead capability label map, lowercase kind once Signed-off-by: streamkit-devin --- apps/skit/src/sample_discovery.rs | 20 +++++++++---------- .../components/converter/TemplateSelector.tsx | 4 ++-- ui/src/utils/samplePipelineOrdering.test.ts | 17 ---------------- ui/src/utils/samplePipelineOrdering.ts | 18 ----------------- 4 files changed, 11 insertions(+), 48 deletions(-) diff --git a/apps/skit/src/sample_discovery.rs b/apps/skit/src/sample_discovery.rs index 205a31a5f..6f8961520 100644 --- a/apps/skit/src/sample_discovery.rs +++ b/apps/skit/src/sample_discovery.rs @@ -22,22 +22,20 @@ use streamkit_core::types::{AudioCodec, VideoCodec}; /// match against [`VideoCodec::as_c_name`] rather than re-spelling codec strings /// — adding a codec variant is then a single edit on the enum. fn video_codec_for_kind(kind: &str) -> Option { - let k = kind.to_lowercase(); - if !k.contains("encoder") { + if !kind.contains("encoder") { return None; } [VideoCodec::Vp9, VideoCodec::H264, VideoCodec::Av1] .into_iter() - .find(|codec| k.contains(codec.as_c_name())) + .find(|codec| kind.contains(codec.as_c_name())) } /// Output codec an audio encoder node produces, or `None` for non-encoder kinds. fn audio_codec_for_kind(kind: &str) -> Option { - let k = kind.to_lowercase(); - if !k.contains("encoder") { + if !kind.contains("encoder") { return None; } - [AudioCodec::Opus, AudioCodec::Aac].into_iter().find(|codec| k.contains(codec.as_c_name())) + [AudioCodec::Opus, AudioCodec::Aac].into_iter().find(|codec| kind.contains(codec.as_c_name())) } /// Discovery metadata for a sample. Used both for the explicit values parsed @@ -146,8 +144,7 @@ fn group_and_variant_from_filename( (group_key, variant) } -fn tags_for_kind(kind: &str) -> Vec<&'static str> { - let k = kind.to_lowercase(); +fn tags_for_kind(k: &str) -> Vec<&'static str> { let mut tags: Vec<&'static str> = Vec::new(); if k.contains("whisper") || k.contains("parakeet") || k.contains("sensevoice") { @@ -219,14 +216,15 @@ fn capability_tags( let mut tags: Vec = Vec::new(); for kind in node_kinds { - for tag in tags_for_kind(kind) { + let kind = kind.to_lowercase(); + for tag in tags_for_kind(&kind) { tags.push(tag.to_string()); } // Output codec, surfaced as the variant axis (pills) and a search term // but excluded from the capability facets (see collectSampleFacets). - let codec = video_codec_for_kind(kind) + let codec = video_codec_for_kind(&kind) .map(VideoCodec::as_c_name) - .or_else(|| audio_codec_for_kind(kind).map(AudioCodec::as_c_name)); + .or_else(|| audio_codec_for_kind(&kind).map(AudioCodec::as_c_name)); if let Some(name) = codec { tags.push(format!("codec:{name}")); } diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 8fee9798e..8b69a03b8 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { RadioGroupRoot, RadioItem, RadioIndicator } from '@/components/ui/RadioGroup'; import type { SamplePipeline } from '@/types/generated/api-types'; +import { labelFromKey } from '@/utils/jsonSchema'; import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { baseVariantLabel, collectSampleFacets, compareSamplePipelinesByName, expandQueryTerms, - formatCapabilityLabel, groupSamplePipelinesByScenario, matchesExpandedQuery, sampleNeedsHardware, @@ -172,7 +172,7 @@ const FacetFilters: React.FC = ({ aria-pressed={capabilityFilter === capability} onClick={() => onToggleCapability(capability)} > - {formatCapabilityLabel(capability)} + {labelFromKey(capability)} ))} diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index b1d0ba819..f3e987eba 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -10,7 +10,6 @@ import { baseVariantLabel, collectSampleFacets, compareSamplePipelinesByName, - formatCapabilityLabel, groupSamplePipelinesByScenario, matchesSamplePipelineQuery, orderSamplePipelinesSystemFirst, @@ -286,22 +285,6 @@ describe('collectSampleFacets', () => { }); }); -describe('formatCapabilityLabel', () => { - it('uses curated acronym casing for known tags', () => { - expect(formatCapabilityLabel('moq')).toBe('MoQ'); - expect(formatCapabilityLabel('mp4')).toBe('MP4'); - expect(formatCapabilityLabel('mse')).toBe('MSE'); - expect(formatCapabilityLabel('rtmp')).toBe('RTMP'); - expect(formatCapabilityLabel('webm')).toBe('WebM'); - expect(formatCapabilityLabel('vad')).toBe('VAD'); - }); - - it('falls back to title-casing for other tags', () => { - expect(formatCapabilityLabel('voice-activity-detection')).toBe('Voice Activity Detection'); - expect(formatCapabilityLabel('colorbars')).toBe('Colorbars'); - }); -}); - describe('baseVariantLabel', () => { it('derives the base label from the output codec tag', () => { const colorbars = makePipeline({ id: 'cb', tags: ['codec:vp9', 'moq'] }); diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 4cc3f5ca1..e5b96ce81 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -4,8 +4,6 @@ import type { AudioCodec, SamplePipeline, VideoCodec } from '@/types/generated/api-types'; -import { labelFromKey } from './jsonSchema'; - let collator: Intl.Collator | null = null; function getCollator(): Intl.Collator { @@ -216,22 +214,6 @@ export function sampleNeedsHardware(sample: SamplePipeline): boolean { return (sample.tags ?? []).some((tag) => tag.startsWith(HARDWARE_TAG_PREFIX)); } -// Acronyms / mixed-case names that the generic title-caser would mangle -// ("Moq", "Mp4"). Anything not listed falls back to labelFromKey. Codec labels -// live in the typed CODEC_LABELS maps above, not here. -const CAPABILITY_LABEL_OVERRIDES: Record = { - moq: 'MoQ', - mp4: 'MP4', - mse: 'MSE', - rtmp: 'RTMP', - webm: 'WebM', - vad: 'VAD', -}; - -export function formatCapabilityLabel(tag: string): string { - return CAPABILITY_LABEL_OVERRIDES[tag] ?? labelFromKey(tag); -} - /** * Pill label for a group's no-variant base, derived from its output codec tag * (e.g. the software colorbars base reads `VP9`, the Opus mixer base `Opus`), From 3db13ca0a9425580a33c56b58d412c257c299586 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 15:12:12 +0000 Subject: [PATCH 12/14] refactor(samples): explicit YAML discovery contract, no runtime heuristics Make sample YAML the source of truth for Convert/Stream discovery UX: authored group/variant/canonical/category/tags/keywords replace the runtime filename/node-kind derivation. The server emits these fields as-authored plus a resolved, lowercased search_terms document; the UI does plain substring matching and groups directly off canonical. - Remove all heuristic derivation from sample_discovery.rs (filename tokenization, substring category/tag inference, codec sniffing). - Add canonical/keywords to the YAML schema and SamplePipeline; emit search_terms (name + description + category + tags + keywords + flattened node kinds). - Backfill all bundled dynamic/ + oneshot/ samples with explicit metadata; group near-duplicate families with one canonical member. - Enforce the contract in CI: bundled samples must carry category+tags, grouped samples must have exactly one canonical and per-member variants, ungrouped samples must not set canonical/variant. - UI consumes resolved fields and search_terms; SYNONYM_GROUPS and the variants[0] card-identity guess are gone. Signed-off-by: streamkit-devin --- apps/skit/src/sample_discovery.rs | 548 +++--------------- apps/skit/src/samples.rs | 111 ++-- .../tests/sample_discovery_metadata_test.rs | 150 +++++ crates/api/src/lib.rs | 10 +- crates/api/src/yaml/compiler.rs | 2 + crates/api/src/yaml/mod.rs | 17 +- samples/pipelines/dynamic/moq.yml | 8 + samples/pipelines/dynamic/moq_aac_echo.yml | 9 + samples/pipelines/dynamic/moq_aac_mixing.yml | 12 + samples/pipelines/dynamic/moq_mixing.yml | 12 + samples/pipelines/dynamic/moq_peer.yml | 8 + samples/pipelines/dynamic/moq_relay_echo.yml | 9 + .../dynamic/moq_relay_screen_cam_pip.yml | 14 + .../dynamic/moq_relay_webcam_pip.yml | 13 + samples/pipelines/dynamic/moq_s3_archive.yml | 9 + .../dynamic/moq_to_rtmp_composite.yml | 13 + .../dynamic/speech-translate-en-es.yaml | 18 + .../dynamic/speech-translate-es-en.yaml | 17 + .../speech-translate-helsinki-en-es.yaml | 16 + .../speech-translate-helsinki-es-en.yaml | 16 + .../dynamic/video_moq_av1_compositor.yml | 13 + .../pipelines/dynamic/video_moq_colorbars.yml | 12 + .../dynamic/video_moq_compositor.yml | 13 + .../dynamic/video_moq_h264_colorbars.yml | 12 + .../dynamic/video_moq_nv_av1_colorbars.yml | 14 + .../dynamic/video_moq_screen_cam_pip.yml | 18 + .../video_moq_screen_cam_svt_av1_pip.yml | 18 + .../dynamic/video_moq_screen_share.yml | 11 + .../dynamic/video_moq_servo_web_overlay.yml | 12 + .../dynamic/video_moq_slint_scoreboard.yml | 11 + .../dynamic/video_moq_svt_av1_compositor.yml | 13 + .../dynamic/video_moq_vaapi_av1_colorbars.yml | 13 + .../video_moq_vaapi_h264_colorbars.yml | 13 + .../video_moq_voice_controlled_compositor.yml | 16 + .../video_moq_vulkan_video_h264_colorbars.yml | 13 + .../dynamic/video_moq_webcam_av1_pip.yml | 15 + .../dynamic/video_moq_webcam_circle_pip.yml | 13 + .../dynamic/video_moq_webcam_dav1d_pip.yml | 15 + .../dynamic/video_moq_webcam_pip.yml | 15 + .../dynamic/video_moq_webcam_subtitles.yml | 16 + .../dynamic/video_moq_webcam_svt_av1_pip.yml | 15 + .../dynamic/video_mse_compositor.yml | 10 + .../dynamic/video_mse_webcam_pip.yml | 17 + .../dynamic/video_mse_webcam_svt_av1_pip.yml | 17 + .../pipelines/dynamic/voice-agent-openai.yaml | 15 + .../dynamic/voice-weather-open-meteo.yaml | 14 + samples/pipelines/oneshot/aac_encode.yml | 8 + samples/pipelines/oneshot/double_volume.yml | 8 + .../pipelines/oneshot/dual_upload_mixing.yml | 11 + .../pipelines/oneshot/gain_filter_rust.yml | 9 + samples/pipelines/oneshot/kokoro-tts.yml | 12 + samples/pipelines/oneshot/matcha-tts.yml | 11 + samples/pipelines/oneshot/mixing.yml | 11 + .../pipelines/oneshot/mp4_mux_aac_h264.yml | 11 + samples/pipelines/oneshot/mp4_mux_audio.yml | 10 + samples/pipelines/oneshot/mp4_mux_video.yml | 11 + .../oneshot/openh264-encode-test.yml | 10 + samples/pipelines/oneshot/parakeet-stt.yml | 12 + samples/pipelines/oneshot/piper-tts.yml | 11 + .../oneshot/pocket-tts-voice-clone.yml | 11 + samples/pipelines/oneshot/pocket-tts.yml | 11 + samples/pipelines/oneshot/sensevoice-stt.yml | 12 + samples/pipelines/oneshot/speech_to_text.yml | 14 +- .../oneshot/speech_to_text_translate.yml | 17 + .../speech_to_text_translate_helsinki.yml | 15 + samples/pipelines/oneshot/supertonic-tts.yml | 12 + samples/pipelines/oneshot/transcode_to_s3.yml | 11 + samples/pipelines/oneshot/tts_to_s3.yml | 10 + .../pipelines/oneshot/useless-facts-tts.yml | 9 + samples/pipelines/oneshot/vad-demo.yml | 7 +- .../pipelines/oneshot/vad-filtered-stt.yml | 14 + .../oneshot/video_av1_compositor_demo.yml | 13 + samples/pipelines/oneshot/video_colorbars.yml | 12 + .../oneshot/video_compositor_demo.yml | 14 + .../oneshot/video_compositor_svg_overlay.yml | 10 + .../oneshot/video_dav1d_compositor_demo.yml | 13 + .../oneshot/video_nv_av1_colorbars.yml | 13 + .../oneshot/video_slint_watermark.yml | 10 + .../oneshot/video_svt_av1_compositor_demo.yml | 13 + .../oneshot/video_vaapi_av1_colorbars.yml | 12 + .../oneshot/video_vaapi_h264_colorbars.yml | 12 + .../video_vulkan_video_h264_colorbars.yml | 12 + samples/pipelines/oneshot/web_capture.yml | 11 + .../pipelines/oneshot/web_pip_compositor.yml | 13 + .../converter/TemplateSelector.test.tsx | 10 +- .../components/converter/TemplateSelector.tsx | 13 +- ui/src/services/fragments.test.ts | 2 + ui/src/services/samples.test.ts | 2 + ui/src/types/generated/api-types.ts | 14 +- ui/src/utils/samplePipelineOrdering.test.ts | 121 ++-- ui/src/utils/samplePipelineOrdering.ts | 155 +---- 91 files changed, 1377 insertions(+), 752 deletions(-) create mode 100644 apps/skit/tests/sample_discovery_metadata_test.rs diff --git a/apps/skit/src/sample_discovery.rs b/apps/skit/src/sample_discovery.rs index 6f8961520..749171ff9 100644 --- a/apps/skit/src/sample_discovery.rs +++ b/apps/skit/src/sample_discovery.rs @@ -2,310 +2,72 @@ // // SPDX-License-Identifier: MPL-2.0 -//! Best-effort discovery metadata for sample pipelines. +//! Explicit discovery metadata for sample pipelines. //! //! The Convert and Stream views group near-duplicate samples (e.g. the -//! h264/av1 colorbars family) into a single card with a variant selector, and -//! expose faceted/fuzzy search over capability tags and categories. Authors may -//! set `group`/`variant`/`category`/`tags` explicitly in a sample's YAML, but -//! most samples omit them — so this module derives sensible defaults from the -//! node kinds, the client section, and the filename. Explicit YAML values -//! always win; derived `tags` are unioned with any curated ones. - -use streamkit_api::yaml::{InputType, OutputType}; -use streamkit_core::types::{AudioCodec, VideoCodec}; - -/// Output codec a video encoder node produces, or `None` for non-encoder kinds. -/// -/// Node kinds embed the codec's canonical name (`video::vp9::encoder`, -/// `video::vaapi::av1_encoder`, `video::vulkan_video::h264_encoder`, ...), so we -/// match against [`VideoCodec::as_c_name`] rather than re-spelling codec strings -/// — adding a codec variant is then a single edit on the enum. -fn video_codec_for_kind(kind: &str) -> Option { - if !kind.contains("encoder") { - return None; - } - [VideoCodec::Vp9, VideoCodec::H264, VideoCodec::Av1] - .into_iter() - .find(|codec| kind.contains(codec.as_c_name())) -} - -/// Output codec an audio encoder node produces, or `None` for non-encoder kinds. -fn audio_codec_for_kind(kind: &str) -> Option { - if !kind.contains("encoder") { - return None; - } - [AudioCodec::Opus, AudioCodec::Aac].into_iter().find(|codec| kind.contains(codec.as_c_name())) -} - -/// Discovery metadata for a sample. Used both for the explicit values parsed -/// from a sample's YAML (all optional) and for the resolved values after -/// merging those with the derived defaults. +//! colorbars codec/hardware family) into a single card with a variant selector, +//! and expose faceted/fuzzy search over capability tags and categories. All of +//! this is driven by metadata authored directly in each sample's YAML +//! (`group`/`variant`/`canonical`/`category`/`tags`/`keywords`) — there is no +//! runtime derivation from filenames or node-kind substrings. Bundled samples +//! are required to carry a consistent set of fields, enforced by +//! `apps/skit/tests/sample_discovery_metadata_test.rs` so a missing or +//! inconsistent field breaks CI rather than silently degrading the UI. + +/// Discovery metadata parsed from a sample's YAML. #[derive(Debug, Default, Clone)] pub struct Discovery { pub group: Option, pub variant: Option, + pub canonical: bool, pub category: Option, pub tags: Vec, + pub keywords: Vec, } -/// Filename tokens that distinguish variants of the same scenario, paired with -/// a human label. Compound tokens are matched before single ones so that e.g. -/// `vulkan_video` does not collapse into a stray `video` group token. -const COMPOUND_TOKENS: &[(&str, &str)] = - &[("vulkan_video", "Vulkan Video"), ("svt_av1", "SVT-AV1")]; - -const SINGLE_TOKENS: &[(&str, &str)] = &[ - ("h264", "H.264"), - ("h265", "HEVC"), - ("hevc", "HEVC"), - ("av1", "AV1"), - ("vp9", "VP9"), - ("aac", "AAC"), - ("opus", "Opus"), - ("vaapi", "VA-API"), - ("nvidia", "NVIDIA"), - ("nvenc", "NVIDIA"), - ("nv", "NVIDIA"), - ("vulkan", "Vulkan"), - ("dav1d", "dav1d"), - ("svt", "SVT"), - ("openh264", "OpenH264"), - ("helsinki", "Helsinki"), - ("nllb", "NLLB"), -]; - -const LANGUAGE_TOKENS: &[(&str, &str)] = &[ - ("en", "English"), - ("es", "Spanish"), - ("fr", "French"), - ("de", "German"), - ("it", "Italian"), - ("pt", "Portuguese"), - ("zh", "Chinese"), - ("ja", "Japanese"), - ("ko", "Korean"), -]; - -fn label_for(table: &[(&'static str, &'static str)], token: &str) -> Option<&'static str> { - table.iter().find(|(t, _)| *t == token).map(|(_, label)| *label) -} - -/// Splits a filename base into its base-scenario group key and a variant label, -/// stripping codec/hardware/language tokens that distinguish near-duplicates. -/// -/// Language tokens are only honoured when `is_translation` is set; the two-letter -/// codes (`de`, `it`, `pt`, ...) collide with common filename fragments, so -/// outside a translation pipeline they stay part of the group key. -fn group_and_variant_from_filename( - filename_base: &str, - is_translation: bool, -) -> (String, Option) { - let mut normalized = filename_base.to_lowercase().replace('-', "_"); - - let mut variant_parts: Vec = Vec::new(); - - for (token, label) in COMPOUND_TOKENS { - let needle = format!("_{token}_"); - let padded = format!("_{normalized}_"); - if padded.contains(&needle) { - normalized = padded.replace(&needle, "_").trim_matches('_').to_string(); - variant_parts.push((*label).to_string()); - } - } - - let mut group_tokens: Vec = Vec::new(); - let mut languages: Vec<&'static str> = Vec::new(); - - for token in normalized.split('_').filter(|t| !t.is_empty()) { - if let Some(lang) = is_translation.then(|| label_for(LANGUAGE_TOKENS, token)).flatten() { - languages.push(lang); - } else if let Some(label) = label_for(SINGLE_TOKENS, token) { - variant_parts.push(label.to_string()); - } else { - group_tokens.push(token.to_string()); - } - } - - if languages.len() >= 2 { - variant_parts.push(format!("{} → {}", languages[0], languages[1])); - } else if let Some(lang) = languages.first() { - variant_parts.push((*lang).to_string()); +fn push_term(terms: &mut Vec, value: &str) { + let term = value.trim().to_lowercase(); + if !term.is_empty() && !terms.contains(&term) { + terms.push(term); } - - let group_key = if group_tokens.is_empty() { - filename_base.to_lowercase().replace('_', "-") - } else { - group_tokens.join("-") - }; - - let variant = if variant_parts.is_empty() { None } else { Some(variant_parts.join(" ")) }; - - (group_key, variant) } -fn tags_for_kind(k: &str) -> Vec<&'static str> { - let mut tags: Vec<&'static str> = Vec::new(); +/// Builds the backend search document the UI matches queries against. +/// +/// Combines the human-facing fields with authored keywords and the pipeline's +/// node kinds (with `::`/`_` separators flattened to spaces so individual +/// segments like `whisper` or `vaapi` are matchable). Lowercased and +/// de-duplicated. +pub fn build_search_terms( + name: &str, + description: &str, + discovery: &Discovery, + node_kinds: &[String], +) -> Vec { + let mut terms = Vec::new(); - if k.contains("whisper") || k.contains("parakeet") || k.contains("sensevoice") { - tags.push("speech-to-text"); + push_term(&mut terms, name); + push_term(&mut terms, description); + if let Some(category) = discovery.category.as_deref() { + push_term(&mut terms, category); } - if k.contains("kokoro") - || k.contains("piper") - || k.contains("matcha") - || k.contains("supertonic") - || k.contains("pocket") - { - tags.push("text-to-speech"); + if let Some(group) = discovery.group.as_deref() { + push_term(&mut terms, group); } - if k.contains("nllb") || k.contains("helsinki") { - tags.push("translation"); + if let Some(variant) = discovery.variant.as_deref() { + push_term(&mut terms, variant); } - if k.ends_with("::vad") { - tags.push("voice-activity-detection"); + for tag in &discovery.tags { + push_term(&mut terms, tag); } - if k.starts_with("video::") && k.contains("encoder") { - tags.push("video-encoding"); + for keyword in &discovery.keywords { + push_term(&mut terms, keyword); } - if k.starts_with("video::") && k.contains("decoder") { - tags.push("video-decoding"); - } - if k == "video::compositor" { - tags.push("compositing"); - } - if k == "video::colorbars" { - tags.push("colorbars"); - } - if k.starts_with("transport::moq") { - tags.push("moq"); - } - if k == "transport::http::mse" { - tags.push("mse"); - } - if k.starts_with("transport::rtmp") { - tags.push("rtmp"); - } - if k.starts_with("containers::mp4") { - tags.push("mp4"); - } - if k.starts_with("containers::webm") { - tags.push("webm"); - } - if k == "audio::mixer" { - tags.push("mixing"); - } - - if k.contains("vaapi") { - tags.push("hardware:vaapi"); - } - if k.starts_with("video::nv::") || k.contains("nvenc") { - tags.push("hardware:nvidia"); - } - if k.contains("vulkan") { - tags.push("hardware:vulkan"); - } - - tags -} - -fn capability_tags( - node_kinds: &[String], - client_input: Option, - client_output: Option, -) -> Vec { - let mut tags: Vec = Vec::new(); - for kind in node_kinds { - let kind = kind.to_lowercase(); - for tag in tags_for_kind(&kind) { - tags.push(tag.to_string()); - } - // Output codec, surfaced as the variant axis (pills) and a search term - // but excluded from the capability facets (see collectSampleFacets). - let codec = video_codec_for_kind(&kind) - .map(VideoCodec::as_c_name) - .or_else(|| audio_codec_for_kind(&kind).map(AudioCodec::as_c_name)); - if let Some(name) = codec { - tags.push(format!("codec:{name}")); - } + push_term(&mut terms, &kind.replace("::", " ").replace('_', " ")); } - match client_output { - Some(OutputType::Transcription) => tags.push("speech-to-text".to_string()), - Some(OutputType::Audio) => tags.push("audio-output".to_string()), - Some(OutputType::Video) => tags.push("video-output".to_string()), - Some(OutputType::Json) | None => {}, - } - match client_input { - Some(InputType::FileUpload) => tags.push("file-input".to_string()), - Some(InputType::Text) => tags.push("text-input".to_string()), - Some(InputType::Trigger | InputType::None) | None => {}, - } - - tags.sort_unstable(); - tags.dedup(); - tags -} - -/// Picks a single faceting bucket. Composition and encoding outrank the speech -/// stack so that a streaming pipeline that happens to transcribe still reads as -/// a video scenario; translation outranks STT/TTS for speech-translate samples. -fn category_from_tags(tags: &[String]) -> Option { - const PRIORITY: &[(&str, &str)] = &[ - ("compositing", "Video Compositing"), - ("video-encoding", "Video Encoding"), - ("translation", "Translation"), - ("speech-to-text", "Speech to Text"), - ("text-to-speech", "Text to Speech"), - ("video-decoding", "Video Processing"), - ("moq", "Streaming"), - ("mse", "Streaming"), - ("rtmp", "Streaming"), - ("mixing", "Audio Processing"), - ]; - - PRIORITY - .iter() - .find(|(tag, _)| tags.iter().any(|t| t == tag)) - .map(|(_, category)| (*category).to_string()) -} - -/// Derives discovery metadata, with explicit YAML values taking precedence. -/// -/// Grouping is only auto-derived for curated system samples; user pipelines -/// group solely via an explicit `group`, so two unrelated saves whose names -/// happen to share a group token are never silently collapsed into one card. -pub fn derive( - filename_base: &str, - node_kinds: &[String], - client_input: Option, - client_output: Option, - is_system: bool, - explicit: Discovery, -) -> Discovery { - let derived_tags = capability_tags(node_kinds, client_input, client_output); - - let mut tags = explicit.tags; - for tag in derived_tags { - if !tags.contains(&tag) { - tags.push(tag); - } - } - tags.sort_unstable(); - tags.dedup(); - - let is_translation = tags.iter().any(|t| t == "translation"); - let (derived_group, derived_variant) = - group_and_variant_from_filename(filename_base, is_translation); - - let category = explicit.category.or_else(|| category_from_tags(&tags)); - - Discovery { - group: explicit.group.or(if is_system { Some(derived_group) } else { None }), - variant: explicit.variant.or(derived_variant), - category, - tags, - } + terms } #[cfg(test)] @@ -317,207 +79,45 @@ mod tests { } #[test] - fn colorbars_family_shares_a_group_with_distinct_variants() { - let (g_plain, v_plain) = group_and_variant_from_filename("video_moq_colorbars", false); - let (g_h264, v_h264) = group_and_variant_from_filename("video_moq_h264_colorbars", false); - let (g_vaapi, v_vaapi) = - group_and_variant_from_filename("video_moq_vaapi_h264_colorbars", false); - let (g_nv, v_nv) = group_and_variant_from_filename("video_moq_nv_av1_colorbars", false); - let (g_vk, v_vk) = - group_and_variant_from_filename("video_moq_vulkan_video_h264_colorbars", false); - - assert_eq!(g_plain, "video-moq-colorbars"); - assert_eq!(g_h264, "video-moq-colorbars"); - assert_eq!(g_vaapi, "video-moq-colorbars"); - assert_eq!(g_nv, "video-moq-colorbars"); - assert_eq!(g_vk, "video-moq-colorbars"); - - assert_eq!(v_plain, None); - assert_eq!(v_h264.as_deref(), Some("H.264")); - assert_eq!(v_vaapi.as_deref(), Some("VA-API H.264")); - assert_eq!(v_nv.as_deref(), Some("NVIDIA AV1")); - assert_eq!(v_vk.as_deref(), Some("Vulkan Video H.264")); - } - - #[test] - fn svt_av1_compound_does_not_leak_into_group_key() { - let (group, variant) = - group_and_variant_from_filename("video_svt_av1_compositor_demo", false); - assert_eq!(group, "video-compositor-demo"); - assert_eq!(variant.as_deref(), Some("SVT-AV1")); - - let (plain_group, plain_variant) = - group_and_variant_from_filename("video_compositor_demo", false); - assert_eq!(plain_group, "video-compositor-demo"); - assert_eq!(plain_variant, None); - } - - #[test] - fn language_pairs_become_directional_variants() { - let (g_en_es, v_en_es) = group_and_variant_from_filename("speech-translate-en-es", true); - let (g_es_en, v_es_en) = group_and_variant_from_filename("speech-translate-es-en", true); - let (g_hel, v_hel) = - group_and_variant_from_filename("speech-translate-helsinki-en-es", true); - - assert_eq!(g_en_es, "speech-translate"); - assert_eq!(g_es_en, "speech-translate"); - assert_eq!(g_hel, "speech-translate"); - assert_eq!(v_en_es.as_deref(), Some("English → Spanish")); - assert_eq!(v_es_en.as_deref(), Some("Spanish → English")); - assert_eq!(v_hel.as_deref(), Some("Helsinki English → Spanish")); - } - - #[test] - fn language_tokens_are_ignored_outside_a_translation_context() { - let (group, variant) = group_and_variant_from_filename("video_de_interlace_demo", false); - assert_eq!(group, "video-de-interlace-demo"); - assert_eq!(variant, None); - } - - #[test] - fn user_pipelines_are_not_auto_grouped() { - let derive_for = |is_system| { - derive( - "encode_h264", - &kinds(&["video::openh264::encoder"]), - None, - Some(OutputType::Video), - is_system, - Discovery::default(), - ) - .group + fn search_terms_include_authored_metadata() { + let discovery = Discovery { + group: Some("video-colorbars".to_string()), + variant: Some("VA-API H.264".to_string()), + canonical: false, + category: Some("Video Encoding".to_string()), + tags: vec!["video-encoding".to_string(), "hardware:vaapi".to_string()], + keywords: vec!["test pattern".to_string(), "smpte".to_string()], }; - assert_eq!(derive_for(true).as_deref(), Some("encode")); - assert_eq!(derive_for(false), None); - } - - #[test] - fn unrelated_samples_do_not_collide() { - let (g_audio, _) = group_and_variant_from_filename("mp4_mux_audio", false); - let (g_video, _) = group_and_variant_from_filename("mp4_mux_video", false); - let (g_av, _) = group_and_variant_from_filename("mp4_mux_aac_h264", false); - assert_ne!(g_audio, g_video); - assert_ne!(g_audio, g_av); - assert_ne!(g_video, g_av); - } - - #[test] - fn derives_capability_tags_from_node_kinds() { - let tags = capability_tags( - &kinds(&[ - "streamkit::http_input", - "audio::resampler", - "plugin::native::whisper", - "plugin::native::nllb", - "plugin::native::piper", - ]), - None, - None, + let terms = build_search_terms( + "Video Color Bars (VA-API H.264)", + "Encodes SMPTE color bars", + &discovery, + &kinds(&["video::vaapi::h264_encoder"]), ); - assert!(tags.contains(&"speech-to-text".to_string())); - assert!(tags.contains(&"translation".to_string())); - assert!(tags.contains(&"text-to-speech".to_string())); - } - - #[test] - fn flags_hardware_encoders_with_a_facet() { - let vaapi = capability_tags(&kinds(&["video::vaapi::h264_encoder"]), None, None); - assert!(vaapi.contains(&"video-encoding".to_string())); - assert!(vaapi.contains(&"hardware:vaapi".to_string())); - - let nv = capability_tags(&kinds(&["video::nv::av1_encoder"]), None, None); - assert!(nv.contains(&"hardware:nvidia".to_string())); - let vulkan = capability_tags(&kinds(&["video::vulkan_video::h264_encoder"]), None, None); - assert!(vulkan.contains(&"hardware:vulkan".to_string())); - - let software = capability_tags(&kinds(&["video::svt_av1::encoder"]), None, None); - assert!(software.contains(&"video-encoding".to_string())); - assert!(!software.iter().any(|t| t.starts_with("hardware:"))); - } - - #[test] - fn aac_encoder_is_not_video_encoding() { - let tags = capability_tags(&kinds(&["plugin::native::aac_encoder"]), None, None); - assert!(!tags.contains(&"video-encoding".to_string())); + assert!(terms.contains(&"video encoding".to_string())); + assert!(terms.contains(&"hardware:vaapi".to_string())); + assert!(terms.contains(&"test pattern".to_string())); + assert!(terms.contains(&"video vaapi h264 encoder".to_string())); } #[test] - fn encoders_emit_the_output_codec_tag() { - // The no-token base of a grouped family (e.g. the software colorbars or - // the Opus mixer) carries no filename variant, so the UI labels its pill - // from this codec tag instead of a hardcoded fallback. - let vp9 = capability_tags(&kinds(&["video::vp9::encoder"]), None, None); - assert!(vp9.contains(&"codec:vp9".to_string())); - - let opus = capability_tags(&kinds(&["audio::opus::encoder"]), None, None); - assert!(opus.contains(&"codec:opus".to_string())); - - let h264 = capability_tags(&kinds(&["video::openh264::encoder"]), None, None); - assert!(h264.contains(&"codec:h264".to_string())); - - let aac = capability_tags(&kinds(&["plugin::native::aac_encoder"]), None, None); - assert!(aac.contains(&"codec:aac".to_string())); - - // Decoders are not the output codec. - let decoder = capability_tags(&kinds(&["audio::opus::decoder"]), None, None); - assert!(!decoder.iter().any(|t| t.starts_with("codec:"))); - } - - #[test] - fn category_prefers_compositing_then_encoding() { - assert_eq!( - category_from_tags(&["compositing".into(), "video-encoding".into(), "moq".into()]), - Some("Video Compositing".to_string()) - ); - assert_eq!( - category_from_tags(&["video-encoding".into(), "moq".into()]), - Some("Video Encoding".to_string()) - ); - assert_eq!( - category_from_tags(&["translation".into(), "speech-to-text".into()]), - Some("Translation".to_string()) - ); - assert_eq!(category_from_tags(&[]), None); - } - - #[test] - fn explicit_values_override_derivation_and_tags_union() { - let discovery = derive( - "video_moq_vaapi_h264_colorbars", - &kinds(&["video::vaapi::h264_encoder", "transport::moq::publisher"]), - None, - Some(OutputType::Video), - true, - Discovery { - group: Some("custom-group".to_string()), - variant: Some("Custom".to_string()), - category: Some("Demos".to_string()), - tags: vec!["curated".to_string()], - }, - ); + fn search_terms_are_lowercased_and_deduped() { + let discovery = Discovery { + category: Some("Streaming".to_string()), + tags: vec!["streaming".to_string()], + ..Discovery::default() + }; + let terms = build_search_terms("Streaming", "STREAMING", &discovery, &[]); - assert_eq!(discovery.group.as_deref(), Some("custom-group")); - assert_eq!(discovery.variant.as_deref(), Some("Custom")); - assert_eq!(discovery.category.as_deref(), Some("Demos")); - assert!(discovery.tags.contains(&"curated".to_string())); - assert!(discovery.tags.contains(&"video-encoding".to_string())); - assert!(discovery.tags.contains(&"hardware:vaapi".to_string())); + assert_eq!(terms.iter().filter(|t| *t == "streaming").count(), 1); + assert!(terms.iter().all(|t| t == &t.to_lowercase())); } #[test] - fn derivation_fills_in_when_explicit_absent() { - let discovery = derive( - "video_moq_nv_av1_colorbars", - &kinds(&["video::nv::av1_encoder", "transport::moq::publisher"]), - None, - Some(OutputType::Video), - true, - Discovery::default(), - ); - assert_eq!(discovery.group.as_deref(), Some("video-moq-colorbars")); - assert_eq!(discovery.variant.as_deref(), Some("NVIDIA AV1")); - assert_eq!(discovery.category.as_deref(), Some("Video Encoding")); - assert!(discovery.tags.contains(&"hardware:nvidia".to_string())); + fn search_terms_skip_absent_optional_fields() { + let discovery = Discovery::default(); + let terms = build_search_terms("Solo Sample", "", &discovery, &[]); + assert_eq!(terms, vec!["solo sample".to_string()]); } } diff --git a/apps/skit/src/samples.rs b/apps/skit/src/samples.rs index 77bd280db..042e4ce9a 100644 --- a/apps/skit/src/samples.rs +++ b/apps/skit/src/samples.rs @@ -183,8 +183,7 @@ fn has_yaml_extension(filename: &str) -> bool { .is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")) } -/// Metadata extracted from a pipeline YAML, including the raw inputs the -/// discovery derivation needs (node kinds + client I/O types). +/// Metadata extracted from a pipeline YAML for listing, search, and discovery. #[derive(Default)] struct PipelineMetadata { name: Option, @@ -192,16 +191,6 @@ struct PipelineMetadata { mode: streamkit_api::EngineMode, explicit: crate::sample_discovery::Discovery, node_kinds: Vec, - client_input: Option, - client_output: Option, -} - -fn client_io_types( - client: Option<&streamkit_api::yaml::ClientSection>, -) -> (Option, Option) { - let input = client.and_then(|c| c.input.as_ref()).map(|i| i.input_type); - let output = client.and_then(|c| c.output.as_ref()).map(|o| o.output_type); - (input, output) } /// Parse pipeline YAML and extract metadata used for listing and discovery. @@ -230,24 +219,38 @@ fn parse_pipeline_metadata(yaml: &str, path: &std::path::Path) -> PipelineMetada mode, group, variant, + canonical, category, tags, - client, + keywords, .. } | UserPipeline::Dag { - name, description, mode, group, variant, category, tags, client, .. + name, + description, + mode, + group, + variant, + canonical, + category, + tags, + keywords, + .. }) = user_pipeline; - let (client_input, client_output) = client_io_types(client.as_ref()); PipelineMetadata { name, description, mode, - explicit: crate::sample_discovery::Discovery { group, variant, category, tags }, + explicit: crate::sample_discovery::Discovery { + group, + variant, + canonical, + category, + tags, + keywords, + }, node_kinds, - client_input, - client_output, } } @@ -293,19 +296,17 @@ async fn load_samples_from_dir( let base_filename = filename.trim_end_matches(".yml").trim_end_matches(".yaml"); let id = format!("{subdir}/{base_filename}"); - let discovery = crate::sample_discovery::derive( - base_filename, - &meta.node_kinds, - meta.client_input, - meta.client_output, - is_system, - meta.explicit, - ); - let name = meta.name.unwrap_or_else(|| filename_to_name(filename)); let description = meta.description.unwrap_or_default(); let is_fragment = name == filename_to_name(filename) && description.is_empty(); + let search_terms = crate::sample_discovery::build_search_terms( + &name, + &description, + &meta.explicit, + &meta.node_kinds, + ); + samples.push(SamplePipeline { id, name, @@ -314,10 +315,12 @@ async fn load_samples_from_dir( is_system, mode: mode_to_string(meta.mode), is_fragment, - group: discovery.group, - variant: discovery.variant, - category: discovery.category, - tags: discovery.tags, + group: meta.explicit.group, + variant: meta.explicit.variant, + canonical: meta.explicit.canonical, + category: meta.explicit.category, + tags: meta.explicit.tags, + search_terms, }); }, Err(e) => { @@ -426,15 +429,6 @@ pub async fn get_sample( let meta = parse_pipeline_metadata(&yaml, &path); let mode_str = mode_to_string(meta.mode); - let discovery = crate::sample_discovery::derive( - filename_base, - &meta.node_kinds, - meta.client_input, - meta.client_output, - is_system, - meta.explicit, - ); - let name = meta.name.unwrap_or_else(|| filename_to_name(&filename)); let description = meta.description.unwrap_or_default(); @@ -447,6 +441,13 @@ pub async fn get_sample( let is_fragment = name == filename_to_name(&filename) && description.is_empty(); let full_id = format!("{subdir}/{filename_base}"); + let search_terms = crate::sample_discovery::build_search_terms( + &name, + &description, + &meta.explicit, + &meta.node_kinds, + ); + return Ok(SamplePipeline { id: full_id, name, @@ -455,10 +456,12 @@ pub async fn get_sample( is_system, mode: mode_str, is_fragment, - group: discovery.group, - variant: discovery.variant, - category: discovery.category, - tags: discovery.tags, + group: meta.explicit.group, + variant: meta.explicit.variant, + canonical: meta.explicit.canonical, + category: meta.explicit.category, + tags: meta.explicit.tags, + search_terms, }); } } @@ -571,13 +574,11 @@ async fn save_sample( let meta = parse_pipeline_metadata(&yaml_with_metadata, &path); let mode_str = mode_to_string(meta.mode); - let discovery = crate::sample_discovery::derive( - base_filename, + let search_terms = crate::sample_discovery::build_search_terms( + &request.name, + &request.description, + &meta.explicit, &meta.node_kinds, - meta.client_input, - meta.client_output, - false, - meta.explicit, ); Ok(SamplePipeline { @@ -588,10 +589,12 @@ async fn save_sample( is_system: false, mode: mode_str, is_fragment: request.is_fragment, - group: discovery.group, - variant: discovery.variant, - category: discovery.category, - tags: discovery.tags, + group: meta.explicit.group, + variant: meta.explicit.variant, + canonical: meta.explicit.canonical, + category: meta.explicit.category, + tags: meta.explicit.tags, + search_terms, }) } diff --git a/apps/skit/tests/sample_discovery_metadata_test.rs b/apps/skit/tests/sample_discovery_metadata_test.rs new file mode 100644 index 000000000..0a5cb5397 --- /dev/null +++ b/apps/skit/tests/sample_discovery_metadata_test.rs @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Enforces the explicit discovery-metadata contract for every bundled sample. +//! +//! The Convert/Stream pickers render group/variant/category/tags straight from +//! the sample YAML — there is no runtime derivation. This test is the single +//! source of truth that the authored metadata is present and internally +//! consistent, so a missing field or a malformed group fails CI loudly instead +//! of silently degrading the UI (e.g. an uncategorised card, or a group that +//! titles itself after an arbitrary member). + +// Test-fixture checks should fail fast with contextual assertion messages. +#![allow(clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use streamkit_api::yaml::{parse_yaml, UserPipeline}; + +/// A sample's authored discovery metadata, flattened across the `Steps`/`Dag` +/// pipeline arms. +struct SampleMeta { + file: String, + group: Option, + variant: Option, + canonical: bool, + category: Option, + tags: Vec, +} + +fn samples_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("streamkit-server should live under workspace_root/apps/skit") + .join("samples/pipelines") +} + +/// Sample subdirectories surfaced in the Convert/Stream pickers. The `test/` +/// fixtures are validation-only and never listed, so they are excluded. +const DISCOVERABLE_SUBDIRS: &[&str] = &["dynamic", "oneshot"]; + +fn read_samples() -> Vec { + let root = samples_root(); + let mut samples = Vec::new(); + + for subdir in DISCOVERABLE_SUBDIRS { + let dir = root.join(subdir); + let entries = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("reading {} failed: {e}", dir.display())); + for entry in entries { + let path = entry.expect("readable dir entry").path(); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext != "yml" && ext != "yaml" { + continue; + } + let file = + format!("{subdir}/{}", path.file_name().expect("file name").to_string_lossy()); + let yaml = std::fs::read_to_string(&path).expect("sample readable"); + let pipeline = + parse_yaml(&yaml).unwrap_or_else(|e| panic!("{file}: parse failed: {e}")); + + let (UserPipeline::Steps { group, variant, canonical, category, tags, .. } + | UserPipeline::Dag { group, variant, canonical, category, tags, .. }) = pipeline; + + samples.push(SampleMeta { file, group, variant, canonical, category, tags }); + } + } + + assert!(!samples.is_empty(), "expected to find bundled sample files"); + samples +} + +#[test] +fn every_bundled_sample_has_required_discovery_metadata() { + let mut failures = Vec::new(); + + for sample in read_samples() { + match &sample.category { + Some(c) if !c.trim().is_empty() => {}, + _ => failures.push(format!("{}: missing required `category`", sample.file)), + } + if sample.tags.iter().all(|t| t.trim().is_empty()) { + failures.push(format!("{}: missing required `tags`", sample.file)); + } + } + + assert!(failures.is_empty(), "discovery metadata check failed:\n{}", failures.join("\n")); +} + +#[test] +fn grouped_samples_are_internally_consistent() { + let samples = read_samples(); + let mut failures = Vec::new(); + let mut groups: BTreeMap> = BTreeMap::new(); + + for sample in &samples { + match sample.group.as_deref().map(str::trim) { + Some(group) if !group.is_empty() => { + groups.entry(group.to_string()).or_default().push(sample); + }, + _ => { + // Ungrouped samples are standalone cards; the canonical flag and + // a variant label are meaningless without a group to represent. + if sample.canonical { + failures.push(format!( + "{}: sets `canonical: true` but has no `group`", + sample.file + )); + } + if sample.variant.as_deref().is_some_and(|v| !v.trim().is_empty()) { + failures.push(format!("{}: sets `variant` but has no `group`", sample.file)); + } + }, + } + } + + for (group, members) in &groups { + if members.len() < 2 { + let member = members[0]; + failures.push(format!( + "{}: `group: {group}` has only one member; drop the group or add siblings", + member.file + )); + continue; + } + + let canonical_count = members.iter().filter(|m| m.canonical).count(); + if canonical_count != 1 { + failures.push(format!( + "group `{group}` must have exactly one `canonical: true` member, found \ + {canonical_count} ({})", + members.iter().map(|m| m.file.as_str()).collect::>().join(", ") + )); + } + + for member in members { + if member.variant.as_deref().is_none_or(|v| v.trim().is_empty()) { + failures.push(format!( + "{}: member of multi-sample group `{group}` must set a `variant` label", + member.file + )); + } + } + } + + assert!(failures.is_empty(), "group consistency check failed:\n{}", failures.join("\n")); +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 13570117c..08093b604 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -398,12 +398,20 @@ pub struct SamplePipeline { /// Human label distinguishing this variant within its `group`. #[serde(default)] pub variant: Option, + /// Whether this member represents its `group` (supplies the card title and + /// description). Exactly one member of a multi-sample group sets this. + #[serde(default)] + pub canonical: bool, /// Top-level bucket used for faceted filtering. #[serde(default)] pub category: Option, - /// Capability keywords powering fuzzy search and facet chips. + /// Facetable capability keywords powering the facet chips. #[serde(default)] pub tags: Vec, + /// Resolved, lowercased search document (name, description, category, + /// tags, authored keywords, node kinds) the UI matches queries against. + #[serde(default)] + pub search_terms: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, TS)] diff --git a/crates/api/src/yaml/compiler.rs b/crates/api/src/yaml/compiler.rs index 80edc60b5..1ff1bafa4 100644 --- a/crates/api/src/yaml/compiler.rs +++ b/crates/api/src/yaml/compiler.rs @@ -304,6 +304,8 @@ mod tests { variant: None, category: None, tags: Vec::new(), + canonical: false, + keywords: Vec::new(), nodes, client: None, } diff --git a/crates/api/src/yaml/mod.rs b/crates/api/src/yaml/mod.rs index 8351957fc..b8ae5cd21 100644 --- a/crates/api/src/yaml/mod.rs +++ b/crates/api/src/yaml/mod.rs @@ -269,10 +269,11 @@ pub struct ControlConfig { pub options: Option>, } -/// Optional top-level discovery fields are repeated across both `UserPipeline` -/// arms (faceted search and variant grouping). They are overrides: when omitted -/// the server derives best-effort values from node kinds, the client section, -/// and filename patterns. See `apps/skit/src/sample_discovery.rs`. +/// Top-level discovery metadata, repeated across both `UserPipeline` arms, +/// powering the Convert/Stream pickers' variant grouping and faceted/fuzzy +/// search. Bundled samples are required to author these explicitly (enforced by +/// `apps/skit/tests/sample_discovery_metadata_test.rs`); there is no runtime +/// derivation. See `apps/skit/src/sample_discovery.rs`. #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum UserPipeline { @@ -291,6 +292,10 @@ pub enum UserPipeline { category: Option, #[serde(default)] tags: Vec, + #[serde(default)] + canonical: bool, + #[serde(default)] + keywords: Vec, steps: Vec, client: Option, }, @@ -309,6 +314,10 @@ pub enum UserPipeline { category: Option, #[serde(default)] tags: Vec, + #[serde(default)] + canonical: bool, + #[serde(default)] + keywords: Vec, nodes: IndexMap, client: Option, }, diff --git a/samples/pipelines/dynamic/moq.yml b/samples/pipelines/dynamic/moq.yml index d21bc2e60..9e520c7ad 100644 --- a/samples/pipelines/dynamic/moq.yml +++ b/samples/pipelines/dynamic/moq.yml @@ -1,5 +1,13 @@ name: Real-Time MoQ Transcoder description: Decodes a MoQ input broadcast, applies gain, and republishes to MoQ +category: Streaming +tags: + - streaming + - transcoding +keywords: + - moq + - media over quic + - opus mode: dynamic client: gateway_path: /moq/transcoder diff --git a/samples/pipelines/dynamic/moq_aac_echo.yml b/samples/pipelines/dynamic/moq_aac_echo.yml index de58660bb..e4a63cdbd 100644 --- a/samples/pipelines/dynamic/moq_aac_echo.yml +++ b/samples/pipelines/dynamic/moq_aac_echo.yml @@ -18,6 +18,15 @@ name: MoQ AAC Echo description: | Accepts Opus audio via MoQ peer, re-encodes to AAC, and broadcasts the AAC echo back to subscribers. +category: Streaming +tags: + - streaming + - transcoding + - audio-encoding +keywords: + - moq + - aac + - echo mode: dynamic client: gateway_path: /moq/aac-echo diff --git a/samples/pipelines/dynamic/moq_aac_mixing.yml b/samples/pipelines/dynamic/moq_aac_mixing.yml index aa2534075..913483fa0 100644 --- a/samples/pipelines/dynamic/moq_aac_mixing.yml +++ b/samples/pipelines/dynamic/moq_aac_mixing.yml @@ -18,6 +18,18 @@ name: Real-Time MoQ Mixing (AAC) description: | Accepts Opus audio via MoQ, mixes with a local music track, encodes to AAC, and broadcasts the mixed AAC stream to subscribers. +group: moq-mixing +variant: AAC +category: Streaming +tags: + - streaming + - mixing + - audio-encoding +keywords: + - moq + - mix + - music + - aac mode: dynamic client: gateway_path: /moq/mixing-aac diff --git a/samples/pipelines/dynamic/moq_mixing.yml b/samples/pipelines/dynamic/moq_mixing.yml index 6c705b464..cc5ce947b 100644 --- a/samples/pipelines/dynamic/moq_mixing.yml +++ b/samples/pipelines/dynamic/moq_mixing.yml @@ -1,5 +1,17 @@ name: Real-Time MoQ Mixing (Mic + Music) description: Mixes a live MoQ input stream with a local music track and republishes to MoQ +group: moq-mixing +variant: Opus +canonical: true +category: Streaming +tags: + - streaming + - mixing +keywords: + - moq + - mix + - music + - opus mode: dynamic client: gateway_path: /moq/mixing diff --git a/samples/pipelines/dynamic/moq_peer.yml b/samples/pipelines/dynamic/moq_peer.yml index b83742fd6..0deef75e6 100644 --- a/samples/pipelines/dynamic/moq_peer.yml +++ b/samples/pipelines/dynamic/moq_peer.yml @@ -4,6 +4,14 @@ name: MoQ Peer Transcoder (Gateway) description: Accepts a WebTransport client via transport::moq::peer, applies gain, and loops processed audio back to subscribers +category: Streaming +tags: + - streaming + - transcoding +keywords: + - moq + - webtransport + - gateway mode: dynamic client: gateway_path: /moq diff --git a/samples/pipelines/dynamic/moq_relay_echo.yml b/samples/pipelines/dynamic/moq_relay_echo.yml index 166873196..809529cb1 100644 --- a/samples/pipelines/dynamic/moq_relay_echo.yml +++ b/samples/pipelines/dynamic/moq_relay_echo.yml @@ -4,6 +4,15 @@ name: MoQ Relay Echo (Audio) description: Subscribes to an audio broadcast on a MoQ relay, applies unity gain, and republishes — validates the full relay pub/sub path +category: Streaming +tags: + - streaming + - relay +keywords: + - moq + - relay + - echo + - pub/sub mode: dynamic client: relay_url: http://127.0.0.1:4443 diff --git a/samples/pipelines/dynamic/moq_relay_screen_cam_pip.yml b/samples/pipelines/dynamic/moq_relay_screen_cam_pip.yml index 7c54297fb..268b0eefd 100644 --- a/samples/pipelines/dynamic/moq_relay_screen_cam_pip.yml +++ b/samples/pipelines/dynamic/moq_relay_screen_cam_pip.yml @@ -11,6 +11,20 @@ description: > # NOTE: The publisher (browser) must already be streaming to the 'screen-input' # and 'cam-input' broadcasts before starting this pipeline, otherwise track # discovery will miss the video/hd pins. +category: Streaming +tags: + - streaming + - relay + - compositing + - picture-in-picture + - screen-capture + - webcam +keywords: + - moq + - relay + - screen + - camera + - pip mode: dynamic client: relay_url: http://127.0.0.1:4443 diff --git a/samples/pipelines/dynamic/moq_relay_webcam_pip.yml b/samples/pipelines/dynamic/moq_relay_webcam_pip.yml index 751d68fdb..f3a58657a 100644 --- a/samples/pipelines/dynamic/moq_relay_webcam_pip.yml +++ b/samples/pipelines/dynamic/moq_relay_webcam_pip.yml @@ -6,6 +6,19 @@ name: MoQ Relay Webcam PiP (Audio + Video) description: Subscribes audio+video from a MoQ relay, composites the webcam as PiP over colorbars, and republishes both tracks — validates multi-track relay pub/sub # NOTE: The publisher must already be streaming to the 'input' broadcast before # starting this pipeline, otherwise track discovery will miss the video/hd pin. +category: Streaming +tags: + - streaming + - relay + - compositing + - picture-in-picture + - webcam +keywords: + - moq + - relay + - webcam + - camera + - pip mode: dynamic client: relay_url: http://127.0.0.1:4443 diff --git a/samples/pipelines/dynamic/moq_s3_archive.yml b/samples/pipelines/dynamic/moq_s3_archive.yml index 780ba20ba..4ea6c1644 100644 --- a/samples/pipelines/dynamic/moq_s3_archive.yml +++ b/samples/pipelines/dynamic/moq_s3_archive.yml @@ -21,6 +21,15 @@ name: MoQ Broadcast + S3 Archive description: | Accepts Opus audio via MoQ, broadcasts back via MoQ for live monitoring, and simultaneously archives the stream to S3-compatible object storage. +category: Streaming +tags: + - streaming + - archival +keywords: + - moq + - s3 + - object storage + - archive mode: dynamic client: gateway_path: /moq/s3-archive diff --git a/samples/pipelines/dynamic/moq_to_rtmp_composite.yml b/samples/pipelines/dynamic/moq_to_rtmp_composite.yml index dac56f2ed..cf584eb6d 100644 --- a/samples/pipelines/dynamic/moq_to_rtmp_composite.yml +++ b/samples/pipelines/dynamic/moq_to_rtmp_composite.yml @@ -18,6 +18,19 @@ name: MoQ to RTMP (Composited) description: | Receives audio+video via MoQ peer, composites with PiP and logo overlay, re-encodes to H.264+AAC, and publishes to an RTMP endpoint (e.g. YouTube Live). +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - audio-encoding +keywords: + - moq + - rtmp + - youtube + - h.264 + - aac + - composite mode: dynamic client: gateway_path: /moq/rtmp-out diff --git a/samples/pipelines/dynamic/speech-translate-en-es.yaml b/samples/pipelines/dynamic/speech-translate-en-es.yaml index 3f1021047..34ec7a448 100644 --- a/samples/pipelines/dynamic/speech-translate-en-es.yaml +++ b/samples/pipelines/dynamic/speech-translate-en-es.yaml @@ -22,6 +22,24 @@ name: Real-Time Speech Translation (English → Spanish) description: Real-time speech translation from English to Spanish via MoQ +group: speech-translation-stream +variant: NLLB (EN→ES) +canonical: true +category: Speech Translation +tags: + - streaming + - translation + - speech-to-text + - text-to-speech +keywords: + - translate + - english + - spanish + - nllb + - whisper + - piper + - stt + - tts mode: dynamic client: gateway_path: /moq/speech-translate-en-es diff --git a/samples/pipelines/dynamic/speech-translate-es-en.yaml b/samples/pipelines/dynamic/speech-translate-es-en.yaml index 3e481e4de..7638e0347 100644 --- a/samples/pipelines/dynamic/speech-translate-es-en.yaml +++ b/samples/pipelines/dynamic/speech-translate-es-en.yaml @@ -23,6 +23,23 @@ name: Real-Time Speech Translation (Spanish → English) description: Real-time speech translation from Spanish to English via MoQ +group: speech-translation-stream +variant: NLLB (ES→EN) +category: Speech Translation +tags: + - streaming + - translation + - speech-to-text + - text-to-speech +keywords: + - translate + - spanish + - english + - nllb + - whisper + - kokoro + - stt + - tts mode: dynamic client: gateway_path: /moq/speech-translate-es-en diff --git a/samples/pipelines/dynamic/speech-translate-helsinki-en-es.yaml b/samples/pipelines/dynamic/speech-translate-helsinki-en-es.yaml index 1c9c394da..900fdad42 100644 --- a/samples/pipelines/dynamic/speech-translate-helsinki-en-es.yaml +++ b/samples/pipelines/dynamic/speech-translate-helsinki-en-es.yaml @@ -26,6 +26,22 @@ name: Real-Time Speech Translation (English → Spanish) - Helsinki description: Real-time speech translation from English to Spanish using Apache 2.0 licensed Helsinki OPUS-MT +group: speech-translation-stream +variant: Helsinki (EN→ES) +category: Speech Translation +tags: + - streaming + - translation + - speech-to-text + - text-to-speech +keywords: + - translate + - english + - spanish + - helsinki + - opus-mt + - stt + - tts mode: dynamic client: gateway_path: /moq/speech-translate-helsinki-en-es diff --git a/samples/pipelines/dynamic/speech-translate-helsinki-es-en.yaml b/samples/pipelines/dynamic/speech-translate-helsinki-es-en.yaml index 7bc497f09..230e75b1e 100644 --- a/samples/pipelines/dynamic/speech-translate-helsinki-es-en.yaml +++ b/samples/pipelines/dynamic/speech-translate-helsinki-es-en.yaml @@ -26,6 +26,22 @@ name: Real-Time Speech Translation (Spanish → English) - Helsinki description: Real-time speech translation from Spanish to English using Apache 2.0 licensed Helsinki OPUS-MT +group: speech-translation-stream +variant: Helsinki (ES→EN) +category: Speech Translation +tags: + - streaming + - translation + - speech-to-text + - text-to-speech +keywords: + - translate + - spanish + - english + - helsinki + - opus-mt + - stt + - tts mode: dynamic client: gateway_path: /moq/speech-translate-helsinki-es-en diff --git a/samples/pipelines/dynamic/video_moq_av1_compositor.yml b/samples/pipelines/dynamic/video_moq_av1_compositor.yml index a110ce2da..9c2502485 100644 --- a/samples/pipelines/dynamic/video_moq_av1_compositor.yml +++ b/samples/pipelines/dynamic/video_moq_av1_compositor.yml @@ -4,6 +4,19 @@ name: Video Compositor – AV1 (MoQ Stream) description: Composites two colorbars sources with the StreamKit logo watermark, encodes as AV1 (rav1e) at 1280x720, and streams via MoQ +group: video-compositor-stream +variant: AV1 (rav1e) +category: Streaming +tags: + - streaming + - compositing + - video-encoding +keywords: + - compositor + - overlay + - watermark + - av1 + - rav1e mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_colorbars.yml b/samples/pipelines/dynamic/video_moq_colorbars.yml index eb18cf847..738969d1f 100644 --- a/samples/pipelines/dynamic/video_moq_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_colorbars.yml @@ -4,6 +4,18 @@ name: Video Color Bars (MoQ Stream) description: Continuously generates SMPTE color bars and streams via MoQ +group: video-colorbars-stream +variant: VP9 (Software) +canonical: true +category: Streaming +tags: + - streaming + - video-encoding +keywords: + - colorbars + - smpte + - test pattern + - vp9 mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_compositor.yml b/samples/pipelines/dynamic/video_moq_compositor.yml index 60a5c28e4..dfb341d17 100644 --- a/samples/pipelines/dynamic/video_moq_compositor.yml +++ b/samples/pipelines/dynamic/video_moq_compositor.yml @@ -4,6 +4,19 @@ name: Video Compositor (MoQ Stream) description: Composites two colorbars sources with the StreamKit logo watermark through the compositor node and streams via MoQ +group: video-compositor-stream +variant: VP9 +canonical: true +category: Streaming +tags: + - streaming + - compositing + - video-encoding +keywords: + - compositor + - overlay + - watermark + - vp9 mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_h264_colorbars.yml b/samples/pipelines/dynamic/video_moq_h264_colorbars.yml index ae6476a71..dba5a7227 100644 --- a/samples/pipelines/dynamic/video_moq_h264_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_h264_colorbars.yml @@ -4,6 +4,18 @@ name: Video Color Bars H.264 (MoQ Stream) description: Continuously generates SMPTE color bars with timer and streams via MoQ using OpenH264 +group: video-colorbars-stream +variant: H.264 (OpenH264) +category: Streaming +tags: + - streaming + - video-encoding +keywords: + - colorbars + - smpte + - test pattern + - h.264 + - openh264 mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_nv_av1_colorbars.yml b/samples/pipelines/dynamic/video_moq_nv_av1_colorbars.yml index fb6572e86..261d5e492 100644 --- a/samples/pipelines/dynamic/video_moq_nv_av1_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_nv_av1_colorbars.yml @@ -11,6 +11,20 @@ name: NVENC AV1 Color Bars (MoQ Stream) description: Continuously generates SMPTE color bars and streams via MoQ using NVIDIA NVENC AV1 HW encoder +group: video-colorbars-stream +variant: AV1 (NVENC) +category: Streaming +tags: + - streaming + - video-encoding + - "hardware:nvidia" +keywords: + - colorbars + - smpte + - av1 + - nvenc + - nvidia + - gpu mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_screen_cam_pip.yml b/samples/pipelines/dynamic/video_moq_screen_cam_pip.yml index 074f4f9f2..300a03a66 100644 --- a/samples/pipelines/dynamic/video_moq_screen_cam_pip.yml +++ b/samples/pipelines/dynamic/video_moq_screen_cam_pip.yml @@ -8,6 +8,24 @@ description: > picture-in-picture overlay, loops microphone audio, and streams via MoQ. Uses multi-broadcast subscription to receive screen and camera inputs on separate broadcasts with namespaced output pins. +group: screen-cam-pip-stream +variant: VP9 +canonical: true +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - screen-capture + - webcam + - video-encoding +keywords: + - screen + - desktop + - camera + - webcam + - pip + - vp9 mode: dynamic client: gateway_path: /moq/screenshare diff --git a/samples/pipelines/dynamic/video_moq_screen_cam_svt_av1_pip.yml b/samples/pipelines/dynamic/video_moq_screen_cam_svt_av1_pip.yml index fa1a68780..3ef318bdd 100644 --- a/samples/pipelines/dynamic/video_moq_screen_cam_svt_av1_pip.yml +++ b/samples/pipelines/dynamic/video_moq_screen_cam_svt_av1_pip.yml @@ -16,6 +16,24 @@ description: > picture-in-picture overlay (circle crop), loops microphone audio through a gain filter, re-encodes video as AV1 (SVT-AV1) at 1280x720, and streams both via MoQ. +group: screen-cam-pip-stream +variant: AV1 (SVT-AV1) +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - screen-capture + - webcam + - video-encoding +keywords: + - screen + - desktop + - camera + - webcam + - pip + - av1 + - svt-av1 mode: dynamic client: gateway_path: /moq/screenshare diff --git a/samples/pipelines/dynamic/video_moq_screen_share.yml b/samples/pipelines/dynamic/video_moq_screen_share.yml index c801fc956..cc25fafba 100644 --- a/samples/pipelines/dynamic/video_moq_screen_share.yml +++ b/samples/pipelines/dynamic/video_moq_screen_share.yml @@ -4,6 +4,17 @@ name: Screen Share (MoQ Stream) description: Captures the user's screen, composites it with a text overlay, encodes as VP9 at 1920×1080, loops microphone audio through a gain filter, and streams both via MoQ +category: Streaming +tags: + - streaming + - compositing + - screen-capture + - video-encoding +keywords: + - screen + - desktop + - screencast + - vp9 mode: dynamic client: gateway_path: /moq/screen diff --git a/samples/pipelines/dynamic/video_moq_servo_web_overlay.yml b/samples/pipelines/dynamic/video_moq_servo_web_overlay.yml index b41df831f..5cd5fab46 100644 --- a/samples/pipelines/dynamic/video_moq_servo_web_overlay.yml +++ b/samples/pipelines/dynamic/video_moq_servo_web_overlay.yml @@ -23,6 +23,18 @@ name: Video Web Overlay (Servo + MoQ) description: Composites a Servo-rendered web page as PiP overlay on colorbars and streams via MoQ +category: Streaming +tags: + - streaming + - compositing + - web-capture + - video-encoding +keywords: + - servo + - web + - browser + - overlay + - pip mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_slint_scoreboard.yml b/samples/pipelines/dynamic/video_moq_slint_scoreboard.yml index 9a535d22e..0a1d60d44 100644 --- a/samples/pipelines/dynamic/video_moq_slint_scoreboard.yml +++ b/samples/pipelines/dynamic/video_moq_slint_scoreboard.yml @@ -31,6 +31,17 @@ name: Video Slint Scoreboard + Lower Third (MoQ) description: Composites colorbars with Slint scoreboard and lower-third overlays, streams via MoQ with runtime property updates +category: Streaming +tags: + - streaming + - compositing + - video-encoding +keywords: + - slint + - scoreboard + - lower third + - overlay + - graphics mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_svt_av1_compositor.yml b/samples/pipelines/dynamic/video_moq_svt_av1_compositor.yml index bb3f989c0..9b6624891 100644 --- a/samples/pipelines/dynamic/video_moq_svt_av1_compositor.yml +++ b/samples/pipelines/dynamic/video_moq_svt_av1_compositor.yml @@ -9,6 +9,19 @@ name: Video Compositor – SVT-AV1 (MoQ Stream) description: Composites two colorbars sources with the StreamKit logo watermark, encodes as AV1 (SVT-AV1) at 1280x720, and streams via MoQ +group: video-compositor-stream +variant: AV1 (SVT-AV1) +category: Streaming +tags: + - streaming + - compositing + - video-encoding +keywords: + - compositor + - overlay + - watermark + - av1 + - svt-av1 mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_vaapi_av1_colorbars.yml b/samples/pipelines/dynamic/video_moq_vaapi_av1_colorbars.yml index 112f2345a..a6b44a1b2 100644 --- a/samples/pipelines/dynamic/video_moq_vaapi_av1_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_vaapi_av1_colorbars.yml @@ -10,6 +10,19 @@ name: VA-API AV1 Color Bars (MoQ Stream) description: Continuously generates SMPTE color bars and streams via MoQ using VA-API AV1 HW encoder +group: video-colorbars-stream +variant: AV1 (VA-API) +category: Streaming +tags: + - streaming + - video-encoding + - "hardware:vaapi" +keywords: + - colorbars + - smpte + - av1 + - vaapi + - gpu mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_vaapi_h264_colorbars.yml b/samples/pipelines/dynamic/video_moq_vaapi_h264_colorbars.yml index 30a10638a..68866bd06 100644 --- a/samples/pipelines/dynamic/video_moq_vaapi_h264_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_vaapi_h264_colorbars.yml @@ -10,6 +10,19 @@ name: VA-API H.264 Color Bars (MoQ Stream) description: Continuously generates SMPTE color bars and streams via MoQ using VA-API H.264 HW encoder +group: video-colorbars-stream +variant: H.264 (VA-API) +category: Streaming +tags: + - streaming + - video-encoding + - "hardware:vaapi" +keywords: + - colorbars + - smpte + - h.264 + - vaapi + - gpu mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_voice_controlled_compositor.yml b/samples/pipelines/dynamic/video_moq_voice_controlled_compositor.yml index 9619c6232..13bb545ee 100644 --- a/samples/pipelines/dynamic/video_moq_voice_controlled_compositor.yml +++ b/samples/pipelines/dynamic/video_moq_voice_controlled_compositor.yml @@ -57,6 +57,22 @@ description: >- speaking indicator are rendered via a separate Slint overlay. Three param_bridge nodes coordinate on two different target nodes (compositor + Slint subtitle). +category: Streaming +tags: + - streaming + - compositing + - webcam + - speech-to-text + - voice-activity-detection + - subtitles + - video-encoding +keywords: + - voice control + - parakeet + - stt + - vad + - subtitles + - compositor mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_vulkan_video_h264_colorbars.yml b/samples/pipelines/dynamic/video_moq_vulkan_video_h264_colorbars.yml index be381fdc6..27e82d357 100644 --- a/samples/pipelines/dynamic/video_moq_vulkan_video_h264_colorbars.yml +++ b/samples/pipelines/dynamic/video_moq_vulkan_video_h264_colorbars.yml @@ -10,6 +10,19 @@ name: Vulkan Video H.264 Color Bars (MoQ Stream) description: Continuously generates SMPTE color bars and streams via MoQ using Vulkan Video H.264 HW encoder +group: video-colorbars-stream +variant: H.264 (Vulkan) +category: Streaming +tags: + - streaming + - video-encoding + - "hardware:vulkan" +keywords: + - colorbars + - smpte + - h.264 + - vulkan + - gpu mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_webcam_av1_pip.yml b/samples/pipelines/dynamic/video_moq_webcam_av1_pip.yml index 2464fcff5..dc260b569 100644 --- a/samples/pipelines/dynamic/video_moq_webcam_av1_pip.yml +++ b/samples/pipelines/dynamic/video_moq_webcam_av1_pip.yml @@ -4,6 +4,21 @@ name: Webcam PiP – AV1 Output (MoQ Stream) description: Composites the user's webcam as picture-in-picture over colorbars with a text overlay, loops audio through a gain filter, re-encodes video as AV1 (rav1e) at 1280x720, and streams both via MoQ +group: webcam-pip-stream +variant: AV1 (rav1e) +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - video-encoding +keywords: + - webcam + - camera + - pip + - av1 + - rav1e mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_webcam_circle_pip.yml b/samples/pipelines/dynamic/video_moq_webcam_circle_pip.yml index fe0f8d8a0..52d600fdb 100644 --- a/samples/pipelines/dynamic/video_moq_webcam_circle_pip.yml +++ b/samples/pipelines/dynamic/video_moq_webcam_circle_pip.yml @@ -4,6 +4,19 @@ name: Webcam Circle PiP (MoQ Stream) description: Composites the user's webcam as a circular picture-in-picture overlay (Loom-style) over colorbars, loops audio through a gain filter, and streams both via MoQ +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - video-encoding +keywords: + - webcam + - camera + - circle + - loom + - pip mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_webcam_dav1d_pip.yml b/samples/pipelines/dynamic/video_moq_webcam_dav1d_pip.yml index e289cf345..30153668d 100644 --- a/samples/pipelines/dynamic/video_moq_webcam_dav1d_pip.yml +++ b/samples/pipelines/dynamic/video_moq_webcam_dav1d_pip.yml @@ -12,6 +12,21 @@ description: > This is the same pipeline as video_moq_webcam_av1_pip.yml but uses the video::dav1d::decoder node, which handles corrupt/truncated AV1 data gracefully via negative error codes — it never panics. +group: webcam-pip-stream +variant: AV1 (dav1d decode) +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - video-encoding +keywords: + - webcam + - camera + - pip + - av1 + - dav1d mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_webcam_pip.yml b/samples/pipelines/dynamic/video_moq_webcam_pip.yml index 7a34304ce..2190a25a9 100644 --- a/samples/pipelines/dynamic/video_moq_webcam_pip.yml +++ b/samples/pipelines/dynamic/video_moq_webcam_pip.yml @@ -4,6 +4,21 @@ name: Webcam PiP (MoQ Stream) description: Composites the user's webcam as picture-in-picture over colorbars with a text overlay, loops audio through a gain filter, and streams both via MoQ +group: webcam-pip-stream +variant: VP9 +canonical: true +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - video-encoding +keywords: + - webcam + - camera + - pip + - vp9 mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_webcam_subtitles.yml b/samples/pipelines/dynamic/video_moq_webcam_subtitles.yml index a62933a66..570698a16 100644 --- a/samples/pipelines/dynamic/video_moq_webcam_subtitles.yml +++ b/samples/pipelines/dynamic/video_moq_webcam_subtitles.yml @@ -28,6 +28,22 @@ description: >- real-time Parakeet TDT transcription displayed as subtitles via a Slint overlay. Demonstrates the param_bridge node for cross-node control messaging. ~10x faster than Whisper on CPU. +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - speech-to-text + - subtitles + - video-encoding +keywords: + - webcam + - subtitles + - captions + - parakeet + - stt + - transcription mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_moq_webcam_svt_av1_pip.yml b/samples/pipelines/dynamic/video_moq_webcam_svt_av1_pip.yml index bf81e36ce..1c0024941 100644 --- a/samples/pipelines/dynamic/video_moq_webcam_svt_av1_pip.yml +++ b/samples/pipelines/dynamic/video_moq_webcam_svt_av1_pip.yml @@ -10,6 +10,21 @@ name: Webcam PiP – SVT-AV1 Output (MoQ Stream) description: Composites the user's webcam as picture-in-picture over colorbars with a text overlay, loops audio through a gain filter, re-encodes video as AV1 (SVT-AV1) at 1280x720, and streams both via MoQ +group: webcam-pip-stream +variant: AV1 (SVT-AV1) +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - video-encoding +keywords: + - webcam + - camera + - pip + - av1 + - svt-av1 mode: dynamic client: gateway_path: /moq/video diff --git a/samples/pipelines/dynamic/video_mse_compositor.yml b/samples/pipelines/dynamic/video_mse_compositor.yml index 99ae80281..10d02a348 100644 --- a/samples/pipelines/dynamic/video_mse_compositor.yml +++ b/samples/pipelines/dynamic/video_mse_compositor.yml @@ -7,6 +7,16 @@ description: > Composites two colorbars sources and streams via HTTP/MSE only. No MoQ output — validates MSE endpoint in isolation. Access the stream at GET /mse/{session_id}/video +category: Streaming +tags: + - streaming + - compositing + - video-encoding +keywords: + - mse + - compositor + - webm + - vp9 mode: dynamic client: watch: diff --git a/samples/pipelines/dynamic/video_mse_webcam_pip.yml b/samples/pipelines/dynamic/video_mse_webcam_pip.yml index e5ef202b5..fe75cac7d 100644 --- a/samples/pipelines/dynamic/video_mse_webcam_pip.yml +++ b/samples/pipelines/dynamic/video_mse_webcam_pip.yml @@ -7,6 +7,23 @@ description: > Receives webcam and microphone via MoQ, composites webcam as PiP over colorbars, muxes audio + video into WebM, and outputs via HTTP/MSE only. Access the stream at GET /mse/{session_id}/video +group: webcam-pip-mse +variant: VP9 +canonical: true +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - video-encoding +keywords: + - mse + - webcam + - camera + - pip + - webm + - vp9 mode: dynamic client: gateway_path: /moq/video-mse-pip diff --git a/samples/pipelines/dynamic/video_mse_webcam_svt_av1_pip.yml b/samples/pipelines/dynamic/video_mse_webcam_svt_av1_pip.yml index 931fa250c..fa18b9613 100644 --- a/samples/pipelines/dynamic/video_mse_webcam_svt_av1_pip.yml +++ b/samples/pipelines/dynamic/video_mse_webcam_svt_av1_pip.yml @@ -16,6 +16,23 @@ description: > colorbars with a text overlay and logo watermark, re-encodes as AV1 (SVT-AV1) at 1280x720, muxes into WebM, and outputs via HTTP/MSE. Access the stream at GET /mse/{session_id}/video +group: webcam-pip-mse +variant: AV1 (SVT-AV1) +category: Streaming +tags: + - streaming + - compositing + - picture-in-picture + - webcam + - video-encoding +keywords: + - mse + - webcam + - camera + - pip + - webm + - av1 + - svt-av1 mode: dynamic client: gateway_path: /moq/video-mse-av1-pip diff --git a/samples/pipelines/dynamic/voice-agent-openai.yaml b/samples/pipelines/dynamic/voice-agent-openai.yaml index d03615900..ace312277 100644 --- a/samples/pipelines/dynamic/voice-agent-openai.yaml +++ b/samples/pipelines/dynamic/voice-agent-openai.yaml @@ -11,6 +11,21 @@ name: Real-Time Voice Agent (OpenAI) description: Runs a MoQ voice agent using Whisper STT, OpenAI, and Kokoro TTS +category: Voice Agent +tags: + - streaming + - voice-agent + - speech-to-text + - text-to-speech +keywords: + - voice agent + - openai + - llm + - whisper + - kokoro + - stt + - tts + - conversational mode: dynamic client: gateway_path: /moq/voice-agent-openai diff --git a/samples/pipelines/dynamic/voice-weather-open-meteo.yaml b/samples/pipelines/dynamic/voice-weather-open-meteo.yaml index 85e60f2cd..e4da4ea7a 100644 --- a/samples/pipelines/dynamic/voice-weather-open-meteo.yaml +++ b/samples/pipelines/dynamic/voice-weather-open-meteo.yaml @@ -29,6 +29,20 @@ name: Real-Time Voice Weather (Open-Meteo) description: Ask for the weather in a location (STT → Open-Meteo → TTS) via MoQ +category: Voice Agent +tags: + - streaming + - voice-agent + - speech-to-text + - text-to-speech +keywords: + - voice agent + - weather + - open-meteo + - whisper + - kokoro + - stt + - tts mode: dynamic client: gateway_path: /moq/voice-weather-open-meteo diff --git a/samples/pipelines/oneshot/aac_encode.yml b/samples/pipelines/oneshot/aac_encode.yml index ae23af870..a01b159b2 100644 --- a/samples/pipelines/oneshot/aac_encode.yml +++ b/samples/pipelines/oneshot/aac_encode.yml @@ -12,6 +12,14 @@ name: AAC Encode (audio-only, MP4) description: Decodes an uploaded Ogg/Opus file, encodes to AAC-LC, and muxes into MP4 +category: Audio Processing +tags: + - audio-encoding +keywords: + - aac + - encode + - mp4 + - audio mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/double_volume.yml b/samples/pipelines/oneshot/double_volume.yml index ac7b48dc5..d5c6990ce 100644 --- a/samples/pipelines/oneshot/double_volume.yml +++ b/samples/pipelines/oneshot/double_volume.yml @@ -1,5 +1,13 @@ name: Volume Boost (2×) description: Boosts the volume of an uploaded Ogg/Opus file and returns Ogg/Opus +category: Audio Processing +tags: + - gain +keywords: + - volume + - boost + - gain + - loud mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/dual_upload_mixing.yml b/samples/pipelines/oneshot/dual_upload_mixing.yml index 2706a6f96..26ffa5297 100644 --- a/samples/pipelines/oneshot/dual_upload_mixing.yml +++ b/samples/pipelines/oneshot/dual_upload_mixing.yml @@ -1,5 +1,16 @@ name: Dual Upload Mixer description: Mix two uploaded Ogg/Opus tracks and return Opus/WebM +group: audio-mixing +variant: Two Uploads +category: Audio Processing +tags: + - mixing +keywords: + - mix + - dual + - two tracks + - opus + - webm mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/gain_filter_rust.yml b/samples/pipelines/oneshot/gain_filter_rust.yml index 44bd3f778..135b13dfe 100644 --- a/samples/pipelines/oneshot/gain_filter_rust.yml +++ b/samples/pipelines/oneshot/gain_filter_rust.yml @@ -1,5 +1,14 @@ name: Gain Filter (Rust WASM) description: Applies a gain filter using the Rust WASM plugin +category: Audio Processing +tags: + - gain +keywords: + - gain + - volume + - wasm + - rust + - plugin mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/kokoro-tts.yml b/samples/pipelines/oneshot/kokoro-tts.yml index e6ae0e263..ea3fdbeb4 100644 --- a/samples/pipelines/oneshot/kokoro-tts.yml +++ b/samples/pipelines/oneshot/kokoro-tts.yml @@ -1,5 +1,17 @@ name: Text-to-Speech (Kokoro) description: Synthesizes speech from text using Kokoro +group: text-to-speech +variant: Kokoro +canonical: true +category: Text to Speech +tags: + - text-to-speech +keywords: + - tts + - speech synthesis + - synthesize + - voice + - kokoro mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/matcha-tts.yml b/samples/pipelines/oneshot/matcha-tts.yml index 571143f0a..6fa86e7c5 100644 --- a/samples/pipelines/oneshot/matcha-tts.yml +++ b/samples/pipelines/oneshot/matcha-tts.yml @@ -4,6 +4,17 @@ name: Text-to-Speech (Matcha) description: Synthesizes speech from text using Matcha +group: text-to-speech +variant: Matcha +category: Text to Speech +tags: + - text-to-speech +keywords: + - tts + - speech synthesis + - synthesize + - voice + - matcha mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/mixing.yml b/samples/pipelines/oneshot/mixing.yml index 0dcc7b98e..c06840ac5 100644 --- a/samples/pipelines/oneshot/mixing.yml +++ b/samples/pipelines/oneshot/mixing.yml @@ -1,5 +1,16 @@ name: Audio Mixing (Upload + Music Track) description: Mixes uploaded audio with a built-in music track and returns Opus/WebM +group: audio-mixing +variant: Upload + Music +canonical: true +category: Audio Processing +tags: + - mixing +keywords: + - mix + - music + - opus + - webm mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/mp4_mux_aac_h264.yml b/samples/pipelines/oneshot/mp4_mux_aac_h264.yml index a4eebc333..36c4167de 100644 --- a/samples/pipelines/oneshot/mp4_mux_aac_h264.yml +++ b/samples/pipelines/oneshot/mp4_mux_aac_h264.yml @@ -12,6 +12,17 @@ name: MP4 Mux (AAC + H264) description: Muxes AAC-encoded audio with H.264 video into an MP4 container +category: Video Encoding +tags: + - video-encoding + - audio-encoding + - muxing +keywords: + - mp4 + - mux + - aac + - h.264 + - container mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/mp4_mux_audio.yml b/samples/pipelines/oneshot/mp4_mux_audio.yml index 7fa497fe8..d03b7c29d 100644 --- a/samples/pipelines/oneshot/mp4_mux_audio.yml +++ b/samples/pipelines/oneshot/mp4_mux_audio.yml @@ -13,6 +13,16 @@ name: MP4 Mux (Opus audio, file mode) description: Decodes an uploaded Ogg/Opus file, re-encodes, and muxes into an MP4 container (file mode) +category: Audio Processing +tags: + - audio-encoding + - muxing +keywords: + - mp4 + - mux + - opus + - audio + - container mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/mp4_mux_video.yml b/samples/pipelines/oneshot/mp4_mux_video.yml index 99f065e5e..b9bef5708 100644 --- a/samples/pipelines/oneshot/mp4_mux_video.yml +++ b/samples/pipelines/oneshot/mp4_mux_video.yml @@ -9,6 +9,17 @@ name: MP4 Mux (AV1 video, file mode) description: Encode colorbars to AV1 and mux into an MP4 file +category: Video Encoding +tags: + - video-encoding + - muxing +keywords: + - mp4 + - mux + - av1 + - svt-av1 + - video + - container mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/openh264-encode-test.yml b/samples/pipelines/oneshot/openh264-encode-test.yml index 451a158e2..55bd7da2a 100644 --- a/samples/pipelines/oneshot/openh264-encode-test.yml +++ b/samples/pipelines/oneshot/openh264-encode-test.yml @@ -8,6 +8,16 @@ name: OpenH264 Encode Test (MP4) description: Generates color bars, encodes to H.264 using OpenH264, and muxes into MP4 +group: video-encoding-oneshot +variant: H.264 (OpenH264) +category: Video Encoding +tags: + - video-encoding +keywords: + - colorbars + - h.264 + - openh264 + - mp4 mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/parakeet-stt.yml b/samples/pipelines/oneshot/parakeet-stt.yml index e06a4f763..a459f64ea 100644 --- a/samples/pipelines/oneshot/parakeet-stt.yml +++ b/samples/pipelines/oneshot/parakeet-stt.yml @@ -1,5 +1,17 @@ name: Speech-to-Text (Parakeet TDT) description: Fast English speech transcription using NVIDIA Parakeet TDT (~10x faster than Whisper on CPU) +group: speech-to-text +variant: Parakeet TDT +category: Speech to Text +tags: + - speech-to-text +keywords: + - stt + - asr + - transcribe + - transcription + - parakeet + - nvidia mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/piper-tts.yml b/samples/pipelines/oneshot/piper-tts.yml index c99874fdb..ac84d46ae 100644 --- a/samples/pipelines/oneshot/piper-tts.yml +++ b/samples/pipelines/oneshot/piper-tts.yml @@ -4,6 +4,17 @@ name: Text-to-Speech (Piper) description: Synthesizes speech from text using Piper +group: text-to-speech +variant: Piper +category: Text to Speech +tags: + - text-to-speech +keywords: + - tts + - speech synthesis + - synthesize + - voice + - piper mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/pocket-tts-voice-clone.yml b/samples/pipelines/oneshot/pocket-tts-voice-clone.yml index 32bc3ffb6..239921e53 100644 --- a/samples/pipelines/oneshot/pocket-tts-voice-clone.yml +++ b/samples/pipelines/oneshot/pocket-tts-voice-clone.yml @@ -4,6 +4,17 @@ name: Text-to-Speech (Pocket TTS Voice Clone) description: Synthesizes speech from text using Pocket TTS with a voice prompt WAV +group: text-to-speech +variant: Pocket TTS (Voice Clone) +category: Text to Speech +tags: + - text-to-speech +keywords: + - tts + - speech synthesis + - voice clone + - pocket + - cloning mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/pocket-tts.yml b/samples/pipelines/oneshot/pocket-tts.yml index 1419d6cdb..757606378 100644 --- a/samples/pipelines/oneshot/pocket-tts.yml +++ b/samples/pipelines/oneshot/pocket-tts.yml @@ -4,6 +4,17 @@ name: Text-to-Speech (Pocket TTS) description: Synthesizes speech from text using Pocket TTS +group: text-to-speech +variant: Pocket TTS +category: Text to Speech +tags: + - text-to-speech +keywords: + - tts + - speech synthesis + - synthesize + - voice + - pocket mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/sensevoice-stt.yml b/samples/pipelines/oneshot/sensevoice-stt.yml index 57cd82a45..6b32d1857 100644 --- a/samples/pipelines/oneshot/sensevoice-stt.yml +++ b/samples/pipelines/oneshot/sensevoice-stt.yml @@ -1,5 +1,17 @@ name: Speech-to-Text (SenseVoice) description: Transcribes speech in multiple languages using SenseVoice +group: speech-to-text +variant: SenseVoice +category: Speech to Text +tags: + - speech-to-text +keywords: + - stt + - asr + - transcribe + - transcription + - sensevoice + - multilingual mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/speech_to_text.yml b/samples/pipelines/oneshot/speech_to_text.yml index 76041408b..fb46dc7a9 100644 --- a/samples/pipelines/oneshot/speech_to_text.yml +++ b/samples/pipelines/oneshot/speech_to_text.yml @@ -1,9 +1,19 @@ name: Speech-to-Text (Whisper) description: Transcribes speech to text using Whisper -mode: oneshot +group: speech-to-text +variant: Whisper +canonical: true +category: Speech to Text tags: - - transcription + - speech-to-text - subtitles +keywords: + - stt + - asr + - transcribe + - transcription + - whisper +mode: oneshot client: input: type: file_upload diff --git a/samples/pipelines/oneshot/speech_to_text_translate.yml b/samples/pipelines/oneshot/speech_to_text_translate.yml index 278c27e82..8d0d34d57 100644 --- a/samples/pipelines/oneshot/speech_to_text_translate.yml +++ b/samples/pipelines/oneshot/speech_to_text_translate.yml @@ -4,6 +4,23 @@ name: Speech Translation (English → Spanish) description: Translates English speech into Spanish speech +group: speech-translation-oneshot +variant: NLLB +canonical: true +category: Speech Translation +tags: + - translation + - speech-to-text + - text-to-speech +keywords: + - translate + - english + - spanish + - nllb + - whisper + - piper + - stt + - tts mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/speech_to_text_translate_helsinki.yml b/samples/pipelines/oneshot/speech_to_text_translate_helsinki.yml index 27d108901..ab16c3d90 100644 --- a/samples/pipelines/oneshot/speech_to_text_translate_helsinki.yml +++ b/samples/pipelines/oneshot/speech_to_text_translate_helsinki.yml @@ -14,6 +14,21 @@ name: Speech Translation (English → Spanish) - Helsinki description: Translates English speech into Spanish speech using Apache 2.0 licensed Helsinki OPUS-MT +group: speech-translation-oneshot +variant: Helsinki +category: Speech Translation +tags: + - translation + - speech-to-text + - text-to-speech +keywords: + - translate + - english + - spanish + - helsinki + - opus-mt + - stt + - tts mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/supertonic-tts.yml b/samples/pipelines/oneshot/supertonic-tts.yml index bd20be870..992ccacaf 100644 --- a/samples/pipelines/oneshot/supertonic-tts.yml +++ b/samples/pipelines/oneshot/supertonic-tts.yml @@ -1,5 +1,17 @@ name: Text-to-Speech (Supertonic) description: Synthesizes speech from text using Supertonic (5 languages, 10 voice styles) +group: text-to-speech +variant: Supertonic +category: Text to Speech +tags: + - text-to-speech +keywords: + - tts + - speech synthesis + - synthesize + - voice + - supertonic + - multilingual mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/transcode_to_s3.yml b/samples/pipelines/oneshot/transcode_to_s3.yml index ec8ab042e..6148344ea 100644 --- a/samples/pipelines/oneshot/transcode_to_s3.yml +++ b/samples/pipelines/oneshot/transcode_to_s3.yml @@ -17,6 +17,17 @@ name: Transcode to S3 (Ogg → MP4) description: Transcodes uploaded Ogg/Opus audio to MP4, uploads to S3, and returns the result +category: Audio Processing +tags: + - transcoding + - archival + - muxing +keywords: + - s3 + - object storage + - transcode + - mp4 + - archive mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/tts_to_s3.yml b/samples/pipelines/oneshot/tts_to_s3.yml index 0f57fae24..373e07926 100644 --- a/samples/pipelines/oneshot/tts_to_s3.yml +++ b/samples/pipelines/oneshot/tts_to_s3.yml @@ -19,6 +19,16 @@ name: TTS to S3 (Kokoro) description: Synthesizes speech from text, uploads OGG audio to S3, and returns the result +category: Text to Speech +tags: + - text-to-speech + - archival +keywords: + - tts + - s3 + - object storage + - kokoro + - archive mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/useless-facts-tts.yml b/samples/pipelines/oneshot/useless-facts-tts.yml index 9b7bcd35c..94a318afb 100644 --- a/samples/pipelines/oneshot/useless-facts-tts.yml +++ b/samples/pipelines/oneshot/useless-facts-tts.yml @@ -4,6 +4,15 @@ name: Text-to-Speech (Useless Facts) description: Fetches a random fact and reads it out loud using Kokoro +category: Text to Speech +tags: + - text-to-speech +keywords: + - tts + - facts + - script + - fetch + - kokoro mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/vad-demo.yml b/samples/pipelines/oneshot/vad-demo.yml index ac4dcad7e..95b299600 100644 --- a/samples/pipelines/oneshot/vad-demo.yml +++ b/samples/pipelines/oneshot/vad-demo.yml @@ -4,10 +4,15 @@ name: Voice Activity Detection description: Detects voice activity and outputs events as JSON -mode: oneshot category: Voice Activity tags: - voice-activity-detection +keywords: + - vad + - voice activity + - silence + - speech detection +mode: oneshot client: input: type: file_upload diff --git a/samples/pipelines/oneshot/vad-filtered-stt.yml b/samples/pipelines/oneshot/vad-filtered-stt.yml index e423285ec..3d5c15e2c 100644 --- a/samples/pipelines/oneshot/vad-filtered-stt.yml +++ b/samples/pipelines/oneshot/vad-filtered-stt.yml @@ -4,6 +4,20 @@ name: Speech-to-Text (Whisper, VAD-Filtered) description: Filters silence with VAD before transcribing with Whisper +group: speech-to-text +variant: Whisper (VAD-Filtered) +category: Speech to Text +tags: + - speech-to-text + - voice-activity-detection +keywords: + - stt + - asr + - transcribe + - transcription + - whisper + - vad + - silence mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_av1_compositor_demo.yml b/samples/pipelines/oneshot/video_av1_compositor_demo.yml index c53498fce..cb16ebd52 100644 --- a/samples/pipelines/oneshot/video_av1_compositor_demo.yml +++ b/samples/pipelines/oneshot/video_av1_compositor_demo.yml @@ -10,6 +10,19 @@ name: Video Compositor Demo (AV1) description: Composites two colorbars sources (main + PiP) with a text overlay and logo watermark, encodes to AV1, and streams a WebM via http_output (30 seconds) +group: video-compositor-oneshot +variant: AV1 (rav1e) +category: Video Compositing +tags: + - compositing + - video-encoding +keywords: + - compositor + - overlay + - watermark + - pip + - av1 + - rav1e mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_colorbars.yml b/samples/pipelines/oneshot/video_colorbars.yml index cd2b76bd6..1a9151e4d 100644 --- a/samples/pipelines/oneshot/video_colorbars.yml +++ b/samples/pipelines/oneshot/video_colorbars.yml @@ -4,6 +4,18 @@ name: Video Color Bars (VP9/WebM) description: Generates SMPTE color bars, encodes to VP9, and outputs a WebM file (30 seconds) +group: video-encoding-oneshot +variant: VP9 (Software) +canonical: true +category: Video Encoding +tags: + - video-encoding +keywords: + - colorbars + - smpte + - test pattern + - vp9 + - webm mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_compositor_demo.yml b/samples/pipelines/oneshot/video_compositor_demo.yml index 262c66c79..846ee3af6 100644 --- a/samples/pipelines/oneshot/video_compositor_demo.yml +++ b/samples/pipelines/oneshot/video_compositor_demo.yml @@ -16,6 +16,20 @@ name: Video Compositor Demo description: Composites two colorbars sources (main + PiP) with a text overlay and the StreamKit logo watermark through the compositor node, encodes to VP9, and streams a WebM via http_output with real-time pacing (30 seconds) +group: video-compositor-oneshot +variant: VP9 +canonical: true +category: Video Compositing +tags: + - compositing + - video-encoding +keywords: + - compositor + - overlay + - watermark + - pip + - vp9 + - webm mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_compositor_svg_overlay.yml b/samples/pipelines/oneshot/video_compositor_svg_overlay.yml index a5d6f7bbe..6da6df0f7 100644 --- a/samples/pipelines/oneshot/video_compositor_svg_overlay.yml +++ b/samples/pipelines/oneshot/video_compositor_svg_overlay.yml @@ -10,6 +10,16 @@ name: Video Compositor SVG Overlay Demo description: Composites colorbars with an SVG overlay (test_logo.svg), encodes to VP9, and streams a WebM via http_output (30 seconds) +category: Video Compositing +tags: + - compositing + - video-encoding +keywords: + - compositor + - svg + - overlay + - vector + - vp9 mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_dav1d_compositor_demo.yml b/samples/pipelines/oneshot/video_dav1d_compositor_demo.yml index 13e124975..0bebc8d7d 100644 --- a/samples/pipelines/oneshot/video_dav1d_compositor_demo.yml +++ b/samples/pipelines/oneshot/video_dav1d_compositor_demo.yml @@ -15,6 +15,19 @@ name: Video Compositor Demo (dav1d AV1) description: Composites two colorbars sources (main + PiP) with a text overlay and logo watermark, encodes to AV1 (SVT-AV1), decodes with C dav1d, re-encodes, and streams a WebM via http_output (30 seconds) +group: video-compositor-oneshot +variant: AV1 (dav1d decode) +category: Video Compositing +tags: + - compositing + - video-encoding +keywords: + - compositor + - overlay + - watermark + - pip + - av1 + - dav1d mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_nv_av1_colorbars.yml b/samples/pipelines/oneshot/video_nv_av1_colorbars.yml index dddddc21f..998d09b0a 100644 --- a/samples/pipelines/oneshot/video_nv_av1_colorbars.yml +++ b/samples/pipelines/oneshot/video_nv_av1_colorbars.yml @@ -13,6 +13,19 @@ name: NVENC AV1 Encode (WebM Oneshot) description: Generates color bars, encodes to AV1 using NVIDIA NVENC HW encoder, and muxes into WebM (30 seconds) +group: video-encoding-oneshot +variant: AV1 (NVENC) +category: Video Encoding +tags: + - video-encoding + - "hardware:nvidia" +keywords: + - colorbars + - av1 + - nvenc + - nvidia + - gpu + - webm mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_slint_watermark.yml b/samples/pipelines/oneshot/video_slint_watermark.yml index 31264a552..703b53384 100644 --- a/samples/pipelines/oneshot/video_slint_watermark.yml +++ b/samples/pipelines/oneshot/video_slint_watermark.yml @@ -17,6 +17,16 @@ name: Video Slint Watermark (Oneshot) description: Composites colorbars with a Slint watermark overlay, encodes to VP9, and streams a WebM via http_output +category: Video Compositing +tags: + - compositing + - video-encoding +keywords: + - compositor + - slint + - watermark + - overlay + - graphics mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_svt_av1_compositor_demo.yml b/samples/pipelines/oneshot/video_svt_av1_compositor_demo.yml index fcf6c08ef..6ffe67243 100644 --- a/samples/pipelines/oneshot/video_svt_av1_compositor_demo.yml +++ b/samples/pipelines/oneshot/video_svt_av1_compositor_demo.yml @@ -12,6 +12,19 @@ name: Video Compositor Demo (SVT-AV1) description: Composites two colorbars sources (main + PiP) with a text overlay and logo watermark, encodes to AV1 via SVT-AV1, and streams a WebM via http_output (30 seconds) +group: video-compositor-oneshot +variant: AV1 (SVT-AV1) +category: Video Compositing +tags: + - compositing + - video-encoding +keywords: + - compositor + - overlay + - watermark + - pip + - av1 + - svt-av1 mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_vaapi_av1_colorbars.yml b/samples/pipelines/oneshot/video_vaapi_av1_colorbars.yml index 9ac5d81cd..b2df4205d 100644 --- a/samples/pipelines/oneshot/video_vaapi_av1_colorbars.yml +++ b/samples/pipelines/oneshot/video_vaapi_av1_colorbars.yml @@ -13,6 +13,18 @@ name: VA-API AV1 Encode (WebM Oneshot) description: Generates color bars, encodes to AV1 using VA-API HW encoder, and muxes into WebM (30 seconds) +group: video-encoding-oneshot +variant: AV1 (VA-API) +category: Video Encoding +tags: + - video-encoding + - "hardware:vaapi" +keywords: + - colorbars + - av1 + - vaapi + - gpu + - webm mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_vaapi_h264_colorbars.yml b/samples/pipelines/oneshot/video_vaapi_h264_colorbars.yml index ac6f513d0..75778d889 100644 --- a/samples/pipelines/oneshot/video_vaapi_h264_colorbars.yml +++ b/samples/pipelines/oneshot/video_vaapi_h264_colorbars.yml @@ -13,6 +13,18 @@ name: VA-API H.264 Encode (MP4 Oneshot) description: Generates color bars, encodes to H.264 using VA-API HW encoder, and muxes into MP4 (30 seconds) +group: video-encoding-oneshot +variant: H.264 (VA-API) +category: Video Encoding +tags: + - video-encoding + - "hardware:vaapi" +keywords: + - colorbars + - h.264 + - vaapi + - gpu + - mp4 mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/video_vulkan_video_h264_colorbars.yml b/samples/pipelines/oneshot/video_vulkan_video_h264_colorbars.yml index acbe95baf..04eaa6507 100644 --- a/samples/pipelines/oneshot/video_vulkan_video_h264_colorbars.yml +++ b/samples/pipelines/oneshot/video_vulkan_video_h264_colorbars.yml @@ -12,6 +12,18 @@ name: Vulkan Video H.264 Encode (MP4 Oneshot) description: Generates color bars, encodes to H.264 using Vulkan Video HW encoder, and muxes into MP4 (30 seconds) +group: video-encoding-oneshot +variant: H.264 (Vulkan) +category: Video Encoding +tags: + - video-encoding + - "hardware:vulkan" +keywords: + - colorbars + - h.264 + - vulkan + - gpu + - mp4 mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/web_capture.yml b/samples/pipelines/oneshot/web_capture.yml index 6e7b0bc11..3baa0c19e 100644 --- a/samples/pipelines/oneshot/web_capture.yml +++ b/samples/pipelines/oneshot/web_capture.yml @@ -12,6 +12,17 @@ name: Web Page Capture (Servo) description: Renders a web page via the Servo engine and outputs a short VP9/WebM clip +category: Web Capture +tags: + - web-capture + - video-encoding +keywords: + - servo + - web + - browser + - render + - vp9 + - webm mode: oneshot client: input: diff --git a/samples/pipelines/oneshot/web_pip_compositor.yml b/samples/pipelines/oneshot/web_pip_compositor.yml index fa6fe3f23..ade20f868 100644 --- a/samples/pipelines/oneshot/web_pip_compositor.yml +++ b/samples/pipelines/oneshot/web_pip_compositor.yml @@ -16,6 +16,19 @@ name: Web PiP Compositor (H.264/MP4) description: Composites a Servo-rendered WebGL page as PiP over colorbars, encodes to H.264/MP4 +category: Web Capture +tags: + - web-capture + - compositing + - picture-in-picture + - video-encoding +keywords: + - servo + - web + - browser + - webgl + - pip + - h.264 mode: oneshot client: input: diff --git a/ui/src/components/converter/TemplateSelector.test.tsx b/ui/src/components/converter/TemplateSelector.test.tsx index 76330a15c..a62092355 100644 --- a/ui/src/components/converter/TemplateSelector.test.tsx +++ b/ui/src/components/converter/TemplateSelector.test.tsx @@ -10,7 +10,7 @@ import type { SamplePipeline } from '@/types/generated/api-types'; import { TemplateSelector } from './TemplateSelector'; function makePipeline(overrides: Partial = {}): SamplePipeline { - return { + const base: SamplePipeline = { id: 'tpl-1', name: 'Test Pipeline', description: 'A test pipeline', @@ -20,10 +20,18 @@ function makePipeline(overrides: Partial = {}): SamplePipeline { is_fragment: false, group: null, variant: null, + canonical: false, category: null, tags: [], + search_terms: [], ...overrides, }; + if (!overrides.search_terms) { + base.search_terms = [base.name, base.description, base.category, ...base.tags] + .filter((t): t is string => Boolean(t)) + .map((t) => t.toLowerCase()); + } + return base; } const SYSTEM_TPL = makePipeline({ id: 'sys-1', name: 'Transcribe Audio', is_system: true }); diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 8b69a03b8..92e24e92e 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -9,13 +9,12 @@ import type { SamplePipeline } from '@/types/generated/api-types'; import { labelFromKey } from '@/utils/jsonSchema'; import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { - baseVariantLabel, collectSampleFacets, compareSamplePipelinesByName, - expandQueryTerms, groupSamplePipelinesByScenario, - matchesExpandedQuery, + matchesQueryTokens, sampleNeedsHardware, + tokenizeQuery, } from '@/utils/samplePipelineOrdering'; import { @@ -89,7 +88,7 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string {variants.map((variant) => ( - {variant.variant ?? baseVariantLabel(variant) ?? 'Default'} + {variant.variant ?? variant.name} ))} @@ -231,7 +230,7 @@ export const TemplateSelector: React.FC = ({ const toggleHardware = React.useCallback(() => setHardwareOnly((current) => !current), []); - const expandedQuery = React.useMemo(() => expandQueryTerms(query), [query]); + const queryTokens = React.useMemo(() => tokenizeQuery(query), [query]); const filteredTemplates = React.useMemo(() => { return templates.filter((template) => { @@ -240,9 +239,9 @@ export const TemplateSelector: React.FC = ({ if (categoryFilter && template.category !== categoryFilter) return false; if (capabilityFilter && !(template.tags ?? []).includes(capabilityFilter)) return false; if (hardwareOnly && !sampleNeedsHardware(template)) return false; - return matchesExpandedQuery(template, expandedQuery); + return matchesQueryTokens(template, queryTokens); }); - }, [templates, originFilter, categoryFilter, capabilityFilter, hardwareOnly, expandedQuery]); + }, [templates, originFilter, categoryFilter, capabilityFilter, hardwareOnly, queryTokens]); const systemGroups = React.useMemo( () => toScenarioGroups(filteredTemplates.filter((template) => template.is_system)), diff --git a/ui/src/services/fragments.test.ts b/ui/src/services/fragments.test.ts index f551a400c..5f386ee3a 100644 --- a/ui/src/services/fragments.test.ts +++ b/ui/src/services/fragments.test.ts @@ -32,8 +32,10 @@ const FRAGMENT_SAMPLE: SamplePipeline = { is_fragment: true, group: null, variant: null, + canonical: false, category: null, tags: [], + search_terms: [], }; beforeEach(() => { diff --git a/ui/src/services/samples.test.ts b/ui/src/services/samples.test.ts index b8cf7338b..122170f71 100644 --- a/ui/src/services/samples.test.ts +++ b/ui/src/services/samples.test.ts @@ -62,8 +62,10 @@ const SAMPLE: SamplePipeline = { is_fragment: false, group: null, variant: null, + canonical: false, category: null, tags: [], + search_terms: [], }; beforeEach(() => { diff --git a/ui/src/types/generated/api-types.ts b/ui/src/types/generated/api-types.ts index 2aafa6a76..88eb04de7 100644 --- a/ui/src/types/generated/api-types.ts +++ b/ui/src/types/generated/api-types.ts @@ -383,14 +383,24 @@ group: string | null, * Human label distinguishing this variant within its `group`. */ variant: string | null, +/** + * Whether this member represents its `group` (supplies the card title and + * description). Exactly one member of a multi-sample group sets this. + */ +canonical: boolean, /** * Top-level bucket used for faceted filtering. */ category: string | null, /** - * Capability keywords powering fuzzy search and facet chips. + * Facetable capability keywords powering the facet chips. + */ +tags: Array, +/** + * Resolved, lowercased search document (name, description, category, + * tags, authored keywords, node kinds) the UI matches queries against. */ -tags: Array, }; +search_terms: Array, }; export type SavePipelineRequest = { name: string, description: string, yaml: string, overwrite: boolean, /** diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index f3e987eba..84c52f64b 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -7,7 +7,6 @@ import { describe, expect, it } from 'vitest'; import type { SamplePipeline } from '@/types/generated/api-types'; import { - baseVariantLabel, collectSampleFacets, compareSamplePipelinesByName, groupSamplePipelinesByScenario, @@ -16,19 +15,35 @@ import { sampleNeedsHardware, } from './samplePipelineOrdering'; +// Mirrors the backend `build_search_terms`: when a fixture does not supply an +// explicit `search_terms`, derive one from the discovery fields so query tests +// exercise the same document the server emits. function makePipeline(partial: Partial & { id: string }): SamplePipeline { + const name = partial.name ?? ''; + const description = partial.description ?? ''; + const category = partial.category ?? null; + const group = partial.group ?? null; + const variant = partial.variant ?? null; + const tags = partial.tags ?? []; + const search_terms = + partial.search_terms ?? + [name, description, category, group, variant, ...tags] + .filter((t): t is string => Boolean(t)) + .map((t) => t.toLowerCase()); return { id: partial.id, - name: partial.name ?? '', - description: partial.description ?? '', + name, + description, yaml: partial.yaml ?? '', is_system: partial.is_system ?? false, mode: partial.mode ?? 'dynamic', is_fragment: partial.is_fragment ?? false, - group: partial.group ?? null, - variant: partial.variant ?? null, - category: partial.category ?? null, - tags: partial.tags ?? [], + group, + variant, + canonical: partial.canonical ?? false, + category, + tags, + search_terms, }; } @@ -119,11 +134,11 @@ describe('matchesSamplePipelineQuery', () => { expect(matchesSamplePipelineQuery(pipeline, 'ffmpeg')).toBe(true); }); - it('matches by id', () => { + it('matches a search term substring', () => { expect(matchesSamplePipelineQuery(pipeline, 'mp4')).toBe(true); }); - it('returns false when the query is not a substring of any field', () => { + it('returns false when the query is not a substring of any search term', () => { expect(matchesSamplePipelineQuery(pipeline, 'flac')).toBe(false); }); @@ -132,7 +147,7 @@ describe('matchesSamplePipelineQuery', () => { }); it('handles missing fields without throwing', () => { - const sparse = makePipeline({ id: 'x', name: '', description: '' }); + const sparse = makePipeline({ id: 'x', name: 'x', description: '' }); expect(matchesSamplePipelineQuery(sparse, 'x')).toBe(true); expect(matchesSamplePipelineQuery(sparse, 'absent')).toBe(false); }); @@ -148,38 +163,17 @@ describe('matchesSamplePipelineQuery', () => { expect(matchesSamplePipelineQuery(sample, 'voice activity')).toBe(true); }); - it('expands synonyms so abbreviations find derived tags', () => { - const stt = makePipeline({ id: 'stt', name: 'Whisper', tags: ['speech-to-text'] }); - const tts = makePipeline({ id: 'tts', name: 'Kokoro', tags: ['text-to-speech'] }); + it('matches authored aliases baked into the resolved search document', () => { + // Aliases/synonyms (e.g. "stt") live in each sample's authored keywords, + // which the backend folds into search_terms; the UI does no expansion. + const stt = makePipeline({ + id: 'stt', + name: 'Whisper', + search_terms: ['whisper', 'speech-to-text', 'stt', 'transcribe'], + }); expect(matchesSamplePipelineQuery(stt, 'stt')).toBe(true); expect(matchesSamplePipelineQuery(stt, 'transcribe')).toBe(true); - expect(matchesSamplePipelineQuery(tts, 'tts')).toBe(true); - expect(matchesSamplePipelineQuery(tts, 'speech synthesis')).toBe(true); - expect(matchesSamplePipelineQuery(tts, 'stt')).toBe(false); - }); - - it('expands a whole-token prefix of a multi-word synonym', () => { - const tts = makePipeline({ id: 'tts', name: 'Kokoro', tags: ['text-to-speech'] }); - expect(matchesSamplePipelineQuery(tts, 'synthesis')).toBe(true); - }); - - it('does not expand both video families from a shared token', () => { - // "video" is a token of the hyphenated derived tags video-encoding and - // video-decoding, but those are expansion targets only, so querying "video" - // must not pull a decoder pipeline in via the encode group's synonyms. - const decoder = makePipeline({ id: 'dec', name: 'AV1 Decode', tags: ['video-decoding'] }); - expect(matchesSamplePipelineQuery(decoder, 'encoder')).toBe(false); - }); - - it('does not let a query that merely contains a short synonym leak in its group', () => { - const mic = makePipeline({ id: 'mic', name: 'Microphone Capture', tags: ['microphone'] }); - const cam = makePipeline({ id: 'cam', name: 'Webcam PiP', tags: ['webcam'] }); - // "dynamic" contains "mic" and "scam" contains "cam"; neither should expand - // the microphone/webcam synonym groups. - expect(matchesSamplePipelineQuery(mic, 'dynamic')).toBe(false); - expect(matchesSamplePipelineQuery(cam, 'scam')).toBe(false); - // Genuine prefixes still expand their group. - expect(matchesSamplePipelineQuery(cam, 'camera')).toBe(true); + expect(matchesSamplePipelineQuery(stt, 'tts')).toBe(false); }); it('requires every query term to match (AND semantics)', () => { @@ -188,7 +182,7 @@ describe('matchesSamplePipelineQuery', () => { name: 'VA-API H.264 Colorbars', tags: ['video-encoding', 'hardware:vaapi'], }); - expect(matchesSamplePipelineQuery(sample, 'vaapi encode')).toBe(true); + expect(matchesSamplePipelineQuery(sample, 'vaapi encoding')).toBe(true); expect(matchesSamplePipelineQuery(sample, 'vaapi audio')).toBe(false); }); }); @@ -198,6 +192,8 @@ describe('groupSamplePipelinesByScenario', () => { id: 'd/colorbars', name: 'Colorbars', group: 'video-moq-colorbars', + variant: 'Software', + canonical: true, }); const h264 = makePipeline({ id: 'd/h264-colorbars', @@ -212,14 +208,14 @@ describe('groupSamplePipelinesByScenario', () => { variant: 'VA-API H.264', }); - it('collapses same-group samples into one entry with sorted variants', () => { + it('collapses same-group samples into one entry with the canonical member first', () => { const groups = groupSamplePipelinesByScenario([h264, plain, vaapi]); expect(groups).toHaveLength(1); expect(groups[0].key).toBe('video-moq-colorbars'); - // Canonical (no variant) is the base and comes first. + // The canonical member is the base and comes first. expect(groups[0].base).toBe(plain); expect(groups[0].variants[0]).toBe(plain); - expect(groups[0].variants.map((v) => v.variant)).toEqual([null, 'H.264', 'VA-API H.264']); + expect(groups[0].variants.map((v) => v.variant)).toEqual(['Software', 'H.264', 'VA-API H.264']); }); it('treats samples without a group as singletons keyed by id', () => { @@ -248,27 +244,26 @@ describe('sampleNeedsHardware', () => { }); describe('collectSampleFacets', () => { - it('aggregates sorted categories and capabilities, excluding hardware and format tags', () => { + it('aggregates sorted categories and capabilities, excluding hardware tags', () => { const facets = collectSampleFacets([ makePipeline({ id: 'a', category: 'Video Encoding', - tags: ['hardware:vaapi', 'colorbars', 'codec:vp9'], + tags: ['hardware:vaapi', 'colorbars'], }), - makePipeline({ id: 'b', category: 'Speech to Text', tags: ['mp4'] }), + makePipeline({ id: 'b', category: 'Speech to Text', tags: ['transcription'] }), ]); expect(facets.categories).toEqual(['Speech to Text', 'Video Encoding']); - // codec:* and container/transport tags are the variant axis, not facets. - expect(facets.capabilities).toEqual(['colorbars']); + expect(facets.capabilities).toEqual(['colorbars', 'transcription']); expect(facets.hasHardware).toBe(true); }); it('keeps tags as capabilities even when a same-named category is shown', () => { - // category is a single priority-picked bucket while tags are multi-valued, - // so a capability chip must survive as a cross-cutting filter (e.g. a - // sample bucketed as `Video Compositing` may still carry `video-encoding`). + // category is a single bucket while tags are multi-valued, so a capability + // chip must survive as a cross-cutting filter (e.g. a sample bucketed as + // `Video Compositing` may still carry `video-encoding`). const facets = collectSampleFacets([ - makePipeline({ id: 'a', category: 'Video Encoding', tags: ['video-encoding', 'codec:vp9'] }), + makePipeline({ id: 'a', category: 'Video Encoding', tags: ['video-encoding'] }), makePipeline({ id: 'b', category: 'Video Compositing', @@ -280,25 +275,7 @@ describe('collectSampleFacets', () => { }); it('reports no hardware when no hardware tags are present', () => { - const facets = collectSampleFacets([makePipeline({ id: 'a', tags: ['mp4'] })]); + const facets = collectSampleFacets([makePipeline({ id: 'a', tags: ['transcription'] })]); expect(facets.hasHardware).toBe(false); }); }); - -describe('baseVariantLabel', () => { - it('derives the base label from the output codec tag', () => { - const colorbars = makePipeline({ id: 'cb', tags: ['codec:vp9', 'moq'] }); - const mixer = makePipeline({ id: 'mix', tags: ['codec:opus', 'mixing'] }); - expect(baseVariantLabel(colorbars)).toBe('VP9'); - expect(baseVariantLabel(mixer)).toBe('Opus'); - }); - - it('prefers the video codec when a sample carries both', () => { - const pip = makePipeline({ id: 'pip', tags: ['codec:opus', 'codec:h264'] }); - expect(baseVariantLabel(pip)).toBe('H.264'); - }); - - it('returns null when there is no codec tag to distinguish the base', () => { - expect(baseVariantLabel(makePipeline({ id: 'x', tags: ['mixing'] }))).toBeNull(); - }); -}); diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index e5b96ce81..eef18a547 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MPL-2.0 -import type { AudioCodec, SamplePipeline, VideoCodec } from '@/types/generated/api-types'; +import type { SamplePipeline } from '@/types/generated/api-types'; let collator: Intl.Collator | null = null; @@ -37,103 +37,35 @@ export function orderSamplePipelinesSystemFirst(pipelines: SamplePipeline[]): Sa return [...system, ...user]; } -/** - * Groups of interchangeable search terms. A query term matches a pipeline if any - * term in the same group appears in the searchable text, so "stt", "transcribe" - * and the derived `speech-to-text` tag are all mutually findable. - */ -const SYNONYM_GROUPS: string[][] = [ - ['stt', 'speech-to-text', 'speech to text', 'transcribe', 'transcription', 'transcript', 'asr'], - ['tts', 'text-to-speech', 'text to speech', 'speech synthesis', 'synthesize', 'voice'], - ['translate', 'translation', 'translator'], - ['vad', 'voice-activity-detection', 'voice activity'], - ['encode', 'encoding', 'encoder', 'video-encoding', 'transcode'], - ['decode', 'decoding', 'decoder', 'video-decoding'], - ['compositor', 'compositing', 'composite', 'overlay'], - ['webcam', 'camera', 'cam'], - ['mic', 'microphone'], - ['moq', 'media over quic', 'stream', 'streaming'], - ['hw', 'hardware', 'gpu', 'accelerated'], - ['pip', 'picture-in-picture', 'picture in picture'], - ['av1', 'aom'], - ['h264', 'avc', 'h.264'], - ['hevc', 'h265', 'h.265'], -]; - -// Whether a query term joins a synonym group via one of its entries. Exact -// matches always count. Otherwise the term must be a >=3 char prefix of a whole -// token of a non-hyphenated entry: this lets "transcrib" find "transcribe" and -// "synthesis" find "speech synthesis", while keeping hyphenated derived tags -// (`video-encoding`, `video-decoding`) as expansion targets only — so a shared -// token like "video" no longer pulls both the encode and decode groups. The -// reverse direction (entry being a substring of the term) is excluded too, so -// "dynamic" never reaches "mic". -function termJoinsEntry(entry: string, term: string): boolean { - if (entry === term) return true; - if (entry.includes('-')) return false; - return entry - .split(/\s+/) - .some((token) => token === term || (term.length >= 3 && token.startsWith(term))); -} - -function expandTerm(term: string): string[] { - const expanded = new Set([term]); - for (const group of SYNONYM_GROUPS) { - if (group.some((entry) => termJoinsEntry(entry, term))) { - for (const entry of group) expanded.add(entry); - } - } - return [...expanded]; -} - -/** - * Expands a query into its per-term synonym candidate lists once, so callers - * filtering a list of pipelines do not re-scan the synonym groups per pipeline. - */ -export function expandQueryTerms(query: string): string[][] { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return []; - return normalizedQuery.split(/\s+/).filter(Boolean).map(expandTerm); -} - -function searchableText(pipeline: SamplePipeline): string { - return [ - pipeline.name, - pipeline.description, - pipeline.id, - pipeline.category, - pipeline.variant, - pipeline.group, - ...(pipeline.tags ?? []), - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); +/** Splits a free-text query into lowercased, whitespace-delimited tokens. */ +export function tokenizeQuery(query: string): string[] { + return query.trim().toLowerCase().split(/\s+/).filter(Boolean); } /** - * Whether a pipeline matches an already-expanded query (see `expandQueryTerms`). - * Every term must match — it or one of its synonyms being a substring of the - * pipeline's searchable text (name, description, id, category, variant, group, - * tags). An empty term list matches everything. + * Whether a pipeline matches a tokenized query. Every token must be a substring + * of the backend-resolved `search_terms` document (name, description, category, + * tags, authored keywords, node kinds). An empty token list matches everything. + * Synonyms/aliases live in each sample's authored `keywords`, not in a + * UI-side table, so the UI does no semantic expansion. */ -export function matchesExpandedQuery(pipeline: SamplePipeline, expandedTerms: string[][]): boolean { - if (expandedTerms.length === 0) return true; - const haystack = searchableText(pipeline); - return expandedTerms.every((candidates) => candidates.some((c) => haystack.includes(c))); +export function matchesQueryTokens(pipeline: SamplePipeline, tokens: string[]): boolean { + if (tokens.length === 0) return true; + const haystack = (pipeline.search_terms ?? []).join(' '); + return tokens.every((token) => haystack.includes(token)); } -/** Convenience wrapper that expands `query` and matches a single pipeline. */ +/** Convenience wrapper that tokenizes `query` and matches a single pipeline. */ export function matchesSamplePipelineQuery(pipeline: SamplePipeline, query: string): boolean { - return matchesExpandedQuery(pipeline, expandQueryTerms(query)); + return matchesQueryTokens(pipeline, tokenizeQuery(query)); } export interface ScenarioGroup { /** Stable key shared by all variants (the pipeline `group`, or the id). */ key: string; - /** Representative sample used for the card title/description. */ + /** Canonical member supplying the card title/description. */ base: SamplePipeline; - /** All samples in the group, canonical (no explicit variant) first. */ + /** All samples in the group, canonical member first. */ variants: SamplePipeline[]; } @@ -165,69 +97,24 @@ export function groupSamplePipelinesByScenario(samples: SamplePipeline[]): Scena return order.map((key) => { const members = byKey.get(key) ?? []; const variants = members.slice().sort((a, b) => { - const aCanonical = a.variant ? 1 : 0; - const bCanonical = b.variant ? 1 : 0; + const aCanonical = a.canonical ? 0 : 1; + const bCanonical = b.canonical ? 0 : 1; if (aCanonical !== bCanonical) return aCanonical - bCanonical; const variantCompare = getCollator().compare(variantSortKey(a), variantSortKey(b)); if (variantCompare !== 0) return variantCompare; return compareSamplePipelinesByName(a, b); }); - const base = variants.find((v) => !v.variant) ?? variants[0]; + const base = variants.find((v) => v.canonical) ?? variants[0]; return { key, base, variants }; }); } const HARDWARE_TAG_PREFIX = 'hardware:'; -const CODEC_TAG_PREFIX = 'codec:'; - -// Codec/container/transport tags are surfaced as the variant axis (pills) and -// remain searchable, but are noise in the capability facets — codec is already -// the pill dimension and the transport is implied by the Streaming category. -const FORMAT_FACET_TAGS = new Set(['moq', 'mse', 'rtmp', 'mp4', 'webm']); - -// Display labels keyed on the generated codec enums, so adding a codec variant -// in Rust is a TypeScript compile error here until a label is supplied (rather -// than silently falling back to a mangled title-case like "H264"). -const VIDEO_CODEC_LABELS: Record = { - vp9: 'VP9', - h264: 'H.264', - av1: 'AV1', -}; -const AUDIO_CODEC_LABELS: Record = { - opus: 'Opus', - aac: 'AAC', -}; -const CODEC_LABELS: Record = { ...VIDEO_CODEC_LABELS, ...AUDIO_CODEC_LABELS }; - -// A group's no-variant base shows the codec its siblings vary on; video codecs -// win over audio when a sample carries both (e.g. webcam PiP encodes both). -const BASE_CODEC_PRIORITY: string[] = [ - ...Object.keys(VIDEO_CODEC_LABELS), - ...Object.keys(AUDIO_CODEC_LABELS), -]; - -function isFormatFacetTag(tag: string): boolean { - return tag.startsWith(CODEC_TAG_PREFIX) || FORMAT_FACET_TAGS.has(tag); -} export function sampleNeedsHardware(sample: SamplePipeline): boolean { return (sample.tags ?? []).some((tag) => tag.startsWith(HARDWARE_TAG_PREFIX)); } -/** - * Pill label for a group's no-variant base, derived from its output codec tag - * (e.g. the software colorbars base reads `VP9`, the Opus mixer base `Opus`), - * rather than a hardcoded fallback that misreads non-encoding groups. - */ -export function baseVariantLabel(sample: SamplePipeline): string | null { - const codecs = (sample.tags ?? []) - .filter((tag) => tag.startsWith(CODEC_TAG_PREFIX)) - .map((tag) => tag.slice(CODEC_TAG_PREFIX.length)); - if (codecs.length === 0) return null; - const pick = BASE_CODEC_PRIORITY.find((codec) => codecs.includes(codec)) ?? codecs[0]; - return CODEC_LABELS[pick] ?? pick.toUpperCase(); -} - export interface SampleFacets { categories: string[]; capabilities: string[]; @@ -245,7 +132,7 @@ export function collectSampleFacets(samples: SamplePipeline[]): SampleFacets { for (const tag of sample.tags ?? []) { if (tag.startsWith(HARDWARE_TAG_PREFIX)) { hasHardware = true; - } else if (!isFormatFacetTag(tag)) { + } else { capabilities.add(tag); } } From 62f90d90ce010ea29896ff8d050f4a4b65a3c12a Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 16:06:12 +0000 Subject: [PATCH 13/14] fix(discovery): validation, a11y, and design-handoff fixes - Route Convert->Design handoff through guarded handleLoadSample (unsaved-work modal + measured auto-layout), deriving name/description from the edited YAML rather than the pristine sample. - Include the sample id in backend search_terms so slug-fragment queries match again; precompute per-template search haystacks once. - Variant pill accessible name now matches its visible label (WCAG 2.5.3); compute facet chips over the origin-filtered set so chips never match zero items. - Tighten the metadata contract test: reject any blank tag and duplicate variant labels within a group. - Collapse the duplicated Steps/Dag discovery fields behind a flattened PipelineMeta struct. - Clear the Customize/editor view when the selection is hidden by active filters; smaller facet chips in a single grouped bar. Signed-off-by: streamkit-devin --- apps/skit/src/sample_discovery.rs | 17 +++-- apps/skit/src/samples.rs | 49 ++++--------- .../tests/sample_discovery_metadata_test.rs | 31 ++++++-- crates/api/src/yaml/compiler.rs | 20 ++---- crates/api/src/yaml/mod.rs | 71 ++++++++---------- .../converter/TemplateSelector.styles.ts | 21 +++--- .../converter/TemplateSelector.test.tsx | 31 ++++++-- .../components/converter/TemplateSelector.tsx | 72 ++++++++++++++----- .../stream/PipelineSelectionSection.tsx | 30 ++++++-- ui/src/utils/samplePipelineOrdering.test.ts | 2 +- ui/src/utils/samplePipelineOrdering.ts | 31 ++++---- ui/src/views/ConvertView.tsx | 65 +++++++++++------ ui/src/views/DesignView.tsx | 23 +++--- 13 files changed, 279 insertions(+), 184 deletions(-) diff --git a/apps/skit/src/sample_discovery.rs b/apps/skit/src/sample_discovery.rs index 749171ff9..03eedebdc 100644 --- a/apps/skit/src/sample_discovery.rs +++ b/apps/skit/src/sample_discovery.rs @@ -34,11 +34,13 @@ fn push_term(terms: &mut Vec, value: &str) { /// Builds the backend search document the UI matches queries against. /// -/// Combines the human-facing fields with authored keywords and the pipeline's -/// node kinds (with `::`/`_` separators flattened to spaces so individual -/// segments like `whisper` or `vaapi` are matchable). Lowercased and +/// Combines the human-facing fields with the sample id (so slug fragments like +/// `vaapi_h264_colorbars` stay searchable), authored keywords, and the +/// pipeline's node kinds (with `::`/`_` separators flattened to spaces so +/// individual segments like `whisper` or `vaapi` are matchable). Lowercased and /// de-duplicated. pub fn build_search_terms( + id: &str, name: &str, description: &str, discovery: &Discovery, @@ -46,6 +48,7 @@ pub fn build_search_terms( ) -> Vec { let mut terms = Vec::new(); + push_term(&mut terms, id); push_term(&mut terms, name); push_term(&mut terms, description); if let Some(category) = discovery.category.as_deref() { @@ -89,6 +92,7 @@ mod tests { keywords: vec!["test pattern".to_string(), "smpte".to_string()], }; let terms = build_search_terms( + "dynamic/video_moq_vaapi_h264_colorbars", "Video Color Bars (VA-API H.264)", "Encodes SMPTE color bars", &discovery, @@ -99,6 +103,7 @@ mod tests { assert!(terms.contains(&"hardware:vaapi".to_string())); assert!(terms.contains(&"test pattern".to_string())); assert!(terms.contains(&"video vaapi h264 encoder".to_string())); + assert!(terms.contains(&"dynamic/video_moq_vaapi_h264_colorbars".to_string())); } #[test] @@ -108,7 +113,7 @@ mod tests { tags: vec!["streaming".to_string()], ..Discovery::default() }; - let terms = build_search_terms("Streaming", "STREAMING", &discovery, &[]); + let terms = build_search_terms("streaming", "Streaming", "STREAMING", &discovery, &[]); assert_eq!(terms.iter().filter(|t| *t == "streaming").count(), 1); assert!(terms.iter().all(|t| t == &t.to_lowercase())); @@ -117,7 +122,7 @@ mod tests { #[test] fn search_terms_skip_absent_optional_fields() { let discovery = Discovery::default(); - let terms = build_search_terms("Solo Sample", "", &discovery, &[]); - assert_eq!(terms, vec!["solo sample".to_string()]); + let terms = build_search_terms("oneshot/solo", "Solo Sample", "", &discovery, &[]); + assert_eq!(terms, vec!["oneshot/solo".to_string(), "solo sample".to_string()]); } } diff --git a/apps/skit/src/samples.rs b/apps/skit/src/samples.rs index 042e4ce9a..6ab0c92c9 100644 --- a/apps/skit/src/samples.rs +++ b/apps/skit/src/samples.rs @@ -205,50 +205,24 @@ fn parse_pipeline_metadata(yaml: &str, path: &std::path::Path) -> PipelineMetada }, }; - // The Steps and Dag arms differ only in how node kinds are collected, so - // pull those out first and bind the shared discovery fields with one - // irrefutable or-pattern. let node_kinds: Vec = match &user_pipeline { UserPipeline::Steps { steps, .. } => steps.iter().map(|s| s.kind.clone()).collect(), UserPipeline::Dag { nodes, .. } => nodes.values().map(|n| n.kind.clone()).collect(), }; - let (UserPipeline::Steps { - name, - description, - mode, - group, - variant, - canonical, - category, - tags, - keywords, - .. - } - | UserPipeline::Dag { - name, - description, - mode, - group, - variant, - canonical, - category, - tags, - keywords, - .. - }) = user_pipeline; + let (UserPipeline::Steps { meta, .. } | UserPipeline::Dag { meta, .. }) = user_pipeline; PipelineMetadata { - name, - description, - mode, + name: meta.name, + description: meta.description, + mode: meta.mode, explicit: crate::sample_discovery::Discovery { - group, - variant, - canonical, - category, - tags, - keywords, + group: meta.group, + variant: meta.variant, + canonical: meta.canonical, + category: meta.category, + tags: meta.tags, + keywords: meta.keywords, }, node_kinds, } @@ -301,6 +275,7 @@ async fn load_samples_from_dir( let is_fragment = name == filename_to_name(filename) && description.is_empty(); let search_terms = crate::sample_discovery::build_search_terms( + &id, &name, &description, &meta.explicit, @@ -442,6 +417,7 @@ pub async fn get_sample( let full_id = format!("{subdir}/{filename_base}"); let search_terms = crate::sample_discovery::build_search_terms( + &full_id, &name, &description, &meta.explicit, @@ -575,6 +551,7 @@ async fn save_sample( let mode_str = mode_to_string(meta.mode); let search_terms = crate::sample_discovery::build_search_terms( + &id, &request.name, &request.description, &meta.explicit, diff --git a/apps/skit/tests/sample_discovery_metadata_test.rs b/apps/skit/tests/sample_discovery_metadata_test.rs index 0a5cb5397..f6a25cdcc 100644 --- a/apps/skit/tests/sample_discovery_metadata_test.rs +++ b/apps/skit/tests/sample_discovery_metadata_test.rs @@ -14,7 +14,7 @@ // Test-fixture checks should fail fast with contextual assertion messages. #![allow(clippy::expect_used, clippy::panic)] -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use streamkit_api::yaml::{parse_yaml, UserPipeline}; @@ -62,10 +62,16 @@ fn read_samples() -> Vec { let pipeline = parse_yaml(&yaml).unwrap_or_else(|e| panic!("{file}: parse failed: {e}")); - let (UserPipeline::Steps { group, variant, canonical, category, tags, .. } - | UserPipeline::Dag { group, variant, canonical, category, tags, .. }) = pipeline; + let (UserPipeline::Steps { meta, .. } | UserPipeline::Dag { meta, .. }) = pipeline; - samples.push(SampleMeta { file, group, variant, canonical, category, tags }); + samples.push(SampleMeta { + file, + group: meta.group, + variant: meta.variant, + canonical: meta.canonical, + category: meta.category, + tags: meta.tags, + }); } } @@ -84,6 +90,8 @@ fn every_bundled_sample_has_required_discovery_metadata() { } if sample.tags.iter().all(|t| t.trim().is_empty()) { failures.push(format!("{}: missing required `tags`", sample.file)); + } else if sample.tags.iter().any(|t| t.trim().is_empty()) { + failures.push(format!("{}: has a blank `tags` entry", sample.file)); } } @@ -136,12 +144,21 @@ fn grouped_samples_are_internally_consistent() { )); } + let mut seen_variants = BTreeSet::new(); for member in members { - if member.variant.as_deref().is_none_or(|v| v.trim().is_empty()) { - failures.push(format!( + match member.variant.as_deref().map(str::trim) { + Some(variant) if !variant.is_empty() => { + if !seen_variants.insert(variant.to_string()) { + failures.push(format!( + "group `{group}` has duplicate `variant: {variant}`; variant labels \ + must be unique within a group" + )); + } + }, + _ => failures.push(format!( "{}: member of multi-sample group `{group}` must set a `variant` label", member.file - )); + )), } } } diff --git a/crates/api/src/yaml/compiler.rs b/crates/api/src/yaml/compiler.rs index 1ff1bafa4..374ee10b6 100644 --- a/crates/api/src/yaml/compiler.rs +++ b/crates/api/src/yaml/compiler.rs @@ -8,11 +8,11 @@ use indexmap::IndexMap; pub fn compile(pipeline: UserPipeline) -> Result { match pipeline { - UserPipeline::Steps { name, description, mode, steps, client, .. } => { - Ok(compile_steps(name, description, mode, steps, client)) + UserPipeline::Steps { meta, steps, client } => { + Ok(compile_steps(meta.name, meta.description, meta.mode, steps, client)) }, - UserPipeline::Dag { name, description, mode, nodes, client, .. } => { - compile_dag(name, description, mode, nodes, client) + UserPipeline::Dag { meta, nodes, client } => { + compile_dag(meta.name, meta.description, meta.mode, nodes, client) }, } } @@ -287,7 +287,7 @@ fn value_type_name(v: &serde_json::Value) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::yaml::{Needs, NeedsDependency, UserNode, UserPipeline}; + use crate::yaml::{Needs, NeedsDependency, PipelineMeta, UserNode, UserPipeline}; use crate::EngineMode; use indexmap::IndexMap; @@ -297,15 +297,7 @@ mod tests { fn dag_pipeline(nodes: IndexMap, mode: EngineMode) -> UserPipeline { UserPipeline::Dag { - name: None, - description: None, - mode, - group: None, - variant: None, - category: None, - tags: Vec::new(), - canonical: false, - keywords: Vec::new(), + meta: PipelineMeta { mode, ..PipelineMeta::default() }, nodes, client: None, } diff --git a/crates/api/src/yaml/mod.rs b/crates/api/src/yaml/mod.rs index b8ae5cd21..c6cd1bec2 100644 --- a/crates/api/src/yaml/mod.rs +++ b/crates/api/src/yaml/mod.rs @@ -269,55 +269,46 @@ pub struct ControlConfig { pub options: Option>, } -/// Top-level discovery metadata, repeated across both `UserPipeline` arms, -/// powering the Convert/Stream pickers' variant grouping and faceted/fuzzy -/// search. Bundled samples are required to author these explicitly (enforced by -/// `apps/skit/tests/sample_discovery_metadata_test.rs`); there is no runtime +/// Top-level pipeline metadata shared by both `UserPipeline` arms. The +/// discovery fields (group/variant/canonical/category/tags/keywords) power the +/// Convert/Stream pickers' variant grouping and faceted/fuzzy search; bundled +/// samples are required to author them explicitly (enforced by +/// `apps/skit/tests/sample_discovery_metadata_test.rs`) — there is no runtime /// derivation. See `apps/skit/src/sample_discovery.rs`. +#[derive(Debug, Default, Deserialize)] +pub struct PipelineMeta { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub mode: EngineMode, + #[serde(default)] + pub group: Option, + #[serde(default)] + pub variant: Option, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub canonical: bool, + #[serde(default)] + pub keywords: Vec, +} + #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum UserPipeline { Steps { - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(default)] - mode: EngineMode, - #[serde(default)] - group: Option, - #[serde(default)] - variant: Option, - #[serde(default)] - category: Option, - #[serde(default)] - tags: Vec, - #[serde(default)] - canonical: bool, - #[serde(default)] - keywords: Vec, + #[serde(flatten)] + meta: PipelineMeta, steps: Vec, client: Option, }, Dag { - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - #[serde(default)] - mode: EngineMode, - #[serde(default)] - group: Option, - #[serde(default)] - variant: Option, - #[serde(default)] - category: Option, - #[serde(default)] - tags: Vec, - #[serde(default)] - canonical: bool, - #[serde(default)] - keywords: Vec, + #[serde(flatten)] + meta: PipelineMeta, nodes: IndexMap, client: Option, }, diff --git a/ui/src/components/converter/TemplateSelector.styles.ts b/ui/src/components/converter/TemplateSelector.styles.ts index 4ec75de82..780701762 100644 --- a/ui/src/components/converter/TemplateSelector.styles.ts +++ b/ui/src/components/converter/TemplateSelector.styles.ts @@ -203,31 +203,36 @@ export const TemplateDescription = styled.div` export const FacetBar = styled.div` display: flex; flex-direction: column; - gap: 8px; + gap: 6px; margin-bottom: 16px; + padding: 10px 12px; + background: var(--sk-panel-bg); + border: 1px solid var(--sk-border); + border-radius: 8px; `; export const FacetRow = styled.div` display: flex; - align-items: center; - gap: 8px; + align-items: baseline; + gap: 6px; flex-wrap: wrap; `; export const FacetRowLabel = styled.span` + flex-shrink: 0; + width: 84px; color: var(--sk-text-muted); - font-size: 11px; + font-size: 10px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; - margin-right: 4px; `; // Filter chips use a squared-off shape so they read as filters, distinct from // the fully-rounded variant pills (which are a selection control, not a filter). export const FacetChip = styled.button<{ active?: boolean }>` - padding: 5px 12px; - font-size: 13px; + padding: 3px 8px; + font-size: 12px; font-weight: 600; border-radius: 6px; cursor: pointer; @@ -276,7 +281,7 @@ export const GroupCard = styled.div` } `; -// Ghost button for clearing all active filters (facet bar + empty state). +// Ghost button for clearing all active filters; rendered once in the Controls row. export const ClearAllButton = styled.button` border: 1px solid var(--sk-border); background: var(--sk-panel-bg); diff --git a/ui/src/components/converter/TemplateSelector.test.tsx b/ui/src/components/converter/TemplateSelector.test.tsx index a62092355..baf9c3245 100644 --- a/ui/src/components/converter/TemplateSelector.test.tsx +++ b/ui/src/components/converter/TemplateSelector.test.tsx @@ -154,6 +154,8 @@ describe('TemplateSelector variant grouping', () => { id: 'd/colorbars', name: 'Colorbars', group: 'video-moq-colorbars', + variant: 'Software', + canonical: true, }); const h264 = makePipeline({ id: 'd/h264-colorbars', @@ -181,9 +183,9 @@ describe('TemplateSelector variant grouping', () => { expect(within(systemHeader).getByText('1')).toBeInTheDocument(); expect(screen.getByRole('group', { name: /Colorbars variants/i })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'Colorbars' })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'H.264 Colorbars' })).toBeInTheDocument(); - expect(screen.getByRole('radio', { name: 'VA-API Colorbars' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Software' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'H.264' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'VA-API H.264' })).toBeInTheDocument(); }); it('selecting a variant loads that variant id', () => { @@ -196,7 +198,7 @@ describe('TemplateSelector variant grouping', () => { /> ); - fireEvent.click(screen.getByRole('radio', { name: 'VA-API Colorbars' })); + fireEvent.click(screen.getByRole('radio', { name: 'VA-API H.264' })); expect(onSelect).toHaveBeenCalledWith('d/vaapi-colorbars'); }); }); @@ -242,4 +244,25 @@ describe('TemplateSelector facets', () => { expect(screen.getByText('VA-API Encode')).toBeInTheDocument(); expect(screen.queryByText('Transcribe')).not.toBeInTheDocument(); }); + + it('hides facet chips that only exist outside the active origin filter', () => { + const userEncode = makePipeline({ + id: 'u/encode', + name: 'My Encode', + is_system: false, + category: 'Video Encoding', + tags: ['video-encoding'], + }); + render( + + ); + + expect(screen.getByRole('button', { name: 'Speech to Text' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'User' })); + expect(screen.queryByRole('button', { name: 'Speech to Text' })).not.toBeInTheDocument(); + }); }); diff --git a/ui/src/components/converter/TemplateSelector.tsx b/ui/src/components/converter/TemplateSelector.tsx index 92e24e92e..c1d05dbe8 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -9,10 +9,11 @@ import type { SamplePipeline } from '@/types/generated/api-types'; import { labelFromKey } from '@/utils/jsonSchema'; import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { + buildSearchHaystack, collectSampleFacets, compareSamplePipelinesByName, groupSamplePipelinesByScenario, - matchesQueryTokens, + haystackMatchesTokens, sampleNeedsHardware, tokenizeQuery, } from '@/utils/samplePipelineOrdering'; @@ -49,6 +50,7 @@ interface TemplateSelectorProps { templates: SamplePipeline[]; selectedTemplateId: string; onTemplateSelect: (templateId: string) => void; + onSelectionHiddenChange?: (hidden: boolean) => void; } const ScenarioHeader: React.FC<{ sample: SamplePipeline }> = ({ sample }) => ( @@ -80,17 +82,20 @@ const ScenarioCard: React.FC<{ group: ScenarioGroup; selectedTemplateId: string ); } - const selectedInGroup = variants.some((variant) => variant.id === selectedTemplateId); + const selectedMember = variants.find((variant) => variant.id === selectedTemplateId); return ( - - + + - {variants.map((variant) => ( - - {variant.variant ?? variant.name} - - ))} + {variants.map((variant) => { + const variantLabel = variant.variant ?? variant.name; + return ( + + {variantLabel} + + ); + })} ); @@ -197,6 +202,7 @@ export const TemplateSelector: React.FC = ({ templates, selectedTemplateId, onTemplateSelect, + onSelectionHiddenChange, }) => { const [query, setQuery] = React.useState(''); const [originFilter, setOriginFilter] = React.useState<'all' | 'system' | 'user'>('all'); @@ -204,7 +210,28 @@ export const TemplateSelector: React.FC = ({ const [capabilityFilter, setCapabilityFilter] = React.useState(null); const [hardwareOnly, setHardwareOnly] = React.useState(false); - const facets = React.useMemo(() => collectSampleFacets(templates), [templates]); + const originFilteredTemplates = React.useMemo( + () => + templates.filter((template) => { + if (originFilter === 'system' && !template.is_system) return false; + if (originFilter === 'user' && template.is_system) return false; + return true; + }), + [templates, originFilter] + ); + + const facets = React.useMemo( + () => collectSampleFacets(originFilteredTemplates), + [originFilteredTemplates] + ); + + const haystacks = React.useMemo(() => { + const map = new Map(); + for (const template of templates) { + map.set(template.id, buildSearchHaystack(template)); + } + return map; + }, [templates]); const facetFiltersActive = Boolean(categoryFilter || capabilityFilter || hardwareOnly); const anyFilterActive = Boolean(query.trim() || originFilter !== 'all' || facetFiltersActive); @@ -233,15 +260,20 @@ export const TemplateSelector: React.FC = ({ const queryTokens = React.useMemo(() => tokenizeQuery(query), [query]); const filteredTemplates = React.useMemo(() => { - return templates.filter((template) => { - if (originFilter === 'system' && !template.is_system) return false; - if (originFilter === 'user' && template.is_system) return false; + return originFilteredTemplates.filter((template) => { if (categoryFilter && template.category !== categoryFilter) return false; if (capabilityFilter && !(template.tags ?? []).includes(capabilityFilter)) return false; if (hardwareOnly && !sampleNeedsHardware(template)) return false; - return matchesQueryTokens(template, queryTokens); + return haystackMatchesTokens(haystacks.get(template.id) ?? '', queryTokens); }); - }, [templates, originFilter, categoryFilter, capabilityFilter, hardwareOnly, queryTokens]); + }, [ + originFilteredTemplates, + categoryFilter, + capabilityFilter, + hardwareOnly, + haystacks, + queryTokens, + ]); const systemGroups = React.useMemo( () => toScenarioGroups(filteredTemplates.filter((template) => template.is_system)), @@ -261,9 +293,13 @@ export const TemplateSelector: React.FC = ({ return filteredTemplates.some((template) => template.id === selectedTemplateId); }, [filteredTemplates, selectedTemplateId]); - const showHiddenSelectionHint = Boolean( - selectedTemplateId && selectedExists && !selectedVisible && anyFilterActive - ); + const selectionHidden = Boolean(selectedTemplateId && selectedExists && !selectedVisible); + + React.useEffect(() => { + onSelectionHiddenChange?.(selectionHidden); + }, [selectionHidden, onSelectionHiddenChange]); + + const showHiddenSelectionHint = selectionHidden && anyFilterActive; const showFacetBar = facets.categories.length > 0 || facets.capabilities.length > 0 || facets.hasHardware; diff --git a/ui/src/components/stream/PipelineSelectionSection.tsx b/ui/src/components/stream/PipelineSelectionSection.tsx index 14d3a00c5..c3eec3661 100644 --- a/ui/src/components/stream/PipelineSelectionSection.tsx +++ b/ui/src/components/stream/PipelineSelectionSection.tsx @@ -105,6 +105,15 @@ const LoadingMessage = styled.div` font-size: 14px; `; +const HiddenSelectionMessage = styled.div` + padding: 24px 16px; + text-align: center; + font-size: 14px; + color: var(--sk-text-muted); + border: 1px dashed var(--sk-border); + border-radius: 8px; +`; + const ActiveSessionBadge = styled.div` display: inline-flex; align-items: center; @@ -272,6 +281,7 @@ export const PipelineSelectionSection: React.FC = }) => { const showEmptyState = !samplesLoading && samples.length === 0; const showTemplates = !samplesLoading && samples.length > 0; + const [selectionHidden, setSelectionHidden] = React.useState(false); return (
@@ -289,13 +299,21 @@ export const PipelineSelectionSection: React.FC = templates={samples} selectedTemplateId={selectedTemplateId} onTemplateSelect={onTemplateSelect} + onSelectionHiddenChange={setSelectionHidden} /> - + {selectionHidden ? ( + + The selected pipeline is hidden by the active filters. Clear the filters or pick a + pipeline to customize it. + + ) : ( + + )} @@ -325,7 +343,7 @@ export const PipelineSelectionSection: React.FC = diff --git a/ui/src/utils/samplePipelineOrdering.test.ts b/ui/src/utils/samplePipelineOrdering.test.ts index 84c52f64b..0046f385d 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -226,7 +226,7 @@ describe('groupSamplePipelinesByScenario', () => { expect(groups.map((g) => g.key)).toEqual(['oneshot/tts', 'oneshot/stt']); }); - it('preserves first-appearance order of groups', () => { + it('returns groups in deterministic insertion order (callers re-sort for display)', () => { const groups = groupSamplePipelinesByScenario([ makePipeline({ id: 'b', group: 'beta' }), makePipeline({ id: 'a', group: 'alpha' }), diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index eef18a547..8edcaa06f 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -42,17 +42,25 @@ export function tokenizeQuery(query: string): string[] { return query.trim().toLowerCase().split(/\s+/).filter(Boolean); } +/** Flattens a pipeline's backend-resolved `search_terms` into one searchable string. */ +export function buildSearchHaystack(pipeline: SamplePipeline): string { + return (pipeline.search_terms ?? []).join(' '); +} + +/** Every token must be a substring of `haystack`; an empty token list matches everything. */ +export function haystackMatchesTokens(haystack: string, tokens: string[]): boolean { + if (tokens.length === 0) return true; + return tokens.every((token) => haystack.includes(token)); +} + /** - * Whether a pipeline matches a tokenized query. Every token must be a substring - * of the backend-resolved `search_terms` document (name, description, category, - * tags, authored keywords, node kinds). An empty token list matches everything. - * Synonyms/aliases live in each sample's authored `keywords`, not in a - * UI-side table, so the UI does no semantic expansion. + * Whether a pipeline matches a tokenized query, against the backend-resolved + * `search_terms` document (name, description, id, category, tags, authored + * keywords, node kinds). Synonyms/aliases live in each sample's authored + * `keywords`, not in a UI-side table, so the UI does no semantic expansion. */ export function matchesQueryTokens(pipeline: SamplePipeline, tokens: string[]): boolean { - if (tokens.length === 0) return true; - const haystack = (pipeline.search_terms ?? []).join(' '); - return tokens.every((token) => haystack.includes(token)); + return haystackMatchesTokens(buildSearchHaystack(pipeline), tokens); } /** Convenience wrapper that tokenizes `query` and matches a single pipeline. */ @@ -77,10 +85,9 @@ function variantSortKey(sample: SamplePipeline): string { * Collapses samples that share a `group` into a single entry with a variant * list, so near-duplicate cards (e.g. the colorbars codec/hardware family) * render once with a variant selector. Samples without a group are singletons. - * Input order of first appearance is preserved. + * Group ordering is the caller's responsibility (the picker sorts by name). */ export function groupSamplePipelinesByScenario(samples: SamplePipeline[]): ScenarioGroup[] { - const order: string[] = []; const byKey = new Map(); for (const sample of samples) { @@ -90,12 +97,10 @@ export function groupSamplePipelinesByScenario(samples: SamplePipeline[]): Scena existing.push(sample); } else { byKey.set(key, [sample]); - order.push(key); } } - return order.map((key) => { - const members = byKey.get(key) ?? []; + return Array.from(byKey, ([key, members]) => { const variants = members.slice().sort((a, b) => { const aCanonical = a.canonical ? 0 : 1; const bCanonical = b.canonical ? 0 : 1; diff --git a/ui/src/views/ConvertView.tsx b/ui/src/views/ConvertView.tsx index db95f1f59..56bb9bd86 100644 --- a/ui/src/views/ConvertView.tsx +++ b/ui/src/views/ConvertView.tsx @@ -151,6 +151,15 @@ const CustomizeActions = styled.div` justify-content: flex-end; `; +const CustomizeHiddenHint = styled.div` + padding: 24px 16px; + text-align: center; + font-size: 14px; + color: var(--sk-text-muted); + border: 1px dashed var(--sk-border); + border-radius: 8px; +`; + const OpenInDesignButton = styled.button` padding: 8px 16px; font-size: 13px; @@ -784,17 +793,25 @@ const ConvertView: React.FC = () => { const navigate = useNavigate(); + const [selectionHidden, setSelectionHidden] = useState(false); + const handleOpenInDesign = useCallback(() => { if (!pipelineYaml.trim()) return; const sample = samples.find((s) => s.id === selectedTemplateId); + const yamlName = + typeof parsedPipelineYaml?.name === 'string' ? parsedPipelineYaml.name : undefined; + const yamlDescription = + typeof parsedPipelineYaml?.description === 'string' + ? parsedPipelineYaml.description + : undefined; navigate('/design', { state: { importYaml: pipelineYaml, - name: sample?.name ?? '', - description: sample?.description ?? '', + name: yamlName ?? sample?.name ?? '', + description: yamlDescription ?? sample?.description ?? '', }, }); - }, [navigate, pipelineYaml, samples, selectedTemplateId]); + }, [navigate, pipelineYaml, parsedPipelineYaml, samples, selectedTemplateId]); const handleTemplateSelect = (templateId: string) => { const sample = samples.find((s) => s.id === templateId); @@ -1268,29 +1285,37 @@ const ConvertView: React.FC = () => { templates={samples} selectedTemplateId={selectedTemplateId} onTemplateSelect={handleTemplateSelect} + onSelectionHiddenChange={setSelectionHidden} /> )}
2. Customize Pipeline (Optional) - - - - - Open in Design view - - - + {selectionHidden ? ( + + The selected pipeline is hidden by the active filters. Clear the filters or pick a + pipeline to customize it. + + ) : ( + + + + + Open in Design view + + + + )}
{!isNoInputPipeline && ( diff --git a/ui/src/views/DesignView.tsx b/ui/src/views/DesignView.tsx index b5215fa51..b5c90a99c 100644 --- a/ui/src/views/DesignView.tsx +++ b/ui/src/views/DesignView.tsx @@ -558,17 +558,6 @@ const DesignViewContent: React.FC = () => { const location = useLocation(); const handoffImportedRef = React.useRef(false); - React.useEffect(() => { - const handoff = location.state as { - importYaml?: string; - name?: string; - description?: string; - } | null; - if (handoffImportedRef.current || !handoff?.importYaml || nodeDefinitions.length === 0) return; - handoffImportedRef.current = true; - handleImportYaml(handoff.importYaml, handoff.description ?? '', handoff.name ?? ''); - navigate('.', { replace: true, state: null }); - }, [location.state, nodeDefinitions, handleImportYaml, navigate]); const cachesRef = React.useRef>>({}); @@ -1344,6 +1333,18 @@ const DesignViewContent: React.FC = () => { } }; + React.useEffect(() => { + const handoff = location.state as { + importYaml?: string; + name?: string; + description?: string; + } | null; + if (handoffImportedRef.current || !handoff?.importYaml || nodeDefinitions.length === 0) return; + handoffImportedRef.current = true; + handleLoadSample(handoff.importYaml, handoff.name ?? '', handoff.description ?? ''); + navigate('.', { replace: true, state: null }); + }, [location.state, nodeDefinitions, handleLoadSample, navigate]); + const handleClearCanvas = React.useCallback(() => { setNodes([]); setEdges([]); From 97407839fc63bc162192619488b037d2e7703bc4 Mon Sep 17 00:00:00 2001 From: streamkit-devin Date: Sun, 31 May 2026 16:30:10 +0000 Subject: [PATCH 14/14] test(e2e): select grouped variant pills by variant label The variant pills' accessible name now equals their visible variant label (WCAG 2.5.3), so selecting a grouped pipeline by its sample name no longer matches. Scope grouped selections to the card's variant group and click by variant label. Signed-off-by: streamkit-devin --- e2e/tests/convert.spec.ts | 6 +++--- e2e/tests/stream.spec.ts | 4 ++-- e2e/tests/test-helpers.ts | 24 ++++++++++++++++++------ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/e2e/tests/convert.spec.ts b/e2e/tests/convert.spec.ts index 945ef4b35..408a590f7 100644 --- a/e2e/tests/convert.spec.ts +++ b/e2e/tests/convert.spec.ts @@ -106,7 +106,7 @@ test.describe('Convert View - Audio Mixing Pipeline', () => { }) => { await expect(page.getByText('1. Select Pipeline Template')).toBeVisible(); - await selectPipelineTemplate(page, 'Audio Mixing (Upload + Music Track)'); + await selectPipelineTemplate(page, 'Audio Mixing (Upload + Music Track)', 'Upload + Music'); await expect(page.locator('input[type="file"]').first()).toBeAttached(); await page.locator('input[type="file"]').first().setInputFiles(sampleOggPath); @@ -134,7 +134,7 @@ test.describe('Convert View - Audio Mixing Pipeline', () => { }) => { await expect(page.getByText('1. Select Pipeline Template')).toBeVisible(); - await selectPipelineTemplate(page, 'Audio Mixing (Upload + Music Track)'); + await selectPipelineTemplate(page, 'Audio Mixing (Upload + Music Track)', 'Upload + Music'); const assetModeButton = page.getByRole('button', { name: /Select Existing Asset/i, @@ -185,7 +185,7 @@ test.describe('Convert View - Video Color Bars Pipeline', () => { await expect(page.getByText('1. Select Pipeline Template')).toBeVisible(); - await selectPipelineTemplate(page, 'Video Color Bars (VP9/WebM)'); + await selectPipelineTemplate(page, 'Video Color Bars (VP9/WebM)', 'VP9 (Software)'); // This is a no-input (generator) pipeline, so the button says "Generate". const generateButton = page.getByRole('button', { name: /Generate/i }); diff --git a/e2e/tests/stream.spec.ts b/e2e/tests/stream.spec.ts index 32abeff7d..6eac46ad1 100644 --- a/e2e/tests/stream.spec.ts +++ b/e2e/tests/stream.spec.ts @@ -226,7 +226,7 @@ test.describe('Stream View - Video MoQ Color Bars Pipeline', () => { } // Select the video colorbars MoQ template. - await selectPipelineTemplate(page, 'Video Color Bars (MoQ Stream)'); + await selectPipelineTemplate(page, 'Video Color Bars (MoQ Stream)', 'VP9 (Software)'); // Create session. const createButton = page.getByRole('button', { name: /Create Session/i }); @@ -364,7 +364,7 @@ test.describe('Stream View - Webcam PiP Pipeline', () => { } // Select the webcam PiP template. - await selectPipelineTemplate(page, 'Webcam PiP (MoQ Stream)'); + await selectPipelineTemplate(page, 'Webcam PiP (MoQ Stream)', 'VP9'); // Create session. const createButton = page.getByRole('button', { name: /Create Session/i }); diff --git a/e2e/tests/test-helpers.ts b/e2e/tests/test-helpers.ts index e63f2ad25..5ad74012f 100644 --- a/e2e/tests/test-helpers.ts +++ b/e2e/tests/test-helpers.ts @@ -33,13 +33,25 @@ export interface ConsoleErrorCollector { } /** - * Selects a sample pipeline in the TemplateSelector by its full name. Both - * ungrouped cards and grouped scenario variant pills expose the radio with the - * sample name as its accessible name, so a single role-based lookup selects - * either one. + * Selects a sample pipeline in the TemplateSelector. + * + * Ungrouped cards expose a single radio whose accessible name is the sample + * name. Grouped scenario cards expose one radio per variant whose accessible + * name is the variant label (which matches its visible text per WCAG 2.5.3); + * to disambiguate identically-labelled variants across groups, pass `variant` + * and the lookup is scoped to that card's `" variants"` group. */ -export async function selectPipelineTemplate(page: Page, name: string): Promise { - const radio = page.getByRole('radio', { name, exact: true }); +export async function selectPipelineTemplate( + page: Page, + name: string, + variant?: string +): Promise { + const radio = variant + ? page.getByRole('group', { name: `${name} variants` }).getByRole('radio', { + name: variant, + exact: true, + }) + : page.getByRole('radio', { name, exact: true }); await expect(radio.first()).toBeVisible({ timeout: 10_000 }); await radio.first().click(); }