Browse Source

generate slug instead of probing and transcoding resource (#2824)

* generate slug instead of probing and using slug resource

* refactor

* more fixes
pull/2825/head
Jason Dove 6 months ago committed by GitHub
parent
commit
c0b8ff1a06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  2. 79
      ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs
  3. 258
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  4. 2
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  5. 2
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  6. 2
      ErsatzTV.Core/FFmpeg/FFmpegProgress.cs
  7. 8
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  8. 8
      ErsatzTV.FFmpeg/InputFile.cs
  9. 1
      ErsatzTV/ErsatzTV.csproj
  10. BIN
      ErsatzTV/Resources/slug.mp4
  11. 1
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

4
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -393,7 +393,8 @@ public class HlsSessionWorker : IHlsSessionWorker
HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime, HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime,
// after completing the item, insert a slug // after completing the item, insert a slug
_ when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead, HlsSessionState.ZeroAndWorkAhead or HlsSessionState.SeekAndWorkAhead when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead,
HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndRealtime,
// after seeking and completing the item, start at zero // after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead, HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
@ -454,6 +455,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime, HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime, HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
HlsSessionState.SlugAndWorkAhead => HlsSessionState.SlugAndRealtime,
_ => _state _ => _state
}; };

79
ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs

@ -1,22 +1,15 @@
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming; namespace ErsatzTV.Application.Streaming;
public class GetSlugProcessByChannelNumberHandler( public class GetSlugProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem, IFFmpegProcessService ffmpegProcessService)
IFFmpegProcessService ffmpegProcessService,
ILocalStatisticsProvider localStatisticsProvider)
: FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory) : FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory)
{ {
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@ -27,82 +20,30 @@ public class GetSlugProcessByChannelNumberHandler(
string ffprobePath, string ffprobePath,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
string videoPath = fileSystem.Path.Combine(FileSystemLayout.ResourcesCacheFolder, "slug.mp4"); var duration = TimeSpan.FromSeconds(await request.SlugSeconds.IfNoneAsync(0));
if (duration <= TimeSpan.Zero)
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken)
.Map(result => result.IfNone(false));
Either<BaseError, MediaVersion> maybeVersion =
await localStatisticsProvider.GetStatistics(ffprobePath, videoPath);
foreach (var error in maybeVersion.LeftToSeq())
{
return error;
}
var version = maybeVersion.RightToSeq().Head();
var mediaItem = new OtherVideo
{
MediaVersions = [version]
};
TimeSpan duration = version.Duration;
foreach (double slugSeconds in request.SlugSeconds)
{ {
TimeSpan seconds = TimeSpan.FromSeconds(slugSeconds); return BaseError.New("Slug seconds must be non-zero");
if (seconds > TimeSpan.Zero && seconds < duration)
{
duration = seconds;
}
} }
DateTimeOffset finish = request.Now.Add(duration); DateTimeOffset finish = request.Now.Add(duration);
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem( var playoutItemResult = await ffmpegProcessService.Slug(
ffmpegPath, ffmpegPath,
ffprobePath,
saveReports,
channel, channel,
new MediaItemVideoVersion(mediaItem, version),
new MediaItemAudioVersion(mediaItem, version),
videoPath,
videoPath,
_ => Task.FromResult<List<Subtitle>>([]),
string.Empty,
string.Empty,
string.Empty,
ChannelSubtitleMode.None,
request.Now,
finish,
request.Now, request.Now,
duration, duration,
[],
[],
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
request.HlsRealtime, request.HlsRealtime,
StreamInputKind.Vod, request.PtsOffset);
FillerKind.None,
inPoint: TimeSpan.Zero,
request.ChannelStartTime,
request.PtsOffset,
request.TargetFramerate,
Option<string>.None,
_ => { },
canProxy: true,
cancellationToken);
var result = new PlayoutItemProcessModel( var result = new PlayoutItemProcessModel(
playoutItemResult.Process, playoutItemResult,
playoutItemResult.GraphicsEngineContext, Option<GraphicsEngineContext>.None,
duration, duration,
finish, finish,
isComplete: true, isComplete: true,
request.Now.ToUnixTimeSeconds(), request.Now.ToUnixTimeSeconds(),
playoutItemResult.MediaItemId, Option<int>.None,
Optional(channel.PlayoutOffset), Optional(channel.PlayoutOffset),
!request.HlsRealtime); !request.HlsRealtime);

258
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -665,7 +665,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string vaapiDevice, string vaapiDevice,
Option<int> qsvExtraHardwareFrames) Option<int> qsvExtraHardwareFrames)
{ {
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateErrorSettings( FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
channel.StreamingMode, channel.StreamingMode,
channel.FFmpegProfile, channel.FFmpegProfile,
hlsRealtime); hlsRealtime);
@ -726,57 +726,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.NormalizeColors, playbackSettings.NormalizeColors,
playbackSettings.Deinterlace); playbackSettings.Deinterlace);
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
switch (channel.StreamingMode)
{
case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.Hls;
break;
}
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None;
long nowSeconds = now.ToUnixTimeSeconds();
Option<string> hlsSegmentTemplate = outputFormat switch
{
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
OutputFormatKind.HlsMp4 => Path.Combine(
FileSystemLayout.TranscodeFolder,
channel.Number,
$"live_{nowSeconds}_%06d.m4s"),
_ => Option<string>.None
};
Option<string> hlsInitTemplate = outputFormat switch
{
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
_ => Option<string>.None
};
Option<string> hlsSegmentOptions = Option<string>.None;
if (outputFormat is OutputFormatKind.Hls)
{
string options = string.Empty;
if (ptsOffset == TimeSpan.Zero)
{
options += "+initial_discontinuity";
}
if (audioFormat == AudioFormat.AacLatm)
{
options += "+latm";
}
if (!string.IsNullOrWhiteSpace(options))
{
hlsSegmentOptions = $"mpegts_flags={options}";
}
}
string videoPath = _localFileSystem.GetCustomOrDefaultFile( string videoPath = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ResourcesCacheFolder, FileSystemLayout.ResourcesCacheFolder,
"background.png"); "background.png");
@ -802,6 +751,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings); HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel); _logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
var ffmpegState = new FFmpegState( var ffmpegState = new FFmpegState(
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel, channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
HardwareAccelerationMode.None, // no hw accel decode since errors loop HardwareAccelerationMode.None, // no hw accel decode since errors loop
@ -816,11 +767,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None, None,
None, None,
None, None,
outputFormat, hlsOptions.OutputFormat,
hlsPlaylistPath, hlsOptions.HlsPlaylistPath,
hlsSegmentTemplate, hlsOptions.HlsSegmentTemplate,
hlsInitTemplate, hlsOptions.HlsInitTemplate,
hlsSegmentOptions, hlsOptions.HlsSegmentOptions,
ptsOffset, ptsOffset,
Option<int>.None, Option<int>.None,
qsvExtraHardwareFrames, qsvExtraHardwareFrames,
@ -862,6 +813,136 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline); return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
} }
public async Task<Command> Slug(
string ffmpegPath,
Channel channel,
DateTimeOffset now,
TimeSpan duration,
bool hlsRealtime,
TimeSpan ptsOffset)
{
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
channel.StreamingMode,
channel.FFmpegProfile,
hlsRealtime);
Resolution desiredResolution = channel.FFmpegProfile.Resolution;
string audioFormat = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm,
_ => AudioFormat.Aac
};
var audioState = new AudioState(
audioFormat,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
false,
AudioFilter.None,
playbackSettings.TargetLoudness);
string videoFormat = GetVideoFormat(playbackSettings);
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
InfiniteLoop: false,
videoFormat,
GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile),
VideoPreset.Unset,
channel.FFmpegProfile.AllowBFrames,
new PixelFormatYuv420P(),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
Option<FrameSize>.None,
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
? FFmpegFilterMode.HardwareIfPossible
: FFmpegFilterMode.Software,
IsAnamorphic: false,
Option<FrameRate>.None,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace);
var frameRate = playbackSettings.FrameRate.IfNone(new FrameRate("24"));
var ffmpegVideoStream = new VideoStream(
0,
VideoFormat.GeneratedImage,
string.Empty,
new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
ColorParams.Default,
desiredState.PaddedSize,
MaybeSampleAspectRatio: "1:1",
DisplayAspectRatio: string.Empty,
frameRate,
true,
ScanKind.Progressive);
var videoInputFile = new LavfiInputFile(
$"color=c=black:s={desiredState.PaddedSize.Width}x{desiredState.PaddedSize.Height}:r={frameRate.FrameRateString}:d={duration.TotalSeconds}",
ffmpegVideoStream);
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
var ffmpegState = new FFmpegState(
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
HardwareAccelerationMode.None, // no hw accel decode since errors loop
hwAccel,
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
playbackSettings.StreamSeek,
duration,
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
"ErsatzTV",
channel.Name,
None,
None,
None,
hlsOptions.OutputFormat,
hlsOptions.HlsPlaylistPath,
hlsOptions.HlsSegmentTemplate,
hlsOptions.HlsInitTemplate,
hlsOptions.HlsSegmentOptions,
ptsOffset,
Option<int>.None,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
var audioInputFile = new NullAudioInputFile(audioState);
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
hwAccel,
videoInputFile,
audioInputFile,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
Option<ConcatInputFile>.None,
Option<GraphicsEngineInput>.None,
VaapiDisplayName(hwAccel, channel.FFmpegProfile.VaapiDisplay),
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
? FileSystemLayout.TranscodeTroubleshootingFolder
: FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
}
public async Task<Command> ConcatChannel( public async Task<Command> ConcatChannel(
string ffmpegPath, string ffmpegPath,
bool saveReports, bool saveReports,
@ -1260,4 +1341,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return false; return false;
} }
private static HlsOptions GetHlsOptions(Channel channel, DateTimeOffset now, TimeSpan ptsOffset, string audioFormat)
{
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
switch (channel.StreamingMode)
{
case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.Hls;
break;
}
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None;
long nowSeconds = now.ToUnixTimeSeconds();
Option<string> hlsSegmentTemplate = outputFormat switch
{
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
OutputFormatKind.HlsMp4 => Path.Combine(
FileSystemLayout.TranscodeFolder,
channel.Number,
$"live_{nowSeconds}_%06d.m4s"),
_ => Option<string>.None
};
Option<string> hlsInitTemplate = outputFormat switch
{
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
_ => Option<string>.None
};
Option<string> hlsSegmentOptions = Option<string>.None;
if (outputFormat is OutputFormatKind.Hls)
{
string options = string.Empty;
if (ptsOffset == TimeSpan.Zero)
{
options += "+initial_discontinuity";
}
if (audioFormat == AudioFormat.AacLatm)
{
options += "+latm";
}
if (!string.IsNullOrWhiteSpace(options))
{
hlsSegmentOptions = $"mpegts_flags={options}";
}
}
return new HlsOptions(outputFormat, hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, hlsSegmentOptions);
}
private sealed record HlsOptions(
OutputFormatKind OutputFormat,
Option<string> HlsPlaylistPath,
Option<string> HlsSegmentTemplate,
Option<string> HlsInitTemplate,
Option<string> HlsSegmentOptions);
} }

