playout-runtime 0.3.0

Sans-IO linear channel playout: a schedule model (programme/ad/slate), transition planning across a join (PTS-rebase offset + discontinuity detection), and SCTE-35 splice_insert() emission points built with scte35-splice. No HTTP, no tokio, no transcoding.
Documentation

playout-runtime

crates.io docs.rs License

Sans-IO linear channel playout (issue #748): a schedule model (programme / ad / slate), transition planning across the join between two sources, and the SCTE-35 splice_insert() emission points a transition implies.

What it does / what it does not

Does. Models a linear channel as an ordered schedule of entries, finds the next join the schedule implies, and computes it: the channel-timeline instant, the PTS-rebase offset that lands the incoming source's clock continuously on the shared timeline, and a discontinuity flag when the codec configuration changes. It also builds (and can wrap into a splice_info_section) the SCTE-35 splice_insert() for entering or returning from a break, with its target conditioned against real candidate boundaries. Used by a channel-playout orchestrator that owns the clock and the media.

Does not.

  • No transcoding or conforming: a differing codec config is a discontinuity to signal, never something re-encoded. Codec bitstreams are never touched.
  • No sample-timestamp rewriting: transmux's IR transform would apply pts_rebase_offset; this crate computes the number, not the rewrite. broadcast_hls::mark_init_discontinuities would act on discontinuity at the playlist level; neither is a dependency here.
  • No boundary conditioning of its own: it delegates to ssai_runtime::splice::condition_splice_point_wrapping so two implementations cannot disagree about the same boundary.
  • No schedule wire format and no SCTE 224 (ESNI): the schedule is an in-memory model of our own (there is no Parse / Serialize pair; SCTE 224 covers policy and blackout signalling, not channel assembly).
  • No HTTP, no tokio, no real-time clock; a multimux adapter driving a channel clock is future work.

Standards supported

Standard Edition Scope Status Notes
ANSI/SCTE 35 2023r1 (vendored, private/specs-md/ansi_scte_35_2023r1_dpi_cueing.md) splice_insert() (section 9.7.3, Table 10) built via scte35-splice: event id, out_of_network_indicator, program splice, splice_time(), break_duration() with auto_return; clear splice_info_section wrapper Partial Only splice_insert; no time_signal, segmentation descriptors, encryption or components. Units must be 90 kHz ticks
(none: schedule model) n/a Programme / ad / slate schedule Not a standard SCTE 224 deliberately not adopted

Features

Feature Default Effect
std yes Links std (and the siblings'). Off gives #![no_std] + alloc.
serde no serde derives on the schedule, transition and decision types.

Quick start

use playout_runtime::{
    BreakEdge, CodecConfigId, EntryKind, Schedule, ScheduleEntry, build_splice_insert,
    next_transition,
};

let mut schedule = Schedule::new();
schedule
    .push(ScheduleEntry {
        id: "news".into(),
        kind: EntryKind::Programme,
        planned_start: 0,
        source_start_pts: 0,
        codec_config: CodecConfigId(1),
    })
    .unwrap();
// A 30 s ad at 10 minutes (90 kHz ticks), cut from an asset whose own clock
// starts at 5_000, with a different codec configuration.
schedule
    .push(ScheduleEntry {
        id: "ad".into(),
        kind: EntryKind::Ad,
        planned_start: 54_000_000,
        source_start_pts: 5_000,
        codec_config: CodecConfigId(2),
    })
    .unwrap();

// The join: when, how to rebase the incoming clock, and whether to signal a
// discontinuity.
let t = next_transition(&schedule, 0).unwrap();
assert_eq!(t.plan.at_pts, 54_000_000);
assert!(t.plan.discontinuity);
assert_eq!(t.plan.rebase(5_000), Some(54_000_000));

// The splice_insert() that entering the break implies, snapped to the nearest
// real boundary (2_000 ticks before the nominal instant beats 3_000 after).
let (point, insert) = build_splice_insert(
    BreakEdge::Enter,
    1,
    t.plan.at_pts,
    &[53_998_000, 54_003_000],
    5_000,
    Some(2_700_000),
)
.unwrap();
assert_eq!(point.snapped_pts, 53_998_000);
assert!(insert.out_of_network_indicator);

What is implemented

  • schedule: Schedule (new, push, entries, len, is_empty, active_at) of ScheduleEntry (id, EntryKind programme / ad / slate, planned_start, source_start_pts, CodecConfigId).
  • transition: next_transition(&Schedule, now_pts) returns a PlannedTransition (from, to, plan); TransitionPlan::plan(from, to) computes at_pts, pts_rebase_offset (i128, never clamped) and discontinuity; TransitionPlan::rebase maps a source PTS onto the channel timeline (None rather than wrapping out of range) and rebase_wrapped unrolls a raw 33-bit PTS first.
  • scte35: build_splice_insert(BreakEdge, event_id, requested_pts, candidates, max_delta_ticks, break_duration_ticks) returns the ConditionedSplicePoint and the SpliceInsert; to_section wraps it in a clear SpliceInfoSection. Distances are circular modulo 2^33.
  • error: Error (including SpliceConditioning).

Timeline continuity: the primary tested property

Issue #748: "a schedule that plays the right thing at the wrong timestamp is worse than no scheduler." The tests hold TransitionPlan to that bar:

  • transition::tests::rebase_lands_the_incoming_source_continuously_on_the_channel_timeline: once rebased, the incoming source's PTS sequence lands exactly on the channel timeline with no gap and no overlap.
  • transition::tests::rebase_returns_none_rather_than_wrapping_out_of_range.
  • tests/real_fixture_transition.rs plans a programme to ad transition against the real, non-IDR-aligned cue in fixtures/scte35-ssai/ (DASH-IF livesim2, Apache-2.0): the nearest keyframe is 6000 ticks (67 ms) after the cue's nominal instant, measured independently from the fragment's own boxes (see fixtures/scte35-ssai/PROVENANCE.md), and the emitted cue carries the conditioned instant, not the raw request.

Examples

cargo run -p playout-runtime --example schedule_and_transition
cargo run -p playout-runtime --example emit_splice_from_real_cue
  • schedule_and_transition: a programme, ad, programme schedule and every transition it implies.
  • emit_splice_from_real_cue: decode the real cue, plan the transition, condition the splice point, emit and byte-exact round-trip the splice_insert().

Design notes

  • Sans-IO, no_std + alloc, #![forbid(unsafe_code)], structured thiserror errors; spec enums carry the workspace name() + Display label pair.
  • The hard part is the transition, not the schedule: units are 90 kHz ticks and the caller must unwrap any source-clock wrap before rebase (or use rebase_wrapped).
  • scte35-splice's own Serialize builds the cue bytes; nothing is hand-assembled.

Related crates

Depends on broadcast-common, ssai-runtime (splice-point conditioning) and scte35-splice (the cue); dev-depends on mp4-emsg for the real-fixture tests. transmux applies the rebase; multimux would drive it.

Spec references

MSRV and license

MSRV 1.95.0. Licensed under either of Apache License, Version 2.0 or MIT license at your option.