From c691dba8e81cb675292cd063b2694580fcf02e70 Mon Sep 17 00:00:00 2001 From: Malte Rosenbjerg Date: Thu, 3 Dec 2020 20:47:20 +0100 Subject: [PATCH 01/12] Init Former-commit-id: 8b45a6b680870b3a7b31ebb10fbf6a8e7e754072 --- FFMpegCore.Test/VideoTest.cs | 17 +++++++++++++++++ .../FFMpeg/Arguments/InputPipeArgument.cs | 3 ++- .../FFMpeg/Arguments/OutputPipeArgument.cs | 1 + FFMpegCore/FFMpeg/Arguments/PipeArgument.cs | 1 - FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs | 8 ++++---- FFMpegCore/FFMpeg/Pipes/IPipeSource.cs | 2 +- FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs | 11 ++++++----- FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs | 6 ++---- FFMpegCore/FFMpegCore.csproj.DotSettings | 2 ++ FFMpegCore/FFProbe/FFProbe.cs | 2 +- 10 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 FFMpegCore/FFMpegCore.csproj.DotSettings diff --git a/FFMpegCore.Test/VideoTest.cs b/FFMpegCore.Test/VideoTest.cs index e4f750d..f4553ef 100644 --- a/FFMpegCore.Test/VideoTest.cs +++ b/FFMpegCore.Test/VideoTest.cs @@ -312,6 +312,23 @@ await FFMpegArguments .ProcessAsynchronously(); }); } + [TestMethod, Timeout(10000)] + public void Video_StreamFile_OutputToMemoryStream() + { + // using var input = File.OpenRead(VideoLibrary.LocalVideo.FullName); + var output = new MemoryStream(); + + FFMpegArguments + // .FromFileInput(VideoLibrary.LocalVideo.FullName) + .FromPipeInput(new StreamPipeSource(File.OpenRead(VideoLibrary.LocalVideoWebm.FullName)), options => options.ForceFormat("webm")) + .OutputToPipe(new StreamPipeSink(output), options => options + .ForceFormat("mp4")) + .ProcessSynchronously(); + + output.Position = 0; + var result = FFProbe.Analyse(output); + Console.WriteLine(result.Duration); + } [TestMethod, Timeout(10000)] public void Video_ToMP4_Args_StreamOutputPipe_Failure() diff --git a/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs b/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs index adc25fb..685a019 100644 --- a/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs @@ -17,7 +17,7 @@ public InputPipeArgument(IPipeSource writer) : base(PipeDirection.Out) Writer = writer; } - public override string Text => $"-y {Writer.GetFormat()} -i \"{PipePath}\""; + public override string Text => $"{(!string.IsNullOrEmpty(Writer.Format) ? $"-f {Writer.Format} " : string.Empty)}-i \"{PipePath}\""; protected override async Task ProcessDataAsync(CancellationToken token) { @@ -25,6 +25,7 @@ protected override async Task ProcessDataAsync(CancellationToken token) if (!Pipe.IsConnected) throw new TaskCanceledException(); await Writer.WriteAsync(Pipe, token).ConfigureAwait(false); + Pipe.Disconnect(); } } } diff --git a/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs b/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs index f089a1e..a2cf9be 100644 --- a/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs @@ -22,6 +22,7 @@ protected override async Task ProcessDataAsync(CancellationToken token) if (!Pipe.IsConnected) throw new TaskCanceledException(); await Reader.ReadAsync(Pipe, token).ConfigureAwait(false); + Pipe.Disconnect(); } } } diff --git a/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs b/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs index 4a6113a..3225b6c 100644 --- a/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs @@ -43,7 +43,6 @@ public async Task During(CancellationToken cancellationToken = default) catch (TaskCanceledException) { } - Pipe.Disconnect(); } protected abstract Task ProcessDataAsync(CancellationToken token); diff --git a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs index 8a30dfc..424b598 100644 --- a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs +++ b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs @@ -65,8 +65,8 @@ void OnCancelEvent(object sender, EventArgs args) { errorCode = t.Result; cancellationTokenSource.Cancel(); - _ffMpegArguments.Post(); - }), _ffMpegArguments.During(cancellationTokenSource.Token)); + // _ffMpegArguments.Post(); + }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())); } catch (Exception e) { @@ -111,8 +111,8 @@ await Task.WhenAll(instance.FinishedRunning().ContinueWith(t => { errorCode = t.Result; cancellationTokenSource.Cancel(); - _ffMpegArguments.Post(); - }), _ffMpegArguments.During(cancellationTokenSource.Token)).ConfigureAwait(false); + // _ffMpegArguments.Post(); + }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())).ConfigureAwait(false); } catch (Exception e) { diff --git a/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs b/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs index 55fdcc3..5fde4ab 100644 --- a/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs +++ b/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs @@ -8,7 +8,7 @@ namespace FFMpegCore.Pipes /// public interface IPipeSource { - string GetFormat(); + string Format { get; } Task WriteAsync(System.IO.Stream outputStream, CancellationToken cancellationToken); } } diff --git a/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs b/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs index eef4343..de3669e 100644 --- a/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs +++ b/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs @@ -11,9 +11,10 @@ namespace FFMpegCore.Pipes /// public class RawVideoPipeSource : IPipeSource { - public string StreamFormat { get; private set; } = null!; public int Width { get; private set; } public int Height { get; private set; } + + public string Format { get; private set; } public int FrameRate { get; set; } = 25; private bool _formatInitialized; private readonly IEnumerator _framesEnumerator; @@ -35,14 +36,14 @@ public string GetFormat() if (!_framesEnumerator.MoveNext()) throw new InvalidOperationException("Enumerator is empty, unable to get frame"); } - StreamFormat = _framesEnumerator.Current!.Format; + Format = _framesEnumerator.Current!.Format; Width = _framesEnumerator.Current!.Width; Height = _framesEnumerator.Current!.Height; _formatInitialized = true; } - return $"-f rawvideo -r {FrameRate} -pix_fmt {StreamFormat} -s {Width}x{Height}"; + return $"-f rawvideo -r {FrameRate} -pix_fmt {Format} -s {Width}x{Height}"; } public async Task WriteAsync(System.IO.Stream outputStream, CancellationToken cancellationToken) @@ -62,10 +63,10 @@ public async Task WriteAsync(System.IO.Stream outputStream, CancellationToken ca private void CheckFrameAndThrow(IVideoFrame frame) { - if (frame.Width != Width || frame.Height != Height || frame.Format != StreamFormat) + if (frame.Width != Width || frame.Height != Height || frame.Format != Format) throw new FFMpegException(FFMpegExceptionType.Operation, "Video frame is not the same format as created raw video stream\r\n" + $"Frame format: {frame.Width}x{frame.Height} pix_fmt: {frame.Format}\r\n" + - $"Stream format: {Width}x{Height} pix_fmt: {StreamFormat}"); + $"Stream format: {Width}x{Height} pix_fmt: {Format}"); } } } diff --git a/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs b/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs index b364037..5d9e666 100644 --- a/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs +++ b/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs @@ -10,15 +10,13 @@ public class StreamPipeSource : IPipeSource { public System.IO.Stream Source { get; } public int BlockSize { get; } = 4096; - public string StreamFormat { get; } = string.Empty; + + public string Format { get; } public StreamPipeSource(System.IO.Stream source) { Source = source; } - public Task WriteAsync(System.IO.Stream outputStream, CancellationToken cancellationToken) => Source.CopyToAsync(outputStream, BlockSize, cancellationToken); - - public string GetFormat() => StreamFormat; } } diff --git a/FFMpegCore/FFMpegCore.csproj.DotSettings b/FFMpegCore/FFMpegCore.csproj.DotSettings new file mode 100644 index 0000000..69be7ec --- /dev/null +++ b/FFMpegCore/FFMpegCore.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/FFMpegCore/FFProbe/FFProbe.cs b/FFMpegCore/FFProbe/FFProbe.cs index 5b7a1e4..5c21b9b 100644 --- a/FFMpegCore/FFProbe/FFProbe.cs +++ b/FFMpegCore/FFProbe/FFProbe.cs @@ -106,7 +106,7 @@ private static Instance PrepareInstance(string filePath, int outputCapacity) { FFProbeHelper.RootExceptionCheck(); FFProbeHelper.VerifyFFProbeExists(); - var arguments = $"-print_format json -show_format -sexagesimal -show_streams \"{filePath}\""; + var arguments = $"-loglevel error -print_format json -show_format -sexagesimal -show_streams \"{filePath}\""; var instance = new Instance(FFMpegOptions.Options.FFProbeBinary(), arguments) {DataBufferCapacity = outputCapacity}; return instance; } From ec671ff8bf42321266e3df5e1298cd9b98eb0e20 Mon Sep 17 00:00:00 2001 From: Malte Rosenbjerg Date: Mon, 7 Dec 2020 00:47:47 +0100 Subject: [PATCH 02/12] Dump Former-commit-id: 3ad127a6821699a2d5a0d827387dd1097c383663 --- FFMpegCore.Test/ArgumentBuilderTest.cs | 2 +- FFMpegCore.Test/AudioTest.cs | 82 ++- FFMpegCore.Test/BaseTest.cs | 15 - FFMpegCore.Test/FFProbeTests.cs | 22 +- FFMpegCore.Test/Resources/TestResources.cs | 27 + FFMpegCore.Test/Resources/VideoLibrary.cs | 51 -- FFMpegCore.Test/TemporaryFile.cs | 22 + FFMpegCore.Test/VideoTest.cs | 520 ++++++++----------- FFMpegCore/FFMpeg/Enums/ContainerFormat.cs | 14 +- FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs | 4 +- 10 files changed, 313 insertions(+), 446 deletions(-) delete mode 100644 FFMpegCore.Test/BaseTest.cs create mode 100644 FFMpegCore.Test/Resources/TestResources.cs delete mode 100644 FFMpegCore.Test/Resources/VideoLibrary.cs create mode 100644 FFMpegCore.Test/TemporaryFile.cs diff --git a/FFMpegCore.Test/ArgumentBuilderTest.cs b/FFMpegCore.Test/ArgumentBuilderTest.cs index f4dbfe9..de625e2 100644 --- a/FFMpegCore.Test/ArgumentBuilderTest.cs +++ b/FFMpegCore.Test/ArgumentBuilderTest.cs @@ -6,7 +6,7 @@ namespace FFMpegCore.Test { [TestClass] - public class ArgumentBuilderTest : BaseTest + public class ArgumentBuilderTest { private readonly string[] _concatFiles = { "1.mp4", "2.mp4", "3.mp4", "4.mp4"}; diff --git a/FFMpegCore.Test/AudioTest.cs b/FFMpegCore.Test/AudioTest.cs index 552ca24..378b7f7 100644 --- a/FFMpegCore.Test/AudioTest.cs +++ b/FFMpegCore.Test/AudioTest.cs @@ -3,80 +3,60 @@ using FFMpegCore.Test.Resources; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.IO; +using System.Linq; namespace FFMpegCore.Test { [TestClass] - public class AudioTest : BaseTest + public class AudioTest { [TestMethod] public void Audio_Remove() { - var output = Input.OutputLocation(VideoType.Mp4); - - try - { - FFMpeg.Mute(Input.FullName, output); - Assert.IsTrue(File.Exists(output)); - } - finally - { - if (File.Exists(output)) File.Delete(output); - } + using var outputFile = new TemporaryFile("out.mp4"); + + FFMpeg.Mute(TestResources.Mp4Video, outputFile); + var analysis = FFProbe.Analyse(outputFile); + + Assert.IsTrue(analysis.VideoStreams.Any()); + Assert.IsTrue(!analysis.AudioStreams.Any()); } [TestMethod] public void Audio_Save() { - var output = Input.OutputLocation(AudioType.Mp3); - - try - { - FFMpeg.ExtractAudio(Input.FullName, output); - Assert.IsTrue(File.Exists(output)); - } - finally - { - if (File.Exists(output)) File.Delete(output); - } + using var outputFile = new TemporaryFile("out.mp3"); + + FFMpeg.ExtractAudio(TestResources.Mp4Video, outputFile); + var analysis = FFProbe.Analyse(outputFile); + + Assert.IsTrue(!analysis.VideoStreams.Any()); + Assert.IsTrue(analysis.AudioStreams.Any()); } [TestMethod] public void Audio_Add() { - var output = Input.OutputLocation(VideoType.Mp4); - try - { - var success = FFMpeg.ReplaceAudio(VideoLibrary.LocalVideoNoAudio.FullName, VideoLibrary.LocalAudio.FullName, output); - Assert.IsTrue(success); - var audioAnalysis = FFProbe.Analyse(VideoLibrary.LocalVideoNoAudio.FullName); - var videoAnalysis = FFProbe.Analyse(VideoLibrary.LocalAudio.FullName); - var outputAnalysis = FFProbe.Analyse(output); - Assert.AreEqual(Math.Max(videoAnalysis.Duration.TotalSeconds, audioAnalysis.Duration.TotalSeconds), outputAnalysis.Duration.TotalSeconds, 0.15); - Assert.IsTrue(File.Exists(output)); - } - finally - { - if (File.Exists(output)) File.Delete(output); - } + using var outputFile = new TemporaryFile("out.mp4"); + + var success = FFMpeg.ReplaceAudio(TestResources.Mp4WithoutAudio, TestResources.Mp3Audio, outputFile); + var videoAnalysis = FFProbe.Analyse(TestResources.Mp4WithoutAudio); + var audioAnalysis = FFProbe.Analyse(TestResources.Mp3Audio); + var outputAnalysis = FFProbe.Analyse(outputFile); + + Assert.IsTrue(success); + Assert.AreEqual(Math.Max(videoAnalysis.Duration.TotalSeconds, audioAnalysis.Duration.TotalSeconds), outputAnalysis.Duration.TotalSeconds, 0.15); + Assert.IsTrue(File.Exists(outputFile)); } [TestMethod] public void Image_AddAudio() { - var output = Input.OutputLocation(VideoType.Mp4); - - try - { - FFMpeg.PosterWithAudio(VideoLibrary.LocalCover.FullName, VideoLibrary.LocalAudio.FullName, output); - var analysis = FFProbe.Analyse(VideoLibrary.LocalAudio.FullName); - Assert.IsTrue(analysis.Duration.TotalSeconds > 0); - Assert.IsTrue(File.Exists(output)); - } - finally - { - if (File.Exists(output)) File.Delete(output); - } + using var outputFile = new TemporaryFile("out.mp4"); + FFMpeg.PosterWithAudio(TestResources.PngImage, TestResources.Mp3Audio, outputFile); + var analysis = FFProbe.Analyse(TestResources.Mp3Audio); + Assert.IsTrue(analysis.Duration.TotalSeconds > 0); + Assert.IsTrue(File.Exists(outputFile)); } } } \ No newline at end of file diff --git a/FFMpegCore.Test/BaseTest.cs b/FFMpegCore.Test/BaseTest.cs deleted file mode 100644 index 38a5bcc..0000000 --- a/FFMpegCore.Test/BaseTest.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FFMpegCore.Test.Resources; -using System.IO; - -namespace FFMpegCore.Test -{ - public class BaseTest - { - protected FileInfo Input; - - public BaseTest() - { - Input = VideoLibrary.LocalVideo; - } - } -} \ No newline at end of file diff --git a/FFMpegCore.Test/FFProbeTests.cs b/FFMpegCore.Test/FFProbeTests.cs index 7fe928e..fe5f9f9 100644 --- a/FFMpegCore.Test/FFProbeTests.cs +++ b/FFMpegCore.Test/FFProbeTests.cs @@ -11,16 +11,26 @@ public class FFProbeTests [TestMethod] public void Probe_TooLongOutput() { - Assert.ThrowsException(() => FFProbe.Analyse(VideoLibrary.LocalVideo.FullName, 5)); + Assert.ThrowsException(() => FFProbe.Analyse(TestResources.Mp4Video, 5)); + } + + + [TestMethod] + public async Task Audio_FromStream_Duration() + { + var fileAnalysis = await FFProbe.AnalyseAsync(TestResources.Mp4Video); + await using var inputStream = File.OpenRead(TestResources.Mp4Video); + var streamAnalysis = await FFProbe.AnalyseAsync(inputStream); + Assert.IsTrue(fileAnalysis.Duration == streamAnalysis.Duration); } [TestMethod] public void Probe_Success() { - var info = FFProbe.Analyse(VideoLibrary.LocalVideo.FullName); + var info = FFProbe.Analyse(TestResources.Mp4Video); Assert.AreEqual(3, info.Duration.Seconds); Assert.AreEqual(".mp4", info.Extension); - Assert.AreEqual(VideoLibrary.LocalVideo.FullName, info.Path); + Assert.AreEqual(TestResources.Mp4Video, info.Path); Assert.AreEqual("5.1", info.PrimaryAudioStream.ChannelLayout); Assert.AreEqual(6, info.PrimaryAudioStream.Channels); @@ -47,14 +57,14 @@ public void Probe_Success() [TestMethod, Timeout(10000)] public async Task Probe_Async_Success() { - var info = await FFProbe.AnalyseAsync(VideoLibrary.LocalVideo.FullName); + var info = await FFProbe.AnalyseAsync(TestResources.Mp4Video); Assert.AreEqual(3, info.Duration.Seconds); } [TestMethod, Timeout(10000)] public void Probe_Success_FromStream() { - using var stream = File.OpenRead(VideoLibrary.LocalVideoWebm.FullName); + using var stream = File.OpenRead(TestResources.WebmVideo); var info = FFProbe.Analyse(stream); Assert.AreEqual(3, info.Duration.Seconds); } @@ -62,7 +72,7 @@ public void Probe_Success_FromStream() [TestMethod, Timeout(10000)] public async Task Probe_Success_FromStream_Async() { - await using var stream = File.OpenRead(VideoLibrary.LocalVideoWebm.FullName); + await using var stream = File.OpenRead(TestResources.WebmVideo); var info = await FFProbe.AnalyseAsync(stream); Assert.AreEqual(3, info.Duration.Seconds); } diff --git a/FFMpegCore.Test/Resources/TestResources.cs b/FFMpegCore.Test/Resources/TestResources.cs new file mode 100644 index 0000000..765df38 --- /dev/null +++ b/FFMpegCore.Test/Resources/TestResources.cs @@ -0,0 +1,27 @@ +using System; +using System.IO; +using FFMpegCore.Enums; + +namespace FFMpegCore.Test.Resources +{ + public enum AudioType + { + Mp3 + } + + public enum ImageType + { + Png + } + + public static class TestResources + { + public static readonly string Mp4Video = "./Resources/input_3sec.mp4"; + public static readonly string WebmVideo = "./Resources/input_3sec.webm"; + public static readonly string Mp4WithoutVideo = "./Resources/input_audio_only_10sec.mp4"; + public static readonly string Mp4WithoutAudio = "./Resources/input_video_only_3sec.mp4"; + public static readonly string Mp3Audio = "./Resources/audio.mp3"; + public static readonly string PngImage = "./Resources/cover.png"; + public static readonly string ImageCollection = "./Resources/images"; + } +} diff --git a/FFMpegCore.Test/Resources/VideoLibrary.cs b/FFMpegCore.Test/Resources/VideoLibrary.cs deleted file mode 100644 index 8bb0139..0000000 --- a/FFMpegCore.Test/Resources/VideoLibrary.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.IO; -using FFMpegCore.Enums; - -namespace FFMpegCore.Test.Resources -{ - public enum AudioType - { - Mp3 - } - - public enum ImageType - { - Png - } - - public static class VideoLibrary - { - public static readonly FileInfo LocalVideo = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}input_3sec.mp4"); - public static readonly FileInfo LocalVideoWebm = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}input_3sec.webm"); - public static readonly FileInfo LocalVideoAudioOnly = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}input_audio_only_10sec.mp4"); - public static readonly FileInfo LocalVideoNoAudio = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}input_video_only_3sec.mp4"); - public static readonly FileInfo LocalAudio = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}audio.mp3"); - public static readonly FileInfo LocalCover = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}cover.png"); - public static readonly FileInfo ImageDirectory = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}images"); - public static readonly FileInfo ImageJoinOutput = new FileInfo($".{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}images{Path.DirectorySeparatorChar}output.mp4"); - - public static string OutputLocation(this FileInfo file, ContainerFormat type) - { - return OutputLocation(file, type.Extension, "_converted"); - } - - public static string OutputLocation(this FileInfo file, AudioType type) - { - return OutputLocation(file, type.ToString(), "_audio"); - } - - public static string OutputLocation(this FileInfo file, ImageType type) - { - return OutputLocation(file, type.ToString(), "_screenshot"); - } - - public static string OutputLocation(this FileInfo file, string type, string keyword) - { - string originalLocation = file.Directory.FullName, - outputFile = file.Name.Replace(file.Extension, keyword + "." + type.ToLowerInvariant()); - - return $"{originalLocation}{Path.DirectorySeparatorChar}{Guid.NewGuid()}_{outputFile}"; - } - } -} diff --git a/FFMpegCore.Test/TemporaryFile.cs b/FFMpegCore.Test/TemporaryFile.cs new file mode 100644 index 0000000..f64f5fe --- /dev/null +++ b/FFMpegCore.Test/TemporaryFile.cs @@ -0,0 +1,22 @@ +using System; +using System.IO; + +namespace FFMpegCore.Test +{ + public class TemporaryFile : IDisposable + { + private readonly string _path; + + public TemporaryFile(string filename) + { + _path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}-{filename}"); + } + + public static implicit operator string(TemporaryFile temporaryFile) => temporaryFile._path; + public void Dispose() + { + if (File.Exists(_path)) + File.Delete(_path); + } + } +} \ No newline at end of file diff --git a/FFMpegCore.Test/VideoTest.cs b/FFMpegCore.Test/VideoTest.cs index f4553ef..b2bff02 100644 --- a/FFMpegCore.Test/VideoTest.cs +++ b/FFMpegCore.Test/VideoTest.cs @@ -15,105 +15,90 @@ namespace FFMpegCore.Test { [TestClass] - public class VideoTest : BaseTest + public class VideoTest { public bool Convert(ContainerFormat type, bool multithreaded = false, VideoSize size = VideoSize.Original) { - var output = Input.OutputLocation(type); + using var outputFile = new TemporaryFile($"out{type.Extension}"); - try + var input = FFProbe.Analyse(TestResources.Mp4Video); + FFMpeg.Convert(input, outputFile, type, size: size, multithreaded: multithreaded); + var outputVideo = FFProbe.Analyse(outputFile); + + Assert.IsTrue(File.Exists(outputFile)); + Assert.AreEqual(outputVideo.Duration.TotalSeconds, input.Duration.TotalSeconds, 0.1); + if (size == VideoSize.Original) { - var input = FFProbe.Analyse(Input.FullName); - FFMpeg.Convert(input, output, type, size: size, multithreaded: multithreaded); - var outputVideo = FFProbe.Analyse(output); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); + } + else + { + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, (int)size); + } - Assert.IsTrue(File.Exists(output)); - Assert.AreEqual(outputVideo.Duration.TotalSeconds, input.Duration.TotalSeconds, 0.1); - if (size == VideoSize.Original) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); - } - else - { - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, (int)size); - } - return File.Exists(output) && - outputVideo.Duration == input.Duration && + return File.Exists(outputFile) && + outputVideo.Duration == input.Duration && + ( ( - ( size == VideoSize.Original && outputVideo.PrimaryVideoStream.Width == input.PrimaryVideoStream.Width && outputVideo.PrimaryVideoStream.Height == input.PrimaryVideoStream.Height - ) || - ( + ) || + ( size != VideoSize.Original && outputVideo.PrimaryVideoStream.Width != input.PrimaryVideoStream.Width && outputVideo.PrimaryVideoStream.Height != input.PrimaryVideoStream.Height && outputVideo.PrimaryVideoStream.Height == (int)size - ) - ); - } - finally - { - if (File.Exists(output)) - File.Delete(output); - } + ) + ); } private void ConvertFromStreamPipe(ContainerFormat type, params IArgument[] arguments) { - var output = Input.OutputLocation(type); + using var outputFile = new TemporaryFile($"out{type.Extension}"); + + var input = FFProbe.Analyse(TestResources.WebmVideo); + using var inputStream = File.OpenRead(input.Path); + var processor = FFMpegArguments + .FromPipeInput(new StreamPipeSource(inputStream)) + .OutputToFile(outputFile, false, opt => + { + foreach (var arg in arguments) + opt.WithArgument(arg); + }); - try + var scaling = arguments.OfType().FirstOrDefault(); + + var success = processor.ProcessSynchronously(); + + var outputVideo = FFProbe.Analyse(outputFile); + + Assert.IsTrue(success); + Assert.IsTrue(File.Exists(outputFile)); + Assert.IsTrue(Math.Abs((outputVideo.Duration - input.Duration).TotalMilliseconds) < 1000.0 / input.PrimaryVideoStream.FrameRate); + + if (scaling?.Size == null) { - var input = FFProbe.Analyse(VideoLibrary.LocalVideoWebm.FullName); - using var inputStream = File.OpenRead(input.Path); - var processor = FFMpegArguments - .FromPipeInput(new StreamPipeSource(inputStream)) - .OutputToFile(output, false, opt => - { - foreach (var arg in arguments) - opt.WithArgument(arg); - }); - - var scaling = arguments.OfType().FirstOrDefault(); - - var success = processor.ProcessSynchronously(); - - var outputVideo = FFProbe.Analyse(output); - - Assert.IsTrue(success); - Assert.IsTrue(File.Exists(output)); - Assert.IsTrue(Math.Abs((outputVideo.Duration - input.Duration).TotalMilliseconds) < 1000.0 / input.PrimaryVideoStream.FrameRate); - - if (scaling?.Size == null) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); - } - else - { - if (scaling.Size.Value.Width != -1) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, scaling.Size.Value.Width); - } - - if (scaling.Size.Value.Height != -1) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, scaling.Size.Value.Height); - } - - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); - } + Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); } - finally + else { - if (File.Exists(output)) - File.Delete(output); + if (scaling.Size.Value.Width != -1) + { + Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, scaling.Size.Value.Width); + } + + if (scaling.Size.Value.Height != -1) + { + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, scaling.Size.Value.Height); + } + + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); } } @@ -121,7 +106,7 @@ private void ConvertToStreamPipe(params IArgument[] arguments) { using var ms = new MemoryStream(); var processor = FFMpegArguments - .FromFileInput(VideoLibrary.LocalVideo) + .FromFileInput(TestResources.Mp4Video) .OutputToPipe(new StreamPipeSink(ms), opt => { foreach (var arg in arguments) @@ -135,7 +120,7 @@ private void ConvertToStreamPipe(params IArgument[] arguments) ms.Position = 0; var outputVideo = FFProbe.Analyse(ms); - var input = FFProbe.Analyse(VideoLibrary.LocalVideo.FullName); + var input = FFProbe.Analyse(TestResources.Mp4Video); // Assert.IsTrue(Math.Abs((outputVideo.Duration - input.Duration).TotalMilliseconds) < 1000.0 / input.PrimaryVideoStream.FrameRate); if (scaling?.Size == null) @@ -162,53 +147,45 @@ private void ConvertToStreamPipe(params IArgument[] arguments) public void Convert(ContainerFormat type, Action validationMethod, params IArgument[] arguments) { - var output = Input.OutputLocation(type); + using var outputFile = new TemporaryFile($"out{type.Extension}"); - try - { - var input = FFProbe.Analyse(Input.FullName); + var input = FFProbe.Analyse(TestResources.Mp4Video); - var processor = FFMpegArguments - .FromFileInput(VideoLibrary.LocalVideo) - .OutputToFile(output, false, opt => + var processor = FFMpegArguments + .FromFileInput(TestResources.Mp4Video) + .OutputToFile(outputFile, false, opt => { foreach (var arg in arguments) opt.WithArgument(arg); }); - var scaling = arguments.OfType().FirstOrDefault(); - processor.ProcessSynchronously(); + var scaling = arguments.OfType().FirstOrDefault(); + processor.ProcessSynchronously(); - var outputVideo = FFProbe.Analyse(output); + var outputVideo = FFProbe.Analyse(outputFile); - Assert.IsTrue(File.Exists(output)); - Assert.AreEqual(outputVideo.Duration.TotalSeconds, input.Duration.TotalSeconds, 0.1); - validationMethod?.Invoke(outputVideo); - if (scaling?.Size == null) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); - } - else - { - if (scaling.Size.Value.Width != -1) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, scaling.Size.Value.Width); - } - - if (scaling.Size.Value.Height != -1) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, scaling.Size.Value.Height); - } - - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); - } - } - finally + Assert.IsTrue(File.Exists(outputFile)); + Assert.AreEqual(outputVideo.Duration.TotalSeconds, input.Duration.TotalSeconds, 0.1); + validationMethod?.Invoke(outputVideo); + if (scaling?.Size == null) { - if (File.Exists(output)) - File.Delete(output); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); + } + else + { + if (scaling.Size.Value.Width != -1) + { + Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, scaling.Size.Value.Width); + } + + if (scaling.Size.Value.Height != -1) + { + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, scaling.Size.Value.Height); + } + + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, input.PrimaryVideoStream.Width); + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, input.PrimaryVideoStream.Height); } } @@ -219,50 +196,41 @@ public void Convert(ContainerFormat type, params IArgument[] inputArguments) public void ConvertFromPipe(ContainerFormat type, System.Drawing.Imaging.PixelFormat fmt, params IArgument[] arguments) { - var output = Input.OutputLocation(type); + using var outputFile = new TemporaryFile($"out{type.Extension}"); - try + var videoFramesSource = new RawVideoPipeSource(BitmapSource.CreateBitmaps(128, fmt, 256, 256)); + var processor = FFMpegArguments.FromPipeInput(videoFramesSource).OutputToFile(outputFile, false, opt => { - var videoFramesSource = new RawVideoPipeSource(BitmapSource.CreateBitmaps(128, fmt, 256, 256)); - var processor = FFMpegArguments.FromPipeInput(videoFramesSource).OutputToFile(output, false, opt => - { - foreach (var arg in arguments) - opt.WithArgument(arg); - }); - var scaling = arguments.OfType().FirstOrDefault(); - processor.ProcessSynchronously(); + foreach (var arg in arguments) + opt.WithArgument(arg); + }); + var scaling = arguments.OfType().FirstOrDefault(); + processor.ProcessSynchronously(); - var outputVideo = FFProbe.Analyse(output); + var outputVideo = FFProbe.Analyse(outputFile); - Assert.IsTrue(File.Exists(output)); + Assert.IsTrue(File.Exists(outputFile)); - if (scaling?.Size == null) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, videoFramesSource.Width); - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, videoFramesSource.Height); - } - else - { - if (scaling.Size.Value.Width != -1) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, scaling.Size.Value.Width); - } - - if (scaling.Size.Value.Height != -1) - { - Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, scaling.Size.Value.Height); - } - - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, videoFramesSource.Width); - Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, videoFramesSource.Height); - } - } - finally + if (scaling?.Size == null) { - if (File.Exists(output)) - File.Delete(output); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, videoFramesSource.Width); + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, videoFramesSource.Height); } + else + { + if (scaling.Size.Value.Width != -1) + { + Assert.AreEqual(outputVideo.PrimaryVideoStream.Width, scaling.Size.Value.Width); + } + if (scaling.Size.Value.Height != -1) + { + Assert.AreEqual(outputVideo.PrimaryVideoStream.Height, scaling.Size.Value.Height); + } + + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Width, videoFramesSource.Width); + Assert.AreNotEqual(outputVideo.PrimaryVideoStream.Height, videoFramesSource.Height); + } } [TestMethod, Timeout(10000)] @@ -287,7 +255,6 @@ public void Video_ToMP4_Args() [DataTestMethod, Timeout(10000)] [DataRow(System.Drawing.Imaging.PixelFormat.Format24bppRgb)] [DataRow(System.Drawing.Imaging.PixelFormat.Format32bppArgb)] - // [DataRow(PixelFormat.Format48bppRgb)] public void Video_ToMP4_Args_Pipe(System.Drawing.Imaging.PixelFormat pixelFormat) { ConvertFromPipe(VideoType.Mp4, pixelFormat, new VideoCodecArgument(VideoCodec.LibX264)); @@ -307,7 +274,7 @@ await Assert.ThrowsExceptionAsync(async () => await using var ms = new MemoryStream(); var pipeSource = new StreamPipeSink(ms); await FFMpegArguments - .FromFileInput(VideoLibrary.LocalVideo) + .FromFileInput(TestResources.Mp4Video) .OutputToPipe(pipeSource, opt => opt.ForceFormat("mkv")) .ProcessAsynchronously(); }); @@ -315,14 +282,12 @@ await FFMpegArguments [TestMethod, Timeout(10000)] public void Video_StreamFile_OutputToMemoryStream() { - // using var input = File.OpenRead(VideoLibrary.LocalVideo.FullName); var output = new MemoryStream(); FFMpegArguments - // .FromFileInput(VideoLibrary.LocalVideo.FullName) - .FromPipeInput(new StreamPipeSource(File.OpenRead(VideoLibrary.LocalVideoWebm.FullName)), options => options.ForceFormat("webm")) + .FromPipeInput(new StreamPipeSource(File.OpenRead(TestResources.WebmVideo)), options => options.ForceFormat("webm")) .OutputToPipe(new StreamPipeSink(output), options => options - .ForceFormat("mp4")) + .ForceFormat("mpegts")) .ProcessSynchronously(); output.Position = 0; @@ -343,7 +308,7 @@ public void Video_ToMP4_Args_StreamOutputPipe_Async() using var ms = new MemoryStream(); var pipeSource = new StreamPipeSink(ms); FFMpegArguments - .FromFileInput(VideoLibrary.LocalVideo) + .FromFileInput(TestResources.Mp4Video) .OutputToPipe(pipeSource, opt => opt .WithVideoCodec(VideoCodec.LibX264) .ForceFormat("matroska")) @@ -354,11 +319,11 @@ public void Video_ToMP4_Args_StreamOutputPipe_Async() [TestMethod, Timeout(10000)] public async Task TestDuplicateRun() { - FFMpegArguments.FromFileInput(VideoLibrary.LocalVideo) + FFMpegArguments.FromFileInput(TestResources.Mp4Video) .OutputToFile("temporary.mp4") .ProcessSynchronously(); - await FFMpegArguments.FromFileInput(VideoLibrary.LocalVideo) + await FFMpegArguments.FromFileInput(TestResources.Mp4Video) .OutputToFile("temporary.mp4") .ProcessAsynchronously(); @@ -389,7 +354,6 @@ public void Video_ToTS_Args() [DataTestMethod, Timeout(10000)] [DataRow(System.Drawing.Imaging.PixelFormat.Format24bppRgb)] [DataRow(System.Drawing.Imaging.PixelFormat.Format32bppArgb)] - // [DataRow(PixelFormat.Format48bppRgb)] public void Video_ToTS_Args_Pipe(System.Drawing.Imaging.PixelFormat pixelFormat) { ConvertFromPipe(VideoType.Ts, pixelFormat, new ForceFormatArgument(VideoType.Ts)); @@ -464,190 +428,126 @@ public void Video_ToOGV_MultiThread() [TestMethod, Timeout(10000)] public void Video_Snapshot_InMemory() { - var output = Input.OutputLocation(ImageType.Png); - - try - { - var input = FFProbe.Analyse(Input.FullName); - - using var bitmap = FFMpeg.Snapshot(input); - Assert.AreEqual(input.PrimaryVideoStream.Width, bitmap.Width); - Assert.AreEqual(input.PrimaryVideoStream.Height, bitmap.Height); - Assert.AreEqual(bitmap.RawFormat, ImageFormat.Png); - } - finally - { - if (File.Exists(output)) - File.Delete(output); - } + var input = FFProbe.Analyse(TestResources.Mp4Video); + using var bitmap = FFMpeg.Snapshot(input); + + Assert.AreEqual(input.PrimaryVideoStream.Width, bitmap.Width); + Assert.AreEqual(input.PrimaryVideoStream.Height, bitmap.Height); + Assert.AreEqual(bitmap.RawFormat, ImageFormat.Png); } [TestMethod, Timeout(10000)] public void Video_Snapshot_PersistSnapshot() { - var output = Input.OutputLocation(ImageType.Png); - try - { - var input = FFProbe.Analyse(Input.FullName); + var outputPath = new TemporaryFile("out.png"); + var input = FFProbe.Analyse(TestResources.Mp4Video); - FFMpeg.Snapshot(input, output); + FFMpeg.Snapshot(input, outputPath); - var bitmap = Image.FromFile(output); - Assert.AreEqual(input.PrimaryVideoStream.Width, bitmap.Width); - Assert.AreEqual(input.PrimaryVideoStream.Height, bitmap.Height); - Assert.AreEqual(bitmap.RawFormat, ImageFormat.Png); - bitmap.Dispose(); - } - finally - { - if (File.Exists(output)) - File.Delete(output); - } + using var bitmap = Image.FromFile(outputPath); + Assert.AreEqual(input.PrimaryVideoStream.Width, bitmap.Width); + Assert.AreEqual(input.PrimaryVideoStream.Height, bitmap.Height); + Assert.AreEqual(bitmap.RawFormat, ImageFormat.Png); } [TestMethod, Timeout(10000)] public void Video_Join() { - var output = Input.OutputLocation(VideoType.Mp4); - var newInput = Input.OutputLocation(VideoType.Mp4.Name, "duplicate"); - try - { - var input = FFProbe.Analyse(Input.FullName); - File.Copy(Input.FullName, newInput); - - var success = FFMpeg.Join(output, Input.FullName, newInput); - Assert.IsTrue(success); - - Assert.IsTrue(File.Exists(output)); - var expectedDuration = input.Duration * 2; - var result = FFProbe.Analyse(output); - Assert.AreEqual(expectedDuration.Days, result.Duration.Days); - Assert.AreEqual(expectedDuration.Hours, result.Duration.Hours); - Assert.AreEqual(expectedDuration.Minutes, result.Duration.Minutes); - Assert.AreEqual(expectedDuration.Seconds, result.Duration.Seconds); - Assert.AreEqual(input.PrimaryVideoStream.Height, result.PrimaryVideoStream.Height); - Assert.AreEqual(input.PrimaryVideoStream.Width, result.PrimaryVideoStream.Width); - } - finally - { - if (File.Exists(output)) - File.Delete(output); - - if (File.Exists(newInput)) - File.Delete(newInput); - } + var inputCopy = new TemporaryFile("copy-input.mp4"); + File.Copy(TestResources.Mp4Video, inputCopy); + var outputPath = new TemporaryFile("out.mp4"); + var input = FFProbe.Analyse(TestResources.Mp4Video); + var success = FFMpeg.Join(outputPath, TestResources.Mp4Video, inputCopy); + Assert.IsTrue(success); + Assert.IsTrue(File.Exists(outputPath)); + + var expectedDuration = input.Duration * 2; + var result = FFProbe.Analyse(outputPath); + Assert.AreEqual(expectedDuration.Days, result.Duration.Days); + Assert.AreEqual(expectedDuration.Hours, result.Duration.Hours); + Assert.AreEqual(expectedDuration.Minutes, result.Duration.Minutes); + Assert.AreEqual(expectedDuration.Seconds, result.Duration.Seconds); + Assert.AreEqual(input.PrimaryVideoStream.Height, result.PrimaryVideoStream.Height); + Assert.AreEqual(input.PrimaryVideoStream.Width, result.PrimaryVideoStream.Width); } [TestMethod, Timeout(10000)] public void Video_Join_Image_Sequence() { - try - { - var imageSet = new List(); - Directory.EnumerateFiles(VideoLibrary.ImageDirectory.FullName) - .Where(file => file.ToLower().EndsWith(".png")) - .ToList() - .ForEach(file => - { - for (var i = 0; i < 15; i++) - { - imageSet.Add(new ImageInfo(file)); - } - }); - - var success = FFMpeg.JoinImageSequence(VideoLibrary.ImageJoinOutput.FullName, images: imageSet.ToArray()); - Assert.IsTrue(success); - var result = FFProbe.Analyse(VideoLibrary.ImageJoinOutput.FullName); - - VideoLibrary.ImageJoinOutput.Refresh(); - - Assert.IsTrue(VideoLibrary.ImageJoinOutput.Exists); - Assert.AreEqual(3, result.Duration.Seconds); - Assert.AreEqual(imageSet.First().Width, result.PrimaryVideoStream.Width); - Assert.AreEqual(imageSet.First().Height, result.PrimaryVideoStream.Height); - } - finally - { - VideoLibrary.ImageJoinOutput.Refresh(); - if (VideoLibrary.ImageJoinOutput.Exists) + var imageSet = new List(); + Directory.EnumerateFiles(TestResources.ImageCollection) + .Where(file => file.ToLower().EndsWith(".png")) + .ToList() + .ForEach(file => { - VideoLibrary.ImageJoinOutput.Delete(); - } - } + for (var i = 0; i < 15; i++) + { + imageSet.Add(new ImageInfo(file)); + } + }); + + var outputFile = new TemporaryFile("out.mp4"); + var success = FFMpeg.JoinImageSequence(outputFile, images: imageSet.ToArray()); + Assert.IsTrue(success); + var result = FFProbe.Analyse(outputFile); + Assert.AreEqual(3, result.Duration.Seconds); + Assert.AreEqual(imageSet.First().Width, result.PrimaryVideoStream.Width); + Assert.AreEqual(imageSet.First().Height, result.PrimaryVideoStream.Height); } [TestMethod, Timeout(10000)] public void Video_With_Only_Audio_Should_Extract_Metadata() { - var video = FFProbe.Analyse(VideoLibrary.LocalVideoAudioOnly.FullName); + var video = FFProbe.Analyse(TestResources.Mp4WithoutVideo); Assert.AreEqual(null, video.PrimaryVideoStream); Assert.AreEqual("aac", video.PrimaryAudioStream.CodecName); Assert.AreEqual(10, video.Duration.TotalSeconds, 0.5); - // Assert.AreEqual(1.25, video.Size); } [TestMethod, Timeout(10000)] public void Video_Duration() { - var video = FFProbe.Analyse(VideoLibrary.LocalVideo.FullName); - var output = Input.OutputLocation(VideoType.Mp4); + var video = FFProbe.Analyse(TestResources.Mp4Video); + var outputFile = new TemporaryFile("out.mp4"); - try - { - FFMpegArguments - .FromFileInput(VideoLibrary.LocalVideo) - .OutputToFile(output, false, opt => opt.WithDuration(TimeSpan.FromSeconds(video.Duration.TotalSeconds - 2))) - .ProcessSynchronously(); + FFMpegArguments + .FromFileInput(TestResources.Mp4Video) + .OutputToFile(outputFile, false, opt => opt.WithDuration(TimeSpan.FromSeconds(video.Duration.TotalSeconds - 2))) + .ProcessSynchronously(); - Assert.IsTrue(File.Exists(output)); - var outputVideo = FFProbe.Analyse(output); + Assert.IsTrue(File.Exists(outputFile)); + var outputVideo = FFProbe.Analyse(outputFile); - Assert.AreEqual(video.Duration.Days, outputVideo.Duration.Days); - Assert.AreEqual(video.Duration.Hours, outputVideo.Duration.Hours); - Assert.AreEqual(video.Duration.Minutes, outputVideo.Duration.Minutes); - Assert.AreEqual(video.Duration.Seconds - 2, outputVideo.Duration.Seconds); - } - finally - { - if (File.Exists(output)) - File.Delete(output); - } + Assert.AreEqual(video.Duration.Days, outputVideo.Duration.Days); + Assert.AreEqual(video.Duration.Hours, outputVideo.Duration.Hours); + Assert.AreEqual(video.Duration.Minutes, outputVideo.Duration.Minutes); + Assert.AreEqual(video.Duration.Seconds - 2, outputVideo.Duration.Seconds); } [TestMethod, Timeout(10000)] public void Video_UpdatesProgress() { - var output = Input.OutputLocation(VideoType.Mp4); + var outputFile = new TemporaryFile("out.mp4"); var percentageDone = 0.0; var timeDone = TimeSpan.Zero; void OnPercentageProgess(double percentage) => percentageDone = percentage; void OnTimeProgess(TimeSpan time) => timeDone = time; - var analysis = FFProbe.Analyse(VideoLibrary.LocalVideo.FullName); + var analysis = FFProbe.Analyse(TestResources.Mp4Video); + var success = FFMpegArguments + .FromFileInput(TestResources.Mp4Video) + .OutputToFile(outputFile, false, opt => opt + .WithDuration(TimeSpan.FromSeconds(2))) + .NotifyOnProgress(OnPercentageProgess, analysis.Duration) + .NotifyOnProgress(OnTimeProgess) + .ProcessSynchronously(); - - try - { - var success = FFMpegArguments - .FromFileInput(VideoLibrary.LocalVideo) - .OutputToFile(output, false, opt => opt - .WithDuration(TimeSpan.FromSeconds(2))) - .NotifyOnProgress(OnPercentageProgess, analysis.Duration) - .NotifyOnProgress(OnTimeProgess) - .ProcessSynchronously(); - - Assert.IsTrue(success); - Assert.IsTrue(File.Exists(output)); - Assert.AreNotEqual(0.0, percentageDone); - Assert.AreNotEqual(TimeSpan.Zero, timeDone); - } - finally - { - if (File.Exists(output)) - File.Delete(output); - } + Assert.IsTrue(success); + Assert.IsTrue(File.Exists(outputFile)); + Assert.AreNotEqual(0.0, percentageDone); + Assert.AreNotEqual(TimeSpan.Zero, timeDone); } [TestMethod, Timeout(10000)] @@ -673,11 +573,11 @@ public void Video_TranscodeInMemory() [TestMethod, Timeout(10000)] public async Task Video_Cancel_Async() { - var output = Input.OutputLocation(VideoType.Mp4); + var outputFile = new TemporaryFile("out.mp4"); var task = FFMpegArguments - .FromFileInput(VideoLibrary.LocalVideo) - .OutputToFile(output, false, opt => opt + .FromFileInput(TestResources.Mp4Video) + .OutputToFile(outputFile, false, opt => opt .Resize(new Size(1000, 1000)) .WithAudioCodec(AudioCodec.Aac) .WithVideoCodec(VideoCodec.LibX264) @@ -687,19 +587,11 @@ public async Task Video_Cancel_Async() .CancellableThrough(out var cancel) .ProcessAsynchronously(false); - try - { - await Task.Delay(300); - cancel(); + await Task.Delay(300); + cancel(); - var result = await task; - Assert.IsFalse(result); - } - finally - { - if (File.Exists(output)) - File.Delete(output); - } + var result = await task; + Assert.IsFalse(result); } } } diff --git a/FFMpegCore/FFMpeg/Enums/ContainerFormat.cs b/FFMpegCore/FFMpeg/Enums/ContainerFormat.cs index 8c046ac..37c2bda 100644 --- a/FFMpegCore/FFMpeg/Enums/ContainerFormat.cs +++ b/FFMpegCore/FFMpeg/Enums/ContainerFormat.cs @@ -4,7 +4,7 @@ namespace FFMpegCore.Enums { public class ContainerFormat { - private static readonly Regex _formatRegex = new Regex(@"([D ])([E ])\s+([a-z0-9_]+)\s+(.+)"); + private static readonly Regex FormatRegex = new Regex(@"([D ])([E ])\s+([a-z0-9_]+)\s+(.+)"); public string Name { get; private set; } public bool DemuxingSupported { get; private set; } @@ -27,17 +27,19 @@ internal ContainerFormat(string name) internal static bool TryParse(string line, out ContainerFormat fmt) { - var match = _formatRegex.Match(line); + var match = FormatRegex.Match(line); if (!match.Success) { fmt = null!; return false; } - fmt = new ContainerFormat(match.Groups[3].Value); - fmt.DemuxingSupported = match.Groups[1].Value == " "; - fmt.MuxingSupported = match.Groups[2].Value == " "; - fmt.Description = match.Groups[4].Value; + fmt = new ContainerFormat(match.Groups[3].Value) + { + DemuxingSupported = match.Groups[1].Value == " ", + MuxingSupported = match.Groups[2].Value == " ", + Description = match.Groups[4].Value + }; return true; } } diff --git a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs index 424b598..ba53a48 100644 --- a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs +++ b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs @@ -65,7 +65,7 @@ void OnCancelEvent(object sender, EventArgs args) { errorCode = t.Result; cancellationTokenSource.Cancel(); - // _ffMpegArguments.Post(); + _ffMpegArguments.Post(); }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())); } catch (Exception e) @@ -111,7 +111,7 @@ await Task.WhenAll(instance.FinishedRunning().ContinueWith(t => { errorCode = t.Result; cancellationTokenSource.Cancel(); - // _ffMpegArguments.Post(); + _ffMpegArguments.Post(); }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())).ConfigureAwait(false); } catch (Exception e) From 105a9fd1f64f24bd56a90e54a41baeedb16e83f1 Mon Sep 17 00:00:00 2001 From: Malte Rosenbjerg Date: Mon, 7 Dec 2020 01:11:09 +0100 Subject: [PATCH 03/12] Partial revert Former-commit-id: 8e2b146f95a0bd967ed5b88bc787904efdef1e81 --- FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs | 2 +- FFMpegCore/FFMpeg/Pipes/IPipeSource.cs | 2 +- FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs | 13 ++++++------- FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs | 6 ++++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs b/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs index 685a019..757b151 100644 --- a/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs @@ -17,7 +17,7 @@ public InputPipeArgument(IPipeSource writer) : base(PipeDirection.Out) Writer = writer; } - public override string Text => $"{(!string.IsNullOrEmpty(Writer.Format) ? $"-f {Writer.Format} " : string.Empty)}-i \"{PipePath}\""; + public override string Text => $"-y {Writer.GetStreamArguments()} -i \"{PipePath}\""; protected override async Task ProcessDataAsync(CancellationToken token) { diff --git a/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs b/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs index 5fde4ab..cdd5139 100644 --- a/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs +++ b/FFMpegCore/FFMpeg/Pipes/IPipeSource.cs @@ -8,7 +8,7 @@ namespace FFMpegCore.Pipes /// public interface IPipeSource { - string Format { get; } + string GetStreamArguments(); Task WriteAsync(System.IO.Stream outputStream, CancellationToken cancellationToken); } } diff --git a/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs b/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs index de3669e..f61bb7c 100644 --- a/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs +++ b/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs @@ -11,10 +11,9 @@ namespace FFMpegCore.Pipes /// public class RawVideoPipeSource : IPipeSource { + public string StreamFormat { get; private set; } = null!; public int Width { get; private set; } public int Height { get; private set; } - - public string Format { get; private set; } public int FrameRate { get; set; } = 25; private bool _formatInitialized; private readonly IEnumerator _framesEnumerator; @@ -26,7 +25,7 @@ public RawVideoPipeSource(IEnumerator framesEnumerator) public RawVideoPipeSource(IEnumerable framesEnumerator) : this(framesEnumerator.GetEnumerator()) { } - public string GetFormat() + public string GetStreamArguments() { if (!_formatInitialized) { @@ -36,14 +35,14 @@ public string GetFormat() if (!_framesEnumerator.MoveNext()) throw new InvalidOperationException("Enumerator is empty, unable to get frame"); } - Format = _framesEnumerator.Current!.Format; + StreamFormat = _framesEnumerator.Current!.Format; Width = _framesEnumerator.Current!.Width; Height = _framesEnumerator.Current!.Height; _formatInitialized = true; } - return $"-f rawvideo -r {FrameRate} -pix_fmt {Format} -s {Width}x{Height}"; + return $"-f rawvideo -r {FrameRate} -pix_fmt {StreamFormat} -s {Width}x{Height}"; } public async Task WriteAsync(System.IO.Stream outputStream, CancellationToken cancellationToken) @@ -63,10 +62,10 @@ public async Task WriteAsync(System.IO.Stream outputStream, CancellationToken ca private void CheckFrameAndThrow(IVideoFrame frame) { - if (frame.Width != Width || frame.Height != Height || frame.Format != Format) + if (frame.Width != Width || frame.Height != Height || frame.Format != StreamFormat) throw new FFMpegException(FFMpegExceptionType.Operation, "Video frame is not the same format as created raw video stream\r\n" + $"Frame format: {frame.Width}x{frame.Height} pix_fmt: {frame.Format}\r\n" + - $"Stream format: {Width}x{Height} pix_fmt: {Format}"); + $"Stream format: {Width}x{Height} pix_fmt: {StreamFormat}"); } } } diff --git a/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs b/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs index 5d9e666..404029f 100644 --- a/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs +++ b/FFMpegCore/FFMpeg/Pipes/StreamPipeSource.cs @@ -10,13 +10,15 @@ public class StreamPipeSource : IPipeSource { public System.IO.Stream Source { get; } public int BlockSize { get; } = 4096; - - public string Format { get; } + public string StreamFormat { get; } = string.Empty; public StreamPipeSource(System.IO.Stream source) { Source = source; } + + public string GetStreamArguments() => StreamFormat; + public Task WriteAsync(System.IO.Stream outputStream, CancellationToken cancellationToken) => Source.CopyToAsync(outputStream, BlockSize, cancellationToken); } } From d172cb0dc7670fe3620b6740eb62782f543823d8 Mon Sep 17 00:00:00 2001 From: Malte Rosenbjerg Date: Mon, 7 Dec 2020 01:13:32 +0100 Subject: [PATCH 04/12] Only post in continuation Former-commit-id: 57258c6f5c58ce3b4954c1221be34303ee383b67 --- FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs index ba53a48..424b598 100644 --- a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs +++ b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs @@ -65,7 +65,7 @@ void OnCancelEvent(object sender, EventArgs args) { errorCode = t.Result; cancellationTokenSource.Cancel(); - _ffMpegArguments.Post(); + // _ffMpegArguments.Post(); }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())); } catch (Exception e) @@ -111,7 +111,7 @@ await Task.WhenAll(instance.FinishedRunning().ContinueWith(t => { errorCode = t.Result; cancellationTokenSource.Cancel(); - _ffMpegArguments.Post(); + // _ffMpegArguments.Post(); }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())).ConfigureAwait(false); } catch (Exception e) From ac82e17d25fbf3da6d9a7810000d8c058ebf7fd8 Mon Sep 17 00:00:00 2001 From: Malte Rosenbjerg Date: Mon, 7 Dec 2020 01:20:13 +0100 Subject: [PATCH 05/12] Fix Former-commit-id: 203da6300bd2e4328e930761a54ba44c701b2731 --- FFMpegCore.Test/AudioTest.cs | 2 +- FFMpegCore.Test/Resources/TestResources.cs | 1 + FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs | 1 - FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs | 1 - 4 files changed, 2 insertions(+), 3 deletions(-) diff --git a/FFMpegCore.Test/AudioTest.cs b/FFMpegCore.Test/AudioTest.cs index 9818e79..aee8982 100644 --- a/FFMpegCore.Test/AudioTest.cs +++ b/FFMpegCore.Test/AudioTest.cs @@ -38,7 +38,7 @@ public void Audio_Save() [TestMethod] public async Task Audio_FromRaw() { - await using var file = File.Open(VideoLibrary.LocalAudioRaw.FullName, FileMode.Open); + await using var file = File.Open(TestResources.RawAudio, FileMode.Open); var memoryStream = new MemoryStream(); await FFMpegArguments .FromPipeInput(new StreamPipeSource(file), options => options.ForceFormat("s16le")) diff --git a/FFMpegCore.Test/Resources/TestResources.cs b/FFMpegCore.Test/Resources/TestResources.cs index 765df38..f37ed0c 100644 --- a/FFMpegCore.Test/Resources/TestResources.cs +++ b/FFMpegCore.Test/Resources/TestResources.cs @@ -20,6 +20,7 @@ public static class TestResources public static readonly string WebmVideo = "./Resources/input_3sec.webm"; public static readonly string Mp4WithoutVideo = "./Resources/input_audio_only_10sec.mp4"; public static readonly string Mp4WithoutAudio = "./Resources/input_video_only_3sec.mp4"; + public static readonly string RawAudio = "./Resources/audio.raw"; public static readonly string Mp3Audio = "./Resources/audio.mp3"; public static readonly string PngImage = "./Resources/cover.png"; public static readonly string ImageCollection = "./Resources/images"; diff --git a/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs b/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs index 757b151..479fa90 100644 --- a/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/InputPipeArgument.cs @@ -25,7 +25,6 @@ protected override async Task ProcessDataAsync(CancellationToken token) if (!Pipe.IsConnected) throw new TaskCanceledException(); await Writer.WriteAsync(Pipe, token).ConfigureAwait(false); - Pipe.Disconnect(); } } } diff --git a/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs b/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs index a2cf9be..f089a1e 100644 --- a/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/OutputPipeArgument.cs @@ -22,7 +22,6 @@ protected override async Task ProcessDataAsync(CancellationToken token) if (!Pipe.IsConnected) throw new TaskCanceledException(); await Reader.ReadAsync(Pipe, token).ConfigureAwait(false); - Pipe.Disconnect(); } } } From ab277aa8fab7b60de4e72a704b3573e5604dc2a3 Mon Sep 17 00:00:00 2001 From: Malte Rosenbjerg Date: Mon, 7 Dec 2020 17:25:54 +0100 Subject: [PATCH 06/12] Run post after ffmpeg has completed Former-commit-id: 418cb943ff96f6390ea49a7f20adb2cf3dedae6f --- FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs index 424b598..8a30dfc 100644 --- a/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs +++ b/FFMpegCore/FFMpeg/FFMpegArgumentProcessor.cs @@ -65,8 +65,8 @@ void OnCancelEvent(object sender, EventArgs args) { errorCode = t.Result; cancellationTokenSource.Cancel(); - // _ffMpegArguments.Post(); - }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())); + _ffMpegArguments.Post(); + }), _ffMpegArguments.During(cancellationTokenSource.Token)); } catch (Exception e) { @@ -111,8 +111,8 @@ await Task.WhenAll(instance.FinishedRunning().ContinueWith(t => { errorCode = t.Result; cancellationTokenSource.Cancel(); - // _ffMpegArguments.Post(); - }), _ffMpegArguments.During(cancellationTokenSource.Token).ContinueWith(t => _ffMpegArguments.Post())).ConfigureAwait(false); + _ffMpegArguments.Post(); + }), _ffMpegArguments.During(cancellationTokenSource.Token)).ConfigureAwait(false); } catch (Exception e) { From 27de93d64c08d317a6a09a293dfc1139f114a618 Mon Sep 17 00:00:00 2001 From: Malte Rosenbjerg Date: Mon, 7 Dec 2020 17:41:31 +0100 Subject: [PATCH 07/12] Reencode raw file Former-commit-id: 1a15c08ea23f47d5fe67c33946c5b0a573b365ca --- FFMpegCore.Test/Resources/audio.raw | Bin 795068 -> 384000 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/FFMpegCore.Test/Resources/audio.raw b/FFMpegCore.Test/Resources/audio.raw index c1811e98db52bb1261f5c01ea61f08080f0534cf..e131095f3065004c4f3566b15623d36a0cf312df 100644 GIT binary patch literal 384000 zcmX`!1>96s_dfigrMtVkyIZ=EP6-tR2^CZnJFvUsv9P-y#m7KIQV9t`knWJqnfJSf z-}{%(_RKwV=Ij;My4KqJ?6W74$e2hZG9^-}jEQ6_L%OB>m+iM9cdnbNY40|9d~-82wz-E@B@N%nG*%HLRw+1NFs}4mPA1c(`5E+hF_lc6OI=8vNu5uf_Ue>& zI(62+bN-$8$pvRz)O$#lOl3;9i;mHf-b?u`^o&goB3#8%vH*`T17on(RWq4 zMXOA|3iKh(e2-mC3Q0DXSLAvjCPu?(avh;ydR%k&@Os6JfHIagzIEXWM{QpUggtELHZT; zPEpzxrEL+dkk|2k0q5mUTjivl57BKGU;j+jUw)Gity zP3_k2ZfEaL6-^xGyW^=kWNr@a?dj2l6y03?a#FO_8hKrn?MrBRw7~iKv?nj5j_BhM z4G+-kAP*k(&I#?DS1EdD<@21*&YSjKR^QL*U-&Ga>%_acT`iYmb}g%(v-mp;y#kxr z`7jqR{^g%NsZDJ7r+;fw%l-cs ze}B)iUyHsk*za?Ff5B5<@b_12yuddX`|MA4*v*p>H+gYQWzsiK6i;*)fddmw5`z-$ z5`B5HQ=)yMu}>-sfc7O4 zD^ukYOHx%6zlrx1;(ZOwY}B@Sy*E_@8mjPkIjuNU77%qgeU=%kT;!3H^f`n}cC*(% z-u=s`>)3UTwu(i5XSL<5_nWg9rCY|tQu?lPwZCY#T`vbyb>XfqZ`OvLYB;81qJh>T zQ3Xz`C7SYZZO5`c$>aYQvG5_+*vi^}^3qBYEKkS8Hn!VJjx9X)H;FdW_AfENfevd^ z4fNVN@ki=%I(JXERjJNCYek<1q^J(3<^PZ4^TKUr);dGO1JJO=)z-kpQkGljivM%w zx2*FOxxYx&*Xky|_VwJIu8M-An0GQeJC9RYv{K*wZs3n_-q|%{(#!$Ji1g*%gFyb-PWW!vVIp< z=|Rul{Mm<|y}a(>y)LlZ4$7OtYn^ls%PRV_rx_~pqQ8!AF+CH||FMrI4_(j={Q7M?pPPIf}U zMsd7~ZcE`}nV49W`i@RNi}F8cxt$kJ@MvZd6iECEd%u!*B|mLWb%nuxvfThE?u)T{ zI9|?T9dLRZmTs;!q+bouULGqIW#61+NwE7VdK~h}E*9HFw^ib0F<<^DHokZEf>bTX zCUkDYhh6yalI8wNb@H9Y^el;^6L5M+WNmSkKiPeqvo|?o2N@2r%rO=_%z`_4?@v1Z zOutW3^HXo6o=Lr&dL;Fq)D!-G6&rks4_4v6qdb`hQ`V4iI+AygXr6#Or@7V*t~*Pt z%@VIS(sPFMu65>kkufCE1t;_pCq3A_BVC)2tt#sj#{>EJCLallLPbSV)*)ph@z#U| z8@X~F=T(J*vaDEC2FeQwQC(yp!9_Y;WXJQVw%D*Uy*jW!3s01%6m2f_C$0RJA2>S$%l#MA!Z}YXrQE!_rrY z^+{s)>`H#@sAA{*fA`y7FdmkdTSW$U=>2cscJ3&hhqFf?cJ73Kn)7w=Zh6Q2A~7=@UC`I13NdP{!{pkfZKlzmGQ}U*A(-KBS^t2z zxgf4`iLw&VQz;#FWpPdotsc$l>92;ED9_tP_&F-#qprGE`^;4z5t);;9$q)mLoM&u zcg^-ZH^g;sfU9Ta(EmaCE|Hjv2kS#?KW&m|oRjFBcmM_;<;zD{@qRVIEo?b~ZoTQ% zoP{dm+x!rjO0CxSN|nkYmi-PRd`YWM`R)^W^9!GStM{Kp+aK)k5APl&K~%i?p`oOl zULLcQ$0_Cfznp(%p`{dT1j35*Xc1m6#)_p9>$RWB{tiDqBGzWR@@QCZ6E72JFkY|2*{(MXwu0GeiJ2^TC;jG$q=#8!o;-6KS+Di2 zp?Irf;uraJA%6Zrt@4GQ-h-z3w0;xky`B1j#lNQe=i=mZ+Wwb~7t5KO#p@|pDoB?G zbm~F7;o4aK8zWl`^KLKrY2#l_)oN67|FZ0Ik@==LpH1gp@@{Q8tAt9c5Q`Sma}~&F z$)`i*uUm~^=Hu>F^gJWFOC`GEkPe9fP&-jSH|YIV@qa6(xCx8QkjW;<1iEsGtb2@#-l3$3eFGbu}5c?HA`%HHFSloOj?!JbkAJ||SZ8k&CG0~An^w*?E zN3lIvd`uJ_)A;RroG=qROyb2+P}GO!QLWbTjf%LVh=|XY=CPl|`rGVwzY2MhK6=U+ zO;Qh~s-h-t$_$zB}g05pwdYK-| z%JKEETyq%hfV+DpZo>r+`|ByJ_c-s}N6+ioU>IFnry0Bfwkxf^$O0kzQ}bAIqL}ZL zdLdPnR|}>dNadhsE{t0&^-8J^JNL%IGwJv|oBTwweUMjx)tbOezjRzwmM6-xYJNPM zOkD-HS3=qd@i>@Y`fEMlx1)^SRxQzn&TVA+_Vn!}KD&7Da?#KM)?33uQz)y=Zso*5 zLI2MUlX>-4LcG=F*;aVGr#4L7jHmrXe_iP~lnufbf#C||y9~x63$1{ZcSZQ^sn=6i z$c{DWm&Ng5^7Yi#tf@m-HY;LW@tJrLN=;weoZ|HkJI?< ziquD`R$`?(jw>xri;I-9Sg>|FpX5l4Ar`X>HE&yVoj zXZ-saTYgC9x9Iqi?Dm-6Z&#CCZG6%zUAzC~YU}arrBUKmytf`=SL6A0GR+P$p1?*i z6IEPZikiCv4-Mqa3Gh1|*W7?>;yBUYgH=-<*doTpF?K(IKYnwKH(_rs4jm#Y8p~EC zc{UgQGQ($9wNDY%TmvyX2=C05XWr$@)zEY%)f09`LB?d!F+=XZE^(Kho`9g2vBVp& z^(O1SA|pSE{qNS}_36yBSuWYEhTjUGJJs%c`F1~x9aNhghS4J|_^*25Bo@7Zce9C$ zg78|Nwd$+q+LFI34SLbMw@V+D)LMrd{x0X zpq|(cJ6tZy_oPub`m}+UdT>*g_i}4VQGZf(cT7|tQ}Z5E>mHR?_scz-vHB7{eJb9b zcJ=G^G*BfHJ}nM4SyD^nlV4M3=$TMs6;g9G;?EIk&Uvo;4e#v1o`n-HsOFwkzuuRa zt73gd|L>9FJJwzdam(np$hhn)y}cw3=g6DGaZf9pQx^YZ)sCv0x2bbB>T`X1Oteuo zw?n=>%=#yxF8rDmW(%-u8CI>4xEoHNl(CgG zdKoVkn!9QNcs-u{8Q=UbwZZZ4v43&ss{=3XM1DWKHUe@- z$y`J5LifbY>^+M-Gg)*Be_Sm`UX^&w@ma?wQe#wES0oZozbVuZqpH`KFlL(~V3U-nG@U2Mrk!OTJ6WGXL_$E^`O#@aiwA(X!8YGdL6IbtT`9PRHj`pRZ6C z{9$ar7A7~SWjDF{R(kL7dN0(R;N#2mP@V?OUB9miV1kP02DQ%3#z3#?`A%ITGr~#UwP@eIk_zLje1~y^1al%$v0EqCFiI9N`9Wo z#0E`p!X#Yv3LE{dKDeN6uPj2knlNm7F(eelmLk@UKZw#rC7;hME++26mZSm18Qr|J9}9KXxIAHeQ>JoKXIco1f9 zgwIjNBVGBnu~;mJpJKl9f@{b9k>lQp>Mp@vx%jRmeAe;$a<$G_uWo_IC$+cq`abWz zrJrZ9(OvqSOpf02Oda3J!TP&g??-j_^Yoi3Z}c?|sFTL$JMzc;)c)j0scrJ*w&d?} z<#v8NnX06E?k&IGVyU(dfb`t8Jqo$1!eyY2b2B|A2j%^Jd9BdBRC zPd2AlJ9(wM>T4j}4wbR5fZ1;R*vxrVoF8NS-29M>4|9vKJY>#Kw-WlO=Ga=?4aB9_ zz~pS7-tMg1wCP@t!bDy8u@+X!P2&S(T_pNn#+%pEqq~SNBX6Ci<7Rd2%H$i`XYjg2 zPT6Vvl`=jllIV+jZpUL^iKs)QD-T1x*n673@5AAb;qOOy<8JLnZJcpuUtH7{{%T`{ zVq*3(Sv!OH%OH1LM#rLBO|jXQu6=l682^tJ5u?Q9Ai8v?Q*cUC`LGebX~ertL})YF zqd8`3>76!Munpasz;;zq#k|2OxQly9UtzbW#oG+*)(=*j;@V2aK*fyR3!CLCq0+60 zj~dXw8@`<=|J)6)Z}Q_J9@&E}vy1(@tUFlWx9H_*{(OriKcw47Eb)$<@*#eplYWYcC*a3-bmt4OebH{xez2*NoR_v?n;>(s3ITN!mITX z_rTI!;`JteyW0Ex>DHV-gR|mZ?vtoBSztRBe#e zxTPvy>WyP>;q&)-U?YA`xKhl-HDKWu{Mnps8;OjF(`q2R&!Oyaom3M5{L1@1!IF;#-C+iw=N!PE0VhNT|eI$=z0TSVt_apK)3!{ zZ?C(FzYY-8+~@UVvufs<%H!gg$<9ZPEaLf$xvIFwyIy7dvsimizI+UBX5-j#YJmZA zWG6FAO|+Wo=PKl>!k@Jvt1bTz!(BJy*Oz%{p>gH`%%4B)(Mph82zIg!83>%pJ(aZV|^#hspPe3)6in+fA*R*_{(kJ1nDY0S7EOSNRLZeslk znR&DtcD$Ilil?S%*L!_4U*5*wxAE-_5D;^|9kp^^pB3>damib>=RnMJ;el0RQjO-)tbMEw)>?78HB z;r0hL`%>euP2%*3dM|3c{E2bU^*o+k#Ut6+vI$9Iw0X0v@u2HIg%_To^ON|09$Dv* zd9pKy;MmT_l1(u}eU(>zzHMln)tJt4tS3fm)3-VdRCR83cB%)X&BR_iw(RPfeY64e zx`OOi(7!Kxc8AhV^lz26K>=qMl5el^$uw;` zj9y2Vsm`9_vuogalykeMZIi{TB(36}yd{a9=*MCB({6W3D~g zPHlT?BrY8y?gn6qUbv%^^IGs*eJoOg-zti=GB6PwUdB7+ysoUp=rLxMdqVHfbUoD6 zwVQg~fS+r)P8HXw3{g?}#^|du3zp~0qKQ{!=Ii8$Hs%ZR(S8qJU7TE)`qH@T^W=)u z&&iE&yH`#*N#8_bELMDy7ZziOv(Q#Ww&+HJsKKU_;U?F|jpR2+&6c~!{2qI4^%*D`9@(sV2$L+20?XWhfwgJ0I-)`h9^a%Kiz-kp3m^`~*> za^vdd$(6>|+fv8OjTDlp+raI$M!K&W@vae3nIWsL9>c3Qu;OF*;$1xQ4Vk|a9iQ_2 ztMqsP$4qw|3A^3pmS#S$$_6F*yC|vR4o^r|L95}aOb-=y@G^L|qxhVRgP$<={(`Q*!@(Z8Gbz&*AyIt{5O|IJd;`?og#{kO0*}!1VRg>^ znC>nW(`_=)Es$^%2D{Pw)8O|?`0c~;t>|78FP9Ogh2+{;^%N^<3Z`w4pkaz_GN@Iv zskQPLi4+l^rD3QFj;qD@bz!#_pI3Bl_%)~AlQcVQ_IwNZSICjyo8S7F-`+H5@LK9) z$8XdI>ytm4D_A4f>^0+l(JXvE2rLg7aX+G=+NzPUUVY=OI_4;=nKP(hyjMbvS6Ho( z-#lh6a%5INT#zqMs@IQ@^#By?OLJ=F^nC0;kg+}eUMu=`P1nCU;G>9EK3(JVhR*I{ z;&LAEMDuoZXoVd@?*{a*u9abv0^W}mcX9uBAEc~>+qgUPs#&Hx@a%YF_O7(3qYf$_ zBWA}8{!SQ6U#8A0#rMsfKSmY$nCkKuvm9qhR)hTp!_Hiqzeun5_41i~@CkX}WRb`4 z=}qdMQDU>bTvAp3j7mD;^=WfwCtUZ^nwo61%dQHDdCFKvmK_`A5)Jv-Gq|>h`n@Q7 z6~U>6@J<2m<)d#ND9-8KY)}v*f%7yu$zw;|HP|PAY)?%$9vQ(`-F?zhy%YBhs%v$; zZYhWMrr&rK^js3XB5!^tp4Z}_{f@D2umD*q;*0vKgw|SDuZOVdWD?&De|PcSee}Ff z@At@HcRJqc?^*cfS{5HovOdmjBR*@e!e;Eo|Pau$m%2^yYGv%w#+*OY9bTtKB8}L5*iM2JH*@>v-~B-1>ld zy=T=0ADWZ?#b@Hke9U|i^S3#mykuwzL!(*zCNe!p!l!WF^ZI+1R8NrZe)`^|r>p2O zkREOMxemKmCRZi-wF=p5_+EYTN3GM&EJimx(iiTpaNR5D7;|*J9J~6vz4Kc-uPF^0 z(zl_$n(=4o)s24Lz1LZ7(9*G%&x-jh!4EssKR?3Eb8^Wv^Az3F^=shu6?3vLq>dzC z)jmv}mtTsC;kK&msj&W2X*8luV1s@F&jrOZ=^2k%bujLpkGd zex0G${$y%Q-qMhkU0lbQ{NMCCi3YsekR=+jRz08AV3+cAF2t7EA;RS%MwzOKwO_aW}Cye>M<@C9r5t9?ouF zuK<3n0=J!v{wBcqJpA)1U;ZhV924<5$P?=f>abw2Y&RAk=+#*D*c7(Cfn2k3&Ma&< zU5rk`r{ice(wJ-r?ZUr(T%{Y|MjhOecN_9TEwWe9%8|be`ODHNd|!+7&0r+fN)Gki zNx0`mR*7-K46nz*e_z*b!k)1@>lA)nsit{DyGb3?8xK@c&nDG9Tkz_y=48Lq{+Ikd z^@m#FP;!-gxjog+H?O1Tt8`qJX8*3x)`LGg^HFR3-q1L=I{nJ1#|o*ZbHZIl-cG7x zFJP>wQ=`S&)dZO{u}oI6oDCkblQpOB=943e`nzm89){3&kVqTMnuGW+=7M|EyED5q z7n`+UBW6!xE+tm~oWemzny)sviw>i3O-r?2N&05MOGnLb?MWVz zL(i%UvL&+0laXFXyvEvzxKF>x=_OI>hVnr~0J=Wv;Nuo(Q7rBfMbC_%%*P!Q`Ii}+?Knw6(%1@FdcyF74^ z5q8hh`!wvIX6>`aKIcXDIoUE+dY_Oz4rA3_WZqyLxy0CVLF%kJU>zNQFlX>p^2OAD zwZ&?L9m!Ad(PDo7$K2m(BbFSbixns}#CuaUR~ww#(wt8n_k@Zi-iFUtobjY8@E*0! zY&FldGVEyM%t30|emJSG`hB3fZv?$3K-g4T+(^z_v^iwHiFQ-SIusUSEpG#QRphf0 zS|JRd%fF0#e3~qWv|Yv%+i~f3eu&j*vF?O|*3vPX(|Xw=6M6`ITRPPF27= z+0?eD)a^&a>gnV%#CPz=3+kB1#OHl9zSWp(h8l08`MZ&3YX@7M z*59$8dA$B=ghA#FMvLI7YQa0$=6O;5wfW-pbUkg(p%@Isn0ElYP9p6sWP5;IPl?!9 z<;%DE=RLmp$k`uy{VxB!E@wZ(7I%~TI=&gkL!If<(0416yP*GPA#*%U5Y_Ejc+EhM z+^iait;I4i{v1f>tJr6j;~dwy!8OKVqb`tEnXMA!*uc*p!|#KzcrDiLXD+0bY*Af} zS2Xn&oW3ikd~R*UPsX4t)PU>NOaGV`+pn59@7sCtP6c%rJ~BR;N86`h_yy9wK$54) z^DudD<=?Bx5%X4^=-$lhhJ4YOPR)G3CH83L{FZcRM*hh6aW^tFE#>?o`pHMToUWJ6 zCz<7#OwP?llNfIm_h~h;-Gois!$=QY*9Ts@Lv!5kt*A1%Xb$ub_05~~xrJUswYKtR z4VWz^v*zZ%+!(ne4sH(h6X5=N8m@xZtk}Fh%UyvvW~i|4#X^tq%G0EMTF;O3^gJ@& zPUfkk8$^zla9R#W1oJ1=i>KAq$7Pp)F~?D}@W*jl61U~2Z}4mfo*k}Dq0wx*%n^|{ z^73TY7!4N#T(`69H%`ZHUjNU@_ZdXS37YMK!ga8>jOTtf2mLKB`jifD8;`vr24AAr z>+C%xPK5;cf1*E}D7!MV-3g6+9O^+%K}f6bslU6 zr%cB)lV~ytekaj!BArme^vHwHD0Vqn|iF$fX;QDU4>1H>n9f`$ZC9& zRorH$cMkKVvG(s2z4uy!6Z4+i`0s#G;yI(sOzxp45)YXp83l*+aZpZNd&Ir2E!xK9 zcaFaqYi=+$@f-to?#xD0==y?@#nN=V#4JR|bk#FTbWIYSQ~7o(9jB5j^7~ZLGzq#U zka{FP4uGj{T08vInig%z8TDs3*zKvx>E+m!&TZ&kpD$w-MSf9v8NbEIBL`0va;!-6 zn3rv;HFur3H&YYel{Y3Rh387iSEXe1xKkIa`gW-A7MO8=$UTD*a%LSZGatr$-k(Wp zo|C5>b0>BtOD8TSD<#UQ4f^P1o-x=`vl`h|((PdOYRJ8tp3lhOuY2cRk@6u)J`hW9 z@Xxa%?Oxn4jqF1qt3AnU`gR#HT9!6dXjOxDHKDYcD6QbTW$0a=4XUwKedo7euMYIO zw5Iwng#Ux9SJP)H^ji->r*mTKgqm)ze6Rt3EaJxxc=ZW>oCCAh&~u1)x{1r?xTu=^ zQXIPT@^x<9T1356k3Wa9&?9P~rK*oi=~;qn)OZu{-4*g@dl;&Phf2$zR#DPn1*H9f zMSg|3PqfFpKZ%|(UtSM)lygp?G43Da^E!C#3=ZF`R*M>Kqnc!g@j|ShEh*w!!RHXD zp6tBozHv4EV@~vPIBm$671HC_HAWY!Nxp^;SId7ZQ)B2;9|z?y$9~wo+Re$A)d;;u+@KIp&;omK7auuwzjt$@?>$!nL!o0IXy zcybJftAUuNpBU|{4G`7CXfzg{$I*ElZO8cTAd%3C_I2r8O5El3-R!=bLtN*^otK`L zI_U4ctg%xJ?i7K$9ryeDfcK;Ahw(l5HC9irrR5?V^o`v4iM59x8k>A59=}K}N&c*E zUz1vx+$ly+$}5=@?aYhZs$Tqpj@#ij*4sBA*I>w+1Cy_jZvmcPrcPL;{qDUV>HD5I ziuF!&$T^V=gY@5%9PR1c*!wa2P?s(Z#a}~l)`;HC_^Ty9wbnYqYB#OB*PXRibg$+7 zSXp|RdNH1nH~^DdXtNgnmcVGNxcQV%-iG2AVfbP1--dCg$r4wo-p9%+BdsVHVQf7D zc89~vaO^);EKV_dbEox0udBY6lkzMkiThps^*jxu-)S825Z#}U%bwzkXYt3&eEOC% z-{rq|&C0x^y-mMYFz91qZVn%g6`dVKVl}UeVw@b18_zw)b2?d^mz`eu=~>qMkue*w zMH`=WW1~JYP;b}k?)n|*-GpB&!ftjPvSTZbLL$7ha?MA+PlC1C0@e^o!Uwppkc#j%jI^G%2 zuVWx!Jb9;*^=6vf~=JosdwUZdXjNiAYW0xeCXlvw^ z?ZzkWF+zG93~(K8eO;BYUbN*?5%kvXCEaJD@^}8+iWhg1V=L)ayZ(-T(SYRdISSYr>^ZFCIy(}{CR3D5}8+3%?8fu@CMl40}Y$-D)HPm*keBLWP z7Z$bPeuzG=&dX;7Pc=Ps6mjvy#kFE*8r)6dpOMC2kxv?#Cn=Tq3i`gJ^XJ+}W~X|$qo^%pjB-#d@H^i5$^81SFuNIklSy}I z#qL-rf7Tdhu`%XhjGjyGD1#Z}PFo$7Oaqlntd?)fOFhXlTwG3e)+`vkjqG>&`z|`( z1-o}a<9)Pw*zswfy(G?F!MQJqtEc!h)+kJNZf`cI=gLL+J}cHu@Mk>3nb~>qL~365 zBMQo!1?ia|Zt|rOo`<#KU#!r~tM_a?eL)p>K;GQwe(X}?&4uu}D76Mwe@*VU)-r2i zYtr*>$$E*>GI1L<-Z*jji2Cb$dGj#*mgl!Y_~%}=&&PCK3Aqn^HZdD>q`aPJ{87kBTDvh-2@ zJEZN`4j6UCv%?2f*?VN!9cDiMHq!W0{#e16zgbhbD0QWr*iF8yZ+@$sTBs1t&0*Y? zfzHX~3daq&XuHo2m;*YFi!XSVD7)%OO+?>%q#GrA?uEMfbo^1|trAb0=&*&Ho5biE zK3a+o|3~JJ;rA7KK8#yt@$D$Bqt`KWR={WRRQoAB^RNC+LSX{a<%i&^;;{`q`_XZf zHeNiBrRzZNw>ADK!>_00k!8jfuj8uQaMqRNjb}(YIyQB^YPhEWz0X=pu}eL$NsIZB zqsfK1XT5xS1Se+|-z6ZUHY7zI&|5rS0l9<8+MiE5@k2fDmh@RRTyw^}Y~05@WqlK8GJFYgk^b~xo zQjdBdyO@lp#tJw;){AFW>m673Y~s;arTQA3=kn1AzG^A2mcv8YMC~za)^=;V~ zHM4J2V2>WIGKWP!V$rQEn2UA|RXKfCTH}r2uZOZbVfO)Oe30x9h^_myyYa?cdfkjS zZ=lz7pH1ewiF6*r*F)vRf$R}0d0PCa}2mZy6Yx(gH_u!Y~prvZ|rFeEJ z&RJo7*LwAV=Lh7Y85a zz1DcPfw-;Z^Qt~6?~~H78ueO17|4;1!2vjFD1IG|PltLvfM2@uWoz#>f|%O$id7BO zjc(mx!?{bmztq_6H|UDzAAi6_3ydhf7qbgRmGOu9;E3helVh{|`7P@FA3jCh)*WH&Y8%-LKXCQxlzuwq*UbgyO{Tu!7GK)m$h(2Bw!q#t^~_d1ZiUgU zUT;q0H1G;u%R?H(mW)!<*yflfk&E#?ALZ?>PFl z_jmAZDI8tedYEeP)6BQJ@c%%}cNIpzQ@#EUB&{cXE}5YntWKftBUtDyc>SDr7try0 z$oUFazKa{4C)d4l`E_FJ3K(of_G)6N1U=#z>g;sPtYvi7OnetR#boil*zqTy*QK@E zY|}<2>jnpXy&fQL`>|U$h;M_V8yeeH6(@yo+eKE`!2-X!@|$AePO&hW?yc!lmj1b6 zIEPli>xwYl8dnV$(Q_dGRm{DJg!|0k$aam7d$#gfDkv%h*BT zG@VbARbZTL5y;X5Ud z>_~3FuUpl#JB>^1DM8l>to#yxu7;efswYTAqNQ+ADPzxy z&aA7?4(u`lJKRFDS4g-pZPBY_?|2&NMtN_xF~%$zb2fcv!s<2d2aT00hq+hXo93JL>3Kgui1NU%cG{;apti*Q@=zAJ4wQtN-P-zae(5vB+{v5PKo~ z3`xI;#TDXnogDeMw%vGi7m4=qU+lILWA5E_-l6u{M%Nt>y4M`^Vexp({e*vwrw_=N z+vP``hI@`Wo;SwKN9UT@tq0bhDv};Iw)_I4ZN%V7y~a}zo%ry|G(P(qH+D(SPd?38 zPrzfWx4akD?u4N^bh=)gO~wvmam+wm*cpG+=Zg~fH8VYq8T;-uN3+rBFm^uqOAg=d z9`b3&9G-D3<4MAXR*GJ3Rx+OB7@V#Rwy8dkiruIWV}G%z{R@*I_C3oYT2H!rxl1mA z5p1*zzkW-y{z0Fj+A1FJIP#rfXdN9eCZzr$uo~E7i%b1)dfwkJ9Nb`D8ku4Aa^}e08!F!D2b-n3Zob;>k>MXAZfvp!{D#4OiM| zy#&-0&~mF)^ZRZI->bkjRrxUXzp4QnRm5$1`6ceu7t``;v8T*AdGY`huft^jzBs3iXsyX-r9^H4$9(CzhS-@e_INvuQ4Yd??0B)> zeUsne)=G8NZC+@OdCT3&-_)}!jioopDe@CE&0(dtNVP-+?8F#nWaJzWQl4+)X~BVd zy;dyVC1Rf;%bRfYK2CX$MPK8q$HdiKa~acN^eXR-Rp*TK`5^JsgFdb3Q&S7QGVxRF z__K%h+w~ng|HUqg1?XE_jCZ6*e|Q`w=10(XI2{MUYOL|^gtr?SyOw0@^Vlm8^P%h9 z=1RTzvz+KZqh9|j`G+;$o_bAsLMwUN*!r?We~5S*V%Dh6a|xK;d-CG zAB)S6F#7wLbiP{e75YBH{x|V_#9}wR(S%;X6Gd@GtWu60DPvasGFZ$7llkGcs2DA! zLMzKQn4iny>v=Hrq`9|FP@~*x-6OGpgUXcV0m5yO^%rBCBO|z1SNj1C8SE*f}zG z8QcpwJ4MP?)yQA!SI_&><60bc1MJVD!4%gS%CGI{Rf&(|zURN>{M%LjkSl*R+qJ-} z_hpu+RC}}OGE8f0v{K8bl`wmC=QPLaeX;Wt%j$OgU*l9_eFZXEP|g`13dy`vG389?A?X`;<>MwgS)gt)jFE3!4hl8xB(+> z#j|^`;z3#Eh`$a)?tcCcy|;+%O?2F7-SLLhv;6p?7t6_GCIlS)PZOMOi(sKpFF>`-@?9Mu!*t`vHh&@y4)44iciFI8i(s;bIn`dzm zUq-7S?kcfOMG;m3#w(=9o2%h*6)dk~^?&#{w?FPcw%Tf|1E?6uYEJdUXQAU zZi3{o`no(lrrd0fV5@t!JH_Nt^?@CIMMot%_BGO(%L^ZvNn3+Sj)|09SiKUgw&bz? zYV}ES=k4@-LPmYni0=&?|2jXsr2i*i>@NIz9ZrdPg6^&o&ls1*yE$>qS@rXNV}^g+ zw~77UwnEx&uSD~o43qjsn(K4X`>L3n2@39jL{>)3Awex1hilgt^8mdkpPs1b%N z%-#o$T)x9z_qxhJ5nr9IS@m*A9lI^L1!nis_l&wISE3_?+@O-0uVUE<6_<&<`WU>w z7BgKpW0twaA+uk!gGKD~fT^J2D`D=Q-gVl7^AS*!?_D~tgO%OtS^ zC7xC&#=^no`Bfnq;qNHFZu1<%I(!rRidajN{FA=h(muU(FQG)D3#Pu6AHTqCd)$R8 zM91bb(NpR1WkI!i78pL0S|C?^hp|3M^F-v!*wg4|`1%FDe&es@xaN2G z{2gAGW1HVdw21W=vFb87-I$&)8VC2W=iX$oFjX$QF5Rm7JMJ-6CPl0@sY-(C-aGTROoA=1#pY!K- zUWz?{TF`Mg9cRGHU6}VlvG))wK8)uc#iNgjoyXzr33DQk^5xy?gPD9YiWD7Tu(BA- z=DgUEbPLpO#D$x*9psAH)dY$2!)8UX9P78bx&C09T*;49Ma*n^&8735jw|e_mO;kJ3ZFZa5Y*4XR1IHe~2YVdQ^ zx7CcNVwG4$@mreih2cCG)Wo`;Jib%hH7feMj`y4RUQ5R|xT&3MwZ+-7+emX&OJMiX z`jdj*$>OY&qGPu;Yq54D);s;r{7>A&{8SD5xiQ!Rd~1cjdbR+YwAa)1BzlLGF)L9( zbadd)sj}tcQ1c!uena~O_~$Em4cO%pUZSX-Y+)zzj&`__PF~v}Loq~UE!wQd@rFatuEs#?-V)65; zk#a`L?PbTflW-mHUb-LF+c>P9IBg)$R4@k1%jXx=u!ntf2itE{m#u-ecStP1O5Hd2;}tc4y(JojbyASF-ey@A~uKK)UpYz3yacD}Oi8Dru!%G473& zAz927l%{VvIjEvzRe#rJpC;x<+PYRZ=k=ra5Z4*$oH6V&#(AUZI-0LXsQJPcJ>a*I zW9+#eD@xvhm}&giQeMs{Cm)k9wXEgs5eMUG zHIj}4aY0X9+m8PlsEeu?pOjJS6~+zOdHS5O?>_$6NQV_N*b?Ymf@hZEoaHLJulMrEd-VOoOypVF zxjbDv({;Q&dMg$T9bdq6FGKXxdb$_4UXQu1#O(da*M*hZs0SKjz~G%yJ`ZNgZ1j*a zzD=5y%HT{Lwl8pNzp`y}vA z4cyv88{_KNKxyou7kgxlaKEG%KekgL#s2Tj;I*~*>&hors0&8wc?^9=kiDP$8T$h^ zH%HLG-L=^Bsdl)G8E2I`O#5lL zC3$lNpGE}KbLG*-DEH9uHFo?2BYnoU@3O@6V&xuj6SnWK-fW)Ek?~}xu>k!trG5B~ z*FQka65Ja1CwJhcLwY*SKBw8|qKL^vm&|&~$c7o^%gh)cn||}ipCuruk~yCm{>7T& zhAdDIBUEPLf;^rS8T;XOqkCZXM>RLnR9#S9Tx1lVr__08l6EGC@rsbsfi1>ki2LdL zA?vQe3>Rd*imusNu8e)FZeia?#l@?vIG=uRh?y5~=_B&u9S}4FyN{(uZ#kus&>tC+eVe)AxH4`8C#x8tj4j6MFN#omJtik_LU zdj;JaVw~8wc`qFo^3Q9cVUDW}#@dZ#uc9!UGG}l^i**La)wS7GCN<^DxG!`E#C$43 zw#X+&WNM7jMw=7U6~Y7@bZMnb+$pN>lUPri-&q+|@$vl95%>(Q-S72YXxI&_dqjc0 zeO3o=R@Q=NW8e0|J}<%^ai6a&TUF9(d)>r)&DpQHYqaF^R(u{~>C5RByB7Ar6tM?w z8?S4?RP5>$bE5BHxa;JRrp5tTV@#0zuQh8wd!FwPd#h|W$7lU9f3{@NX{waBN%fbj z=76BaGSo0y-a?~i;r0XB^K1O`U;TW}p7ZhRg6oMNOh%Xeet>!IXrEEzKrIaSFG zRp)(FIiqFAiH?)}Jw-N|rpFmjIt|0dxFMd&AH%1^_+y}$>c{SV(m0MCE;4(aaJ=aK zljf0Q=Zmd;_$QrK^6v`SYz?1pp?Bzi*5C09FG&(bwm5~mUo0-t_o~!(Awmg@* zrzy0KP{G}z)_a0huVbP2<(UtRXXn%LCH38X`ny&P_T~8|tXr1svG>nKwa5werKda~ z%Q{DREuQwV=jvd!sqeJozu3ELAdVX2dsnHxu2COMls}^;7%JEFgYhnK5qk(!W~&&- zT!erF{9_k2)obk0EtI4e}e4*2ReZMf;5y@&#D^ zo}OD}{0yp|N@~6CMm{rn?OC4rO!O===36Ff{lsq{z}KUAV;VgNnu}_MORA8o2>j*N zV*Hhb|FWqOVh`wiR+AS?kAi*>A&b>^tMzJ(Nbk7sd$V?@TIxREy^sH5Z`bMKeH43k zAyNGHOdjzPPb>V)g3qz$WO3IXhnF?Z%*dy)mSP(p?o4jA9_Wx-HYq*}BxaB-?k7Zs z&j@Md#9@7{F})gLxQ3!4_6KWbmZz1!+NX1L>)-t!C&;s$O*M!guZH-bze2_uY7kyrI_A>lnXvaIGFbAEb?e9L{lx2ysBTw9X*(Y(Q?^M1Cr8A0_V|X&w=v`rK+0`1D=4#mLFkBeNUbF{C>|wA*-LsJF-?G+!S?x#u zi`l46EFQma61%6Kk{?ddH=aQ`g4vFUx8vq`&f=L2)@8=I(yb; z>-zrRz+d&eTOG@nbY}bx$uUU%+x>*4oZfN`A7X8&zaOHeS5>1i!?B;PIcQl;Zyi~1q~lC}zK5=l)Ac!MehGSB!0`_|dluY{mH&If zU^9AGgWdQwm^?nqOrQ7_tXRpN3tttXQ+ch9zuM!_!F&_XR?Xw5S5-M5s6#%X*}Kko zjSC)3(1y_w-i9f*Bx8bked>L!JGnh}?m;A+A(q;1ITD8HJ^mA^pX0|5@ zW~=(&kOBXn3PA>B;QVb;rZz|JtIEf^Fs@gzu{T? zXtGOBb>97Ejej83zv44iFm)x>c)iaQi?=|_9Ch9-vQ5+1Bz<40zbhQO;J&8t80*ms z;)|R(F@Dn~em9|<)_^}R_t{XgUE}=geeY&Ax{dyG`1eLOh%7diZwGnZlRh1t+ma;p z;UMlM6xV0`epznz$>LvB2r)BZ2QDjvS6ajUgSoCR@a!kn7JO>$$d|b22XmGyHM0xO ztpH73v0u!Mye2-EV1h&R&8_;ViF>+Xn(;h1i>2=(#e*u8$HmGMV(4KQn#)Hae}DdK z$;OrGnU{~FQaNIrun)HO+hzDVo>cgg1;5tAe_ZV$SDgvBL(R5#pkHn4pv$@^T#U{o<(1eQu$6OSFS)s@ z$+s}%Vi{o{q~ulu#9gp%FgwJ*k*dfM&@vdx`qJ%kG1S&sE!n*lT*W$vUMw?6y)#lw zj*;(%GRRa>;4)?)egRFymkz zc)*o^;0?bl;2OgrcBuMhko$Q3%?tF_x_aFXR-?a){>q1i;@3(dXUDIVtkzbj%a`Jv zSm*mEoW+i4Tht4&Q_B{5{v&t9@5vk#{io!Lcnk{PB&3v{&1s5wl)dB@zrf7P*m5lf_$ z#Z)cTKco5cKA07`q^z&!7|k`8y*fZ>PkQ!)&7L%F|Ug(wJ=vv+;a-CrFRq zRO-%$y-6@g%{R)`#_;2C+#9pn-S{$|p^f^y5X|P_nfQgmf+R05E*s#UPGUCh#SeGg zcz*h7b=I}UdsmZWM7lz0?7Y}hs|;jkF>>E$oxyT@8hj=$Up3BrR=xhRJw`q>_q{Egs8N_EiH<}k0#ZEM}wB~8{K8lzBrT@Qj#BnhkzC0^RVlSagGICbe z%)*9QSlRC!z;I(0Z@{Lt#7RY?%VPSEJB8;(c>Ge_I+(QEJPkiHk3QdXHgCHJ{h4vf zPsSvx<6ep#V+C*AosM6{DyYSJ*=lg!h%Ybq-J!;(u@}xQ{P+Or zV;`kw>GrJaKcU_4y_>}CmGW5ZCsT!A}RGvLBVmwtphNZ_r&nOxVq*?69*i>}JlitO({E!WRCqX;|^l%NE6SC#nag__Y=?e$KGQt z#eD4J)SUm~-e^yK#9Fwk{5?gVlhPQ9-+0PRrc5k!QnuLPIk%`~zu~ht`7nO}W&*TaP2&k45i%T7_b661Vpe)dCo@jV>$x%m85 zJZ|FCL-e-aBs7(Ursi0C6xP3A74wBS-3G(CV6-kByNUbZymz(exL&Vw^%c+d&%*+- zM@7s*PsbOruR&KbH=;*bH9{UOen<2SY@LSQSh*9=8pe}mu}@HY-|H{e$DWmw*l;%K z@1WA)RmemYj zLA60Ocd|PeZ;zIp=U~0~&8W}eb|pz;J^MwhP*C2iVocHy+Tyo|+S9rRP9I3xVWb|N z_RC>ojQ!$kx16%utV4|P&*GNMz7>1m!%nfEUT#>}E?35{{M?3tI?E*m@y{W3!5Ve! z&&kg`fAORFgEerwmw!{{M~b>e>m<{-?~?de|TF^*Yzd@$oEWPck=qq&VUYP|iP?B8gg)x~uA z%ACl%#+cSM8lz-1CM&F_uP?g|qT^f{ZN99&k{^%jIiG4Pp02E+Ca5J>#!h-wRqwHr zdJFyaV&75Zx)#62et>hlzE!))-&5%s`>}drjOAiIR?NX(y3d!HJ{f$T;)9f2mIZg^ zhl7faP4RTB`5xu`DcTLbb0d3AhnH*kax}a4g|g;iHg?yIogRMV(}!GV82r@YyDSjA z%Ur=q&rAPkUiN$U6n;ydQ6uURp6UD)_vB;uy_lygrB&5q9n4T2SHy3=<$&t5&=~9XcbEs-;yGcz zvw@2;t8RCiGgv1sHzt>g%Xt3ZPI(a4U8OQvY|fM2-<7pj$aJUpwmiRfqi@te^JKT@ z_53nVzoP%=apglWHk&SEw0It&9zPeSR~9}vsXmF{7mRiiZZqTIqHGfTPsDFg#y!jF zd=_&K@4@(w?D8A=e}v1A<;ADP{|&G+fWA=&#V@{P!0mpq4esLk!!v4wt;SwIr}vAw zjJ5~m%YTxOr?w~UT#<~uJFUHe>BeS)2VjXCRBJC7{VZV3sP$q;{8*z`k(Ld3CH5_h zF@0}(4Wh?L5i*94uH?Tf*+2H!9YxpC;%FS%COUVbD~y-VM~Z@Y4mtMOi@na{9zzqc zeChX1&iif_ILR$X<)>RN^CJnr;dIP;wf*9BzjYeN*(2r+@~TVX7sB(aVKNzG9H!$s zb@@-`K|X`e_fucf?F(}S-^1h8E8eM6D3_PMYvj{Kj?c3KKh|m2zxG&iKWknS6eibMK}9?Q9Wy zuT63OB;T9NPuJ1!dNv8aO?UnzI*k>-Lt(i;d-U|3ZhRbj3bkeR`2FG7i9U9PSdGc& zr^hD0s&Bql>wW5XFJix=Z|S^Wm5c=(WEn=t;*m z7$~0mDkmlj;l7+^-m|lJE_2c`&lU5~u}@kSp1(+*z~ea)9{2Dv8#hJl#(sEN$#=%c z$L|8-+1{9PB96KR$INqnJh$?I@62JV>)0ti8;h6Y*^Gg7yj-17k3{i%w)>54zL#wu zQ;Sc6*=}m~n#Ps+))Js}f(uQIjD7Aj-c+xYdZ zSO?UJo-s=sRYD0hK>;Hddnr5PCed=An(Q&{VV})~(aC%g^Fr}^rFHqD3T(!6?lt)+ z?qjx~S8Kk$w4;fACh5~j>w{ZkO~Fj(+)ILI==K`Bz5zootHmBB$qXLtEpjTdeeC_U z0N36j@;j>!@|pA5Ud&k*Qoo+J_IYo5z5dtf@$?z|Q%ptHmL!v8>}UA@2U&cF z$}J-;OQ=L+SJ)=7(vgm_FX1?GcpZ6f!j*Hy&~5aaP2Sk^WhnVNiO=}O>v%FK58P!D zi}B0jSs*MgZZ6FxHJlgzj9eRQ{0H;SSa_K1TGu;w4xHTX`?0U~onj&O^^M=r?Zdte z$PlZZ;ujg_%MVj=Pa}Eq0=`*c4cGg)_G$ON9!)M!y#TLYC6AhmPQh#r`LduIzoe&I zE9#|=tF?j7Uhq1^SYf1?7%KL9vT<|c+luB?WA~g`#}(uKWAJtWK6jX1*(MHm%S!*M zs$zY1E;<)wt}+39m665V+zFNB9)A2sjj+{R&vrhHym3;U7rzY?&w*Xy@FDu? z?!6ep)zxo|zhd{(1Ms(u_5P!NoUW>EW6Tx%bFDWg@_zE2)I*N9GJ9V8k*@o&Lq04~AG&&KR~i}56dkvU)q7a=K^O{Nod-d;L-^Iqe;O{>1qo z)9-D%J;Q#tiqm1NSXai3J-+`Z-flDYX{UzGs(N3Ye9CWPJ)L?i`C94$Jo^j|`q~`( zzT^q>K-r9=JwJl&f1_ip9t)H;q*wf=z;OIFSs$}`cP@X%+L0-A8%DRzbf{163hZ7I z?qXk}*k>}HJ8ehqK6HuS7m7Ri@tfS^_+umt404S5tY+S?qSh-#r&6k}czz>xj_xiO zjeyLnan-f@ih0}Ed+73X#>|cNvgz%D*@1)fbYEA_4DQ)0k1Uik-%oy%dYz7MCAYap zea>@z1rleHl@nF?awy53j)`~sWbh|l@(_7tqe zJ-YE^iYLmu!ea+A#y(;#wPvi|7!Nh_dF<;Mb2l-6K822Vi{0m0^?ka1Nyo2o^oO|i z1?+OS_o5E!>ASIWcKkl`afsLi`HRiteXb^WQN12}unxkpHUB@B?gHGZs_6qbq9`B; z3P?&S-Q98N?(XjHOLwP~2ndQGh=i{wD2k1Z*a#-r-F@!&o5T0;Y|gpop1b#&`p>Lc zvu3f$I6vScIP{XZ=jp_I&XcsqE^)Qmyu9gda+d~1GnVIyeTzDo)1VqgRUM+$@vjl| zX$;dcK5}&&AJ}$M+E*#5oc-uoWi`tI3l0 zwc!nGID0(`U-i|fKv2p}s*w`3jA0-Pq zObx+6lIUYr(G?wS`O21L*ot09Z`E$DzOLvuJc6E&U=NW|j5F$5nJ?5cs`99e4zKCp z-N!g~!d!g`zua2pAe$W5c^LFSVykl&ccd+aKkLNo_a(mKXLQU==w*w}L7uvk&gy{R z&OJ!37dm^g`|e_Z&S-1Hr!?YIqN=1A9LhmouZ5Xu#*-kIG}@40YjL{4_q6Mhi1R$V zjT2U1@%xvPG%rE_4)%Q5_fL?_DR}aj>mg6QkN)4m?pDz08EiT1IqFYBy9&^k*!ds( zreA?4hhfkPDBee$P}Y3RPG$iQn}=946-9SP>mVpLo>tAp ztvE}49r>la$-UrI+-h&@)&$))gpp(oGsy9wld1>dgp zh%7{&vFkk6;P2;^XQ8vHyjoWI*$ZU(plhqV^Gde4O6}U6iP)EV86SSNXZ&jW#B*A+ zEn#%k$gv*ntj|K@?7GtMrx4w}*_a}Cn#mP;DCei*Nc6dm`BHCo)(J+&T6_!o+9v4> zzxMgp_pOpSS5etxl}z7VlbusYkQ!xkFRo928^8#>#8{dDl# z)-*dZe3g=`hzDWq9C@q`VxKs#?ytl>VuBs?b!XacvfXM}N7Ak07sp z=;(;9s6(v9yH!AYoP%1DjLXp3%3kXlX-oLp15bwI$t2&6HJHWZyow&jc{m%%b1glZ z!-o$dMP8)N{;F^4?IpVoXXSSNW>kG%WR_R!hIiFIi+6C2^|E_5E(r~M5W{G+*M zCTl~5)$LbhJ>k_lyIX(QGo0*3igSjducuh2xt}Y+_1us+s%O3S(mZxdWl6bNQ*+Y;_ub zj-f}<|Fto$$N3sD!+8q^?S-)uWufcxC7H^&vWQ95A(!_^pYtXkh@h=`& z`R8j>$Z{opjWZ>4p|UNSW{Og_!=7V)ehNQd7UP|T1+RMU^Dy>tc=$hdx)1J0o$CUx zv3IL89yCqH*^%dqZqlE#+l%I-=UDs;Xnc+gU!liQA@>Vj#5^vaxrlfg4O8jJQ1o{4 z8hfQGp)SsyjuX-1Jmxr6>_M_z24j23VU@H7|EC!J)x>`NjSh*?A9g*QkCI=VDzH)Uad6@4kpMcGgEt$GTNkDzxOdKc2y z*ef!`xVsrm)QQw)ht=GzIvQ)Dv9{`>+M?weuIk>`C6$(B9{rFa{~9$20(yb=#0Jq9ocUuc-0+``k{3Q z8lz9tK)mdNuZ_vEiaA7_9dBHt$1GwT9;;1w@+qRs~T0v^hW>xXy$g-%ww z+Tn7u%M$1L%irV_?1waKnkxp1oe>x14E}NDLQiSWsE?)&s2fkClPHO1e@{^R=i)yRFD zduj*x(;eNt*kliKjB}-8e`cIrlmqSm8RunkJ!8ffbxz~WPpYa#`5WFmpLoKV;ZMWZ zm-&?U%s0O@7fZ0&T@t6|nrzpz z#dUn|auQ3WC&N(`XKF@mLG;{?Q<^e(?ziOl2Aw-D_Sp=3;w;-Xd`Ss$-nD6u!$>qeZd%=Tj)3F#4!sC`4n*psywkj=V>2Rx*jS zSCQ9lHuwM}IAZMg8TWRyEFrUr>~XO8yf+-}Np`(pPP}&UbJWeYggdSMO*gz5PA^jF z%1SuC+1%q!RPCUfn_P?iJjT=8qpg&)s~zqiVo4VNvfCeM|@8s7!;=f}kA z_loiEG#A^A-iOfpu3T3J->WUQ8&BVNi3y$&^Ef%47PWWJmY!UX&kCF?l=NYfjA;(O zFT#bzzBLElOvIHTKSvN|zUrTkbGqf zgzqr7yen-1>{(_`y@xzsP3UO{vzkDRg*bl7Z188+R755%b`g&?gIq&^*<`GmeMe8DTk_l11V|&(E&iMQpuP4nH4j6y*Sy*nqGRJ&n3N#!|jswWGd(s-7W)Uya z-`B|hEm(D4%>RM-{WIq>ea}Y!G>=J>H@FtIM-5FbGAxP%6?x!@GJ<=x+_`+ZRbrWb z{6huvl634c;(~{)N#83DIwZD@o=#tidv3s!N_4zCjGf1a9DqY-#X!-OIl8#z;v-{y zx(p6i74_BkFV0}=gU3>*PYPTf0mNkm9UZdu_Bx>U%)#z%O8P|T- z6X#R-b51?M8QAl*{Oi-sS$r;SpLz6A`PjgoKN2^kD+!x>(X7}oQBGIp0$i^tfo_|>FQE)okgyrjjK;Gehr`aySv1BA90RktaC-zl9+!)F62dX z&lmWa7sd3i!rHg&LHLBsf8=NWW7iqbcb&*X%qhmZ)xG0vi5rdP23VT~-I+Z5U$NRH znWQJ->>bHG`$y)}XVn5nkNy9_*ZbuP9uUJmVjcdB8oi5j_Gf*8uGKTx`3+)=B_hv5 zqRcoUI*rYiLQfaZNkvoi6FEl0PvYh?o_N~W9x=wl=-CZ3R?~t>=DUKMIjPpzR+vxXJ0)Cf7 zTRA+fA`e*`mNt-=scQ~hFv{WyI#o{ z-~(Rx5?%e0twyE%&+Ju=IbE29AItpxX4jqgvmK2a*u_FLjy1#VYy{OIem*#H6%_i4 z#9v0^z3g+Q`E)b6k?ip2V|kF%^6W>+kWlDOb6H6>ZCOBbN;(M=+D zkd^S1lF%m3NG?yJv6H2scX94aoDmg0xT5wodU8dL(ihg|K1XZpH;Fwm|FE}MAB**a zH1?-&CRugn4Wfrt3pBP$Mjz}2oxS``)X_#Cl_Bmu0-wgQ`^o-(3Jkb%R!#0CMpP%| z?CyO@4Eq4wnP9yn&WZX-KID{okD$6{S zNnTIE*64Zs2KwK}QMosg=uhtB$!#urqkl>C;fsEX(c6A5T%W*R`k=Hq?Wuz5LJ%mU zIrX)7?o|PeHcx z;)|GNL>1*NWLc3+8>6=)#O=%eN5ZCw_#2orkNqrVtC0_1Nw=1<*M-UW_9rxdFNXUD z9bdwr&%{2F!;HQJZ{le5C_gJAiF3H42XyrAys};y=WIp|`w(=;-jZ%;Z;R#z(6u!B zbFi5>)h7CUofa2tl`|M9W-n$9{&SdfRNS)?2Bpdq%;zh&TN`>Np`%?QkNxkZ>`kbl z$9^+2hpx~hPO}~C{sZAjZ!vo8`-eeovqIT%HdpYIO`mGeq^k9S5@mJY4Uh)it`>eKe^AGbARH3HNM}IYkYqsO4T)*H~Yn(Q@df69s#O8I?MGQ>W&*nilJj5?vBPg0gw)=7rrVdQ}N($=Vg zY(#S-bCAtE=?8!P0-vyhTt{2?uE9#LmCw5*Z*VqQ8*@m0uwYZ$9`gZ#>QE#R{o-6N}e1#aeZl_t}Mp3wusk2Z#>sGEl88n3DHvU9B=+#xsn0<6uTsFlVhhQ3aCIMnkd7{N4@&wy~T4iI&oBYm^0EAX@uT1%jzxX&xVm%31_1%2!c ziw2ojPP5Xq+O^C3dwIJ(_^>e<8+_yUaXwVkd}U)5Ig|dmf#=o7e&l>y$O#q z(dO7y9X|&dds?xMDWa@< zXjpU^kJGS=(AfrjaZkLPU=|zrw*#+_`t9Rn@HnJ8=5;%}Ohso;GO7n>oa%yG?~7w1 zM32}$dgNY1M$tX$7SD=IX$iO!buhQG#A1*=JaXK;xCnpZ4Erbf{X_6{o0!h&+Wh8m^Xb!c_5z%}N*r4nozXLI2Fuw& zn_?HR6N-!?^1StMG|sgxPh(=Ye@4FL7xRcu;mP~_;QP)G`p~-Mr{r{*ZGLJF_M!P# z%o{&4_i#=e8PCLnF=XD$cbcKKs@KwVwhXC6ZC469+mTaGPa8;baSrGJxZ0PGjB9|O zhoF4`)Q=s`@h?2;IU2v-Jh>&RuU0wxJi6|5Ei#XuC-1z@TF}u%`g9jzY!0^A38!!8 zE6q&N0b#J;;P$>>Ale}~<@Nl)KG&wF(7 z0$=iBGHzXo-sso5-gj5BsM+v%Ec&AMv>khlTE(a_t;rs0B#|L%_+phSY&G^xMQ63E zX>~gPCwNN!o;**;6|9nD?|{ba;(|BOcsJWzp3LQajLu)hKi8&vDv`rki%uKm!CKL> zm1sUg%MzYgj%9RCt^<|i{|n3C<(GH9+1%?!d6CTWB-h9nTmyGAisdtjldemy1C00C zP~+<>*6B_syU^_(G%f08#-lBjx0>$?{~CRoVr_Go&zJh{LUhbQ*K|H*969zwVG}xA z43E={F*-c_&pPc|dAyP8D;uiIyNPXHhBc3q-)=d)O>#bWCVnEvtJA%n$er%@L>YTb zYS?>PFWp3%vz4x%gedR(IXHVgoQ<=JD|t%PV#Nt)bI=ezW0Ns&HTKQq89B%3ffDnu zDap9*YvcaV@6I{b_j!4~$JK?L;B$`C>l1PyPr5(K>=(iMraw__fJXcahp| z(u*3vHJ(0$b@n3hs`M((G>?v^N7Pcx;zv@@nGycHX5C=B7-%AY(gOx{-M+g;Jh9y0M=jArvKnpd!+5Q}Y_1cm zX#rT%tvas&&ad?&)n+(TJKLgXr8$N#;%Z) z-H^B{-97RmPb7-c+4AXfvV@k>zvmdy6YNxmPPsZo)R}!t>Y{RYIO1))KDgQ?521-KW^n z-ANihv7Yb_`Mu_^Ulor>Kg+12cr*Fh)Ms_fc*@h&qCEL6Nzd#oIhb69F4SOyb=Z3& zw6!I}zOM1;orB&5BsmYKXVR{5e&3U9t9yQW8uujIoJN-A`IGO(uaA-8R`|0Rt@Gi} zN-^GUIgzL3Wxq}2W1HpVx$4MQHZsE4QCi0=s|G(-6}DG2&nx3uMLaWy*Yx!4$D~X~ zqiqO!;!KYo^t2m|>dI=O4@czfdYPXL)W=~2`o@_NOcEDNLi2byJc?Y0(C@xY!5G)|yugVKhyt1PZ&ORgt$lYh=-U(|d2_*xqllot~eQnQjx zAE0#J|3;qQv%NT#=u5fh%jEcx$l`q{7V9Lhh=by^iPPSn^!agG@-TUy^4+MVxj=U# zrieA^La?V2n-@>hp$#l^6$(?m_V;rGpT}vXS^fXJTu7Xxam1Wtp1D_h5kl;5yDDwF z-Xh!OxFXknT>kkba{SmF>^EyhnVij+-}+Yxdy1WfE{@#=U!!YSL@oc(sN9}k9ws!P zN3A?5ayx_A<~X!XC%?I{W*)lc^0@QNL+0~F^XTXTIoKs+xl{&qzH7XwqnpngkU|wS z#+pH0QfUQWd%~K?voC??+db_*XmCj6cP~0OyP`MhAUf8-SaSPLM8T)TK}*GXE!72Q zRAcb6I*~iA)utwHPaA2DGE&XcENftU)XGNB{XF8>J}@Wfd)p`y@U{R8s7zN=#E~7* z*O$Hy;TNMmZK|=&aV@}w1;#iB?bD5OiYs~##Ca}}OPInh&f#yCvGlb@w%)Z?EVM#w zFb@VrOg!3W1KC+GIMmHEVz+A-Xw$>f`_iGlbg7q!ue0Aa_kRtun1X2iLk{3+bIrx* zY=X{AYAoNj_P(FKE>UYR$$WaE{J|17`T!gK2p;9;e>(9gtLXdVxE#5%ES?kdlD6bH z*0Yzxh&#~`F~D9l?1GVjwJVHso-5Aj8|t$j_z?T>TH-_WDQZP#ZP3%v)fbK9V0El5 zt;Cbt-FK6C?RNZJ&!?=VyK8ZHz0cS3TT94q66$&;S9igmO=#O?PI5~8^98KU3Hch)(-Ev`g(z(=yF6utZ;PgXLTh%Fg}nGn2K1ub)XDXSrs=r70|Y41FHVR(rd;`d&xhZ-zv)3i!!721&DE;V3cJ0vE48BHnTr)C^ZbPj-wv&#n(Thxo}hOtM|wu#-3$hDtSGxE2XuM`aGD>JoTwj#O` z#mqN%@+tMvM}KrVioJ@H)KL^g=LdB4K6N6CVeMo(J58*x)|&J~Y79P2*agV`n($~- z(6|qc;pNkzuqZCpr!k%3`Vd+iy&7Wv75lcMm%$t|jXM1yFs3Vi+0I|KhKun=k>gmOPvHGx4l@Q_ajx9}dfNwr#+jn+@UfNmE#N|9{H;r$ zs-my7@8qZNk(vEOe0P}6j+ZMa>D?#tKKGz+I(!{$Jz<1+cCNLAyI|1s^z|n(Z9!b_ zK(0&p&6998PF>H--W##WVI&)I`hM~|X;_R;!vTbghvG=h%8yJYD(QtJd*)2fH5r8s9E8X_MV{owkD z3%dEagI6bmpebUY7tsEic=lZpNSwYN``-Ta^Hs(k-4>&lNFh2>2ENwdvl_bMjF?t@ zSR1&|1_n3BkGeFh43xT=Cj6Url0+x7%`);LU!w6S{8_9ncr5%GD{r}2F7GI;{hHpE z6-AFl<9<6!DM${Xf&(waRY8{WHTGcAkL;)DAt>1%#rsPOc#6e z7qM5ZC%Fwk-w4+PXfX}$On3Llsf?uKgW*koW9@~15d-vKcfI+Gi0KBSZ78~ixuW;$ zSkE8lif%+bX;*Z3DZ++-=Sv<}51cCYsUk!BBbz+sx1!`!e(Do!0rDN@S$7 zxyZDLp0wrBS_?un5Y;pw$2xSSDn6IT)8fgT!ATnPGMT<@tZ(w0Pr;Xce9Ce@GU~fq ziY%&HUoWM$ASwiJgKEY7T%1mqOzQCGa5&bmGvZ<%G?d~KYthr@_}1Cns}K8#bE$@) zEl&UG!@G51J+1IQPNOWx+vf4KgpO*TstbNmy?&e%zE2+ZF8*W>TJKjUtHX+z{i3-h zQHpo%^W{rsq4REQw$B+^biKcpofLKFTCR?+;W!xhvy}{v!2MHX`2>!hV51L^t95W2^tj-o*a*oBVx6PwGf#$9vimIIxklw!^o>PX+&RZ(aVZtS;7^uo_b7l&h@0|FFpv)MqO_Q zSQPyjS|+dPMgB$dYL50s^tcv%EeETjXUvVzEi-v$VjWq?G*%8`ovS1sR%dn1ji9^d z4n^+-xHA?0OrTXm{3dq8R`Z?cff%Pw?3Ux|kIo`0fqoFjzQku9Pu7&4Li6k5pUt)sd-fq4%|Ktv@J}86AM@H}?izdD?3l@&_l>>< zHKA5y5eveae6Y5Ft0+7wLxyqIXDxE9&OWP%Amb^q!>krrHgwKfSwlkJ`xKI)=Zou20t!bT;r#v9OnY`vuX|A7t{Wl zh&`FshtW8kMy+C#4;aO3p8Nwkv%|9ruGV-u7(T@Lv`gXO?V_T){B|!H>|~$o%)_Go zWjtRvl)ese4M%g#IeXIMHuzBA?_(wB7TA-`YkD^Q2hIJ19$if?c}S-MTy2uHz$WNQ zL0==-nnI77@+&Ppza{!pMDCS&n*t)p^ep`gIriw4@}POi&ZN#hNN4X%Y!k;GkS}-w z?!1lG@6#%>meEFVH-8e9tyjB$N%w2a)+4_@n!U$a;E|7AOv+37fW>rlj`2=J+hA8$ zde|IlMQvB-SNDM5yrY$MhFmnET1OJ=oNE2Ij$DVuC+I6o80UHHI}E< zJDs;i^oJb0off?JG*%Vo*?z_gMxI$S|~6g8Pm#0H(jbOYUYtou(v zTkJEN>6w%Hk`c7E554W=wF#UJndk8T&$#mfjo55mx-VH4NxOs3TrF3yiVU~#CkNGZ zJr8Fu>1CNFKFY0zy|j9vYLM|tXU1G;s_0pb*+i_>_QTT=N#E4~-R1E2W@AdwPqoL! z+tj$Lh{=ob8TnXuPC1Y)=u7XjzhJ{JY6E`|i~S%E9_MEMD?Ym(PYTiZ*yq=l4Ge|p zGoa5B)U5Qp6`maD8BHbCF|=wZYU5uk6qmw_KXL6b^OG6yr@FY{_ryl|=f&oii_{*j zCCk0&d{Q0TM`|geXR!SM5d9JM_z8(br9>{zD+$+Y^ObE}QBOYE>nuE%zEJ z?%jhthp?6LI6R9@E+x~|v}%?2OME_^4Tszt(Aeu?pi|z(vWL|$t!J0>_>Vbap5<~Q zQD=FIOy5^?;H;^HY9D@ffRSuB%GYUBI+85Q6L<5>Ij-o*{+Lf*5F4CfL62B*+sQ`f zLV?ITx1@W~@1;C^DQ*0vT*dj2+k94>1>8z*x43e8ALl~F>7EgD=irCR()|{2Z73ct zgmpV$LYy`BD5@W&rw@|lJ#1$K`%E>WzVs{BRU}9PF61>gx!yp&8C(hZi@((a{3n-jz4>MI)lcCk zqjTLX{$d?@?I6efXbk#}!-vD7y}MaVoO3;s&UQy_uBh zqi5p8aCRAMT}>cCU3AtFyGN~IoJkXBebhvMWpPhAabF3#TLiuNU~u#~iqlB4z*eVu z(>}Rj@~P#SJz&jbb{MhVZZz(PulIPpgDh9F({y&>!Wq9>do*H>A?yJ(MG!O#3p@F^=B zFTm!arl=NKHbYAXwbfnuu3l_n0Gk<#r-R{L51%$=Nu_w5YjOCJsNfmedeHj&HnqX) zY!}l$;5BM2KeP6Jt-V6G+N;PWNabak_l?m<$J)T4a>+=!Jil2=3{=dV z$liKmJB-#NEO;OMh}xy7IqL-ZYVs>_+ED>loUwQ#yNxb|*SW6tK0#yu5sUl>3!($n zEpV+4%#8hdbI5Qjnhrs>$D!-<(B@ejJuXVv3F#NWp`pgp1~!!U*Vnq=>-f47cGnfl z{+w88U*eA~uM1 znnCU!eXX10W-~YwJ*GN)-vws2A*1MjSl;Ki(&b`gTmrV1m4~*Q++APudVvl{O+r+~ zWndfGSw?>LRy0{#G&O0XRg!a+Ic#OFr_B{r&t#ipaJvU9u21H9&>CIrckxXfWP;L@ z&Zsp=SO=?z>$4%nBP{1T+EthiMUT1}?sq%P+l}9Q z;Oq{v+{&6Zu!`90H5|Q7;7~p`@*}>U)63+vb(+Um%ya%-OnZ{gj1D%Z>2}n2J&Olt z;P1=)$Xn?D3}&UHnFY~O6UMYO|7Z{ITH|RcKp<2aV35RbX;)|KGrueB((^ z!JlpVIL^>pWT=|pKH~Mh`p%CKBh0llZmay ziHm7$Dw7;nREegrgCR*=YtA0J=u6R$jtqnT|%i)(0L=?6ykp?8*}u}9!_r-qvI|%dffFC4#zp(r%3atQS8K(MRYnk zEtJH|56z?3no&0t$NrsIpf+tbKeCkGZkH=KDwchYoxW!+eC{w9G6cqshUc+wcr!YV;pz)e<{cXHo*Bu@Q13w~yoDCc z7F`Z8ia4FDDk^ib>o4H>5qdk?Osp{7y$E9uiucxv4OYp|-UVl$QlIywI;R|B!g@4m z5br&oc5NZwgCr9k?jpMX^;#76wB+5!(6gm}f)17{`&iA%@wMWdOchS|=)@hfjiCKfrJ#w;-+YA1ITK+%M8k53k zw5?_>I_jB-3rsHu=$q!Rey4wNN;De z&w2E99=n`PmnV{LFSb;l21Iv@G@Uuk|KQzKXg$s*7s_#sF)ta3&8kBah830 zH2rPg*;UEd{S=FTl^HbLJo^icGj^|`P2DkB<3CtZP1|ZvSmQ<}3Bz zaW>8GZ%pLUhq#A_x z)PdXM0UdYKrWZsoUm95k&nyVBtN0fg=r(YuGg!3 zS^rrJ`x(Amf;Vr$sMyc52O4%1Ex zFIyM7M5oo+!`ad7b1nNk#4cYDYhLCdt`arg;f~3(`%>()j4OI1-%3|+qm!l4TESH#>5;#eU-*fy{S^lP3x6{wZDJ1YY@x4*(DMW| zcmaJcx}HVjNfN$~wys6xC_XOwux7=(cf{-4Wi0!dUlz4H{7bre5`DYr>rV0PA#uXX zi4*3YXXQmc!-YS@y|I`4M%I&`rrpXTmNu&LC@D*-#Yil6CFP>YIdC;rIpYkuLgX0r zpLw8S4jL6_sze`~h#w-ib3wfOrnSD;>FrzO`hm6gZ{W}0Nq;%e-^58xBaD3_Ud|TR zEvJ*4XzVUg-hDnf#tI&$IS)bMd(pa-{q$rfg~THt(AUjr`_$}j5X&xtGjsGDTVSnZ zP1+DNPD11DV)iGk*ZwM&El*!ZlHVREAE%H+m+Hc(sW0y6$u=UNw%F)b)7n)qZXvp- zqBVBm_7T7IB;Ou>joD zeN^setN3TVczqk}IYeh)W}82&E4>8xKkm*)_@)6g0e&rw6E%dXlD^Bx`I)gD} zF^TO>q?6;xEY>B*(!(+IF!oOONzPF&%b2~5W6$w1kBajirpph}+Y@y5dAj;B*~VI0 zUUPyn^1PM#MVT@h^E8@YM)NtE@)}M)0|6dnp<6`t1uPr=&SJ$+584Ozei{EZ~j4z-LHI#nt1vey@ThXe-2uv@grl%FwVp1>WXer?eL|8 zdxiJwK?nPg&w!-erNHiH<|l2~Xg9I!AaqX98*4T{GgtgRo<4QqRidlz4G``-=Lo!| zr|L86f=`%Z9#wOANL+9johQ>~hzssWyhD~^P^fVPEv%Q~7mUF$P`fNNM?u9dTA$l$rby>80zuEL? z|CeE#zl-nAk>{g{b^8C@B^G*!O}->=@CjdnB6`_QbUn>X;tsEmh+-n6@trHPI4Dj% zZtUs?-D02HWO2Yu{$K_{fG=;^vi6O;p3x*!xyKi#7T- z=#HA8*nK__@1~3K*1_<-^z|rxeFz;#aBvT~twdSmMB0&W?2yO~6TUa2drl0qkqo>pQXFe`yQp?fqtnFTV@AElfOZqU&oKP z#0JrC;|KmUA#T5(9^{3rB~Va}-lnkm7JOw3@@z(iO~^0>PgBs@m_!>Ub-OjL$4bI4 zZ0&%#dnbPLUu)6FT%FL}nw%T^T|+)J&R^^4Zc}l6z4-MmUicn# z?%_M{q~pKqMSaD{WVw4l&QxiV-IbdZCWPz{)=Q> z5PNCc!>u^iV;EZFeD&$>Jr`G3(wi;N?Jo4*i(~iD**nQ|J^fwi^YLix!nam5o;YLc z6|>om?6Mo&DPZmEb2j-1eci-wE@YdF#6H{kk>|`W|4ulcniY*OyV%T=pXABUd;JZ9 zWOd(SFtD2EM?4#Ajs3izNXHgIrgi+}Cf{0*?pPB`rKc15{^;*FnhlQPGe*nx3^xyn zUJhO8ZaWcZoW<9iZN~o0IAwJ(9!+!Ks3W}tZTFDa0Z%)`e;(r_V}5-v8Lu;|pTLha z_xwDp=~MZp-Dc8V)}+I3@e*o zR%1!gt-2QO)`Nfz@vi}|9BUP|;a(NEQ;PfwvXju$v}EqCEMH#$9of*8m43!7yabzuZ z5UWB_8xyCDWTUZBNAa}$j}Cu!b=_c#@ZTp5>pD+X^iHnEifZMe!fD8?~0D;A)Ea)vjX}a0y30LhCu2 za+;;yj{}?hJO%2tKxa;W_pW*D7Lh?W@m+q^-d~XAX*PL(VuR~G{__cQ%eTl`FJ`me zq3ByHN{I@OQ$~A+Md_rB)+!LQmQmC)BZxjKQF|S^x0_v2?-4r~zA}&clm>i~9D7;% zSdGp$f;VmWkPhOVwyqR3S0m$6^!HXaTas;7hik3eGdiNrpeyUe2lu)jLGN==?IkjN zmX@AG_XD1|llH{kh{2v;o;1JUOSYo3S=tgk8K#&|j+WaRlQsng%|vVTta@5p@SA)= z8L}KmmYb5=mfK(726Za(Ak{p*8cV1o9w;xCEaR!gJ+UC%yP1vNzz4*RbhV4HwGf&M zL-3g$kVI@fybFm=EE=;&Z;d(-5fx4KTr16?pr z`APhj&Y77R)3qkgIW)|POIBNR(YWYBnM(vv0Mf*qry@C4W+mleO)2_w8{EG|%o4pR zvKvG6D387w8K7kJ(ogU8cXWL&wtEk*#@yr${^euu|3GK<IMs=|ER+cWe)X8_P1*hvISmRaf^M12Y%Uh&9l21D#yY zW35Be26600D7O(eR=Z~7!(d#D9U;-T^GoYb`_S1(?2}Emt(OxW>1ccPm3`6%B_^f~ zGe4QjpX|2=|7qeQ@z2lV!GxSxbQq7--ki{-5Y3EJDr=KToPv?!NzrShn&*|oJMs2;#}18@&j*seV3oSoRnppk(6Szah6yM^6ca( z{ps!~a-9I#C&S&TJn%G6p6<8PNOB}?je7IaY~?C;@fN$>lXgeih_tC{nEI$?>Hu@P zk>_Z0ua)MThs?vy!d>;4)@*M=3gE(6#&LiH)Huu1v zgJgPue0TDfD{&^p0XaXfGiuy%>IYACldA4 ziPU6^EyM&P$aRhQ?y&tOFIY4A5Dx!fe)+G=!nN!&&Opfy1p}?~(x@UdstgHM#rIlf z1+_i38a|h%uLZ=K&WFX@sQr$!kRlEmm&|oUUiNcwKp@Sv-D@fYj94$lcyh9pcSU(?%)aUx`A>Z1 z@kDEJKvTHW+BHz^;e2x4YfbZ8;{eL7o5`PD>%67E7JicFt$`9?3K7r2d_CYUE z6Fbv7&N2wK2rIJ6PdsIx;$$&i zNmZ2*LP8H~ENw1}8Q*~o#j^n-PRqQdDhHI(xcDDDC zpS>~<&qNRM(95FocV*~p#E)0bo!I6sn`y;bJXnFl3*p}+--*+mYSNh;Y$49vdRXi; z-M@-DAby=_NtVsby*i}zM{_Uh31h9BY?k|ZIq|Q0HFIS8I$6}U0}?(BEk4Ede|fgt z^r(hAcXAE)ZaRrB@Vv<7O{Z&veWN9r#M!$A*kkTw9P}5C{fSO}Q6b_WS}_b3ML*&2 zEm8X%{c{%in^^OGz}TN>`xkKSb5HtGl=CUxp7ZlbT#Q<|sBs$4c3R9 zN6^*ilQYD3;@s9@_%nu4$ zW7&)@y3}Q(DS2?Sgr`=9eAUrah2E6%bIe$>_$kiay~b-s&yE%QOx|DTN{@m#J@s?( z+y%JvmOWH&tL?v-%$cOplsLg?Bz}#8J&}8!iGvHpBFnulcell4xB$)b&^yNyXL#aR zUc77anIA!u6KSWcl}x0ww}{4L9bu99rwa^fC}wYI{cEIsCfnHNIXasK_B15V(d4*J zwQIBOEusydC~5hCj>Y@^+%}v^@#mCH}Eyox^^iLiDw; z99LeREt_a9v&j2~B*K;zaWrCwv1SlwMNJ~7IqYu%oLz*kOFT1rj!#e0{V%RL2i@H3I^_NTXy`GY@5AMd z>~%8wqh2H@9z|SmyDV30v+1jKb2^R2Rput+*yl)dv1!)eHzi^Z^~Y*S{$;;e zr@R@`=O?+MP%d^7SAn)wX-Xy5Q{EGz-%ue}teM2wt7*{rA9KdYiv3Fi{*mkXLu~h} z_~sWh#mS;^-rT2ho9~k4IXL^4*Vq^S3;HvlBsZ%oftsr5txrdq(v@a(wK2VI2xIHe zNpdU>^W%(~k~A#xwbjwtOq>v>$M%IFBk*bh44X`Aroy#3?z;?U*W>7&o_m+) zZ$s-!&z|n;?RT~DKQQBa)f!KzWn6Bi(b=k95!C)JUm0sAXRKj9XOHIV)@i>~Cz6@$ zs){oE)1>Ihbr4#-;Pq0{n5-9@G+T+ey`EWA63 zd;971?P#2B6#ad#jxpSb%8%4g?N)c#8-2M|&Ax43xr2R9N9)MMHkkV`fBBxer@)?@ z<=2ZR_tu^=gNm*^8E8ZS5kUo`Z4B$WdOwKX#@RX(=<`_EdnHDX(1fo^ z*Ad~>$BWveTgkTq6R*a}kHxYNi)B|?;~S;+t`{urAx0l2R$qk1{d6|YQu+yzA(d#trR{_U*(w=}AHMi3o0N+<2Hzt`B8u!c77!238CXd69SMrT9c2D{?isy5`@ zFsbd)QR;Kti&fEQWm=*`-9zm71kE`Ee=dmKowY)qYw7Ks{&p{&J?NSDqj3-GU(bhz z4;f`<*~@Dy9Ir~JbHMs<^|d}NkGC4eb`{YU;Zc4e%a>h;#j(4rf9*7nJ|>QRAI4^Z zuk~rsD0Hn7`$Rq0ljwfeh`wi0nb1~T^cFbVmyD**o{=e-J?%#oB`DpuO`x*Rp`45iXK4G{kVI8 z9Y)UUPP)4SeQ{<(dy*`Hmw#AGe?qO=Oj(1nqM#4t3AQBK$rE%*>n8pgD*qGbL7cYk z^@~`xxEVwb9AA#EigzA&~sIYx)GZn)UbeMh5l78)ZrvfUGQdeT;Q z66Yi=^!%994u^MLe7BZo<-(`S>KgaUH!LO|0Ydnc~_Dl5sO5WF*bLFQKWyGqr=xSrW(Yf>F z_yAwB+h479hv~Q*Yn*YKN>zA~mtOxPU+}j1*M3?%MP8&XiKg+FFC=kBumi{Sg6lCq@An$9Uw>S$1mEKvu`|})7em>G=9m8^x|=trs1vDgF4Bq& z2dXhxV$b3OdQ-e1p8r`L=Jn3?xLJKcK6%PpjH0N~m7tmBNUM_3RzPb+wPn1E*q|ht z6^A*6jWNI10>)cFB|?4}bPF9UM6PjOMeIY#O4kzfG}eQ@O3pEBp|LjIZ|ol3*xe}T znMzvA%<9%d(~Ybz&MUZ`ZLY)P)nvWK_m2R$1Ri_J;*+CgR40|bx1JU6zm;d$TcF&KkBOF%qJWd>Yt9i)< z@!eiJyNvEe{&^kziT=-Ts#Qy8{!t$4^|T^4M?PeS*GKrxpfy(Le(^pd+HOH}4SekG z-3-=oyXW15mZ)iuvq3j{jsBg1!DIbyI9nZHJkeLRD_d;uZq3c38;B30UcV|?#5n@Z z-F=|HS-?Nt4QEcVi5JQ6B{upj&-5tS9%3omU9rzC>dj-${*Q{)Q&#yV%ZrrcH9s~F zxf`}l7Z(gnOt*%)+v##dr`jqVlsYD z1