2
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -181,7 +181,7 @@ public static class FFmpegPlaybackSettingsCalculator
return result; return result;
} }
public static FFmpegPlaybackSettings CalculateErrorSettings( public static FFmpegPlaybackSettings CalculateGeneratedImageSettings(
StreamingMode streamingMode, StreamingMode streamingMode,
FFmpegProfile ffmpegProfile, FFmpegProfile ffmpegProfile,
bool hlsRealtime) => bool hlsRealtime) =>

2
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -71,7 +71,7 @@ public class FFmpegProcessService
} }
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettings playbackSettings =
FFmpegPlaybackSettingsCalculator.CalculateErrorSettings( FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
StreamingMode.TransportStream, StreamingMode.TransportStream,
channel.FFmpegProfile, channel.FFmpegProfile,
false); false);

2
ErsatzTV.Core/FFmpeg/FFmpegProgress.cs

@ -39,7 +39,7 @@ public partial class FFmpegProgress
speed); speed);
} }
} }
else if (speed < 1.0) else if (speed < 0.99)
{ {
logger.LogWarning( logger.LogWarning(
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (throttled) which may not be fast enough to support playback", "Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (throttled) which may not be fast enough to support playback",

8
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -58,6 +58,14 @@ public interface IFFmpegProcessService
string vaapiDevice, string vaapiDevice,
Option<int> qsvExtraHardwareFrames); Option<int> qsvExtraHardwareFrames);
Task<Command> Slug(
string ffmpegPath,
Channel channel,
DateTimeOffset now,
TimeSpan duration,
bool hlsRealtime,
TimeSpan ptsOffset);
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host); Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> WrapSegmenter( Task<Command> WrapSegmenter(

8
ErsatzTV.FFmpeg/InputFile.cs

@ -60,7 +60,7 @@ public record NullAudioInputFile : AudioInputFile
DesiredState) => DesiredState) =>
InputOptions.Add(new LavfiInputOption()); InputOptions.Add(new LavfiInputOption());
public void Deconstruct(out AudioState DesiredState) => DesiredState = this.DesiredState; public void Deconstruct(out AudioState desiredState) => desiredState = DesiredState;
} }
public record VideoInputFile( public record VideoInputFile(
@ -79,6 +79,12 @@ public record VideoInputFile(
} }
} }
public record LavfiInputFile : VideoInputFile
{
public LavfiInputFile(string parameters, VideoStream videoStream) : base(parameters, [videoStream])
=> InputOptions.Add(new LavfiInputOption());
}
public record WatermarkInputFile(string Path, IList<VideoStream> VideoStreams, WatermarkState DesiredState) public record WatermarkInputFile(string Path, IList<VideoStream> VideoStreams, WatermarkState DesiredState)
: VideoInputFile(Path, VideoStreams); : VideoInputFile(Path, VideoStreams);

