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 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
793fn 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
804fn escape_dot(s: &str) -> String {
806 s.replace('\\', "\\\\")
807 .replace('"', "\\\"")
808 .replace('\n', "\\n")
809}
810
811#[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 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}