Browse Source

feat: use probe hints with next engine

pull/2920/head
Jason Dove 2 months ago
parent
commit
a08d1b3d48
No known key found for this signature in database
  1. 17
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  2. 204
      ErsatzTV.Core/Next/Playout.cs
  3. 59
      ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

17
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -199,12 +199,12 @@ public partial class SyncNextPlayoutHandler(
logger.LogDebug("Located {Count} local playout items", playoutItems.Count); logger.LogDebug("Located {Count} local playout items", playoutItems.Count);
// fill in all gaps // fill in all gaps
List<PlayoutItem> newPlayoutItems = [];
Option<DateTimeOffset> lastFinish = Option<DateTimeOffset>.None;
playoutItems.Sort((a, b) => a.StartOffset.CompareTo(b.StartOffset)); playoutItems.Sort((a, b) => a.StartOffset.CompareTo(b.StartOffset));
foreach (var playoutItem in playoutItems) if (playoutItems.Count > 0)
{ {
foreach (var finish in lastFinish) List<PlayoutItem> newPlayoutItems = [];
DateTimeOffset finish = DateTimeOffset.Now;
foreach (var playoutItem in playoutItems)
{ {
if (finish < playoutItem.StartOffset) if (finish < playoutItem.StartOffset)
{ {
@ -215,12 +215,13 @@ public partial class SyncNextPlayoutHandler(
Finish = playoutItem.Start Finish = playoutItem.Start
}); });
} }
finish = playoutItem.FinishOffset;
newPlayoutItems.Add(playoutItem);
} }
lastFinish = playoutItem.FinishOffset; playoutItems = newPlayoutItems;
newPlayoutItems.Add(playoutItem);
} }
playoutItems = newPlayoutItems;
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken) .GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken)
@ -244,7 +245,7 @@ public partial class SyncNextPlayoutHandler(
targetFolder, targetFolder,
$"{first.StartOffset.ToUnixTimeMilliseconds()}_{last.FinishOffset.ToUnixTimeMilliseconds()}.json"); $"{first.StartOffset.ToUnixTimeMilliseconds()}_{last.FinishOffset.ToUnixTimeMilliseconds()}.json");
var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.1", Items = [] }; var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.2", Items = [] };
foreach (PlayoutItem playoutItem in group) foreach (PlayoutItem playoutItem in group)
{ {
Option<Core.Next.PlayoutItem> maybeNextPlayoutItem = await playoutItemConverter.ToNext( Option<Core.Next.PlayoutItem> maybeNextPlayoutItem = await playoutItemConverter.ToNext(

204
ErsatzTV.Core/Next/Playout.cs

@ -111,18 +111,19 @@ namespace ErsatzTV.Core.Next
/// transcode position and its `finish` is clamped to the placeholder's `finish`. Because /// transcode position and its `finish` is clamped to the placeholder's `finish`. Because
/// `start` advances each tick, the resolver is re-hit while the transcode position remains /// `start` advances each tick, the resolver is re-hit while the transcode position remains
/// inside the placeholder window, allowing a sequence of distinct items to be returned for a /// inside the placeholder window, allowing a sequence of distinct items to be returned for a
/// single placeholder. The resolved item's `source` may not itself be `dynamic`. /// single placeholder. The resolved item's `source` may not itself be `dynamic`, but it may
/// carry its own `probe_hint`, which is honored exactly as for a directly-specified source.
/// ///
/// The channel automatically injects the following request headers (in addition to any /// The channel automatically injects the following request headers (in addition to any
/// configured via `headers`), all formatted per RFC 3339 for timestamps: /// configured via `headers`), all formatted per RFC 3339 for timestamps:
/// ///
/// - `X-Etv-Channel` — the channel number this request is for. /// - `x-etv-channel` — the channel number this request is for.
/// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to /// - `x-etv-dynamic-id` — a stable identifier for the placeholder; resolvers can use it to
/// coalesce or cache decisions across the multiple calls that occur while transcoding stays /// coalesce or cache decisions across the multiple calls that occur while transcoding stays
/// inside one placeholder window. /// inside one placeholder window.
/// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced /// - `x-etv-now` — the current transcode position; this is the `start` that will be forced
/// onto the resolved item. /// onto the resolved item.
/// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that /// - `x-etv-until` — the placeholder's `finish`; the resolver should not return an item that
/// extends beyond this (it will be clamped if it does). /// extends beyond this (it will be clamped if it does).
/// </summary> /// </summary>
public partial class Source public partial class Source
@ -145,6 +146,17 @@ namespace ErsatzTV.Core.Next
[JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)]
public string Path { get; set; } public string Path { get; set; }
/// <summary>
/// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is
/// read only once (at playback). See `ProbeHint`.
///
/// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is
/// read only once (at playback). Especially useful here: a scripted source (e.g. a yt-dlp
/// pipeline) is otherwise opened twice, once to probe and once to play. See `ProbeHint`.
/// </summary>
[JsonProperty("probe_hint")]
public ProbeHint ProbeHint { get; set; }
[JsonProperty("source_type")] [JsonProperty("source_type")]
public SourceType SourceType { get; set; } public SourceType SourceType { get; set; }
@ -228,6 +240,166 @@ namespace ErsatzTV.Core.Next
public bool? IsLive { get; set; } public bool? IsLive { get; set; }
} }
/// <summary>
/// Pre-supplied probe metadata for a source. When present, the server trusts these values
/// and skips running ffprobe entirely, so the source is opened only once (at playback)
/// instead of twice. This matters most for slow or expensive inputs such as scripted yt-dlp
/// pipelines.
///
/// The hint fully replaces probing: values are not validated up front, so incorrect metadata
/// surfaces as an ffmpeg error during playback rather than a probe failure. Provide an entry
/// for every stream the pipeline needs to select — typically one video and one audio.
/// </summary>
public partial class ProbeHint
{
/// <summary>
/// Audio streams in the source. Omit (or use `[]`) for video-only sources; defaults to empty.
/// </summary>
[JsonProperty("audio", NullValueHandling = NullValueHandling.Ignore)]
public List<AudioHint> Audio { get; set; }
/// <summary>
/// Source duration in milliseconds. Omit for live or unbounded sources.
/// </summary>
[JsonProperty("duration_ms")]
public long? DurationMs { get; set; }
/// <summary>
/// Container format name as reported by ffprobe's `format_name` (e.g. "mpegts", "image2",
/// "png_pipe"). Used to detect still images (a single video stream in an `image2` or
/// `*_pipe` container). Defaults to "mpegts" when omitted.
/// </summary>
[JsonProperty("format_name")]
public string FormatName { get; set; }
/// <summary>
/// Video (and still-image) streams in the source. Omit (or use `[]`) for audio-only sources;
/// defaults to empty.
/// </summary>
[JsonProperty("video", NullValueHandling = NullValueHandling.Ignore)]
public List<VideoHint> Video { get; set; }
}
/// <summary>
/// Probe metadata for a single audio stream.
/// </summary>
public partial class AudioHint
{
/// <summary>
/// Number of audio channels.
/// </summary>
[JsonProperty("channels")]
public long Channels { get; set; }
/// <summary>
/// Codec name as reported by ffprobe (e.g. "aac", "ac3", "mp2"). Compared case-insensitively.
/// </summary>
[JsonProperty("codec")]
public string Codec { get; set; }
/// <summary>
/// Zero-based index of this stream within the source.
/// </summary>
[JsonProperty("stream_index")]
public long StreamIndex { get; set; }
}
/// <summary>
/// Probe metadata for a single video (or still-image) stream. Optional fields left out
/// assume progressive, square-pixel, SDR content at 24 fps — the same fallbacks used when
/// ffprobe omits a field.
/// </summary>
public partial class VideoHint
{
/// <summary>
/// Codec name as reported by ffprobe (e.g. "h264", "hevc", "mpeg2video", "png"). Compared
/// case-insensitively.
/// </summary>
[JsonProperty("codec")]
public string Codec { get; set; }
/// <summary>
/// Color primaries (e.g. "bt709", "bt2020"). Omit if unknown.
/// </summary>
[JsonProperty("color_primaries")]
public string ColorPrimaries { get; set; }
/// <summary>
/// Color range (e.g. "tv", "pc"). Omit if unknown.
/// </summary>
[JsonProperty("color_range")]
public string ColorRange { get; set; }
/// <summary>
/// Color space / matrix coefficients (e.g. "bt709", "bt2020nc"). Omit if unknown.
/// </summary>
[JsonProperty("color_space")]
public string ColorSpace { get; set; }
/// <summary>
/// Color transfer characteristics (e.g. "bt709", "smpte2084", "arib-std-b67"). The values
/// "smpte2084" and "arib-std-b67" mark the stream as HDR. Omit for SDR.
/// </summary>
[JsonProperty("color_transfer")]
public string ColorTransfer { get; set; }
/// <summary>
/// Display aspect ratio, e.g. "16:9". Omit if unknown.
/// </summary>
[JsonProperty("display_aspect_ratio")]
public string DisplayAspectRatio { get; set; }
/// <summary>
/// Interlacing field order as reported by ffprobe ("progressive", "tt", "bb", "tb", "bt").
/// Omit for progressive.
/// </summary>
[JsonProperty("field_order")]
public string FieldOrder { get; set; }
/// <summary>
/// Frame rate as a number or rational string (e.g. "24", "30000/1001"). Defaults to 24 when
/// omitted.
/// </summary>
[JsonProperty("frame_rate")]
public string FrameRate { get; set; }
/// <summary>
/// Coded frame height in pixels.
/// </summary>
[JsonProperty("height")]
public long Height { get; set; }
/// <summary>
/// Pixel format (e.g. "yuv420p", "yuv420p10le").
/// </summary>
[JsonProperty("pix_fmt")]
public string PixFmt { get; set; }
/// <summary>
/// Codec profile (e.g. "high", "main 10"). Compared case-insensitively. Omit if unknown.
/// </summary>
[JsonProperty("profile")]
public string Profile { get; set; }
/// <summary>
/// Sample (pixel) aspect ratio, e.g. "1:1". Omit for square pixels.
/// </summary>
[JsonProperty("sample_aspect_ratio")]
public string SampleAspectRatio { get; set; }
/// <summary>
/// Zero-based index of this stream within the source.
/// </summary>
[JsonProperty("stream_index")]
public long StreamIndex { get; set; }
/// <summary>
/// Coded frame width in pixels.
/// </summary>
[JsonProperty("width")]
public long Width { get; set; }
}
/// <summary> /// <summary>
/// Per-track overrides for a playout item. Omit a field to use the server default for that /// Per-track overrides for a playout item. Omit a field to use the server default for that
/// track kind (first stream of that kind in the item's `source`, if any). /// track kind (first stream of that kind in the item's `source`, if any).
@ -375,18 +547,19 @@ namespace ErsatzTV.Core.Next
/// transcode position and its `finish` is clamped to the placeholder's `finish`. Because /// transcode position and its `finish` is clamped to the placeholder's `finish`. Because
/// `start` advances each tick, the resolver is re-hit while the transcode position remains /// `start` advances each tick, the resolver is re-hit while the transcode position remains
/// inside the placeholder window, allowing a sequence of distinct items to be returned for a /// inside the placeholder window, allowing a sequence of distinct items to be returned for a
/// single placeholder. The resolved item's `source` may not itself be `dynamic`. /// single placeholder. The resolved item's `source` may not itself be `dynamic`, but it may
/// carry its own `probe_hint`, which is honored exactly as for a directly-specified source.
/// ///
/// The channel automatically injects the following request headers (in addition to any /// The channel automatically injects the following request headers (in addition to any
/// configured via `headers`), all formatted per RFC 3339 for timestamps: /// configured via `headers`), all formatted per RFC 3339 for timestamps:
/// ///
/// - `X-Etv-Channel` — the channel number this request is for. /// - `x-etv-channel` — the channel number this request is for.
/// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to /// - `x-etv-dynamic-id` — a stable identifier for the placeholder; resolvers can use it to
/// coalesce or cache decisions across the multiple calls that occur while transcoding stays /// coalesce or cache decisions across the multiple calls that occur while transcoding stays
/// inside one placeholder window. /// inside one placeholder window.
/// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced /// - `x-etv-now` — the current transcode position; this is the `start` that will be forced
/// onto the resolved item. /// onto the resolved item.
/// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that /// - `x-etv-until` — the placeholder's `finish`; the resolver should not return an item that
/// extends beyond this (it will be clamped if it does). /// extends beyond this (it will be clamped if it does).
/// </summary> /// </summary>
public partial class PlayoutItemSource public partial class PlayoutItemSource
@ -409,6 +582,17 @@ namespace ErsatzTV.Core.Next
[JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)]
public string Path { get; set; } public string Path { get; set; }
/// <summary>
/// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is
/// read only once (at playback). See `ProbeHint`.
///
/// Optional pre-supplied probe metadata. When present, ffprobe is skipped and the source is
/// read only once (at playback). Especially useful here: a scripted source (e.g. a yt-dlp
/// pipeline) is otherwise opened twice, once to probe and once to play. See `ProbeHint`.
/// </summary>
[JsonProperty("probe_hint")]
public ProbeHint ProbeHint { get; set; }
[JsonProperty("source_type")] [JsonProperty("source_type")]
public SourceType SourceType { get; set; } public SourceType SourceType { get; set; }

