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..03eedebdc --- /dev/null +++ b/apps/skit/src/sample_discovery.rs @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: © 2025 StreamKit Contributors +// +// SPDX-License-Identifier: MPL-2.0 + +//! Explicit discovery metadata for sample pipelines. +//! +//! The Convert and Stream views group near-duplicate samples (e.g. the +//! 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, +} + +fn push_term(terms: &mut Vec, value: &str) { + let term = value.trim().to_lowercase(); + if !term.is_empty() && !terms.contains(&term) { + terms.push(term); + } +} + +/// Builds the backend search document the UI matches queries against. +/// +/// 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, + node_kinds: &[String], +) -> 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() { + push_term(&mut terms, category); + } + if let Some(group) = discovery.group.as_deref() { + push_term(&mut terms, group); + } + if let Some(variant) = discovery.variant.as_deref() { + push_term(&mut terms, variant); + } + for tag in &discovery.tags { + push_term(&mut terms, tag); + } + for keyword in &discovery.keywords { + push_term(&mut terms, keyword); + } + for kind in node_kinds { + push_term(&mut terms, &kind.replace("::", " ").replace('_', " ")); + } + + terms +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(list: &[&str]) -> Vec { + list.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + 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()], + }; + let terms = build_search_terms( + "dynamic/video_moq_vaapi_h264_colorbars", + "Video Color Bars (VA-API H.264)", + "Encodes SMPTE color bars", + &discovery, + &kinds(&["video::vaapi::h264_encoder"]), + ); + + 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())); + assert!(terms.contains(&"dynamic/video_moq_vaapi_h264_colorbars".to_string())); + } + + #[test] + 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", "STREAMING", &discovery, &[]); + + assert_eq!(terms.iter().filter(|t| *t == "streaming").count(), 1); + assert!(terms.iter().all(|t| t == &t.to_lowercase())); + } + + #[test] + fn search_terms_skip_absent_optional_fields() { + let discovery = Discovery::default(); + 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 ed07eaefa..6ab0c92c9 100644 --- a/apps/skit/src/samples.rs +++ b/apps/skit/src/samples.rs @@ -183,24 +183,49 @@ 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 for listing, search, and discovery. +#[derive(Default)] +struct PipelineMetadata { + name: Option, + description: Option, + mode: streamkit_api::EngineMode, + explicit: crate::sample_discovery::Discovery, + node_kinds: Vec, +} + +/// 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 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 { meta, .. } | UserPipeline::Dag { meta, .. }) = user_pipeline; + + PipelineMetadata { + name: meta.name, + description: meta.description, + mode: meta.mode, + explicit: crate::sample_discovery::Discovery { + group: meta.group, + variant: meta.variant, + canonical: meta.canonical, + category: meta.category, + tags: meta.tags, + keywords: meta.keywords, }, - ) + node_kinds, + } } fn mode_to_string(mode: streamkit_api::EngineMode) -> String { @@ -240,23 +265,37 @@ 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 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( + &id, + &name, + &description, + &meta.explicit, + &meta.node_kinds, + ); + samples.push(SamplePipeline { id, name, description, yaml, is_system, - mode: mode_to_string(mode), + mode: mode_to_string(meta.mode), is_fragment, + group: meta.explicit.group, + variant: meta.explicit.variant, + canonical: meta.explicit.canonical, + category: meta.explicit.category, + tags: meta.explicit.tags, + search_terms, }); }, Err(e) => { @@ -362,11 +401,11 @@ 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 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(); @@ -377,6 +416,14 @@ 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( + &full_id, + &name, + &description, + &meta.explicit, + &meta.node_kinds, + ); + return Ok(SamplePipeline { id: full_id, name, @@ -385,6 +432,12 @@ pub async fn get_sample( is_system, mode: mode_str, is_fragment, + group: meta.explicit.group, + variant: meta.explicit.variant, + canonical: meta.explicit.canonical, + category: meta.explicit.category, + tags: meta.explicit.tags, + search_terms, }); } } @@ -494,8 +547,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 search_terms = crate::sample_discovery::build_search_terms( + &id, + &request.name, + &request.description, + &meta.explicit, + &meta.node_kinds, + ); Ok(SamplePipeline { id, @@ -505,6 +566,12 @@ async fn save_sample( is_system: false, mode: mode_str, is_fragment: request.is_fragment, + group: meta.explicit.group, + variant: meta.explicit.variant, + canonical: meta.explicit.canonical, + category: meta.explicit.category, + tags: meta.explicit.tags, + search_terms, }) } @@ -836,11 +903,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 +921,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 +939,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 +956,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 +970,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/apps/skit/tests/sample_discovery_metadata_test.rs b/apps/skit/tests/sample_discovery_metadata_test.rs new file mode 100644 index 000000000..f6a25cdcc --- /dev/null +++ b/apps/skit/tests/sample_discovery_metadata_test.rs @@ -0,0 +1,167 @@ +// 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, BTreeSet}; +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 { meta, .. } | UserPipeline::Dag { meta, .. }) = pipeline; + + samples.push(SampleMeta { + file, + group: meta.group, + variant: meta.variant, + canonical: meta.canonical, + category: meta.category, + tags: meta.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)); + } else if sample.tags.iter().any(|t| t.trim().is_empty()) { + failures.push(format!("{}: has a blank `tags` entry", 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(", ") + )); + } + + let mut seen_variants = BTreeSet::new(); + for member in members { + 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 + )), + } + } + } + + 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 82ee1e3e2..08093b604 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -391,6 +391,27 @@ 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, + /// 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, + /// 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 85046a9c7..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; @@ -296,7 +296,11 @@ mod tests { } fn dag_pipeline(nodes: IndexMap, mode: EngineMode) -> UserPipeline { - UserPipeline::Dag { name: None, description: None, mode, nodes, client: None } + UserPipeline::Dag { + meta: PipelineMeta { mode, ..PipelineMeta::default() }, + 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..c6cd1bec2 100644 --- a/crates/api/src/yaml/mod.rs +++ b/crates/api/src/yaml/mod.rs @@ -269,26 +269,46 @@ pub struct ControlConfig { pub options: Option>, } +/// 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(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(flatten)] + meta: PipelineMeta, nodes: IndexMap, client: Option, }, diff --git a/e2e/tests/convert.spec.ts b/e2e/tests/convert.spec.ts index 49673350c..408a590f7 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)', 'Upload + Music'); 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)', 'Upload + Music'); 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)', '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 6a8f94b31..6eac46ad1 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)', 'VP9 (Software)'); // 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)', '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 2660095b2..5ad74012f 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,30 @@ export interface ConsoleErrorCollector { getUnexpected(extraBenignPatterns?: string[]): string[]; } +/** + * 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, + 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(); +} + /** * Attach a console-error listener to `page` and return a collector object. * 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 8d797f5f2..fb46dc7a9 100644 --- a/samples/pipelines/oneshot/speech_to_text.yml +++ b/samples/pipelines/oneshot/speech_to_text.yml @@ -1,5 +1,18 @@ name: Speech-to-Text (Whisper) description: Transcribes speech to text using Whisper +group: speech-to-text +variant: Whisper +canonical: true +category: Speech to Text +tags: + - speech-to-text + - subtitles +keywords: + - stt + - asr + - transcribe + - transcription + - whisper mode: oneshot client: input: 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 dad8056ba..95b299600 100644 --- a/samples/pipelines/oneshot/vad-demo.yml +++ b/samples/pipelines/oneshot/vad-demo.yml @@ -4,6 +4,14 @@ name: Voice Activity Detection description: Detects voice activity and outputs events as JSON +category: Voice Activity +tags: + - voice-activity-detection +keywords: + - vad + - voice activity + - silence + - speech detection mode: oneshot client: input: 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.styles.ts b/ui/src/components/converter/TemplateSelector.styles.ts new file mode 100644 index 000000000..780701762 --- /dev/null +++ b/ui/src/components/converter/TemplateSelector.styles.ts @@ -0,0 +1,337 @@ +// 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` + 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 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: 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: baseline; + gap: 6px; + flex-wrap: wrap; +`; + +export const FacetRowLabel = styled.span` + flex-shrink: 0; + width: 84px; + color: var(--sk-text-muted); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +`; + +// 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: 3px 8px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + 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; + + &: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; rendered once in the Controls row. +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; + } +`; + +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..baf9c3245 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', @@ -18,8 +18,20 @@ function makePipeline(overrides: Partial = {}): SamplePipeline { is_system: true, mode: 'oneshot', 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 }); @@ -95,14 +107,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(); @@ -137,3 +148,121 @@ 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', + variant: 'Software', + canonical: true, + }); + 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: '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', () => { + 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 GPU' })); + 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 9706b0684..c1d05dbe8 100644 --- a/ui/src/components/converter/TemplateSelector.tsx +++ b/ui/src/components/converter/TemplateSelector.tsx @@ -2,265 +2,288 @@ // // 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 { labelFromKey } from '@/utils/jsonSchema'; +import type { SampleFacets, ScenarioGroup } from '@/utils/samplePipelineOrdering'; import { + buildSearchHaystack, + collectSampleFacets, compareSamplePipelinesByName, - matchesSamplePipelineQuery, + groupSamplePipelinesByScenario, + haystackMatchesTokens, + sampleNeedsHardware, + tokenizeQuery, } 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 { + ClearAllButton, + Controls, + EmptyState, + FacetBar, + FacetChip, + FacetRow, + FacetRowLabel, + FilterButton, + FilterGroup, + GroupCard, + HiddenSelectionHint, + SearchInput, + Section, + SectionCount, + SectionHeader, + SelectorContainer, + TemplateBadge, + TemplateCard, + TemplateContent, + TemplateDescription, + TemplateGrid, + TemplateHeader, + TemplateName, + VariantOption, + VariantSelector, +} from './TemplateSelector.styles'; - &: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; + onSelectionHiddenChange?: (hidden: boolean) => 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 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, +}) => { + const { base, variants } = group; + + if (variants.length === 1) { + return ( + + + + + + + ); } - &:has([data-state='checked']) { - background: var(--sk-primary); - color: var(--sk-primary-contrast); - border-color: var(--sk-primary); - } + const selectedMember = variants.find((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 ( + + + + {variants.map((variant) => { + const variantLabel = variant.variant ?? variant.name; + return ( + + {variantLabel} + + ); + })} + + + ); +}; -const TemplateDescription = styled.div` - font-size: 13px; - line-height: 1.4; - color: inherit; - opacity: 0.9; -`; +function toScenarioGroups(samples: SamplePipeline[]): ScenarioGroup[] { + return groupSamplePipelinesByScenario(samples).sort((a, b) => + compareSamplePipelinesByName(a.base, b.base) + ); +} -interface TemplateSelectorProps { - templates: SamplePipeline[]; +const GroupSection: React.FC<{ + title: string; + groups: ScenarioGroup[]; selectedTemplateId: string; - onTemplateSelect: (templateId: string) => void; +}> = ({ title, groups, selectedTemplateId }) => { + if (groups.length === 0) return null; + return ( +
+ + {title} + {groups.length} + + + {groups.map((group) => ( + + ))} + +
+ ); +}; + +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)} + > + {labelFromKey(capability)} + + ))} + + )} + + {facets.hasHardware && ( + + Requirements + + Needs GPU + + + )} + +); + export const TemplateSelector: React.FC = ({ templates, selectedTemplateId, onTemplateSelect, + onSelectionHiddenChange, }) => { 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 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); 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 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 matchesSamplePipelineQuery(template, query); + 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 haystackMatchesTokens(haystacks.get(template.id) ?? '', queryTokens); }); - }, [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]); + }, [ + originFilteredTemplates, + categoryFilter, + capabilityFilter, + hardwareOnly, + haystacks, + queryTokens, + ]); + + 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 +293,16 @@ export const TemplateSelector: React.FC = ({ return filteredTemplates.some((template) => template.id === selectedTemplateId); }, [filteredTemplates, selectedTemplateId]); - const showHiddenSelectionHint = - selectedTemplateId && - selectedExists && - !selectedVisible && - (query.trim() || originFilter !== 'all'); + 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; return ( @@ -308,15 +336,27 @@ export const TemplateSelector: React.FC = ({ User + {anyFilterActive && ( + + Clear all filters + + )} + {showFacetBar && ( + + )} + {showHiddenSelectionHint && ( - -
Selected template is hidden by your filters.
- - Clear filters - -
+ Selected template is hidden by your filters. )} = ({ 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 && ( -
- - System Pipelines - {systemTemplates.length} - - - {systemTemplates.map((template) => ( - - - - - - - {template.name} - - {template.is_system ? 'System' : 'User'} - - - {template.description} - - - ))} - -
- )} - - {userTemplates.length > 0 && ( -
- - User Pipelines - {userTemplates.length} - - - {userTemplates.map((template) => ( - - - - - - - {template.name} - - {template.is_system ? 'System' : 'User'} - - - {template.description} - - - ))} - -
- )} + +
); 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/services/fragments.test.ts b/ui/src/services/fragments.test.ts index b77290554..5f386ee3a 100644 --- a/ui/src/services/fragments.test.ts +++ b/ui/src/services/fragments.test.ts @@ -30,6 +30,12 @@ const FRAGMENT_SAMPLE: SamplePipeline = { is_system: false, mode: 'oneshot', 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 915f6fb0a..122170f71 100644 --- a/ui/src/services/samples.test.ts +++ b/ui/src/services/samples.test.ts @@ -60,6 +60,12 @@ const SAMPLE: SamplePipeline = { is_system: false, mode: 'oneshot', 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 781e5f112..88eb04de7 100644 --- a/ui/src/types/generated/api-types.ts +++ b/ui/src/types/generated/api-types.ts @@ -373,7 +373,34 @@ 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, +/** + * 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, +/** + * 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. + */ +search_terms: Array, }; export type SavePipelineRequest = { name: string, description: string, yaml: string, overwrite: boolean, /** 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 2d66bb4c3..0046f385d 100644 --- a/ui/src/utils/samplePipelineOrdering.test.ts +++ b/ui/src/utils/samplePipelineOrdering.test.ts @@ -7,20 +7,43 @@ import { describe, expect, it } from 'vitest'; import type { SamplePipeline } from '@/types/generated/api-types'; import { + collectSampleFacets, compareSamplePipelinesByName, + groupSamplePipelinesByScenario, matchesSamplePipelineQuery, orderSamplePipelinesSystemFirst, + 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, + variant, + canonical: partial.canonical ?? false, + category, + tags, + search_terms, }; } @@ -111,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); }); @@ -124,8 +147,135 @@ 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); }); + + 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('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(stt, 'tts')).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 encoding')).toBe(true); + expect(matchesSamplePipelineQuery(sample, 'vaapi audio')).toBe(false); + }); +}); + +describe('groupSamplePipelinesByScenario', () => { + const plain = makePipeline({ + id: 'd/colorbars', + name: 'Colorbars', + group: 'video-moq-colorbars', + variant: 'Software', + canonical: true, + }); + 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 the canonical member first', () => { + const groups = groupSamplePipelinesByScenario([h264, plain, vaapi]); + expect(groups).toHaveLength(1); + expect(groups[0].key).toBe('video-moq-colorbars'); + // 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(['Software', '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('returns groups in deterministic insertion order (callers re-sort for display)', () => { + 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: ['hardware:vaapi', 'colorbars'], + }), + makePipeline({ id: 'b', category: 'Speech to Text', tags: ['transcription'] }), + ]); + expect(facets.categories).toEqual(['Speech to Text', 'Video Encoding']); + 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 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'] }), + makePipeline({ + id: 'b', + category: 'Video Compositing', + tags: ['compositing', 'video-encoding'], + }), + ]); + expect(facets.categories).toEqual(['Video Compositing', 'Video Encoding']); + expect(facets.capabilities).toEqual(['compositing', 'video-encoding']); + }); + + it('reports no hardware when no hardware tags are present', () => { + const facets = collectSampleFacets([makePipeline({ id: 'a', tags: ['transcription'] })]); + expect(facets.hasHardware).toBe(false); + }); }); diff --git a/ui/src/utils/samplePipelineOrdering.ts b/ui/src/utils/samplePipelineOrdering.ts index 5507f57bb..8edcaa06f 100644 --- a/ui/src/utils/samplePipelineOrdering.ts +++ b/ui/src/utils/samplePipelineOrdering.ts @@ -37,14 +37,118 @@ export function orderSamplePipelinesSystemFirst(pipelines: SamplePipeline[]): Sa return [...system, ...user]; } +/** Splits a free-text query into lowercased, whitespace-delimited tokens. */ +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, 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 { + return haystackMatchesTokens(buildSearchHaystack(pipeline), tokens); +} + +/** Convenience wrapper that tokenizes `query` and matches a single pipeline. */ export function matchesSamplePipelineQuery(pipeline: SamplePipeline, query: string): boolean { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return true; + return matchesQueryTokens(pipeline, tokenizeQuery(query)); +} + +export interface ScenarioGroup { + /** Stable key shared by all variants (the pipeline `group`, or the id). */ + key: string; + /** Canonical member supplying the card title/description. */ + base: SamplePipeline; + /** All samples in the group, canonical member 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. + * Group ordering is the caller's responsibility (the picker sorts by name). + */ +export function groupSamplePipelinesByScenario(samples: SamplePipeline[]): ScenarioGroup[] { + 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]); + } + } + + 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; + 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.canonical) ?? 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 haystack = [pipeline.name, pipeline.description, pipeline.id] - .filter(Boolean) - .join(' ') - .toLowerCase(); + 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, + }; } diff --git a/ui/src/views/ConvertView.tsx b/ui/src/views/ConvertView.tsx index 3da95bd9b..56bb9bd86 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,41 @@ const ConvertButtonContainer = styled.div` justify-content: center; `; +const CustomizeActions = styled.div` + display: flex; + 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; + 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 +791,28 @@ const ConvertView: React.FC = () => { fetchSamples(); }, [setSamples, setSamplesLoading, setSamplesError, setSelectedTemplateId, setPipelineYaml]); + 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: yamlName ?? sample?.name ?? '', + description: yamlDescription ?? sample?.description ?? '', + }, + }); + }, [navigate, pipelineYaml, parsedPipelineYaml, samples, selectedTemplateId]); + const handleTemplateSelect = (templateId: string) => { const sample = samples.find((s) => s.id === templateId); if (sample) { @@ -1227,19 +1285,37 @@ const ConvertView: React.FC = () => { templates={samples} selectedTemplateId={selectedTemplateId} onTemplateSelect={handleTemplateSelect} + onSelectionHiddenChange={setSelectionHidden} /> )}
2. Customize Pipeline (Optional) - - - + {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 b7ded4578..b5c90a99c 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,9 @@ const DesignViewContent: React.FC = () => { getId, } = usePipeline(); + const location = useLocation(); + const handoffImportedRef = React.useRef(false); + const cachesRef = React.useRef>>({}); React.useEffect(() => { @@ -1330,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([]);