Retain selectable audio languages and DVB subtitles through live continuity
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Enigma2;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
public static class BroadcastTrackTests
|
||||
{
|
||||
public static async Task Run(Action<bool,string> check)
|
||||
{
|
||||
const string json="""{"streams":[{"index":0,"id":"0x100","codec_type":"video","codec_name":"h264","width":1920,"height":1080},{"index":2,"id":"0x102","codec_type":"audio","codec_name":"ac3","channels":6,"tags":{"language":"pol","title":"Polski"},"disposition":{"default":1}},{"index":3,"id":"0x103","codec_type":"audio","codec_name":"aac","channels":2,"tags":{"language":"eng","title":"English"}},{"index":5,"id":"0x105","codec_type":"subtitle","codec_name":"dvb_subtitle","tags":{"language":"pol","title":"Napisy"},"disposition":{"forced":1,"hearing_impaired":1},"extradata":"\n00000000: 0001 0001 ....\n"},{"index":6,"id":"0x106","codec_type":"subtitle","codec_name":"dvb_teletext","tags":{"language":"eng"}},{"index":1,"codec_type":"data","codec_name":"bin_data"}]}""";
|
||||
var parsed=LiveStreamProbe.Parse(json);
|
||||
check(parsed.Count==5&&parsed.Count(s=>s.Type==MediaStreamType.Subtitle)==2,"probe retains both audio languages and broadcast subtitles while excluding data PIDs");
|
||||
check(parsed[3].Codec=="DVBSUB"&&!parsed[3].IsTextSubtitleStream&&parsed[4].Codec=="DVBTXT","native Jellyfin codec names distinguish DVB bitmap and teletext subtitles");
|
||||
check(parsed[3].IsForced&&parsed[3].IsHearingImpaired&&parsed[1].IsDefault&&parsed[1].Title=="Polski","language labels and default/forced/hearing-impaired dispositions survive probing");
|
||||
check(parsed[3] is BroadcastMediaStream {TransportId:261,SubtitleExtraData:var extra}&&extra.SequenceEqual(new byte[]{0,1,0,1}),"probe preserves transport PIDs and DVB page IDs");
|
||||
var layout=new BroadcastTracks(parsed);
|
||||
check(layout.Output[0].Width==1920&&layout.Output[0].Height==1080&&BroadcastTracks.Fallback().Output[0].Width==null,"native negotiation uses probed channel dimensions, never the status picture dimensions");
|
||||
check(layout.Output.Select(s=>s.Index).SequenceEqual(Enumerable.Range(0,5))&&layout.Output[2].Language=="eng","explicitly ordered program exposes distinct stable selectable indices");
|
||||
check(layout.Output.Where(s=>s.Type==MediaStreamType.Audio).All(s=>s.Codec=="aac"&&s.Channels==2),"each normalized audio language has its own AAC track");
|
||||
check(EncodingHelper.FindIndex(layout.Output,layout.Output[2])==2&&EncodingHelper.GetSubtitleStreamIndexForFfmpeg(new(){MediaStreams=layout.Output},layout.Output[3])==3,"installed Jellyfin resolves alternate audio and subtitle maps without unknown-index auto-selection");
|
||||
check(layout.MapArguments().Contains("0:i:261")&&!layout.MapArguments().Contains("-sn"),"producer maps broadcast PIDs instead of unstable probe positions and retains subtitles");
|
||||
var untouched=new BroadcastTracks(parsed,false);
|
||||
check(untouched.Output[1].Codec=="ac3"&&untouched.Output[1].Channels==6,"disabled slates preserve original audio codec and surround channels through remux");
|
||||
var section=BroadcastSlateProgram.Section(layout);uint crc=0xffffffff;
|
||||
foreach(var value in section){crc^=(uint)value<<24;for(var bit=0;bit<8;bit++)crc=(crc&0x80000000)!=0?(crc<<1)^0x04c11db7:crc<<1;}
|
||||
check(crc==0&§ion.Contains((byte)89)&§ion.Contains((byte)86),"status program map has valid MPEG-TS CRC and DVB/teletext descriptors");
|
||||
var subtitleClock=new DvbSubtitleVersions(SyntheticBroadcastFixture.Tracks());var cc=0;
|
||||
var wire=SyntheticBroadcastFixture.Packets(SyntheticBroadcastFixture.Bitmap(0),126000,259,ref cc).SelectMany(b=>b).ToArray();
|
||||
var first=wire.ToArray();subtitleClock.Apply(first,0);var next=wire.ToArray();subtitleClock.Apply(next,1);
|
||||
check(!first.SequenceEqual(next),"DVB pages, objects and display definitions get fresh versions across status/live boundaries");
|
||||
var retry=wire.ToArray();subtitleClock.Apply(retry,1);check(retry.SequenceEqual(next),"repeated HLS segment requests reuse the same DVB version plan");
|
||||
var probe=new LiveStreamProbe((_,_)=>Task.FromResult(json));await probe.Get("channel",CancellationToken.None);
|
||||
check(probe.LastKnown("channel")?.Count==5&&probe.LastKnown("other",1)==null&&probe.LastKnown("channel",2)==null,"last-known track catalogue is isolated by source and programme");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Enigma2;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
public static class BroadcastTracksE2E
|
||||
{
|
||||
private static string encoder,output,fixture;
|
||||
private static volatile bool enabled=true;
|
||||
private static int active,maximum,connections;
|
||||
public static async Task Run(string executable,string folder)
|
||||
{
|
||||
encoder=executable;output=Path.GetFullPath(folder);if(Directory.Exists(output))throw new Exception("Choose a new output directory");Directory.CreateDirectory(output);
|
||||
using(var make=Start(new[]{"-f","lavfi","-i","testsrc2=size=640x360:rate=25","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-f","lavfi","-i","sine=frequency=880:sample_rate=48000","-map","0:v","-map","1:a","-map","2:a","-t","16","-c:v","libx264","-threads","1","-preset","ultrafast","-g","25","-c:a","aac","-ac","2","-metadata:s:a:0","language=pol","-metadata:s:a:1","language=eng","-f","mpegts",Path.Combine(output,"base.ts")},"fixture"))
|
||||
{await make.WaitForExitAsync();Require(make.ExitCode==0,"synthetic two-language AV fixture");}
|
||||
fixture=Path.Combine(output,"broadcast.ts");await File.WriteAllBytesAsync(fixture,SyntheticBroadcastFixture.AddSubtitles(await File.ReadAllBytesAsync(Path.Combine(output,"base.ts"))));
|
||||
var metadata=LiveStreamProbe.Parse(await Probe(fixture));
|
||||
Require(metadata.Count(s=>s.Type==MediaBrowser.Model.Entities.MediaStreamType.Audio)==2&&metadata.Count(s=>s.Codec=="DVBSUB")==2&&metadata.Count(s=>s.Codec=="DVBTXT")==1,"actual ffprobe sees two audio, two DVB bitmap languages and a teletext subtitle track");
|
||||
using var listener=new TcpListener(IPAddress.Loopback,0);listener.Start();var port=((IPEndPoint)listener.LocalEndpoint).Port;listener.Stop();
|
||||
var builder=WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
||||
using var streams=new ContinuityStreams(encoder,Path.Combine(output,"cache"),port,new StartupMessages(),NullLogger.Instance);
|
||||
using var relay=new LiveStreamRelay(new System.Net.Http.HttpClient(),port,TimeSpan.FromSeconds(1),TimeSpan.FromSeconds(10),TimeSpan.FromSeconds(5));
|
||||
builder.Services.AddSingleton(streams);builder.Services.AddSingleton(relay);builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
|
||||
await using var app=builder.Build();app.MapControllers();app.MapGet("/broadcast.ts",Fixture);await app.StartAsync();
|
||||
Process recording=null,english=null,subtitled=null;
|
||||
try
|
||||
{
|
||||
var url=relay.Register($"http://127.0.0.1:{port}/broadcast.ts");var layout=new BroadcastTracks(metadata);
|
||||
var pl=await streams.Open(url,"pl",CancellationToken.None,layout);var en=await streams.Open(url,"en",CancellationToken.None,layout);
|
||||
Require(pl.MediaStreams.Count==6&&pl.MediaStreams[2].Language=="eng"&&pl.MediaStreams[4].Codec=="DVBSUB","native source advertises every selectable track before live data");
|
||||
await File.WriteAllBytesAsync(Path.Combine(output,"waiting.ts"),streams.Segment(pl.Id,0));
|
||||
var waiting=LiveStreamProbe.Parse(await Probe(Path.Combine(output,"waiting.ts")));
|
||||
Require(waiting.Count==6&&waiting[2].Language=="eng"&&waiting[4].Language=="eng","waiting clip declares both silent audio tracks and both DVB subtitle tracks");
|
||||
recording=Start(new[]{"-xerror","-i",pl.Path,"-t","60","-map","0","-c","copy",Path.Combine(output,"all.ts")},"all-client");
|
||||
english=Start(new[]{"-xerror","-i",en.Path,"-t","60","-map","0:v:0","-map","0:2","-c","copy",Path.Combine(output,"english.mkv")},"english-client");
|
||||
subtitled=Start(new[]{"-xerror","-i",en.Path,"-filter_complex","[0:v:0][0:s:1]overlay=eof_action=pass:shortest=0[v]","-map","[v]","-map","0:2","-t","60","-c:v","libx264","-threads","1","-preset","ultrafast","-c:a","copy",Path.Combine(output,"subtitled.mkv")},"subtitled-client");
|
||||
await Task.Delay(18000);Require(JsonSerializer.SerializeToElement(streams.Status()).GetProperty("LiveSegmentsProduced").GetInt64()>0,"multitrack live segments reach viewers");
|
||||
enabled=false;await Task.Delay(12000);enabled=true;
|
||||
await Task.WhenAll(recording.WaitForExitAsync(),english.WaitForExitAsync(),subtitled.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
|
||||
Require(recording.ExitCode==0&&english.ExitCode==0&&subtitled.ExitCode==0,"all-track, selected-English and selected-subtitle clients survive source loss and recovery");
|
||||
streams.Close(pl.Id);streams.Close(en.Id);await Task.Delay(2000);Require(active==0&&maximum==1,"two viewer languages keep one receiver subscription and release it");
|
||||
using(var decode=Start(new[]{"-xerror","-i",Path.Combine(output,"all.ts"),"-map","0:v","-map","0:a","-f","null","-"},"decode")){await decode.WaitForExitAsync();Require(decode.ExitCode==0,"strict video and both-audio decode across status/live switches");}
|
||||
var actual=JsonDocument.Parse(await Probe(Path.Combine(output,"all.ts"),true));
|
||||
var subtitles=actual.RootElement.GetProperty("streams").EnumerateArray().Where(s=>s.GetProperty("codec_type").GetString()=="subtitle").ToArray();
|
||||
Require(subtitles.Length==3&&subtitles.All(s=>int.Parse(s.GetProperty("nb_read_packets").GetString())>0),"actual packets for both DVB languages and teletext survive source loss and recovery");
|
||||
var frequencies=new List<double>();
|
||||
for(var track=0;track<2;track++)
|
||||
{
|
||||
var path=Path.Combine(output,$"audio-{track}.pcm");using var audio=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-map",$"0:a:{track}","-ac","1","-ar","48000","-f","s16le",path},"audio-"+track);
|
||||
await audio.WaitForExitAsync();Require(audio.ExitCode==0,"extract independently selectable audio "+track);var bytes=await File.ReadAllBytesAsync(path);var crossings=0;
|
||||
var windows=new List<double>();
|
||||
for(var at=0;at+96000<=bytes.Length;at+=96000)
|
||||
{
|
||||
var peak=0;crossings=0;
|
||||
for(var i=at;i<at+96000;i+=2){var value=BitConverter.ToInt16(bytes,i);peak=Math.Max(peak,Math.Abs((int)value));if(i>at&&BitConverter.ToInt16(bytes,i-2)<0&&value>=0)crossings++;}
|
||||
if(peak>500)windows.Add(crossings);
|
||||
}
|
||||
Require(windows.Count>0,"audio language has non-silent live data "+track);frequencies.Add(windows.Order().ElementAt(windows.Count/2));
|
||||
}
|
||||
Require(Math.Abs(frequencies[0]-440)<12&&Math.Abs(frequencies[1]-880)<12,"audio choices contain distinct Polish/English fixture tones, not duplicate first audio");
|
||||
// Exercise Jellyfin's bitmap-burn-in delivery mechanism, independently of a physical client.
|
||||
using(var overlay=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-filter_complex","[0:v:0][0:s:1]overlay=eof_action=pass:shortest=0[v]","-map","[v]","-t","60","-f","null","-"},"subtitle-overlay"))
|
||||
{await overlay.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45));Require(overlay.ExitCode==0,"selected DVB bitmap subtitles can be decoded and overlaid");}
|
||||
var difference=Path.Combine(output,"subtitle-difference.gray");
|
||||
using(var pixels=Start(new[]{"-i",Path.Combine(output,"all.ts"),"-filter_complex","[0:v:0]split[a][b];[b][0:s:1]overlay=eof_action=pass:shortest=0[sub];[a][sub]blend=all_mode=difference,crop=96:32:64:300,format=gray[v]","-map","[v]","-t","60","-f","rawvideo",difference},"subtitle-pixels"))
|
||||
{await pixels.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45));Require(pixels.ExitCode==0,"compare selected subtitle render against the same video frames");}
|
||||
var rendered=await File.ReadAllBytesAsync(difference);Require(rendered.Count(b=>b>40)>96*32,"selected subtitle bitmap changes actual rendered pixels");
|
||||
// With slates disabled the ordered transport only remuxes original codecs.
|
||||
var direct=await streams.Open(url,"en",CancellationToken.None,new BroadcastTracks(metadata,false),false);
|
||||
using(var client=Start(new[]{"-xerror","-i",direct.Path,"-t","5","-map","0:v","-map","0:a:1","-c","copy",Path.Combine(output,"passthrough.ts")},"passthrough"))
|
||||
{try{await client.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));Require(client.ExitCode==0,"disabled-slates multitrack remux supports alternate audio");}finally{if(!client.HasExited)client.Kill(true);streams.Close(direct.Id);}}
|
||||
await Task.Delay(2000);
|
||||
var report=new{Passed=true,SyntheticOnly=true,ReceiverConnectionsOpened=0,AudioLanguages=new[]{"pol","eng"},SubtitleLanguages=new[]{"pol","eng"},AudioFrequencies=frequencies,SourceFailureSeconds=12,MaximumConcurrentSourceConnections=maximum,RemainingConnections=active,SourceConnections=connections,SubtitlePacketsVerified=true,TeletextPacketsVerified=true,SubtitleOverlayDecoded=true,SubtitleRenderedPixelsVerified=true,SelectedSubtitleLiveClientVerified=true,PhysicalAndroidTvTested=false,ActualJellyfinPlayerTested=false};
|
||||
await File.WriteAllTextAsync(Path.Combine(output,"result.json"),JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true}));Console.WriteLine(JsonSerializer.Serialize(report));
|
||||
}
|
||||
finally{if(recording is{HasExited:false})recording.Kill(true);if(english is{HasExited:false})english.Kill(true);if(subtitled is{HasExited:false})subtitled.Kill(true);recording?.Dispose();english?.Dispose();subtitled?.Dispose();streams.Dispose();await app.StopAsync();}
|
||||
}
|
||||
private static async Task<string> Probe(string file,bool packets=false)
|
||||
{
|
||||
var info=new ProcessStartInfo(Path.Combine(Path.GetDirectoryName(encoder),"ffprobe")){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
|
||||
foreach(var arg in new[]{"-v","error","-analyzeduration","5000000","-probesize","8000000","-show_streams","-show_data","-of","json"})info.ArgumentList.Add(arg);if(packets)info.ArgumentList.Add("-count_packets");info.ArgumentList.Add(file);
|
||||
using var process=Process.Start(info);var text=process.StandardOutput.ReadToEndAsync();var errors=process.StandardError.ReadToEndAsync();await process.WaitForExitAsync();Require(process.ExitCode==0,"real FFmpeg track probe "+Path.GetFileName(file));var error=await errors;if(error.Length>0)await File.WriteAllTextAsync(Path.Combine(output,"probe-"+Path.GetFileName(file)+".log"),error);return await text;
|
||||
}
|
||||
private static Process Start(IEnumerable<string> args,string name)
|
||||
{
|
||||
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardError=true,RedirectStandardOutput=true};
|
||||
foreach(var arg in new[]{"-hide_banner","-loglevel","warning","-nostdin","-threads","1","-filter_threads","1"}.Concat(args))info.ArgumentList.Add(arg);
|
||||
var process=Process.Start(info);process.ErrorDataReceived+=(_,e)=>{if(e.Data!=null)lock(typeof(BroadcastTracksE2E))File.AppendAllText(Path.Combine(output,name+".log"),e.Data+"\n");};process.BeginErrorReadLine();return process;
|
||||
}
|
||||
private static async Task Fixture(HttpContext context)
|
||||
{
|
||||
Interlocked.Increment(ref connections);if(!enabled){context.Response.StatusCode=503;return;}var count=Interlocked.Increment(ref active);maximum=Math.Max(maximum,count);
|
||||
var info=new ProcessStartInfo(encoder){UseShellExecute=false,RedirectStandardOutput=true,RedirectStandardError=true};
|
||||
foreach(var arg in new[]{"-hide_banner","-loglevel","error","-nostdin","-re","-stream_loop","-1","-i",fixture,"-map","0","-c","copy","-f","mpegts","pipe:1"})info.ArgumentList.Add(arg);
|
||||
using var process=Process.Start(info);process.ErrorDataReceived+=(_,_)=>{};process.BeginErrorReadLine();context.Response.ContentType="video/mp2t";
|
||||
try{var buffer=new byte[65536];while(enabled&&!context.RequestAborted.IsCancellationRequested){var n=await process.StandardOutput.BaseStream.ReadAsync(buffer,context.RequestAborted);if(n==0)break;await context.Response.Body.WriteAsync(buffer.AsMemory(0,n),context.RequestAborted);await context.Response.Body.FlushAsync(context.RequestAborted);}}
|
||||
catch(OperationCanceledException){}catch(IOException){}
|
||||
finally{if(!process.HasExited)process.Kill(true);await process.WaitForExitAsync();Interlocked.Decrement(ref active);context.Abort();}
|
||||
}
|
||||
private static void Require(bool condition,string text){if(!condition)throw new Exception(text);Console.WriteLine("PASS "+text);}
|
||||
}
|
||||
@@ -18,12 +18,14 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
if (args.Length == 3 && args[0] == "--broadcast-tracks-e2e") { await BroadcastTracksE2E.Run(args[1], args[2]); return; }
|
||||
if (args.Length == 3 && args[0] == "--native-continuity-e2e") { await ContinuityE2E.Run(args[1], args[2], true, true); return; }
|
||||
if (args.Length == 3 && args[0] == "--resolution-e2e") { await ResolutionE2E.Run(args[1], args[2]); return; }
|
||||
if (args.Length == 3 && (args[0] == "--continuity-e2e" || args[0] == "--long-continuity-e2e")) { await ContinuityE2E.Run(args[1], args[2], args[0].StartsWith("--long")); return; }
|
||||
var time=DateTime.UtcNow;
|
||||
void Check(bool ok,string name){if(!ok)throw new Exception(name);Console.WriteLine("PASS "+name);}
|
||||
ChannelReferenceTests.Run(Check);
|
||||
await BroadcastTrackTests.Run(Check);
|
||||
var entry=new InternetEpg.Entry("Barwy szczęścia",time,"https://ptv-prod.wphcdn.cloud/media/a.jpg");
|
||||
Check(InternetEpg.Match(new[]{entry},"Barwy szczęścia",time.AddMinutes(5))==entry,"matching channel programme time");
|
||||
Check(InternetEpg.Match(new[]{entry},"Inny program",time)==null,"reject different title");
|
||||
|
||||
@@ -30,7 +30,8 @@ public static class ResolutionE2E
|
||||
if (Directory.Exists(output)) throw new Exception("Choose a new output directory"); Directory.CreateDirectory(output);
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop();
|
||||
var builder = WebApplication.CreateBuilder(); builder.Logging.ClearProviders(); builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
|
||||
using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), NullLogger.Instance, preserveResolution: true);
|
||||
using var logging = LoggerFactory.Create(b => b.AddSimpleConsole().SetMinimumLevel(LogLevel.Warning));
|
||||
using var manager = new ContinuityStreams(encoder, Path.Combine(output, "cache"), port, new StartupMessages(), logging.CreateLogger<ContinuityStreams>(), preserveResolution: true);
|
||||
using var relay = new LiveStreamRelay(new System.Net.Http.HttpClient(), port, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5));
|
||||
builder.Services.AddSingleton(manager); builder.Services.AddSingleton(relay); builder.Services.AddControllers().AddApplicationPart(typeof(ContinuityController).Assembly);
|
||||
await using var app = builder.Build(); app.MapControllers(); app.MapGet("/fixture/{size}.ts", Fixture); await app.StartAsync();
|
||||
@@ -49,6 +50,7 @@ public static class ResolutionE2E
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(recording.WaitForExitAsync(), packaging.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(65));
|
||||
Console.WriteLine("Source status: " + JsonSerializer.Serialize(manager.Status()));
|
||||
Require(recording.ExitCode == 0 && packaging.ExitCode == 0, "strict slate/live read and fMP4 packaging: " + size);
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Plugin.Enigma2;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
/// <summary>Self-authored DVB bitmap fixtures; no broadcaster samples or receiver connection.</summary>
|
||||
public static class SyntheticBroadcastFixture
|
||||
{
|
||||
public static BroadcastTracks Tracks(bool normalize = true) => new(new MediaStream[] {
|
||||
new BroadcastMediaStream { Type=MediaStreamType.Video,Index=0,TransportId=256,Codec="h264",Width=640,Height=360 },
|
||||
new BroadcastMediaStream { Type=MediaStreamType.Audio,Index=1,TransportId=257,Codec="aac",Language="pol",Title="Polski",Channels=2,SampleRate=48000,IsDefault=true },
|
||||
new BroadcastMediaStream { Type=MediaStreamType.Audio,Index=2,TransportId=258,Codec="aac",Language="eng",Title="English",Channels=2,SampleRate=48000 },
|
||||
new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=3,TransportId=259,Codec="DVBSUB",Language="pol",SubtitleExtraData=new byte[]{0,1,0,1},IsHearingImpaired=true },
|
||||
new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=4,TransportId=260,Codec="DVBSUB",Language="eng",SubtitleExtraData=new byte[]{0,1,0,1},IsForced=true },
|
||||
new BroadcastMediaStream { Type=MediaStreamType.Subtitle,Index=5,TransportId=261,Codec="DVBTXT",Language="pol",SubtitleExtraData=new byte[]{16,136} }
|
||||
},normalize);
|
||||
private static long Pts(byte[] b,int p) => ((long)(b[p]&14)<<29)|((long)b[p+1]<<22)|((long)(b[p+2]&254)<<14)|((long)b[p+3]<<7)|((long)b[p+4]>>1);
|
||||
private static byte[] Stamp(long t) => new[]{(byte)(33L|((t>>29)&14)),(byte)(t>>22),(byte)(1L|((t>>14)&254)),(byte)(t>>7),(byte)(1L|(t<<1))};
|
||||
public static byte[] AddSubtitles(byte[] input)
|
||||
{
|
||||
input=BroadcastSlateProgram.Apply(input,Tracks(),false);
|
||||
using var output=new MemoryStream();long next=0;var counters=new int[3];
|
||||
for(var pos=0;pos<input.Length;pos+=188)
|
||||
{
|
||||
output.Write(input,pos,188);var pid=((input[pos+1]&31)<<8)|input[pos+2];
|
||||
if(pid!=256||(input[pos+1]&64)==0)continue;
|
||||
var p=pos+4+((input[pos+3]&32)!=0?1+input[pos+4]:0);
|
||||
if(p+14>pos+188||input[p]!=0||input[p+1]!=0||input[p+2]!=1||(input[p+7]&128)==0)continue;
|
||||
var pts=Pts(input,p+9);if(pts<next)continue;next=pts+180000;
|
||||
for(var track=0;track<3;track++)
|
||||
foreach(var packet in Packets(track==2?Teletext():Bitmap(track),pts,259+track,ref counters[track]))output.Write(packet);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
public static byte[][] Packets(byte[] bitmap,long pts,int pid,ref int counter)
|
||||
{
|
||||
var pes=new List<byte>{0,0,1,189};var length=bitmap.Length+8;
|
||||
pes.Add((byte)(length>>8));pes.Add((byte)length);pes.AddRange(new byte[]{128,128,5});pes.AddRange(Stamp(pts));pes.AddRange(bitmap);
|
||||
var result=new List<byte[]>();
|
||||
for(var i=0;i<pes.Count;)
|
||||
{
|
||||
var packet=Enumerable.Repeat((byte)255,188).ToArray();packet[0]=71;packet[1]=(byte)((pid>>8)|(i==0?64:0));packet[2]=(byte)pid;
|
||||
var count=Math.Min(184,pes.Count-i);var p=4;
|
||||
if(count<184){packet[3]=(byte)(48|(counter++&15));packet[4]=(byte)(183-count);if(packet[4]>0)packet[5]=0;p+=184-count;}
|
||||
else packet[3]=(byte)(16|(counter++&15));
|
||||
pes.CopyTo(i,packet,p,count);i+=count;result.Add(packet);
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
private static byte[] Teletext()
|
||||
{
|
||||
// A self-authored page 888, using standard Hamming-address and odd-parity text bytes.
|
||||
byte Reverse(byte value) { byte result=0;for(var bit=0;bit<8;bit++)result=(byte)((result<<1)|((value>>bit)&1));return result; }
|
||||
var hamming=new byte[]{21,2,73,94,100,115,56,47,208,199,140,155,161,182,253,234};
|
||||
byte Character(char c) { var value=(byte)(c&127);if(System.Numerics.BitOperations.PopCount((uint)value)%2==0)value|=128;return Reverse(value); }
|
||||
var result=new List<byte>{16};
|
||||
void Row(int row,byte[] text){result.AddRange(new byte[]{3,44,199,228,Reverse(hamming[(row&1)<<3]),Reverse(hamming[row>>1])});result.AddRange(text);}
|
||||
var header=new List<byte>();foreach(var n in new[]{8,8,0,8,0,8,0,0})header.Add(Reverse(hamming[n]));header.AddRange(Enumerable.Repeat(Character(' '),32));Row(0,header.ToArray());
|
||||
Row(1,"SYNTHETIC TELETEXT SUBTITLE".PadRight(40).Select(Character).ToArray());return result.ToArray();
|
||||
}
|
||||
public static byte[] Bitmap(int variant)
|
||||
{
|
||||
var result=new List<byte>{32,0};
|
||||
void Segment(byte type,byte[] data){result.AddRange(new byte[]{15,type,0,1,(byte)(data.Length>>8),(byte)data.Length});result.AddRange(data);}
|
||||
Segment(20,new byte[]{0,2,127,1,103}); // display 640 x 360
|
||||
Segment(16,new byte[]{2,11,0,255,0,64,1,44});
|
||||
Segment(17,new byte[]{0,7,0,96,0,32,39,0,0,3,0,0,0,0,240,0});
|
||||
var field=new List<byte>();
|
||||
for(var y=0;y<16;y++)
|
||||
{
|
||||
field.Add(16);
|
||||
for(var x=0;x<24;x++)field.Add((byte)((x/4+y/4+variant)%2==0?85:170));
|
||||
field.Add(0);field.Add(240);
|
||||
}
|
||||
var data=new List<byte>{0,0,0,(byte)(field.Count>>8),(byte)field.Count,(byte)(field.Count>>8),(byte)field.Count};
|
||||
data.AddRange(field);data.AddRange(field);Segment(19,data.ToArray());Segment(128,Array.Empty<byte>());result.Add(255);
|
||||
return result.ToArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user