59
ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

@ -122,8 +122,43 @@ public class PlayoutItemConverter(
if (playoutItem is not DynamicPlayoutItem) if (playoutItem is not DynamicPlayoutItem)
{ {
// if no audio streams, use lavfi to insert silence
MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion(); MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion();
var sourceVideoHints = headVersion.Streams
.Where(s => s.MediaStreamKind is MediaStreamKind.Video)
.Select(s => new Core.Next.VideoHint
{
StreamIndex = s.Index,
Codec = s.Codec,
Height = headVersion.Height,
Width = headVersion.Width,
Profile = s.Profile,
FieldOrder = headVersion.VideoScanKind is VideoScanKind.Interlaced ? "tt" : "progressive",
PixFmt = string.IsNullOrWhiteSpace(s.PixelFormat) ? PixelFormatForBitDepth(s.BitsPerRawSample) : s.PixelFormat,
FrameRate = headVersion.RFrameRate,
SampleAspectRatio = headVersion.SampleAspectRatio,
DisplayAspectRatio = headVersion.DisplayAspectRatio,
ColorPrimaries = s.ColorPrimaries,
ColorRange = s.ColorRange,
ColorSpace = s.ColorSpace,
ColorTransfer = s.ColorTransfer
}).ToList();
var sourceAudioHints = headVersion.Streams
.Where(s => s.MediaStreamKind is MediaStreamKind.Audio)
.Select(s => new Core.Next.AudioHint
{
StreamIndex = s.Index,
Codec = s.Codec,
Channels = s.Channels
}).ToList();
nextPlayoutItem.Source.ProbeHint = new Core.Next.ProbeHint
{
Audio = sourceAudioHints,
Video = sourceVideoHints,
DurationMs = (long)headVersion.Duration.TotalMilliseconds
};
// if no audio streams, use lavfi to insert silence
if (headVersion.Streams.All(s => s.MediaStreamKind is not MediaStreamKind.Audio)) if (headVersion.Streams.All(s => s.MediaStreamKind is not MediaStreamKind.Audio))
{ {
var videoSource = nextPlayoutItem.Source; var videoSource = nextPlayoutItem.Source;
@ -137,7 +172,18 @@ public class PlayoutItemConverter(
new Core.Next.Source new Core.Next.Source
{ {
SourceType = Core.Next.SourceType.Lavfi, SourceType = Core.Next.SourceType.Lavfi,
Params = "anullsrc=channel_layout=stereo:sample_rate=48000" Params = "anullsrc=channel_layout=stereo:sample_rate=48000",
ProbeHint = new Core.Next.ProbeHint
{
Audio = [
new Core.Next.AudioHint
{
StreamIndex = 0,
Codec = "pcm_s16le",
Channels = 2
}
]
}
} }
}, },
Video = new Core.Next.TrackSelection Video = new Core.Next.TrackSelection
@ -171,6 +217,15 @@ public class PlayoutItemConverter(
return nextPlayoutItem; return nextPlayoutItem;
} }
private static string PixelFormatForBitDepth(int bitDepth)
{
return bitDepth switch
{
10 => "yuv420p10le",
_ => "yuv420p"
};
}
private async Task<Option<Core.Next.Source>> SourceForItem( private async Task<Option<Core.Next.Source>> SourceForItem(
PlayoutItem playoutItem, PlayoutItem playoutItem,
CancellationToken cancellationToken) CancellationToken cancellationToken)

Loading…
Cancel
Save