1
ErsatzTV/ErsatzTV.csproj

@ -119,7 +119,6 @@
<Generator>ResXFileCodeGenerator</Generator> <Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Common.Designer.cs</LastGenOutput> <LastGenOutput>Common.Designer.cs</LastGenOutput>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Resources\slug.mp4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

BIN
ErsatzTV/Resources/slug.mp4

Binary file not shown.

1
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -27,7 +27,6 @@ public class ResourceExtractorService : BackgroundService
await ExtractResource(assembly, "sequential-schedule.schema.json", stoppingToken); await ExtractResource(assembly, "sequential-schedule.schema.json", stoppingToken);
await ExtractResource(assembly, "sequential-schedule-import.schema.json", stoppingToken); await ExtractResource(assembly, "sequential-schedule-import.schema.json", stoppingToken);
await ExtractResource(assembly, "test.avs", stoppingToken); await ExtractResource(assembly, "test.avs", stoppingToken);
await ExtractResource(assembly, "slug.mp4", stoppingToken);
await ExtractFontResource(assembly, "Sen.ttf", stoppingToken); await ExtractFontResource(assembly, "Sen.ttf", stoppingToken);
await ExtractFontResource(assembly, "Roboto-Regular.ttf", stoppingToken); await ExtractFontResource(assembly, "Roboto-Regular.ttf", stoppingToken);

Loading…
Cancel
Save