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 steps = vec![
371            TemplateStep::new("probe", "Probe Media", "probe")
372                .with_config("input", "{media_path}")
373                .with_config("output_json", "{probe_output}")
374                .with_timeout(120)
375                .with_retries(1),
376            TemplateStep::new("quality_gate", "Quality Gate", "validate")
377                .with_depends_on(vec!["probe".to_string()])
378                .with_config("profile", "{qc_profile}")
379                .with_config("threshold", "{pass_threshold}")
380                .with_timeout(300)
381                .with_retries(0),
382            TemplateStep::new("decision", "Approve or Reject", "approve_reject")
383                .with_depends_on(vec!["quality_gate".to_string()])
384                .with_config("auto_approve_on_pass", "true")
385                .with_timeout(86400)
386                .with_retries(0),
387        ];
388        let parameters = vec![
389            TemplateParameter::required("media_path", "Path to the media file to check"),
390            TemplateParameter::optional(
391                "probe_output",
392                "Path for probe JSON output",
393                "/tmp/probe.json",
394            ),
395            TemplateParameter::optional(
396                "qc_profile",
397                "QC profile (broadcast, streaming, cinema)",
398                "broadcast",
399            ),
400            TemplateParameter::optional(
401                "pass_threshold",
402                "Minimum score (0.0–1.0) to pass",
403                "0.95",
404            ),
405        ];
406        Self {
407            kind: WorkflowTemplateKind::QualityCheck,
408            name: "Quality Check".to_string(),
409            description:
410                "Probe media metadata, evaluate against a quality gate, then approve or reject."
411                    .to_string(),
412            steps,
413            parameters,
414        }
415    }
416
417    fn build_archive_and_proxy() -> Self {
418        let steps = vec![
419            TemplateStep::new("proxy", "Transcode Proxy", "transcode")
420                .with_config("input", "{source_path}")
421                .with_config("preset", "proxy_h264")
422                .with_config("output", "{proxy_output}")
423                .with_timeout(3600)
424                .with_retries(1),
425            TemplateStep::new("archive", "Archive Original", "archive")
426                .with_depends_on(vec!["proxy".to_string()])
427                .with_config("source", "{source_path}")
428                .with_config("destination", "{archive_path}")
429                .with_config("checksum", "sha256")
430                .with_timeout(7200)
431                .with_retries(2),
432            TemplateStep::new("catalog", "Catalog Entry", "catalog")
433                .with_depends_on(vec!["archive".to_string()])
434                .with_config("proxy_path", "{proxy_output}")
435                .with_config("archive_path", "{archive_path}")
436                .with_config("metadata_tags", "{metadata_tags}")
437                .with_timeout(300)
438                .with_retries(1),
439        ];
440        let parameters = vec![
441            TemplateParameter::required("source_path", "Path to the original media file"),
442            TemplateParameter::required("archive_path", "Long-term archive destination path"),
443            TemplateParameter::optional(
444                "proxy_output",
445                "Path for the proxy file",
446                "/tmp/proxy.mp4",
447            ),
448            TemplateParameter::optional("metadata_tags", "Comma-separated metadata tags", ""),
449        ];
450        Self {
451            kind: WorkflowTemplateKind::ArchiveAndProxy,
452            name: "Archive and Proxy".to_string(),
453            description: "Generate a proxy file, archive the original to long-term storage, and catalog both.".to_string(),
454            steps,
455            parameters,
456        }
457    }
458
459    fn build_broadcast_delivery() -> Self {
460        let steps = vec![
461            TemplateStep::new("normalize", "Normalize Audio/Video", "normalize")
462                .with_config("input", "{source_path}")
463                .with_config("loudness_target", "{loudness_lufs}")
464                .with_timeout(1800)
465                .with_retries(1),
466            TemplateStep::new("transcode", "Transcode", "transcode")
467                .with_depends_on(vec!["normalize".to_string()])
468                .with_config("preset", "{broadcast_preset}")
469                .with_config("output", "{work_path}")
470                .with_timeout(7200)
471                .with_retries(1),
472            TemplateStep::new("package", "Package", "package")
473                .with_depends_on(vec!["transcode".to_string()])
474                .with_config("format", "{package_format}")
475                .with_config("output", "{package_output}")
476                .with_timeout(1800)
477                .with_retries(1),
478            TemplateStep::new("deliver", "Deliver", "deliver")
479                .with_depends_on(vec!["package".to_string()])
480                .with_config("destination", "{broadcast_destination}")
481                .with_config("protocol", "ftp")
482                .with_timeout(3600)
483                .with_retries(3),
484        ];
485        let parameters = vec![
486            TemplateParameter::required("source_path", "Source media path"),
487            TemplateParameter::required("broadcast_destination", "FTP/delivery endpoint URL"),
488            TemplateParameter::optional("loudness_lufs", "Target loudness in LUFS", "-23"),
489            TemplateParameter::optional(
490                "broadcast_preset",
491                "Broadcast transcode preset",
492                "broadcast_hd",
493            ),
494            TemplateParameter::optional("package_format", "Packaging format (mxf, ts, mp4)", "mxf"),
495            TemplateParameter::optional(
496                "package_output",
497                "Packaged output path",
498                "/tmp/broadcast.mxf",
499            ),
500            TemplateParameter::optional(
501                "work_path",
502                "Intermediate transcode output path",
503                "/tmp/broadcast_work.mxf",
504            ),
505        ];
506        Self {
507            kind: WorkflowTemplateKind::BroadcastDelivery,
508            name: "Broadcast Delivery".to_string(),
509            description: "Normalize levels, transcode to broadcast spec, package, and deliver."
510                .to_string(),
511            steps,
512            parameters,
513        }
514    }
515
516    fn build_social_media_package() -> Self {
517        let steps = vec![
518            TemplateStep::new("transcode_16_9", "Transcode 16:9", "transcode")
519                .with_config("input", "{source_path}")
520                .with_config("aspect", "16:9")
521                .with_config("output", "{output_16_9}")
522                .with_timeout(3600)
523                .with_retries(1),
524            TemplateStep::new("transcode_1_1", "Transcode 1:1 (Square)", "transcode")
525                .with_config("input", "{source_path}")
526                .with_config("aspect", "1:1")
527                .with_config("output", "{output_1_1}")
528                .with_timeout(3600)
529                .with_retries(1),
530            TemplateStep::new("transcode_9_16", "Transcode 9:16 (Vertical)", "transcode")
531                .with_config("input", "{source_path}")
532                .with_config("aspect", "9:16")
533                .with_config("output", "{output_9_16}")
534                .with_timeout(3600)
535                .with_retries(1),
536            TemplateStep::new("thumbnail", "Generate Thumbnail", "thumbnail")
537                .with_depends_on(vec!["transcode_16_9".to_string()])
538                .with_config("input", "{output_16_9}")
539                .with_config("output", "{thumbnail_path}")
540                .with_timeout(120)
541                .with_retries(1),
542            TemplateStep::new("upload", "Upload to Platform", "upload")
543                .with_depends_on(vec![
544                    "transcode_16_9".to_string(),
545                    "transcode_1_1".to_string(),
546                    "transcode_9_16".to_string(),
547                    "thumbnail".to_string(),
548                ])
549                .with_config("platform", "{platform}")
550                .with_config("api_key", "{platform_api_key}")
551                .with_timeout(3600)
552                .with_retries(2),
553        ];
554        let parameters = vec![
555            TemplateParameter::required("source_path", "Source video path"),
556            TemplateParameter::required("platform", "Social platform (youtube, instagram, tiktok)"),
557            TemplateParameter::required("platform_api_key", "Platform API key for upload"),
558            TemplateParameter::optional("output_16_9", "16:9 output path", "/tmp/social_16_9.mp4"),
559            TemplateParameter::optional("output_1_1", "1:1 output path", "/tmp/social_1_1.mp4"),
560            TemplateParameter::optional("output_9_16", "9:16 output path", "/tmp/social_9_16.mp4"),
561            TemplateParameter::optional(
562                "thumbnail_path",
563                "Thumbnail image path",
564                "/tmp/thumbnail.jpg",
565            ),
566        ];
567        Self {
568            kind: WorkflowTemplateKind::SocialMediaPackage,
569            name: "Social Media Package".to_string(),
570            description:
571                "Transcode to 16:9, 1:1, and 9:16 aspect ratios, generate a thumbnail, then upload."
572                    .to_string(),
573            steps,
574            parameters,
575        }
576    }
577
578    fn build_audio_podcast() -> Self {
579        let steps = vec![
580            TemplateStep::new("normalize_audio", "Normalize Audio", "normalize")
581                .with_config("input", "{source_audio}")
582                .with_config("loudness_target", "{loudness_lufs}")
583                .with_timeout(600)
584                .with_retries(1),
585            TemplateStep::new("encode_mp3", "Encode MP3", "transcode")
586                .with_depends_on(vec!["normalize_audio".to_string()])
587                .with_config("codec", "mp3")
588                .with_config("bitrate", "{mp3_bitrate}")
589                .with_config("output", "{mp3_output}")
590                .with_timeout(600)
591                .with_retries(1),
592            TemplateStep::new("encode_aac", "Encode AAC", "transcode")
593                .with_depends_on(vec!["normalize_audio".to_string()])
594                .with_config("codec", "aac")
595                .with_config("bitrate", "{aac_bitrate}")
596                .with_config("output", "{aac_output}")
597                .with_timeout(600)
598                .with_retries(1),
599            TemplateStep::new("chapters", "Embed Chapters", "metadata")
600                .with_depends_on(vec!["encode_mp3".to_string(), "encode_aac".to_string()])
601                .with_config("chapter_file", "{chapter_file}")
602                .with_timeout(120)
603                .with_retries(1),
604            TemplateStep::new("rss", "Publish RSS", "notify")
605                .with_depends_on(vec!["chapters".to_string()])
606                .with_config("feed_url", "{rss_feed_url}")
607                .with_config("title", "{episode_title}")
608                .with_timeout(300)
609                .with_retries(2),
610        ];
611        let parameters = vec![
612            TemplateParameter::required("source_audio", "Source audio file path"),
613            TemplateParameter::required("rss_feed_url", "RSS feed endpoint URL"),
614            TemplateParameter::required("episode_title", "Podcast episode title"),
615            TemplateParameter::optional("loudness_lufs", "Target loudness in LUFS", "-16"),
616            TemplateParameter::optional("mp3_bitrate", "MP3 bitrate (kbps)", "128k"),
617            TemplateParameter::optional("aac_bitrate", "AAC bitrate (kbps)", "96k"),
618            TemplateParameter::optional("mp3_output", "MP3 output path", "/tmp/episode.mp3"),
619            TemplateParameter::optional("aac_output", "AAC output path", "/tmp/episode.m4a"),
620            TemplateParameter::optional("chapter_file", "Chapter marker file path", ""),
621        ];
622        Self {
623            kind: WorkflowTemplateKind::AudioPodcast,
624            name: "Audio Podcast".to_string(),
625            description:
626                "Normalize audio, encode to MP3 and AAC, embed chapters, and publish RSS feed."
627                    .to_string(),
628            steps,
629            parameters,
630        }
631    }
632
633    fn build_newsroom_fast() -> Self {
634        let steps = vec![
635            TemplateStep::new("proxy", "Quick Proxy", "transcode")
636                .with_config("input", "{source_path}")
637                .with_config("preset", "proxy_fast")
638                .with_config("output", "{proxy_output}")
639                .with_timeout(300)
640                .with_retries(1),
641            TemplateStep::new("caption", "Generate Captions", "caption")
642                .with_depends_on(vec!["proxy".to_string()])
643                .with_config("input", "{proxy_output}")
644                .with_config("language", "{caption_language}")
645                .with_config("output", "{caption_file}")
646                .with_timeout(600)
647                .with_retries(1),
648            TemplateStep::new("publish", "Publish", "deliver")
649                .with_depends_on(vec!["caption".to_string()])
650                .with_config("destination", "{publish_destination}")
651                .with_config("caption_file", "{caption_file}")
652                .with_timeout(300)
653                .with_retries(2),
654        ];
655        let parameters = vec![
656            TemplateParameter::required("source_path", "Source media file path"),
657            TemplateParameter::required("publish_destination", "Publishing endpoint URL"),
658            TemplateParameter::optional("proxy_output", "Proxy output path", "/tmp/proxy_fast.mp4"),
659            TemplateParameter::optional("caption_language", "Caption language code", "en"),
660            TemplateParameter::optional(
661                "caption_file",
662                "Caption file output path",
663                "/tmp/captions.vtt",
664            ),
665        ];
666        Self {
667            kind: WorkflowTemplateKind::NewsroomFast,
668            name: "Newsroom Fast".to_string(),
669            description: "Rapid proxy generation, automatic captioning, and immediate publish."
670                .to_string(),
671            steps,
672            parameters,
673        }
674    }
675
676    fn build_live_event_clip() -> Self {
677        let steps = vec![
678            TemplateStep::new("clip", "Clip Segment", "clip")
679                .with_config("input", "{source_path}")
680                .with_config("start_tc", "{clip_start}")
681                .with_config("end_tc", "{clip_end}")
682                .with_config("output", "{clip_output}")
683                .with_timeout(600)
684                .with_retries(1),
685            TemplateStep::new("transcode", "Transcode Clip", "transcode")
686                .with_depends_on(vec!["clip".to_string()])
687                .with_config("input", "{clip_output}")
688                .with_config("preset", "{clip_preset}")
689                .with_config("output", "{transcode_output}")
690                .with_timeout(1800)
691                .with_retries(1),
692            TemplateStep::new("metadata", "Embed Metadata", "metadata")
693                .with_depends_on(vec!["transcode".to_string()])
694                .with_config("input", "{transcode_output}")
695                .with_config("title", "{clip_title}")
696                .with_config("event", "{event_name}")
697                .with_timeout(120)
698                .with_retries(1),
699            TemplateStep::new("cdn_push", "CDN Push", "deliver")
700                .with_depends_on(vec!["metadata".to_string()])
701                .with_config("destination", "{cdn_url}")
702                .with_config("protocol", "https")
703                .with_timeout(1800)
704                .with_retries(3),
705        ];
706        let parameters = vec![
707            TemplateParameter::required("source_path", "Live recording or VOD source path"),
708            TemplateParameter::required("cdn_url", "CDN ingest URL"),
709            TemplateParameter::required("clip_start", "Clip start timecode (HH:MM:SS or frames)"),
710            TemplateParameter::required("clip_end", "Clip end timecode (HH:MM:SS or frames)"),
711            TemplateParameter::optional("clip_output", "Raw clip output path", "/tmp/raw_clip.mp4"),
712            TemplateParameter::optional(
713                "transcode_output",
714                "Transcoded clip output path",
715                "/tmp/clip.mp4",
716            ),
717            TemplateParameter::optional("clip_preset", "Transcode preset for clip", "web_h264"),
718            TemplateParameter::optional("clip_title", "Clip title for metadata", ""),
719            TemplateParameter::optional("event_name", "Event name for metadata", ""),
720        ];
721        Self {
722            kind: WorkflowTemplateKind::LiveEventClip,
723            name: "Live Event Clip".to_string(),
724            description: "Extract a timed clip, transcode, embed metadata, and push to CDN."
725                .to_string(),
726            steps,
727            parameters,
728        }
729    }
730}
731
732// ── Helpers ───────────────────────────────────────────────────────────────────
733
734/// Replace `{param_name}` placeholders in `value` using `params`.
735fn substitute(value: &str, params: &HashMap<String, String>) -> String {
736    let mut out = value.to_string();
737    for (k, v) in params {
738        out = out.replace(&format!("{{{k}}}"), v);
739    }
740    out
741}
742
743/// Escape a string for use in a DOT label or node name.
744fn escape_dot(s: &str) -> String {
745    s.replace('\\', "\\\\")
746        .replace('"', "\\\"")
747        .replace('\n', "\\n")
748}
749
750// ── Tests ─────────────────────────────────────────────────────────────────────
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    fn full_params_ingest() -> HashMap<String, String> {
757        let mut m = HashMap::new();
758        m.insert("source_path".into(), "/mnt/src/video.mxf".into());
759        m.insert("output_path".into(), "/mnt/out/video.mp4".into());
760        m.insert(
761            "delivery_destination".into(),
762            "ftp://server/delivery/".into(),
763        );
764        m
765    }
766
767    #[test]
768    fn test_list_all_returns_eight_templates() {
769        let all = WorkflowTemplate::list_all();
770        assert_eq!(all.len(), 8);
771    }
772
773    #[test]
774    fn test_ingest_and_transcode_has_four_steps() {
775        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
776        assert_eq!(t.steps.len(), 4);
777    }
778
779    #[test]
780    fn test_quality_check_has_three_steps() {
781        let t = WorkflowTemplate::new(WorkflowTemplateKind::QualityCheck);
782        assert_eq!(t.steps.len(), 3);
783    }
784
785    #[test]
786    fn test_archive_and_proxy_has_three_steps() {
787        let t = WorkflowTemplate::new(WorkflowTemplateKind::ArchiveAndProxy);
788        assert_eq!(t.steps.len(), 3);
789    }
790
791    #[test]
792    fn test_broadcast_delivery_has_four_steps() {
793        let t = WorkflowTemplate::new(WorkflowTemplateKind::BroadcastDelivery);
794        assert_eq!(t.steps.len(), 4);
795    }
796
797    #[test]
798    fn test_social_media_package_has_five_steps() {
799        let t = WorkflowTemplate::new(WorkflowTemplateKind::SocialMediaPackage);
800        assert_eq!(t.steps.len(), 5);
801    }
802
803    #[test]
804    fn test_audio_podcast_has_five_steps() {
805        let t = WorkflowTemplate::new(WorkflowTemplateKind::AudioPodcast);
806        assert_eq!(t.steps.len(), 5);
807    }
808
809    #[test]
810    fn test_newsroom_fast_has_three_steps() {
811        let t = WorkflowTemplate::new(WorkflowTemplateKind::NewsroomFast);
812        assert_eq!(t.steps.len(), 3);
813    }
814
815    #[test]
816    fn test_live_event_clip_has_four_steps() {
817        let t = WorkflowTemplate::new(WorkflowTemplateKind::LiveEventClip);
818        assert_eq!(t.steps.len(), 4);
819    }
820
821    #[test]
822    fn test_instantiate_substitutes_placeholders() {
823        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
824        let instance = t
825            .instantiate(&full_params_ingest())
826            .expect("instantiate should succeed");
827        let ingest_step = instance
828            .steps
829            .iter()
830            .find(|s| s.id == "ingest")
831            .expect("ingest step must exist");
832        assert_eq!(
833            ingest_step.config.get("source").map(String::as_str),
834            Some("/mnt/src/video.mxf")
835        );
836    }
837
838    #[test]
839    fn test_instantiate_missing_required_param_returns_err() {
840        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
841        let result = t.instantiate(&HashMap::new());
842        assert!(result.is_err());
843    }
844
845    #[test]
846    fn test_instantiate_optional_defaults_applied() {
847        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
848        let instance = t
849            .instantiate(&full_params_ingest())
850            .expect("instantiate should succeed");
851        assert_eq!(
852            instance
853                .resolved_params
854                .get("transcode_preset")
855                .map(String::as_str),
856            Some("web_h264")
857        );
858    }
859
860    #[test]
861    fn test_to_dot_contains_digraph() {
862        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
863        let dot = t.to_dot();
864        assert!(dot.contains("digraph"));
865        assert!(dot.contains("rankdir=LR"));
866    }
867
868    #[test]
869    fn test_to_dot_contains_step_ids() {
870        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
871        let dot = t.to_dot();
872        assert!(dot.contains("ingest"));
873        assert!(dot.contains("validate"));
874        assert!(dot.contains("transcode"));
875        assert!(dot.contains("deliver"));
876    }
877
878    #[test]
879    fn test_to_dot_contains_edges() {
880        let t = WorkflowTemplate::new(WorkflowTemplateKind::IngestAndTranscode);
881        let dot = t.to_dot();
882        // Deliver depends on transcode → edge transcode -> deliver
883        assert!(dot.contains("->"));
884    }
885
886    #[test]
887    fn test_workflow_template_kind_display() {
888        assert_eq!(
889            WorkflowTemplateKind::IngestAndTranscode.to_string(),
890            "Ingest and Transcode"
891        );
892        assert_eq!(
893            WorkflowTemplateKind::AudioPodcast.to_string(),
894            "Audio Podcast"
895        );
896    }
897}