diff --git a/src/StreamShorts.Console/Commands/DefaultCommand.cs b/src/StreamShorts.Console/Commands/DefaultCommand.cs index 7208db7..b0a2241 100644 --- a/src/StreamShorts.Console/Commands/DefaultCommand.cs +++ b/src/StreamShorts.Console/Commands/DefaultCommand.cs @@ -69,13 +69,28 @@ internal sealed class DefaultCommand( public override async Task ExecuteAsync(CommandContext context, Settings settings) { _console.MarkupLine($"[blue]Processing stream:[/] {settings.Stream.EscapeMarkup()}"); - var videoStream = _fileSystem.File.OpenRead(settings.Stream); + var videoStream = (FileStream)_fileSystem.File.OpenRead(settings.Stream); - Stream? audioStream = null; + var now = _timeProvider.GetUtcNow(); + var inputFileName = _fileSystem.Path.GetFileNameWithoutExtension(settings.Stream); + var baseDirectory = ValidateAndGetBaseOutputDirectory(); + var artifactsOutputDirectory = $"{now:yyyy_MM_dd_HH_mm_ss}_{inputFileName}"; + var outputDirectoryPath = _fileSystem.Path.Combine(baseDirectory, artifactsOutputDirectory); + + _fileSystem.Directory.CreateDirectory(outputDirectoryPath); + + using var audioStream = new FileStream( + _fileSystem.Path.Combine(outputDirectoryPath, "audio.mp3"), + FileMode.Create, + FileAccess.ReadWrite, + FileShare.ReadWrite, + 4096, + FileOptions.Asynchronous + ); await _console.Status() .Spinner(Spinner.Known.Dots) - .StartAsync("Extracting audio...", async _ => audioStream = await _audioExtractor.ExtractMp3FromMp4Async(videoStream)); + .StartAsync("Extracting audio...", async _ => await _audioExtractor.ExtractMp3FromMp4Async(videoStream, audioStream)); if (audioStream is null) { @@ -101,14 +116,6 @@ internal sealed class DefaultCommand( _console.MarkupLine($"[blue]Transcription completed[/] [green]successfully![/]"); - var now = _timeProvider.GetUtcNow(); - var inputFileName = _fileSystem.Path.GetFileNameWithoutExtension(settings.Stream); - var baseDirectory = ValidateAndGetBaseOutputDirectory(); - var artifactsOutputDirectory = $"{now:yyyy_MM_dd_HH_mm_ss}_{inputFileName}"; - var outputDirectoryPath = _fileSystem.Path.Combine(baseDirectory, artifactsOutputDirectory); - - _fileSystem.Directory.CreateDirectory(outputDirectoryPath); - await _fileSystem.File.WriteAllTextAsync( _fileSystem.Path.Combine(outputDirectoryPath, "transcription.txt"), string.Join(Environment.NewLine, transcriptionSegments) diff --git a/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs index 6e0a3f0..e852638 100644 --- a/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs +++ b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs @@ -25,7 +25,7 @@ public sealed class AudioExtractor : IAudioExtractor _videoService = videoService ?? throw new ArgumentNullException(nameof(videoService), $"{nameof(videoService)} cannot be null"); } - public async Task ExtractMp3FromMp4Async(Stream video) + public async Task ExtractMp3FromMp4Async(Stream video, Stream audio) { if (video is null) { @@ -42,25 +42,29 @@ public sealed class AudioExtractor : IAudioExtractor throw new ArgumentException("Video stream must be seekable", nameof(video)); } + if (audio is null) + { + throw new ArgumentNullException(nameof(audio), "Audio stream cannot be null"); + } + + if (audio.CanWrite is false) + { + throw new ArgumentException("Audio stream must be writable", nameof(audio)); + } + var originalPosition = video.Position; try { - var mp3Stream = new MemoryStream(); - using var mp4Stream = new MemoryStream(); - - await video.CopyToAsync(mp4Stream).ConfigureAwait(false); - mp4Stream.Position = 0; - - var wasExtracted = await _videoService.ExtractAudioFromVideoAsync(mp4Stream, mp3Stream).ConfigureAwait(false); + var wasExtracted = await _videoService.ExtractAudioFromVideoAsync(video, audio).ConfigureAwait(false); if (wasExtracted is false) { throw new FailedAudioExtractionException("Failed to extract audio from the video stream."); } - mp3Stream.Position = 0; - return mp3Stream; + audio.Position = 0; + return audio; } catch (Exception e) when (e is not FailedAudioExtractionException) { diff --git a/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs index 9996292..61f4954 100644 --- a/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs +++ b/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs @@ -9,11 +9,14 @@ public interface IAudioExtractor /// Extracts MP3 audio from an MP4 video stream. /// /// The input video stream. + /// The output audio stream where the extracted MP3 will be written. /// A stream containing the extracted MP3 audio. /// Thrown when the video stream is null. /// Thrown when the video stream is not readable. /// Thrown when the video stream is not seekable. + /// Thrown when the audio stream is null. + /// Thrown when the audio stream is not writable. /// Thrown when the audio extraction fails. /// The method will preserve the passed video stream's data and position. - Task ExtractMp3FromMp4Async(Stream video); + Task ExtractMp3FromMp4Async(Stream video, Stream audio); } \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/FFMpegService.cs b/src/StreamShorts.Library/Media/FFMpegService.cs index 8ab00fd..18566d2 100644 --- a/src/StreamShorts.Library/Media/FFMpegService.cs +++ b/src/StreamShorts.Library/Media/FFMpegService.cs @@ -12,14 +12,17 @@ internal sealed class FFMpegService : IVideoService { public async Task ExtractAudioFromVideoAsync(Stream video, Stream audio) { - return await FFMpegArguments - .FromPipeInput(new StreamPipeSource(video)) - .OutputToPipe( - new StreamPipeSink(audio), - static o => o.DisableChannel(Channel.Video).ForceFormat("mp3") - ) - .ProcessAsynchronously() - .ConfigureAwait(false); + var inputArguments = video is FileStream videoFileStream + ? FFMpegArguments.FromFileInput(videoFileStream.Name) + : FFMpegArguments.FromPipeInput(new StreamPipeSource(video)); + + Action arguments = static o => o.DisableChannel(Channel.Video).ForceFormat("mp3"); + + var processor = audio is FileStream audioFileStream + ? inputArguments.OutputToFile(audioFileStream.Name, addArguments: arguments) + : inputArguments.OutputToPipe(new StreamPipeSink(audio), addArguments: arguments); + + return await processor.ProcessAsynchronously().ConfigureAwait(false); } public async Task CreateClipFromVideoAsync( diff --git a/tests/StreamShorts.Library.Tests/Integration/Media/Audio/AudioExtractorTests.cs b/tests/StreamShorts.Library.Tests/Integration/Media/Audio/AudioExtractorTests.cs index 39ca12d..aa1ce08 100644 --- a/tests/StreamShorts.Library.Tests/Integration/Media/Audio/AudioExtractorTests.cs +++ b/tests/StreamShorts.Library.Tests/Integration/Media/Audio/AudioExtractorTests.cs @@ -9,13 +9,15 @@ public class AudioExtractorTests { using var testVideo = TestData.GetTestVideo(); using var extractedAudio = TestData.GetExtractedAudio(); + using var audioStream = new MemoryStream(); - var result = await _sut.ExtractMp3FromMp4Async(testVideo); + var result = await _sut.ExtractMp3FromMp4Async(testVideo, audioStream); var audioBytes = await ConvertStreamToBytesAsync(extractedAudio); var resultBytes = await ConvertStreamToBytesAsync(result); resultBytes.Should().Equal(audioBytes); + result.Should().BeSameAs(audioStream); } private static async Task ConvertStreamToBytesAsync(Stream stream) diff --git a/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs index d70f76b..36f9755 100644 --- a/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs +++ b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs @@ -25,7 +25,7 @@ public class AudioExtractorTests [Fact] public async Task ExtractMp3FromMp4Async_WhenVideoIsNull_ItShouldThrow() { - var action = async () => await _sut.ExtractMp3FromMp4Async(null!); + var action = async () => await _sut.ExtractMp3FromMp4Async(null!, new MemoryStream()); await action.Should().ThrowAsync(); } @@ -36,7 +36,7 @@ public class AudioExtractorTests var mockStream = new Mock(); mockStream.Setup(s => s.CanRead).Returns(false); - var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object, new MemoryStream()); await action.Should().ThrowAsync(); } @@ -48,7 +48,7 @@ public class AudioExtractorTests mockStream.Setup(s => s.CanRead).Returns(true); mockStream.Setup(s => s.CanSeek).Returns(false); - var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object, new MemoryStream()); await action.Should().ThrowAsync(); } @@ -69,7 +69,7 @@ public class AudioExtractorTests ) .ThrowsAsync(new Exception()); - var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object, new MemoryStream()); await action.Should().ThrowAsync(); } @@ -90,7 +90,7 @@ public class AudioExtractorTests ) .ReturnsAsync(false); - var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object, new MemoryStream()); await action.Should().ThrowAsync(); } @@ -111,7 +111,7 @@ public class AudioExtractorTests ) .ReturnsAsync(true); - var result = await _sut.ExtractMp3FromMp4Async(mockStream.Object); + var result = await _sut.ExtractMp3FromMp4Async(mockStream.Object, new MemoryStream()); result.Should().BeAssignableTo(); result.Should().BeOfType(); @@ -137,7 +137,7 @@ public class AudioExtractorTests ) .ReturnsAsync(true); - await _sut.ExtractMp3FromMp4Async(stream); + await _sut.ExtractMp3FromMp4Async(stream, new MemoryStream()); stream.Position.Should().Be(positionToRead); }