diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3f365d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.mp3 binary +*.mp4 binary \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c787a2d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "coreclr", + "request": "launch", + "name": "Launch StreamShorts.Console", + "program": "${workspaceFolder}/src/StreamShorts.Console/bin/Debug/net9.0/win-x64/StreamShorts.Console.dll", + "args": [ + "${input:filePath}" + ], + "cwd": "${workspaceFolder}/src/StreamShorts.Console", + "stopAtEntry": false, + "console": "integratedTerminal", + "preLaunchTask": "build", + "justMyCode": false + } + ], + "inputs": [ + { + "id": "filePath", + "type": "promptString", + "description": "Enter the path to the video file", + "default": "" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..21360d6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/StreamShorts.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/src/StreamShorts.Console/Commands/DefaultCommand.cs b/src/StreamShorts.Console/Commands/DefaultCommand.cs index 7208db7..c932267 100644 --- a/src/StreamShorts.Console/Commands/DefaultCommand.cs +++ b/src/StreamShorts.Console/Commands/DefaultCommand.cs @@ -69,19 +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, $"{inputFileName}.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)); - - if (audioStream is null) - { - _console.MarkupLine("[red]Failed[/] to extract audio from the stream."); - return (int)ExitCode.FailedToExtractAudio; - } + .StartAsync("Extracting audio...", async _ => await _audioExtractor.ExtractMp3FromMp4Async(videoStream, audioStream)); _console.MarkupLine($"[blue]Audio extracted[/] [green]successfully![/]"); @@ -101,14 +110,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) @@ -192,7 +193,6 @@ internal sealed class DefaultCommand( private enum ExitCode { - FailedToExtractAudio, FailedToAnalyzeTranscript, SuccessFullyProcessedStream, } diff --git a/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs index 6e0a3f0..c49ea01 100644 --- a/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs +++ b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs @@ -25,42 +25,41 @@ 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) { throw new ArgumentNullException(nameof(video), "Video stream cannot be null"); } - if (video.CanRead is false) + if (IsVideoStreamUsable(video) is false) { - throw new ArgumentException("Video stream must be readable", nameof(video)); + throw new ArgumentException("Stream must be readable and seekable", nameof(video)); } - if (video.CanSeek is false) + if (audio is null) { - throw new ArgumentException("Video stream must be seekable", nameof(video)); + throw new ArgumentNullException(nameof(audio), "Audio stream cannot be null"); + } + + if (IsAudioStreamUsable(audio) is false) + { + throw new ArgumentException("Stream must be writable and seekable", 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) { @@ -71,4 +70,14 @@ public sealed class AudioExtractor : IAudioExtractor video.Position = originalPosition; } } + + private static bool IsVideoStreamUsable(Stream stream) + { + return stream.CanRead && stream.CanSeek; + } + + private static bool IsAudioStreamUsable(Stream stream) + { + return stream.CanWrite && stream.CanSeek; + } } \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs index 9996292..6730c18 100644 --- a/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs +++ b/src/StreamShorts.Library/Media/Audio/IAudioExtractor.cs @@ -9,11 +9,15 @@ 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 stream is not seekable. /// 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..0b90c16 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/src/StreamShorts.Library/Media/NAudioService.cs b/src/StreamShorts.Library/Media/NAudioService.cs index 89aa733..0b71d35 100644 --- a/src/StreamShorts.Library/Media/NAudioService.cs +++ b/src/StreamShorts.Library/Media/NAudioService.cs @@ -1,4 +1,3 @@ - using NAudio.Wave; namespace StreamShorts.Library.Media; 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); }