Skip to main content

oximedia_workflow/
workflow_template.rs

1//! Pre-built workflow templates for common media processing pipelines.
2//!
3//! Provides [`WorkflowTemplate`] with factory constructors for each
4//! [`WorkflowTemplateKind`], parameter substitution, DOT graph generation,
5//! and an `instantiate` method that returns a lightweight [`WorkflowInstance`].
6
7#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12// ── Template Kind ─────────────────────────────────────────────────────────────
13
14/// Discriminates which pre-built media pipeline template to use.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum WorkflowTemplateKind {
17    /// ingest → validate → transcode → deliver
18    IngestAndTranscode,
19    /// probe → quality-gate → approve/reject
20    QualityCheck,
21    /// transcode proxy → archive original → catalog
22    ArchiveAndProxy,
23    /// normalize → transcode → package → deliver
24    BroadcastDelivery,
25    /// transcode multiple formats → thumbnail → upload
26    SocialMediaPackage,
27    /// normalize audio → encode MP3+AAC → chapters → RSS
28    AudioPodcast,
29    /// quick proxy → caption → publish
30    NewsroomFast,
31    /// clip → transcode → metadata → CDN push
32    LiveEventClip,
33}
34
35impl WorkflowTemplateKind {
36    /// Human-readable display name.
37    #[must_use]
38    pub fn display_name(&self) -> &'static str {
39        match self {
40            Self::IngestAndTranscode => "Ingest and Transcode",
41            Self::QualityCheck => "Quality Check",
42            Self::ArchiveAndProxy => "Archive and Proxy",
43            Self::BroadcastDelivery => "Broadcast Delivery",
44            Self::SocialMediaPackage => "Social Media Package",
45            Self::AudioPodcast => "Audio Podcast",
46            Self::NewsroomFast => "Newsroom Fast",
47            Self::LiveEventClip => "Live Event Clip",
48        }
49    }
50}
51
52impl std::fmt::Display for WorkflowTemplateKind {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}", self.display_name())
55    }
56}
57
58// ── TemplateStep ──────────────────────────────────────────────────────────────
59
60/// A single step definition within a [`WorkflowTemplate`].
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TemplateStep {
63    /// Unique identifier for this step within the template.
64    pub id: String,
65    /// Human-readable name.
66    pub name: String,
67    /// Step type: "transcode", "normalize", "archive", "deliver", "validate", "notify", etc.
68    pub step_type: String,
69    /// IDs of steps that must complete before this step starts.
70    pub depends_on: Vec<String>,
71    /// Step-specific configuration. Values may contain `{param_name}` placeholders.
72    pub config: HashMap<String, String>,
73    /// Maximum wall-clock time allowed for the step (seconds).
74    pub timeout_secs: u64,
75    /// Number of automatic retry attempts on failure.
76    pub retry_count: u32,
77}
78
79impl TemplateStep {
80    /// Create a new step with no dependencies and sensible defaults.
81    #[must_use]
82    pub fn new(
83        id: impl Into<String>,
84        name: impl Into<String>,
85        step_type: impl Into<String>,
86    ) -> Self {
87        Self {
88            id: id.into(),
89            name: name.into(),
90            step_type: step_type.into(),
91            depends_on: Vec::new(),
92            config: HashMap::new(),
93            timeout_secs: 3600,
94            retry_count: 1,
95        }
96    }
97
98    /// Set dependencies.
99    #[must_use]
100    pub fn with_depends_on(mut self, deps: Vec<String>) -> Self {
101        self.depends_on = deps;
102        self
103    }
104
105    /// Insert a config entry.
106    #[must_use]
107    pub fn with_config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
108        self.config.insert(key.into(), value.into());
109        self
110    }
111
112    /// Set timeout.
113    #[must_use]
114    pub fn with_timeout(mut self, secs: u64) -> Self {
115        self.timeout_secs = secs;
116        self
117    }
118
119    /// Set retry count.
120    #[must_use]
121    pub fn with_retries(mut self, count: u32) -> Self {
122        self.retry_count = count;
123        self
124    }
125}
126
127// ── TemplateParameter ─────────────────────────────────────────────────────────
128
129/// A parameter that callers must or may supply when instantiating a template.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TemplateParameter {
132    /// Parameter name (used as `{name}` placeholder in step config values).
133    pub name: String,
134    /// Human-readable description shown to the caller.
135    pub description: String,
136    /// Optional default value used when the caller does not supply a value.
137    pub default_value: Option<String>,
138    /// Whether the caller must supply this parameter.
139    pub required: bool,
140}
141
142impl TemplateParameter {
143    /// Create a required parameter with no default.
144    #[must_use]
145    pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
146        Self {
147            name: name.into(),
148            description: description.into(),
149            default_value: None,
150            required: true,
151        }
152    }
153
154    /// Create an optional parameter with a default value.
155    #[must_use]
156    pub fn optional(
157        name: impl Into<String>,
158        description: impl Into<String>,
159        default: impl Into<String>,
160    ) -> Self {
161        Self {
162            name: name.into(),
163            description: description.into(),
164            default_value: Some(default.into()),
165            required: false,
166        }
167    }
168}
169
170// ── WorkflowInstance ──────────────────────────────────────────────────────────
171
172/// A concrete workflow produced by instantiating a [`WorkflowTemplate`].
173///
174/// Steps have had all `{param}` placeholders substituted with resolved values.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct WorkflowInstance {
177    /// Name inherited from the template.
178    pub name: String,
179    /// Kind of the originating template.
180    pub kind: WorkflowTemplateKind,
181    /// Resolved parameters (including defaults).
182    pub resolved_params: HashMap<String, String>,
183    /// Instantiated steps with substituted config values.
184    pub steps: Vec<TemplateStep>,
185}
186
187// ── WorkflowTemplate ─────────────────────────────────────────────────────────
188
189/// A pre-built workflow template for a common media processing pipeline.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct WorkflowTemplate {
192    /// The kind of pipeline this template implements.
193    pub kind: WorkflowTemplateKind,
194    /// Human-readable name.
195    pub name: String,
196    /// Longer description of what the pipeline does.
197    pub description: String,
198    /// Ordered list of steps (order determines DOT rendering; DAG edges come from `depends_on`).
199    pub steps: Vec<TemplateStep>,
200    /// Parameters that can be tuned at instantiation time.
201    pub parameters: Vec<TemplateParameter>,
202}
203
204impl WorkflowTemplate {
205    // ── Factory ───────────────────────────────────────────────────────────────
206
207    /// Construct a fully-configured template for the given kind.
208    #[must_use]
209    pub fn new(kind: WorkflowTemplateKind) -> Self {
210        match kind {
211            WorkflowTemplateKind::IngestAndTranscode => Self::build_ingest_and_transcode(),
212            WorkflowTemplateKind::QualityCheck => Self::build_quality_check(),
213            WorkflowTemplateKind::ArchiveAndProxy => Self::build_archive_and_proxy(),
214            WorkflowTemplateKind::BroadcastDelivery => Self::build_broadcast_delivery(),
215            WorkflowTemplateKind::SocialMediaPackage => Self::build_social_media_package(),
216            WorkflowTemplateKind::AudioPodcast => Self::build_audio_podcast(),
217            WorkflowTemplateKind::NewsroomFast => Self::build_newsroom_fast(),
218            WorkflowTemplateKind::LiveEventClip => Self::build_live_event_clip(),
219        }
220    }
221
222    /// Return one instance of each template kind.
223    #[must_use]
224    pub fn list_all() -> Vec<WorkflowTemplate> {
225        vec![
226            Self::new(WorkflowTemplateKind::IngestAndTranscode),
227            Self::new(WorkflowTemplateKind::QualityCheck),
228            Self::new(WorkflowTemplateKind::ArchiveAndProxy),
229            Self::new(WorkflowTemplateKind::BroadcastDelivery),
230            Self::new(WorkflowTemplateKind::SocialMediaPackage),
231            Self::new(WorkflowTemplateKind::AudioPodcast),
232            Self::new(WorkflowTemplateKind::NewsroomFast),
233            Self::new(WorkflowTemplateKind::LiveEventClip),
234        ]
235    }
236
237    // ── Instantiation ─────────────────────────────────────────────────────────
238
239    /// Substitute `{param_name}` placeholders in step config values and return
240    /// a [`WorkflowInstance`].
241    ///
242    /// # Errors
243    ///
244    /// Returns an error string when a required parameter is not present in
245    /// `params` and has no default.
246    pub fn instantiate(
247        &self,
248        params: &HashMap<String, String>,
249    ) -> Result<WorkflowInstance, String> {
250        // Build resolved parameter map (caller-supplied wins over default).
251        let mut resolved: HashMap<String, String> = HashMap::new();
252        for p in &self.parameters {
253            if let Some(v) = params.get(&p.name) {
254                resolved.insert(p.name.clone(), v.clone());
255            } else if let Some(d) = &p.default_value {
256                resolved.insert(p.name.clone(), d.clone());
257            } else if p.required {
258                return Err(format!("Required parameter '{}' was not provided", p.name));
259            }
260        }
261
262        // Deep-clone steps with substituted config.
263        let steps = self
264            .steps
265            .iter()
266            .map(|s| {
267                let config = s
268                    .config
269                    .iter()
270                    .map(|(k, v)| (k.clone(), substitute(v, &resolved)))
271                    .collect();
272                TemplateStep {
273                    id: s.id.clone(),
274                    name: s.name.clone(),
275                    step_type: s.step_type.clone(),
276                    depends_on: s.depends_on.clone(),
277                    config,
278                    timeout_secs: s.timeout_secs,
279                    retry_count: s.retry_count,
280                }
281            })
282            .collect();
283
284        Ok(WorkflowInstance {
285            name: self.name.clone(),
286            kind: self.kind.clone(),
287            resolved_params: resolved,
288            steps,
289        })
290    }
291
292    // ── DOT graph generation ──────────────────────────────────────────────────
293
294    /// Generate a Graphviz DOT representation of the step dependency graph.
295    #[must_use]
296    pub fn to_dot(&self) -> String {
297        let mut out = String::new();
298        out.push_str(&format!(
299            "digraph \"{}\" {{\n    rankdir=LR;\n    node [shape=box style=filled fillcolor=lightblue];\n",
300            escape_dot(&self.name)
301        ));
302
303        // Declare nodes.
304        for step in &self.steps {
305            out.push_str(&format!(
306                "    \"{}\" [label=\"{}\\n({})\"];\n",
307                escape_dot(&step.id),
308                escape_dot(&step.name),
309                escape_dot(&step.step_type),
310            ));
311        }
312
313        // Declare edges.
314        for step in &self.steps {
315            for dep in &step.depends_on {
316                out.push_str(&format!(
317                    "    \"{}\" -> \"{}\";\n",
318                    escape_dot(dep),
319                    escape_dot(&step.id),
320                ));
321            }
322        }
323
324        out.push('}');
325        out
326    }
327
328    // ── Private builders ──────────────────────────────────────────────────────
329
330    fn build_ingest_and_transcode() -> Self {
331        let steps = vec![
332            TemplateStep::new("ingest", "Ingest", "ingest")
333                .with_config("source", "{source_path}")
334                .with_timeout(600)
335                .with_retries(2),
336            TemplateStep::new("validate", "Validate", "validate")
337                .with_depends_on(vec!["ingest".to_string()])
338                .with_config("checks", "format,integrity,container")
339                .with_timeout(300)
340                .with_retries(1),
341            TemplateStep::new("transcode", "Transcode", "transcode")
342                .with_depends_on(vec!["validate".to_string()])
343                .with_config("preset", "{transcode_preset}")
344                .with_config("output", "{output_path}")
345                .with_timeout(7200)
346                .with_retries(1),
347            TemplateStep::new("deliver", "Deliver", "deliver")
348                .with_depends_on(vec!["transcode".to_string()])
349                .with_config("destination", "{delivery_destination}")
350                .with_config("verify_checksum", "true")
351                .with_timeout(1800)
352                .with_retries(3),
353        ];
354        let parameters = vec![
355            TemplateParameter::required("source_path", "Path to the source media file"),
356            TemplateParameter::required("output_path", "Path for the transcoded output"),
357            TemplateParameter::required("delivery_destination", "Delivery target (path or URL)"),
358            TemplateParameter::optional("transcode_preset", "Transcode quality preset", "web_h264"),
359        ];
360        Self {
361            kind: WorkflowTemplateKind::IngestAndTranscode,
362            name: "Ingest and Transcode".to_string(),
363            description: "Ingest source media, validate integrity, transcode to target format, and deliver to destination.".to_string(),
364            steps,
365            parameters,
366        }
367    }
368
369    fn build_quality_check() -> Self {
370        let tmp = std::env::temp_dir();
371        let steps = vec![
372            TemplateStep::new("probe", "Probe Media", "probe")
373                .with_config("input", "{media_path}")
374                .with_config("output_json", "{probe_output}")
375                .with_timeout(120)
376                .with_retries(1),
377            TemplateStep::new("quality_gate", "Quality Gate", "validate")
378                .with_depends_on(vec!["probe".to_string()])
379                .with_config("profile", "{qc_profile}")
380                .with_config("threshold", "{pass_threshold}")
381                .with_timeout(300)
382                .with_retries(0),
383            TemplateStep::new("decision", "Approve or Reject", "approve_reject")
384                .with_depends_on(vec!["quality_gate".to_string()])
385                .with_config("auto_approve_on_pass", "true")
386                .with_timeout(86400)
387                .with_retries(0),
388        ];
389        let parameters = vec![
390            TemplateParameter::required("media_path", "Path to the media file to check"),
391            TemplateParameter::optional(
392                "probe_output",
393                "Path for probe JSON output",
394                tmp.join("oximedia-probe.json")
395                    .to_string_lossy()
396                    .into_owned(),
397            ),
398            TemplateParameter::optional(
399                "qc_profile",
400                "QC profile (broadcast, streaming, cinema)",
401                "broadcast",
402            ),
403            TemplateParameter::optional(
404                "pass_threshold",
405                "Minimum score (0.0–1.0) to pass",
406                "0.95",
407            ),
408        ];
409        Self {
410            kind: WorkflowTemplateKind::QualityCheck,
411            name: "Quality Check".to_string(),
412            description:
413                "Probe media metadata, evaluate against a quality gate, then approve or reject."
414                    .to_string(),
415            steps,
416            parameters,
417        }
418    }
419
420    fn build_archive_and_proxy() -> Self {
421        let tmp = std::env::temp_dir();
422        let steps = vec![
423            TemplateStep::new("proxy", "Transcode Proxy", "transcode")
424                .with_config("input", "{source_path}")
425                .with_config("preset", "proxy_h264")
426                .with_config("output", "{proxy_output}")
427                .with_timeout(3600)
428                .with_retries(1),
429            TemplateStep::new("archive", "Archive Original", "archive")
430                .with_depends_on(vec!["proxy".to_string()])
431                .with_config("source", "{source_path}")
432                .with_config("destination", "{archive_path}")
433                .with_config("checksum", "sha256")
434                .with_timeout(7200)
435                .with_retries(2),
436            TemplateStep::new("catalog", "Catalog Entry", "catalog")
437                .with_depends_on(vec!["archive".to_string()])
438                .with_config("proxy_path", "{proxy_output}")
439                .with_config("archive_path", "{archive_path}")
440                .with_config("metadata_tags", "{metadata_tags}")
441                .with_timeout(300)
442                .with_retries(1),
443        ];
444        let parameters = vec![
445            TemplateParameter::required("source_path", "Path to the original media file"),
446            TemplateParameter::required("archive_path", "Long-term archive destination path"),
447            TemplateParameter::optional(
448                "proxy_output",
449                "Path for the proxy file",
450                tmp.join("oximedia-proxy.mp4")
451                    .to_string_lossy()
452                    .into_owned(),
453            ),
454            TemplateParameter::optional("metadata_tags", "Comma-separated metadata tags", ""),
455        ];
456        Self {
457            kind: WorkflowTemplateKind::ArchiveAndProxy,
458            name: "Archive and Proxy".to_string(),
459            description: "Generate a proxy file, archive the original to long-term storage, and catalog both.".to_string(),
460            steps,
461            parameters,
462        }
463    }
464
465    fn build_broadcast_delivery() -> Self {
466        let tmp = std::env::temp_dir();
467        let steps = vec![
468            TemplateStep::new("normalize", "Normalize Audio/Video", "normalize")
469                .with_config("input", "{source_path}")
470                .with_config("loudness_target", "{loudness_lufs}")
471                .with_timeout(1800)
472                .with_retries(1),
473            TemplateStep::new("transcode", "Transcode", "transcode")
474                .with_depends_on(vec!["normalize".to_string()])
475                .with_config("preset", "{broadcast_preset}")
476                .with_config("output", "{work_path}")
477                .with_timeout(7200)
478                .with_retries(1),
479            TemplateStep::new("package", "Package", "package")
480                .with_depends_on(vec!["transcode".to_string()])
481                .with_config("format", "{package_format}")
482                .with_config("output", "{package_output}")
483                .with_timeout(1800)
484                .with_retries(1),
485            TemplateStep::new("deliver", "Deliver", "deliver")
486                .with_depends_on(vec!["package".to_string()])
487                .with_config("destination", "{broadcast_destination}")
488                .with_config("protocol", "ftp")
489                .with_timeout(3600)
490                .with_retries(3),
491        ];
492        let parameters = vec![
493            TemplateParameter::required("source_path", "Source media path"),
494            TemplateParameter::required("broadcast_destination", "FTP/delivery endpoint URL"),
495            TemplateParameter::optional("loudness_lufs", "Target loudness in LUFS", "-23"),
496            TemplateParameter::optional(
497                "broadcast_preset",
498                "Broadcast transcode preset",
499                "broadcast_hd",
500            ),
501            TemplateParameter::optional("package_format", "Packaging format (mxf, ts, mp4)", "mxf"),
502            TemplateParameter::optional(
503                "package_output",
504                "Packaged output path",
505                tmp.join("oximedia-broadcast.mxf")
506                    .to_string_lossy()
507                    .into_owned(),
508            ),
509            TemplateParameter::optional(
510                "work_path",
511                "Intermediate transcode output path",
512                tmp.join("oximedia-broadcast-work.mxf")
513                    .to_string_lossy()
514                    .into_owned(),
515            ),
516        ];
517        Self {
518            kind: WorkflowTemplateKind::BroadcastDelivery,
519            name: "Broadcast Delivery".to_string(),
520            description: "Normalize levels, transcode to broadcast spec, package, and deliver."
521                .to_string(),
522            steps,
523            parameters,
524        }
525    }
526
527    fn build_social_media_package() -> Self {
528        let tmp = std::env::temp_dir();
529        let steps = vec![
530            TemplateStep::new("transcode_16_9", "Transcode 16:9", "transcode")
531                .with_config("input", "{source_path}")
532                .with_config("aspect", "16:9")
533                .with_config("output", "{output_16_9}")
534                .with_timeout(3600)
535                .with_retries(1),
536            TemplateStep::new("transcode_1_1", "Transcode 1:1 (Square)", "transcode")
537                .with_config("input", "{source_path}")
538                .with_config("aspect", "1:1")
539                .with_config("output", "{output_1_1}")
540                .with_timeout(3600)
541                .with_retries(1),
542            TemplateStep::new("transcode_9_16", "Transcode 9:16 (Vertical)", "transcode")
543                .with_config("input", "{source_path}")
544                .with_config("aspect", "9:16")
545                .with_config("output", "{output_9_16}")
546                .with_timeout(3600)
547                .with_retries(1),
548            TemplateStep::new("thumbnail", "Generate Thumbnail", "thumbnail")
549                .with_depends_on(vec!["transcode_16_9".to_string()])
550                .with_config("input", "{output_16_9}")
551                .with_config("output", "{thumbnail_path}")
552                .with_timeout(120)
553                .with_retries(1),
554            TemplateStep::new("upload", "Upload to Platform", "upload")
555                .with_depends_on(vec![
556                    "transcode_16_9".to_string(),
557                    "transcode_1_1".to_string(),
558                    "transcode_9_16".to_string(),
559                    "thumbnail".to_string(),
560                ])
561                .with_config("platform", "{platform}")
562                .with_config("api_key", "{platform_api_key}")
563                .with_timeout(3600)
564                .with_retries(2),
565        ];
566        let parameters = vec![
567            TemplateParameter::required("source_path", "Source video path"),
568            TemplateParameter::required("platform", "Social platform (youtube, instagram, tiktok)"),
569            TemplateParameter::required("platform_api_key", "Platform API key for upload"),
570            TemplateParameter::optional(
571                "output_16_9",
572                "16:9 output path",
573                tmp.join("oximedia-social-16-9.mp4")
574                    .to_string_lossy()
575                    .into_owned(),
576            ),
577            TemplateParameter::optional(
578                "output_1_1",
579                "1:1 output path",
580                tmp.join("oximedia-social-1-1.mp4")
581                    .to_string_lossy()
582                    .into_owned(),
583            ),
584            TemplateParameter::optional(
585                "output_9_16",
586                "9:16 output path",
587                tmp.join("oximedia-social-9-16.mp4")
588                    .to_string_lossy()
589                    .into_owned(),
590            ),
591            TemplateParameter::optional(
592                "thumbnail_path",
593                "Thumbnail image path",
594                tmp.join("oximedia-thumbnail.jpg")
595                    .to_string_lossy()
596                    .into_owned(),
597            ),
598        ];
599        Self {
600            kind: WorkflowTemplateKind::SocialMediaPackage,
601            name: "Social Media Package".to_string(),
602            description:
603                "Transcode to 16:9, 1:1, and 9:16 aspect ratios, generate a thumbnail, then upload."
604                    .to_string(),
605            steps,
606            parameters,
607        }
608    }
609
610    fn build_audio_podcast() -> Self {
611        let tmp = std::env::temp_dir();
612        let steps = vec![
613            TemplateStep::new("normalize_audio", "Normalize Audio", "normalize")
614                .with_config("input", "{source_audio}")
615                .with_config("loudness_target", "{loudness_lufs}")
616                .with_timeout(600)
617                .with_retries(1),
618            TemplateStep::new("encode_mp3", "Encode MP3", "transcode")
619                .with_depends_on(vec!["normalize_audio".to_string()])
620                .with_config("codec", "mp3")
621                .with_config("bitrate", "{mp3_bitrate}")
622                .with_config("output", "{mp3_output}")
623                .with_timeout(600)
624                .with_retries(1),
625            TemplateStep::new("encode_aac", "Encode AAC", "transcode")
626                .with_depends_on(vec!["normalize_audio".to_string()])
627                .with_config("codec", "aac")
628                .with_config("bitrate", "{aac_bitrate}")
629                .with_config("output", "{aac_output}")
630                .with_timeout(600)
631                .with_retries(1),
632            TemplateStep::new("chapters", "Embed Chapters", "metadata")
633                .with_depends_on(vec!["encode_mp3".to_string(), "encode_aac".to_string()])
634                .with_config("chapter_file", "{chapter_file}")
635                .with_timeout(120)
636                .with_retries(1),
637            TemplateStep::new("rss", "Publish RSS", "notify")
638                .with_depends_on(vec!["chapters".to_string()])
639                .with_config("feed_url", "{rss_feed_url}")
640                .with_config("title", "{episode_title}")
641                .with_timeout(300)
642                .with_retries(2),
643        ];
644        let parameters = vec![
645            TemplateParameter::required("source_audio", "Source audio file path"),
646            TemplateParameter::required("rss_feed_url", "RSS feed endpoint URL"),
647            TemplateParameter::required("episode_title", "Podcast episode title"),
648            TemplateParameter::optional("loudness_lufs", "Target loudness in LUFS", "-16"),
649            TemplateParameter::optional("mp3_bitrate", "MP3 bitrate (kbps)", "128k"),
650            TemplateParameter::optional("aac_bitrate", "AAC bitrate (kbps)", "96k"),
651            TemplateParameter::optional(
652                "mp3_output",
653                "MP3 output path",
654                tmp.join("oximedia-episode.mp3")
655                    .to_string_lossy()
656                    .into_owned(),
657            ),
658            TemplateParameter::optional(
659                "aac_output",
660                "AAC output path",
661                tmp.join("oximedia-episode.m4a")
662                    .to_string_lossy()
663                    .into_owned(),
664            ),
665            TemplateParameter::optional("chapter_file", "Chapter marker file path", ""),
666        ];
667        Self {
668            kind: WorkflowTemplateKind::AudioPodcast,
669            name: "Audio Podcast".to_string(),
670            description:
671                "Normalize audio, encode to MP3 and AAC, embed chapters, and publish RSS feed."
672                    .to_string(),
673            steps,
674            parameters,
675        }
676    }
677
678    fn build_newsroom_fast() -> Self {
679        let tmp = std::env::temp_dir();
680        let steps = vec![
681            TemplateStep::new("proxy", "Quick Proxy", "transcode")
682                .with_config("input", "{source_path}")
683                .with_config("preset", "proxy_fast")
684                .with_config("output", "{proxy_output}")
685                .with_timeout(300)
686                .with_retries(1),
687            TemplateStep::new("caption", "Generate Captions", "caption")
688                .with_depends_on(vec!["proxy".to_string()])
689                .with_config("input", "{proxy_output}")
690                .with_config("language", "{caption_language}")
691                .with_config("output", "{caption_file}")
692                .with_timeout(600)
693                .with_retries(1),
694            TemplateStep::new("publish", "Publish", "deliver")
695                .with_depends_on(vec!["caption".to_string()])
696                .with_config("destination", "{publish_destination}")
697                .with_config("caption_file", "{caption_file}")
698                .with_timeout(300)
699                .with_retries(2),
700        ];
701        let parameters = vec![
702            TemplateParameter::required("source_path", "Source media file path"),
703            TemplateParameter::required("publish_destination", "Publishing endpoint URL"),
704            TemplateParameter::optional(
705                "proxy_output",
706                "Proxy output path",
707                tmp.join("oximedia-proxy-fast.mp4")
708                    .to_string_lossy()
709                    .into_owned(),
710            ),
711            TemplateParameter::optional("caption_language", "Caption language code", "en"),
712            TemplateParameter::optional(
713                "caption_file",
714                "Caption file output path",
715                tmp.join("oximedia-captions.vtt")
716                    .to_string_lossy()
717                    .into_owned(),
718            ),
719        ];
720        Self {
721            kind: WorkflowTemplateKind::NewsroomFast,
722            name: "Newsroom Fast".to_string(),
723            description: "Rapid proxy generation, automatic captioning, and immediate publish."
724                .to_string(),
725            steps,
726            parameters,
727        }
728    }
729
730    fn build_live_event_clip() -> Self {
731        let tmp = std::env::temp_dir();
732        let steps = vec![
733            TemplateStep::new("clip", "Clip Segment", "clip")
734                .with_config("input", "{source_path}")
735                .with_config("start_tc", "{clip_start}")
736                .with_config("end_tc", "{clip_end}")
737                .with_config("output", "{clip_output}")
738                .with_timeout(600)
739                .with_retries(1),
740            TemplateStep::new("transcode", "Transcode Clip", "transcode")
741                .with_depends_on(vec!["clip".to_string()])
742                .with_config("input", "{clip_output}")
743                .with_config("preset", "{clip_preset}")
744                .with_config("output", "{transcode_output}")
745                .with_timeout(1800)
746                .with_retries(1),
747            TemplateStep::new("metadata", "Embed Metadata", "metadata")
748                .with_depends_on(vec!["transcode".to_string()])
749                .with_config("input", "{transcode_output}")
750                .with_config("title", "{clip_title}")
751                .with_config("event", "{event_name}")
752                .with_timeout(120)
753                .with_retries(1),
754            TemplateStep::new("cdn_push", "CDN Push", "deliver")
755                .with_depends_on(vec!["metadata".to_string()])
756                .with_config("destination", "{cdn_url}")
757                .with_config("protocol", "https")
758                .with_timeout(1800)
759                .with_retries(3),
760        ];
761        let parameters = vec![
762            TemplateParameter::required("source_path", "Live recording or VOD source path"),
763            TemplateParameter::required("cdn_url", "CDN ingest URL"),
764            TemplateParameter::required("clip_start", "Clip start timecode (HH:MM:SS or frames)"),
765            TemplateParameter::required("clip_end", "Clip end timecode (HH:MM:SS or frames)"),
766            TemplateParameter::optional(
767                "clip_output",
768                "Raw clip output path",
769                tmp.join("oximedia-raw-clip.mp4")
770                    .to_string_lossy()
771                    .into_owned(),
772            ),
773            TemplateParameter::optional(
774                "transcode_output",
775                "Transcoded clip output path",
776                tmp.join("oximedia-clip.mp4").to_string_lossy().into_owned(),
777            ),
778            TemplateParameter::optional("clip_preset", "Transcode preset for clip", "web_h264"),
779            TemplateParameter::optional("clip_title", "Clip title for metadata", ""),
780            TemplateParameter::optional("event_name", "Event name for metadata", ""),
781        ];
782        Self {
783            kind: WorkflowTemplateKind::LiveEventClip,
784            name: "Live Event Clip".to_string(),
785            description: "Extract a timed clip, transcode, embed metadata, and push to CDN."
786                .to_string(),
787            steps,
788            parameters,
789        }
790    }
791}
792
793// ── Helpers ───────────────────────────────────────────────────────────────────
794
795/// Replace `{param_name}` placeholders in `value` using `params`.
796fn substitute(value: &str, params: &HashMap<String, String>) -> String {
797    let mut out = value.to_string();
798    for (k, v) in params {
799        out = out.replace(&format!("{{{k}}}"), v);
800    }
801    out
802}
803
804/// Escape a string for use in a DOT label or node name.
805fn escape_dot(s: &str) -> String {
806    s.replace('\\', "\\\\")
807        .replace('"', "\\\"")
808        .replace('\n', "\\n")
809}
810
811// ── Tests ─────────────────────────────────────────────────────────────────────
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    fn full_params_ingest() -> HashMap<String, String> {
818        let mut m = HashMap::new();
819        m.insert("source_path".into(), "/mnt/src/video.mxf".into());
820        m.insert("output_path".into(), "/mnt/out/video.mp4".into());
821        m.insert(
822            "delivery_destination".into(),
823            "ftp://server/delivery/".into(),
824        );
825        m
826    }
827
828    #[test]
829    fn test_list_all_returns_eight_templates() {
830        let all = WorkflowTemplate::list_all();
831        assert_eq!(all.len(), 8);
832    }
833
834    #[test]
835    fn test_ingest_and_transcode_has_four_steps() {
836        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
837        assert_eq!(t.steps.len(), 4);
838    }
839
840    #[test]
841    fn test_quality_check_has_three_steps() {
842        let t = WorkflowTemplate::new(WorkflowTemplateKind::QualityCheck);
843        assert_eq!(t.steps.len(), 3);
844    }
845
846    #[test]
847    fn test_archive_and_proxy_has_three_steps() {
848        let t = WorkflowTemplate::new(WorkflowTemplateKind::ArchiveAndProxy);
849        assert_eq!(t.steps.len(), 3);
850    }
851
852    #[test]
853    fn test_broadcast_delivery_has_four_steps() {
854        let t = WorkflowTemplate::new(WorkflowTemplateKind::BroadcastDelivery);
855        assert_eq!(t.steps.len(), 4);
856    }
857
858    #[test]
859    fn test_social_media_package_has_five_steps() {
860        let t = WorkflowTemplate::new(WorkflowTemplateKind::SocialMediaPackage);
861        assert_eq!(t.steps.len(), 5);
862    }
863
864    #[test]
865    fn test_audio_podcast_has_five_steps() {
866        let t = WorkflowTemplate::new(WorkflowTemplateKind::AudioPodcast);
867        assert_eq!(t.steps.len(), 5);
868    }
869
870    #[test]
871    fn test_newsroom_fast_has_three_steps() {
872        let t = WorkflowTemplate::new(WorkflowTemplateKind::NewsroomFast);
873        assert_eq!(t.steps.len(), 3);
874    }
875
876    #[test]
877    fn test_live_event_clip_has_four_steps() {
878        let t = WorkflowTemplate::new(WorkflowTemplateKind::LiveEventClip);
879        assert_eq!(t.steps.len(), 4);
880    }
881
882    #[test]
883    fn test_instantiate_substitutes_placeholders() {
884        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
885        let instance = t
886            .instantiate(&full_params_ingest())
887            .expect("instantiate should succeed");
888        let ingest_step = instance
889            .steps
890            .iter()
891            .find(|s| s.id == "ingest")
892            .expect("ingest step must exist");
893        assert_eq!(
894            ingest_step.config.get("source").map(String::as_str),
895            Some("/mnt/src/video.mxf")
896        );
897    }
898
899    #[test]
900    fn test_instantiate_missing_required_param_returns_err() {
901        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
902        let result = t.instantiate(&HashMap::new());
903        assert!(result.is_err());
904    }
905
906    #[test]
907    fn test_instantiate_optional_defaults_applied() {
908        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
909        let instance = t
910            .instantiate(&full_params_ingest())
911            .expect("instantiate should succeed");
912        assert_eq!(
913            instance
914                .resolved_params
915                .get("transcode_preset")
916                .map(String::as_str),
917            Some("web_h264")
918        );
919    }
920
921    #[test]
922    fn test_to_dot_contains_digraph() {
923        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
924        let dot = t.to_dot();
925        assert!(dot.contains("digraph"));
926        assert!(dot.contains("rankdir=LR"));
927    }
928
929    #[test]
930    fn test_to_dot_contains_step_ids() {
931        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
932        let dot = t.to_dot();
933        assert!(dot.contains("ingest"));
934        assert!(dot.contains("validate"));
935        assert!(dot.contains("transcode"));
936        assert!(dot.contains("deliver"));
937    }
938
939    #[test]
940    fn test_to_dot_contains_edges() {
941        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
942        let dot = t.to_dot();
943        // Deliver depends on transcode → edge transcode -> deliver
944        assert!(dot.contains("->"));
945    }
946
947    #[test]
948    fn test_workflow_template_kind_display() {
949        assert_eq!(
950            WorkflowTemplateKind::IngestAndTranscode.to_string(),
951            "Ingest and Transcode"
952        );
953        assert_eq!(
954            WorkflowTemplateKind::AudioPodcast.to_string(),
955            "Audio Podcast"
956        );
957    }
958}