From 0d0c0acb0573235b78dfd8a932df20e9f9f0b3b2 Mon Sep 17 00:00:00 2001 From: Marcos Cordeiro Date: Thu, 23 Nov 2023 21:15:36 -0300 Subject: [PATCH] Fixes video duration when subtitles are longer then video stream This happens a lot in kamikatsu, but for now only work on ass subtitles --- Wasari.FFmpeg/FFmpegService.cs | 167 ++++++++++++++++++++++++++++++--- Wasari.FFmpeg/SubtitleFile.cs | 3 + 2 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 Wasari.FFmpeg/SubtitleFile.cs diff --git a/Wasari.FFmpeg/FFmpegService.cs b/Wasari.FFmpeg/FFmpegService.cs index c4a8b53..6c34131 100644 --- a/Wasari.FFmpeg/FFmpegService.cs +++ b/Wasari.FFmpeg/FFmpegService.cs @@ -154,13 +154,13 @@ private async IAsyncEnumerable BuildArgumentsForEpisode(IWasariEpisode e { yield return "-c:v copy"; } - + var fileExtension = Path.GetExtension(filePath); var isMp4 = fileExtension == ".mp4"; - + if (isMp4) yield return "-c:s mov_text"; - + yield return "-y"; yield return $"\"{filePath}\""; } @@ -177,10 +177,10 @@ private static Command CreateCommand() { var fileName = Path.GetFileNameWithoutExtension(baseFilePath); var fileDirectory = Path.GetDirectoryName(baseFilePath); - + return $"{fileDirectory}{Path.DirectorySeparatorChar}{fileName}_wasari_tmp{extension}"; } - + if (!Options.Value.UseTemporaryEncodingPath) return null; @@ -212,7 +212,7 @@ private static Command CreateCommand() return videoDuration == TimeSpan.Zero ? null : videoDuration; } - + public async Task CheckIfVideoStreamIsValid(string filePath) { try @@ -224,7 +224,7 @@ public async Task CheckIfVideoStreamIsValid(string filePath) var videoDuration = GetVideoDuration(fileAnalysis); - if(videoDuration == null) + if (videoDuration == null) return false; var delta = fileAnalysis.Duration - videoDuration; @@ -234,7 +234,7 @@ public async Task CheckIfVideoStreamIsValid(string filePath) { Logger.LogWarning("File was found to be invalid: {FilePath}, the difference between the video duration and the file duration is {Delta}", filePath, delta); } - + return isValid; } catch (Exception e) @@ -247,13 +247,45 @@ public async Task CheckIfVideoStreamIsValid(string filePath) public async Task DownloadEpisode(T episode, string filePath, IProgress? progress) where T : IWasariEpisode { var tempFileName = GetTemporaryFile(Path.GetExtension(filePath)); - var arguments = await BuildArgumentsForEpisode(episode, tempFileName ?? filePath).ToArrayAsync(); + var outputFile = tempFileName ?? filePath; + var arguments = await BuildArgumentsForEpisode(episode, outputFile).ToArrayAsync(); var ffmpegCommand = CreateCommand() .WithValidation(CommandResultValidation.None) .WithArguments(arguments, false); - + await foreach (var commandEvent in ffmpegCommand.ListenAsync()) ProcessEvent(episode, progress, commandEvent, ffmpegCommand); - + + var checkIfVideoStreamIsValid = await CheckIfVideoStreamIsValid(outputFile); + + if (!checkIfVideoStreamIsValid) + { + var outputDirectory = Path.Combine(Path.GetTempPath(), $"{Path.GetFileName(filePath)}-subs"); + if (!Directory.Exists(outputDirectory)) Directory.CreateDirectory(outputDirectory); + + var mediaAnalysis = await FFProbe.AnalyseAsync(outputFile); + var duration = GetVideoDuration(mediaAnalysis); + + if (duration != null && mediaAnalysis.SubtitleStreams.All(x => x.CodecName == "ass")) + { + var subtitleFiles = await ExtractSubtitles(outputFile, outputDirectory, mediaAnalysis, duration.Value).ToListAsync(); + var modifiedSubtitlesFile = await subtitleFiles + .ToAsyncEnumerable() + .SelectAwait(i => FixSubtitleDuration(i, duration.Value)) + .ToHashSetAsync(); + + var outputFileName = $"{outputFile}.modified{Path.GetExtension(outputFile)}"; + await ReplaceSubtitles(outputFile, modifiedSubtitlesFile, outputFileName); + Directory.Delete(outputDirectory, true); + + if (await CheckIfVideoStreamIsValid(outputFileName)) + { + File.Delete(outputFile); + File.Move(outputFileName, outputFile); + Logger.LogInformation("Video duration has been fixed"); + } + } + } + if (tempFileName != null) { var destFileTempName = $"{filePath}.wasari_tmp"; @@ -265,6 +297,117 @@ public async Task DownloadEpisode(T episode, string filePath, IProgress FixSubtitleDuration(SubtitleFile subtitleFile, TimeSpan duration) + { + var filePath = subtitleFile.FilePath; + var modifiedFilePath = $"{filePath}.modified{Path.GetExtension(filePath)}"; + + await using var readStream = File.OpenRead(filePath); + using var readStreamReader = new StreamReader(readStream); + + await using var writeStream = File.OpenWrite(modifiedFilePath); + await using var streamWriter = new StreamWriter(writeStream); + var hasReachedEvents = false; + + while (await readStreamReader.ReadLineAsync() is {} line) + { + if (!hasReachedEvents && line.StartsWith("[Events]", StringComparison.InvariantCultureIgnoreCase)) + { + hasReachedEvents = true; + } else if (hasReachedEvents) + { + var match = AssEventRegex().Match(line); + + if (match.Success) + { + var dateEnd = TimeSpan.Parse(match.Groups["dateEnd"].Value); + + if (dateEnd > duration) + { + line = line.Replace(match.Groups["dateEnd"].Value, duration.ToString(@"hh\:mm\:ss\.ff")); + await streamWriter.WriteLineAsync(line); + break; + } + } + } + + await streamWriter.WriteLineAsync(line); + } + + await streamWriter.FlushAsync(); + return new SubtitleFile(modifiedFilePath, subtitleFile.Language); + } + + private async IAsyncEnumerable ExtractSubtitles(string filePath, string outputDirectory, IMediaAnalysis mediaAnalysis, TimeSpan duration) + { + if (mediaAnalysis.PrimaryVideoStream != null) + { + foreach (var mediaAnalysisSubtitleStream in mediaAnalysis.SubtitleStreams) + { + var outputFile = await ExtractSubtitle(filePath, outputDirectory, duration, mediaAnalysisSubtitleStream); + yield return outputFile; + } + + Logger.LogInformation("Extracted subtitles from {FilePath} to {OutputDirectory}", filePath, outputDirectory); + } + } + + private CommandTask ReplaceSubtitles(string videoFilePath, ICollection subtitlesFilePaths, string outputFileName) + { + var arguments = CreateArguments().ToArray(); + var ffmpegCommand = CreateCommand() + .WithArguments(arguments, false); + + Logger.LogInformation("Replacing subtitles for {VideoFilePath} with {SubtitlesFilePaths} to {OutputFileName}", videoFilePath, subtitlesFilePaths, outputFileName); + + return ffmpegCommand.ExecuteAsync(); + + IEnumerable CreateArguments() + { + yield return $"-i \"{videoFilePath}\""; + + foreach (var subtitlesFilePath in subtitlesFilePaths) + { + yield return $"-i \"{subtitlesFilePath.FilePath}\""; + } + + yield return "-map 0"; + yield return "-map -0:s"; + + foreach (var subtitle in subtitlesFilePaths.Select((s, y) => new {Index = y, s.Language})) + { + yield return $"-map {subtitle.Index + 1}"; + + if (subtitle.Language != null) + yield return $"-metadata:s:s:{subtitle.Index} language=\"{subtitle.Language}\""; + } + + yield return "-c copy"; + yield return "-y"; + yield return $"\"{outputFileName}\""; + } + } + + private static async ValueTask ExtractSubtitle(string filePath, string outputDirectory, TimeSpan duration, MediaStream mediaAnalysisSubtitleStream) + { + var outputFileName = $"{outputDirectory}{Path.DirectorySeparatorChar}sub{mediaAnalysisSubtitleStream.Index:00}.{mediaAnalysisSubtitleStream.CodecName}"; + + var arguments = new[] + { + $"-i \"{filePath}\"", + $"-map 0:{mediaAnalysisSubtitleStream.Index}", + $"-t \"{duration}\"", + "-y", + $"\"{outputFileName}\"" + }; + + var ffmpegCommand = CreateCommand() + .WithArguments(arguments, false); + + await ffmpegCommand.ExecuteAsync(); + return new SubtitleFile(outputFileName, mediaAnalysisSubtitleStream.Language); + } + private void ProcessEvent(T episode, IProgress? progress, CommandEvent commandEvent, ICommandConfiguration ffmpegCommand) where T : IWasariEpisode { switch (commandEvent) @@ -309,4 +452,6 @@ private void ProcessEvent(T episode, IProgress? progres [GeneratedRegex("\\d+\\:\\d+\\:\\d+(?\\.\\d+)")] private static partial Regex DurationRegex(); + [GeneratedRegex(@"^Dialogue:\s\d+,(?\d+:\d+:\d+\.\d+),(?\d+:\d+:\d+\.\d+),")] + private static partial Regex AssEventRegex(); } \ No newline at end of file diff --git a/Wasari.FFmpeg/SubtitleFile.cs b/Wasari.FFmpeg/SubtitleFile.cs new file mode 100644 index 0000000..f459008 --- /dev/null +++ b/Wasari.FFmpeg/SubtitleFile.cs @@ -0,0 +1,3 @@ +namespace Wasari.FFmpeg; + +internal record SubtitleFile(string FilePath, string? Language); \ No newline at end of file