Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
280 lines
8.9 KiB
Go
280 lines
8.9 KiB
Go
package mpd
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SyntheticConfig describes a live encoder's DASH grid well enough to publish a
|
|
// synthetic manifest for it. Every value a provider could differ on lives here;
|
|
// nothing in this package names a provider.
|
|
type SyntheticConfig struct {
|
|
// Timescales.
|
|
MSSTimescale int64 `yaml:"mss_timescale"`
|
|
VideoTimescale int64 `yaml:"video_timescale"`
|
|
AudioTimescale int64 `yaml:"audio_timescale"`
|
|
|
|
// Smooth Streaming stream names used to learn the segment grid.
|
|
ManifestPath string `yaml:"manifest_path"`
|
|
VideoName string `yaml:"video_name"`
|
|
AudioName string `yaml:"audio_name"`
|
|
|
|
// Segment path appended to the origin base for DASH segments.
|
|
SegmentPathSuffix string `yaml:"segment_path_suffix"`
|
|
|
|
// Representation attributes.
|
|
VideoBandwidth int `yaml:"video_bandwidth"`
|
|
AudioBandwidth int `yaml:"audio_bandwidth"`
|
|
VideoCodecs string `yaml:"video_codecs"`
|
|
AudioCodecs string `yaml:"audio_codecs"`
|
|
Width int `yaml:"width"`
|
|
Height int `yaml:"height"`
|
|
AudioSamplingRate int `yaml:"audio_sampling_rate"`
|
|
AudioChannels int `yaml:"audio_channels"`
|
|
Lang string `yaml:"lang"`
|
|
|
|
// Segment naming. "{channel}" is substituted; empty means derive as
|
|
// <channel>-<stream name>=<bandwidth>[-$Time$].<ext>.
|
|
VideoInitTemplate string `yaml:"video_init_template"`
|
|
VideoMediaTemplate string `yaml:"video_media_template"`
|
|
AudioInitTemplate string `yaml:"audio_init_template"`
|
|
AudioMediaTemplate string `yaml:"audio_media_template"`
|
|
SegmentExt string `yaml:"segment_ext"`
|
|
|
|
// Timeline shape.
|
|
LiveEdgeOffset float64 `yaml:"live_edge_offset_s"`
|
|
WindowSegments int64 `yaml:"window_segments"`
|
|
FallbackSegmentDuration int64 `yaml:"fallback_segment_duration"`
|
|
|
|
// MPD-level durations, as ISO-8601 strings.
|
|
MinUpdatePeriod string `yaml:"min_update_period"`
|
|
MinBufferTime string `yaml:"min_buffer_time"`
|
|
TimeShiftBufferDepth string `yaml:"time_shift_buffer_depth"`
|
|
MaxSegmentDuration string `yaml:"max_segment_duration"`
|
|
}
|
|
|
|
// WithDefaults fills anything left zero with the common Smooth-to-DASH values.
|
|
// These are container mechanics, not provider identity, so defaulting is safe.
|
|
func (c SyntheticConfig) WithDefaults() SyntheticConfig {
|
|
if c.MSSTimescale == 0 {
|
|
c.MSSTimescale = DefaultMSSTimescale
|
|
}
|
|
if c.VideoTimescale == 0 {
|
|
c.VideoTimescale = 600
|
|
}
|
|
if c.AudioTimescale == 0 {
|
|
c.AudioTimescale = 48_000
|
|
}
|
|
if c.ManifestPath == "" {
|
|
c.ManifestPath = "Manifest"
|
|
}
|
|
if c.VideoName == "" {
|
|
c.VideoName = "video"
|
|
}
|
|
if c.AudioName == "" {
|
|
c.AudioName = "audio_128k"
|
|
}
|
|
if c.SegmentPathSuffix == "" {
|
|
c.SegmentPathSuffix = "dash/"
|
|
}
|
|
if c.VideoBandwidth == 0 {
|
|
c.VideoBandwidth = 6_000_000
|
|
}
|
|
if c.AudioBandwidth == 0 {
|
|
c.AudioBandwidth = 128_000
|
|
}
|
|
if c.VideoCodecs == "" {
|
|
c.VideoCodecs = "avc1.640028"
|
|
}
|
|
if c.AudioCodecs == "" {
|
|
c.AudioCodecs = "mp4a.40.2"
|
|
}
|
|
if c.Width == 0 {
|
|
c.Width = 1920
|
|
}
|
|
if c.Height == 0 {
|
|
c.Height = 1080
|
|
}
|
|
if c.AudioSamplingRate == 0 {
|
|
c.AudioSamplingRate = 48_000
|
|
}
|
|
if c.AudioChannels == 0 {
|
|
c.AudioChannels = 2
|
|
}
|
|
if c.Lang == "" {
|
|
c.Lang = "en"
|
|
}
|
|
if c.SegmentExt == "" {
|
|
c.SegmentExt = "dash"
|
|
}
|
|
if c.LiveEdgeOffset == 0 {
|
|
c.LiveEdgeOffset = 12
|
|
}
|
|
if c.WindowSegments == 0 {
|
|
c.WindowSegments = 6
|
|
}
|
|
if c.FallbackSegmentDuration == 0 {
|
|
c.FallbackSegmentDuration = 38_400_000
|
|
}
|
|
if c.MinUpdatePeriod == "" {
|
|
c.MinUpdatePeriod = "PT2S"
|
|
}
|
|
if c.MinBufferTime == "" {
|
|
c.MinBufferTime = "PT8S"
|
|
}
|
|
if c.TimeShiftBufferDepth == "" {
|
|
c.TimeShiftBufferDepth = "PT1M"
|
|
}
|
|
if c.MaxSegmentDuration == "" {
|
|
c.MaxSegmentDuration = "PT4S"
|
|
}
|
|
return c
|
|
}
|
|
|
|
// GridSpec is the LearnGrid view of this config, exported so a provider module can
|
|
// learn the same encoder grid the builder uses.
|
|
func (c SyntheticConfig) GridSpec() GridSpec {
|
|
return GridSpec{
|
|
ManifestPath: c.ManifestPath,
|
|
VideoName: c.VideoName,
|
|
AudioName: c.AudioName,
|
|
VideoTimescale: c.VideoTimescale,
|
|
AudioTimescale: c.AudioTimescale,
|
|
MSSTimescale: c.MSSTimescale,
|
|
FallbackSegmentDuration: c.FallbackSegmentDuration,
|
|
}
|
|
}
|
|
|
|
func (c SyntheticConfig) segName(tmpl, channel, stream string, bandwidth int, withTime bool) string {
|
|
if tmpl != "" {
|
|
return strings.ReplaceAll(tmpl, "{channel}", channel)
|
|
}
|
|
if withTime {
|
|
return fmt.Sprintf("%s-%s=%d-$Time$.%s", channel, stream, bandwidth, c.SegmentExt)
|
|
}
|
|
return fmt.Sprintf("%s-%s=%d.%s", channel, stream, bandwidth, c.SegmentExt)
|
|
}
|
|
|
|
type anchor struct {
|
|
V, A int64
|
|
}
|
|
|
|
var (
|
|
anchorMu sync.Mutex
|
|
anchors = map[string]*anchor{}
|
|
)
|
|
|
|
const syntheticTemplate = `<?xml version="1.0" encoding="utf-8"?>
|
|
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:cenc="urn:mpeg:cenc:2013"
|
|
profiles="urn:mpeg:dash:profile:isoff-live:2011" type="dynamic"
|
|
availabilityStartTime="1970-01-01T00:00:00Z" publishTime="%s"
|
|
minimumUpdatePeriod="%s" minBufferTime="%s" timeShiftBufferDepth="%s"
|
|
maxSegmentDuration="%s">
|
|
<BaseURL>%s</BaseURL>
|
|
<Period id="1" start="PT0S">
|
|
<AdaptationSet id="1" contentType="video" mimeType="video/mp4" lang="%s">
|
|
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
|
|
<Representation id="r0" bandwidth="%d" codecs="%s" width="%d" height="%d">
|
|
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
|
|
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
|
|
</SegmentTemplate>
|
|
</Representation>
|
|
</AdaptationSet>
|
|
<AdaptationSet id="2" contentType="audio" mimeType="audio/mp4" lang="%s">
|
|
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc" cenc:default_KID="%s"/>
|
|
<Representation id="r1" bandwidth="%d" codecs="%s" audioSamplingRate="%d">
|
|
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="%d"/>
|
|
<SegmentTemplate timescale="%d" initialization="%s" media="%s" startNumber="1">
|
|
<SegmentTimeline><S t="%d" d="%d" r="%d"/></SegmentTimeline>
|
|
</SegmentTemplate>
|
|
</Representation>
|
|
</AdaptationSet>
|
|
</Period>
|
|
</MPD>
|
|
`
|
|
|
|
// BuildSynthetic publishes a dynamic MPD on the encoder's own segment grid, so a
|
|
// downloader keeps fetching live segments even once the upstream (ad-stitched)
|
|
// manifest session has expired.
|
|
func BuildSynthetic(cfg SyntheticConfig, channel, originBase, kid string) ([]byte, error) {
|
|
cfg = cfg.WithDefaults()
|
|
if !strings.HasSuffix(originBase, "/") {
|
|
originBase += "/"
|
|
}
|
|
segBase := originBase + cfg.SegmentPathSuffix
|
|
kidU, err := KIDToUUID(kid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grid, err := LearnGrid(channel, originBase, cfg.GridSpec())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Float seconds so sub-second phase stays close to the encoder.
|
|
now := float64(time.Now().UTC().UnixNano())/1e9 - cfg.LiveEdgeOffset
|
|
align := func(ts, dur, mod int64) int64 {
|
|
raw := int64(now * float64(ts))
|
|
return raw - (raw-mod)%dur
|
|
}
|
|
vt := align(cfg.VideoTimescale, grid.VDur, grid.VMod)
|
|
at := align(cfg.AudioTimescale, grid.ADur, grid.AMod)
|
|
maxCount := cfg.WindowSegments
|
|
|
|
anchorMu.Lock()
|
|
a := anchors[channel]
|
|
if a == nil {
|
|
a = &anchor{
|
|
V: vt - (maxCount-1)*grid.VDur,
|
|
A: at - (maxCount-1)*grid.ADur,
|
|
}
|
|
anchors[channel] = a
|
|
}
|
|
vStart, aStart := a.V, a.A
|
|
if vt > vStart+(maxCount-1)*grid.VDur {
|
|
vStart = vt - (maxCount-1)*grid.VDur
|
|
aStart = at - (maxCount-1)*grid.ADur
|
|
a.V, a.A = vStart, aStart
|
|
} else if vt < vStart {
|
|
vStart = vt - (maxCount-1)*grid.VDur
|
|
aStart = at - (maxCount-1)*grid.ADur
|
|
a.V, a.A = vStart, aStart
|
|
}
|
|
anchorMu.Unlock()
|
|
|
|
vCount := (vt-vStart)/grid.VDur + 1
|
|
aCount := (at-aStart)/grid.ADur + 1
|
|
if vCount < 1 {
|
|
vCount = 1
|
|
}
|
|
if aCount < 1 {
|
|
aCount = 1
|
|
}
|
|
if vCount > maxCount {
|
|
vStart = vt - (maxCount-1)*grid.VDur
|
|
aStart = at - (maxCount-1)*grid.ADur
|
|
vCount, aCount = maxCount, maxCount
|
|
anchorMu.Lock()
|
|
anchors[channel] = &anchor{V: vStart, A: aStart}
|
|
anchorMu.Unlock()
|
|
}
|
|
|
|
published := time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
|
|
vInit := cfg.segName(cfg.VideoInitTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, false)
|
|
vMedia := cfg.segName(cfg.VideoMediaTemplate, channel, cfg.VideoName, cfg.VideoBandwidth, true)
|
|
aInit := cfg.segName(cfg.AudioInitTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, false)
|
|
aMedia := cfg.segName(cfg.AudioMediaTemplate, channel, cfg.AudioName, cfg.AudioBandwidth, true)
|
|
|
|
out := fmt.Sprintf(syntheticTemplate,
|
|
published, cfg.MinUpdatePeriod, cfg.MinBufferTime, cfg.TimeShiftBufferDepth, cfg.MaxSegmentDuration,
|
|
XMLEscape(segBase),
|
|
cfg.Lang, kidU,
|
|
cfg.VideoBandwidth, cfg.VideoCodecs, cfg.Width, cfg.Height,
|
|
cfg.VideoTimescale, vInit, vMedia, vStart, grid.VDur, vCount-1,
|
|
cfg.Lang, kidU,
|
|
cfg.AudioBandwidth, cfg.AudioCodecs, cfg.AudioSamplingRate, cfg.AudioChannels,
|
|
cfg.AudioTimescale, aInit, aMedia, aStart, grid.ADur, aCount-1,
|
|
)
|
|
return []byte(out), nil
|
|
}
|