1#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum WorkflowTemplateKind {
17 IngestAndTranscode,
19 QualityCheck,
21 ArchiveAndProxy,
23 BroadcastDelivery,
25 SocialMediaPackage,
27 AudioPodcast,
29 NewsroomFast,
31 LiveEventClip,
33}
34
35impl WorkflowTemplateKind {
36 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TemplateStep {
63 pub id: String,
65 pub name: String,
67 pub step_type: String,
69 pub depends_on: Vec<String>,
71 pub config: HashMap<String, String>,
73 pub timeout_secs: u64,
75 pub retry_count: u32,
77}
78
79impl TemplateStep {
80 #[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 #[must_use]
100 pub fn with_depends_on(mut self, deps: Vec<String>) -> Self {
101 self.depends_on = deps;
102 self
103 }
104
105 #[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 #[must_use]
114 pub fn with_timeout(mut self, secs: u64) -> Self {
115 self.timeout_secs = secs;
116 self
117 }
118
119 #[must_use]
121 pub fn with_retries(mut self, count: u32) -> Self {
122 self.retry_count = count;
123 self
124 }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TemplateParameter {
132 pub name: String,
134 pub description: String,
136 pub default_value: Option<String>,
138 pub required: bool,
140}
141
142impl TemplateParameter {
143 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct WorkflowInstance {
177 pub name: String,
179 pub kind: WorkflowTemplateKind,
181 pub resolved_params: HashMap<String, String>,
183 pub steps: Vec<TemplateStep>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct WorkflowTemplate {
192 pub kind: WorkflowTemplateKind,
194 pub name: String,
196 pub description: String,
198 pub steps: Vec<TemplateStep>,
200 pub parameters: Vec<TemplateParameter>,
202}
203
204impl WorkflowTemplate {
205 #[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 #[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 pub fn instantiate(
247 &self,
248 params: &HashMap<String, String>,
249 ) -> Result<WorkflowInstance, String> {
250 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 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 #[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 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 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 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
732fn 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
743fn escape_dot(s: &str) -> String {
745 s.replace('\\', "\\\\")
746 .replace('"', "\\\"")
747 .replace('\n', "\\n")
748}
749
750#[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 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}