SCTE-35 markers + overlay metadata in playout (#10)
This commit is contained in:
@@ -1,34 +1,53 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlayoutConfig {
|
||||
pub listen_addr: String,
|
||||
pub segment_duration_secs: u32,
|
||||
pub target_duration_secs: u32,
|
||||
pub window_size: usize,
|
||||
pub storage_path: PathBuf,
|
||||
pub tick_interval_ms: u64,
|
||||
pub overlay_trigger_offset: Duration,
|
||||
}
|
||||
|
||||
impl Default for PlayoutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen_addr: "0.0.0.0:9090".into(),
|
||||
segment_duration_secs: 6,
|
||||
target_duration_secs: 6,
|
||||
window_size: 10,
|
||||
storage_path: PathBuf::from("/tmp/k-tv-playout"),
|
||||
tick_interval_ms: 1000,
|
||||
overlay_trigger_offset: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayoutConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
listen_addr: std::env::var("PLAYOUT_LISTEN_ADDR")
|
||||
.unwrap_or_else(|_| "0.0.0.0:9090".into()),
|
||||
segment_duration_secs: std::env::var("PLAYOUT_SEGMENT_DURATION")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(6),
|
||||
window_size: std::env::var("PLAYOUT_WINDOW_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10),
|
||||
storage_path: std::env::var("PLAYOUT_STORAGE_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/tmp/k-tv-playout")),
|
||||
tick_interval_ms: std::env::var("PLAYOUT_TICK_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000),
|
||||
let mut config = Self::default();
|
||||
if let Ok(v) = std::env::var("PLAYOUT_LISTEN_ADDR") {
|
||||
config.listen_addr = v;
|
||||
}
|
||||
if let Some(v) = std::env::var("PLAYOUT_SEGMENT_DURATION").ok().and_then(|v| v.parse().ok()) {
|
||||
config.segment_duration_secs = v;
|
||||
config.target_duration_secs = v;
|
||||
}
|
||||
if let Some(v) = std::env::var("PLAYOUT_WINDOW_SIZE").ok().and_then(|v| v.parse().ok()) {
|
||||
config.window_size = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("PLAYOUT_STORAGE_PATH") {
|
||||
config.storage_path = PathBuf::from(v);
|
||||
}
|
||||
if let Some(v) = std::env::var("PLAYOUT_TICK_INTERVAL_MS").ok().and_then(|v| v.parse().ok()) {
|
||||
config.tick_interval_ms = v;
|
||||
}
|
||||
if let Some(v) = std::env::var("PLAYOUT_OVERLAY_OFFSET_SECS").ok().and_then(|v| v.parse::<u64>().ok()) {
|
||||
config.overlay_trigger_offset = Duration::from_secs(v);
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,7 @@ pub mod config;
|
||||
pub mod engine;
|
||||
pub mod ffmpeg;
|
||||
pub mod http;
|
||||
pub mod metadata;
|
||||
pub mod playlist;
|
||||
pub mod scte35;
|
||||
pub mod segment_store;
|
||||
|
||||
47
crates/playout/src/metadata.rs
Normal file
47
crates/playout/src/metadata.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct OverlayPayload {
|
||||
pub title: String,
|
||||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimedMetadata {
|
||||
pub trigger_at: DateTime<Utc>,
|
||||
pub payload: OverlayPayload,
|
||||
}
|
||||
|
||||
impl TimedMetadata {
|
||||
pub fn to_id3_json(&self) -> String {
|
||||
serde_json::to_string(&self.payload).expect("OverlayPayload is always serializable")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn payload_serializes_to_json() {
|
||||
let payload = OverlayPayload {
|
||||
title: "Next: The Matrix".into(),
|
||||
thumbnail_url: Some("https://example.com/matrix.jpg".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
assert!(json.contains("Next: The Matrix"));
|
||||
assert!(json.contains("matrix.jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_without_thumbnail() {
|
||||
let payload = OverlayPayload {
|
||||
title: "Coming up: News".into(),
|
||||
thumbnail_url: None,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let roundtrip: OverlayPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(roundtrip, payload);
|
||||
}
|
||||
}
|
||||
230
crates/playout/src/scte35.rs
Normal file
230
crates/playout/src/scte35.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use domain::{MediaRole, ScheduledSlot};
|
||||
|
||||
use crate::config::PlayoutConfig;
|
||||
use crate::metadata::{OverlayPayload, TimedMetadata};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Scte35Event {
|
||||
SpliceOut {
|
||||
id: String,
|
||||
start: DateTime<Utc>,
|
||||
duration_secs: u32,
|
||||
},
|
||||
SpliceIn {
|
||||
id: String,
|
||||
time: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlayoutPlan {
|
||||
pub slots: Vec<ScheduledSlot>,
|
||||
pub scte35_events: Vec<Scte35Event>,
|
||||
pub timed_metadata: Vec<TimedMetadata>,
|
||||
}
|
||||
|
||||
pub fn build_playout_plan(slots: &[ScheduledSlot], config: &PlayoutConfig) -> PlayoutPlan {
|
||||
let scte35_events = detect_midroll_breaks(slots);
|
||||
let timed_metadata = generate_overlay_triggers(slots, config);
|
||||
|
||||
PlayoutPlan {
|
||||
slots: slots.to_vec(),
|
||||
scte35_events,
|
||||
timed_metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_midroll_breaks(slots: &[ScheduledSlot]) -> Vec<Scte35Event> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
for window in slots.windows(3) {
|
||||
let before = &window[0];
|
||||
let break_slot = &window[1];
|
||||
let after = &window[2];
|
||||
|
||||
let is_break = *break_slot.item().role() == MediaRole::Interstitial;
|
||||
let same_source = before.source_block_id() == after.source_block_id()
|
||||
&& before.source_block_id() == break_slot.source_block_id();
|
||||
|
||||
if is_break && same_source {
|
||||
let break_duration = (break_slot.end_at() - break_slot.start_at())
|
||||
.num_seconds()
|
||||
.max(0) as u32;
|
||||
|
||||
let event_id = format!("midroll-{}", break_slot.id());
|
||||
|
||||
events.push(Scte35Event::SpliceOut {
|
||||
id: event_id.clone(),
|
||||
start: break_slot.start_at(),
|
||||
duration_secs: break_duration,
|
||||
});
|
||||
|
||||
events.push(Scte35Event::SpliceIn {
|
||||
id: event_id,
|
||||
time: break_slot.end_at(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn generate_overlay_triggers(
|
||||
slots: &[ScheduledSlot],
|
||||
config: &PlayoutConfig,
|
||||
) -> Vec<TimedMetadata> {
|
||||
let mut triggers = Vec::new();
|
||||
let offset =
|
||||
Duration::from_std(config.overlay_trigger_offset).unwrap_or(Duration::seconds(30));
|
||||
|
||||
for i in 0..slots.len().saturating_sub(1) {
|
||||
let current = &slots[i];
|
||||
let next = &slots[i + 1];
|
||||
|
||||
if *next.item().role() == MediaRole::Interstitial {
|
||||
continue;
|
||||
}
|
||||
|
||||
let trigger_at = current.end_at() - offset;
|
||||
if trigger_at <= current.start_at() {
|
||||
continue;
|
||||
}
|
||||
|
||||
triggers.push(TimedMetadata {
|
||||
trigger_at,
|
||||
payload: OverlayPayload {
|
||||
title: format!("Coming up next: {}", next.item().title()),
|
||||
thumbnail_url: next.item().thumbnail_url().map(String::from),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
triggers
|
||||
}
|
||||
|
||||
pub fn render_scte35_daterange(event: &Scte35Event) -> String {
|
||||
match event {
|
||||
Scte35Event::SpliceOut {
|
||||
id,
|
||||
start,
|
||||
duration_secs,
|
||||
} => {
|
||||
format!(
|
||||
"#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",PLANNED-DURATION={duration_secs},SCTE35-OUT=0xFC30",
|
||||
start.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
)
|
||||
}
|
||||
Scte35Event::SpliceIn { id, time } => {
|
||||
format!(
|
||||
"#EXT-X-DATERANGE:ID=\"{id}-in\",START-DATE=\"{}\",SCTE35-IN=0xFC30",
|
||||
time.format("%Y-%m-%dT%H:%M:%S%.3fZ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
use domain::{BlockId, ContentType, MediaItem, MediaItemId, MediaItemRow};
|
||||
|
||||
fn make_item(title: &str, duration_secs: u32, role: MediaRole) -> MediaItem {
|
||||
MediaItem::from_persistence(MediaItemRow {
|
||||
id: MediaItemId::new(format!("item-{title}")),
|
||||
title: title.into(),
|
||||
content_type: ContentType::Movie,
|
||||
duration_secs,
|
||||
description: None,
|
||||
genres: vec![],
|
||||
year: None,
|
||||
tags: vec![],
|
||||
series_name: None,
|
||||
season_number: None,
|
||||
episode_number: None,
|
||||
thumbnail_url: None,
|
||||
collection_id: None,
|
||||
provider_id: String::new(),
|
||||
external_id: String::new(),
|
||||
collection_name: None,
|
||||
collection_type: None,
|
||||
synced_at: None,
|
||||
role,
|
||||
chapters: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn make_slot(
|
||||
start_min: i64,
|
||||
end_min: i64,
|
||||
title: &str,
|
||||
role: MediaRole,
|
||||
block_id: BlockId,
|
||||
) -> ScheduledSlot {
|
||||
let base = Utc.with_ymd_and_hms(2026, 7, 12, 20, 0, 0).unwrap();
|
||||
let duration = ((end_min - start_min) * 60) as u32;
|
||||
let item = make_item(title, duration, role);
|
||||
ScheduledSlot::new(
|
||||
base + Duration::minutes(start_min),
|
||||
base + Duration::minutes(end_min),
|
||||
item,
|
||||
block_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_midroll_break_between_program_segments() {
|
||||
let block_id = BlockId::generate();
|
||||
let slots = vec![
|
||||
make_slot(0, 30, "Movie Part 1", MediaRole::Program, block_id),
|
||||
make_slot(30, 32, "Ad Break", MediaRole::Interstitial, block_id),
|
||||
make_slot(32, 62, "Movie Part 2", MediaRole::Program, block_id),
|
||||
];
|
||||
let events = detect_midroll_breaks(&slots);
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(matches!(&events[0], Scte35Event::SpliceOut { duration_secs: 120, .. }));
|
||||
assert!(matches!(&events[1], Scte35Event::SpliceIn { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_midroll_for_different_blocks() {
|
||||
let block_a = BlockId::generate();
|
||||
let block_b = BlockId::generate();
|
||||
let slots = vec![
|
||||
make_slot(0, 30, "Show A", MediaRole::Program, block_a),
|
||||
make_slot(30, 32, "Bumper", MediaRole::Interstitial, block_b),
|
||||
make_slot(32, 62, "Show B", MediaRole::Program, block_a),
|
||||
];
|
||||
assert!(detect_midroll_breaks(&slots).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_trigger_fires_before_slot_end() {
|
||||
let block_id = BlockId::generate();
|
||||
let slots = vec![
|
||||
make_slot(0, 60, "Current Movie", MediaRole::Program, block_id),
|
||||
make_slot(60, 120, "Next Movie", MediaRole::Program, block_id),
|
||||
];
|
||||
let config = PlayoutConfig {
|
||||
overlay_trigger_offset: std::time::Duration::from_secs(30),
|
||||
..Default::default()
|
||||
};
|
||||
let triggers = generate_overlay_triggers(&slots, &config);
|
||||
assert_eq!(triggers.len(), 1);
|
||||
assert!(triggers[0].payload.title.contains("Next Movie"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_overlay_before_interstitial() {
|
||||
let block_id = BlockId::generate();
|
||||
let slots = vec![
|
||||
make_slot(0, 30, "Movie Part 1", MediaRole::Program, block_id),
|
||||
make_slot(30, 32, "Ad Break", MediaRole::Interstitial, block_id),
|
||||
make_slot(32, 62, "Movie Part 2", MediaRole::Program, block_id),
|
||||
];
|
||||
let triggers = generate_overlay_triggers(&slots, &PlayoutConfig::default());
|
||||
assert_eq!(triggers.len(), 1);
|
||||
assert!(triggers[0].payload.title.contains("Movie Part 2"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user