Skip to content

Commit

Permalink
Fix incorrect video finalization percentages and chapters caused by b…
Browse files Browse the repository at this point in the history
…ad math (#1004)

* Fix video finalization reporting int.MinValue under specific video crop conditions only possible in the CLI

* Replace "Finalizing Video..." with log

* Fix potential negative crop issues only possible in CLI

* Use TimeSpans instead of doubles

* Fix videos that end on the same second as a new chapter beginning keeping said chapter
  • Loading branch information
ScrubN authored Mar 21, 2024
1 parent 8de54ef commit d02269d
Show file tree
Hide file tree
Showing 6 changed files with 51 additions and 42 deletions.
4 changes: 2 additions & 2 deletions TwitchDownloaderCLI/Modes/DownloadVideo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ private static VideoDownloadOptions GetDownloadOptions(VideoDownloadArgs inputOp
_ => throw new ArgumentException("Only MP4 and M4A audio files are supported.")
},
CropBeginning = inputOptions.CropBeginningTime > 0.0,
CropBeginningTime = inputOptions.CropBeginningTime,
CropBeginningTime = TimeSpan.FromSeconds(inputOptions.CropBeginningTime),
CropEnding = inputOptions.CropEndingTime > 0.0,
CropEndingTime = inputOptions.CropEndingTime,
CropEndingTime = TimeSpan.FromSeconds(inputOptions.CropEndingTime),
FfmpegPath = string.IsNullOrWhiteSpace(inputOptions.FfmpegPath) ? FfmpegHandler.FfmpegExecutableName : Path.GetFullPath(inputOptions.FfmpegPath),
TempFolder = inputOptions.TempFolder
};
Expand Down
4 changes: 2 additions & 2 deletions TwitchDownloaderCore/Options/VideoDownloadOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ public class VideoDownloadOptions
public string Quality { get; set; }
public string Filename { get; set; }
public bool CropBeginning { get; set; }
public double CropBeginningTime { get; set; }
public TimeSpan CropBeginningTime { get; set; }
public bool CropEnding { get; set; }
public double CropEndingTime { get; set; }
public TimeSpan CropEndingTime { get; set; }
public int DownloadThreads { get; set; }
public int ThrottleKib { get; set; }
public string Oauth { get; set; }
Expand Down
15 changes: 11 additions & 4 deletions TwitchDownloaderCore/Tools/FfmpegMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ public static class FfmpegMetadata
private const string LINE_FEED = "\u000A";

public static async Task SerializeAsync(string filePath, string streamerName, string videoId, string videoTitle, DateTime videoCreation, int viewCount, string videoDescription = null,
double startOffsetSeconds = 0, IEnumerable<VideoMomentEdge> videoMomentEdges = null, CancellationToken cancellationToken = default)
TimeSpan startOffset = default, TimeSpan videoLength = default, IEnumerable<VideoMomentEdge> videoMomentEdges = null, CancellationToken cancellationToken = default)
{
await using var fs = new FileStream(filePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read);
await using var sw = new StreamWriter(fs) { NewLine = LINE_FEED };

await SerializeGlobalMetadata(sw, streamerName, videoId, videoTitle, videoCreation, viewCount, videoDescription);
await fs.FlushAsync(cancellationToken);

await SerializeChapters(sw, videoMomentEdges, startOffsetSeconds);
await SerializeChapters(sw, videoMomentEdges, startOffset, videoLength);
await fs.FlushAsync(cancellationToken);
}

Expand All @@ -43,15 +43,15 @@ private static async Task SerializeGlobalMetadata(StreamWriter sw, string stream
await sw.WriteLineAsync(@$"Views: {viewCount}");
}

private static async Task SerializeChapters(StreamWriter sw, IEnumerable<VideoMomentEdge> videoMomentEdges, double startOffsetSeconds)
private static async Task SerializeChapters(StreamWriter sw, IEnumerable<VideoMomentEdge> videoMomentEdges, TimeSpan startOffset, TimeSpan videoLength)
{
if (videoMomentEdges is null)
{
return;
}

// Note: FFmpeg automatically handles out of range chapters for us
var startOffsetMillis = (int)(startOffsetSeconds * 1000);
var startOffsetMillis = (int)startOffset.TotalMilliseconds;
foreach (var momentEdge in videoMomentEdges)
{
if (momentEdge.node._type != "GAME_CHANGE")
Expand All @@ -60,6 +60,13 @@ private static async Task SerializeChapters(StreamWriter sw, IEnumerable<VideoMo
}

var startMillis = momentEdge.node.positionMilliseconds - startOffsetMillis;
if (startMillis >= videoLength.TotalMilliseconds)
{
// Because of floating point error, if the video ends on the same second as a new chapter beings, FFmpeg will keep the chapter
// The kept chapter is less than a second long, but a user complained.
continue;
}

var lengthMillis = momentEdge.node.durationMilliseconds;
var gameName = momentEdge.node.details.game?.displayName ?? momentEdge.node.description;

Expand Down
56 changes: 29 additions & 27 deletions TwitchDownloaderCore/VideoDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public VideoDownloader(VideoDownloadOptions videoDownloadOptions, IProgress<Prog
downloadOptions.TempFolder = Path.Combine(
string.IsNullOrWhiteSpace(downloadOptions.TempFolder) ? Path.GetTempPath() : downloadOptions.TempFolder,
"TwitchDownloader");
downloadOptions.CropBeginningTime = downloadOptions.CropBeginningTime >= TimeSpan.Zero ? downloadOptions.CropBeginningTime : TimeSpan.Zero;
downloadOptions.CropEndingTime = downloadOptions.CropEndingTime >= TimeSpan.Zero ? downloadOptions.CropEndingTime : TimeSpan.Zero;
_progress = progress;
}

Expand Down Expand Up @@ -84,18 +86,18 @@ public async Task DownloadAsync(CancellationToken cancellationToken)

_progress.Report(new ProgressReport() { ReportType = ReportType.NewLineStatus, Data = "Finalizing Video 0% [5/5]" });

var startOffsetSeconds = (double)playlist.Streams
var startOffset = TimeSpan.FromSeconds((double)playlist.Streams
.Take(videoListCrop.Start.Value)
.Sum(x => x.PartInfo.Duration);
.Sum(x => x.PartInfo.Duration));

startOffsetSeconds = downloadOptions.CropBeginningTime - startOffsetSeconds;
double seekDuration = Math.Round(downloadOptions.CropEndingTime - downloadOptions.CropBeginningTime);
startOffset = downloadOptions.CropBeginningTime - startOffset;
var seekDuration = downloadOptions.CropEndingTime - downloadOptions.CropBeginningTime;

string metadataPath = Path.Combine(downloadFolder, "metadata.txt");
VideoInfo videoInfo = videoInfoResponse.data.video;
var chaptersOffset = downloadOptions.CropBeginning ? downloadOptions.CropBeginningTime : 0;
await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, downloadOptions.Id.ToString(), videoInfo.title, videoInfo.createdAt, videoInfo.viewCount,
videoInfo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd(), chaptersOffset, videoChapterResponse.data.video.moments.edges, cancellationToken);
videoInfo.description?.Replace(" \n", "\n").Replace("\n\n", "\n").TrimEnd(), downloadOptions.CropBeginningTime, seekDuration > TimeSpan.Zero ? seekDuration : videoLength,
videoChapterResponse.data.video.moments.edges, cancellationToken);

var finalizedFileDirectory = Directory.GetParent(Path.GetFullPath(downloadOptions.Filename))!;
if (!finalizedFileDirectory.Exists)
Expand All @@ -107,7 +109,7 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d
var ffmpegRetries = 0;
do
{
ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, metadataPath, startOffsetSeconds, seekDuration), cancellationToken);
ffmpegExitCode = await Task.Run(() => RunFfmpegVideoCopy(downloadFolder, metadataPath, startOffset, seekDuration > TimeSpan.Zero ? seekDuration : videoLength), cancellationToken);
if (ffmpegExitCode != 0)
{
_progress.Report(new ProgressReport(ReportType.Log, $"Failed to finalize video (code {ffmpegExitCode}), retrying in 10 seconds..."));
Expand Down Expand Up @@ -136,8 +138,8 @@ await FfmpegMetadata.SerializeAsync(metadataPath, videoInfo.owner.displayName, d
private void CheckAvailableStorageSpace(int bandwidth, TimeSpan videoLength)
{
var videoSizeInBytes = VideoSizeEstimator.EstimateVideoSize(bandwidth,
downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero,
downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : videoLength);
downloadOptions.CropBeginning ? downloadOptions.CropBeginningTime : TimeSpan.Zero,
downloadOptions.CropEnding ? downloadOptions.CropEndingTime : videoLength);
var tempFolderDrive = DriveHelper.GetOutputDrive(downloadOptions.TempFolder);
var destinationDrive = DriveHelper.GetOutputDrive(downloadOptions.Filename);

Expand Down Expand Up @@ -383,7 +385,7 @@ private static bool VerifyVideoPart(string filePath)
return true;
}

private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, double startOffset, double seekDuration)
private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, TimeSpan startOffset, TimeSpan seekDuration)
{
var process = new Process
{
Expand All @@ -392,7 +394,7 @@ private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, doubl
FileName = downloadOptions.FfmpegPath,
Arguments = string.Format(
"-hide_banner -stats -y -avoid_negative_ts make_zero " + (downloadOptions.CropBeginning ? "-ss {2} " : "") + "-i \"{0}\" -i \"{1}\" -map_metadata 1 -analyzeduration {3} -probesize {3} " + (downloadOptions.CropEnding ? "-t {4} " : "") + "-c:v copy \"{5}\"",
Path.Combine(downloadFolder, "output.ts"), metadataPath, startOffset.ToString(CultureInfo.InvariantCulture), int.MaxValue, seekDuration.ToString(CultureInfo.InvariantCulture), Path.GetFullPath(downloadOptions.Filename)),
Path.Combine(downloadFolder, "output.ts"), metadataPath, startOffset.TotalSeconds.ToString(CultureInfo.InvariantCulture), int.MaxValue, seekDuration.TotalSeconds.ToString(CultureInfo.InvariantCulture), Path.GetFullPath(downloadOptions.Filename)),
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardInput = false,
Expand Down Expand Up @@ -430,7 +432,9 @@ private int RunFfmpegVideoCopy(string downloadFolder, string metadataPath, doubl
return process.ExitCode;
}

private static void HandleFfmpegOutput(string output, Regex encodingTimeRegex, double videoLength, IProgress<ProgressReport> progress)
private bool _reportedPercentIssue;

private void HandleFfmpegOutput(string output, Regex encodingTimeRegex, TimeSpan videoLength, IProgress<ProgressReport> progress)
{
var encodingTimeMatch = encodingTimeRegex.Match(output);
if (!encodingTimeMatch.Success)
Expand All @@ -443,19 +447,20 @@ private static void HandleFfmpegOutput(string output, Regex encodingTimeRegex, d
if (!int.TryParse(encodingTimeMatch.Groups[4].ValueSpan, out var milliseconds)) return;
var encodingTime = new TimeSpan(0, hours, minutes, seconds, milliseconds);

var percent = (int)Math.Round(encodingTime.TotalSeconds / videoLength * 100);
var percent = (int)Math.Round(encodingTime / videoLength * 100);

// Apparently it is possible for the percent to not be within the range of 0-100. lay295#716
if (percent is < 0 or > 100)
{
progress.Report(new ProgressReport(ReportType.SameLineStatus, "Finalizing Video... [5/5]"));
progress.Report(new ProgressReport(0));
}
else
if (percent is < 0 or > 100 && !_reportedPercentIssue)
{
progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Finalizing Video {percent}% [5/5]"));
progress.Report(new ProgressReport(percent));
_reportedPercentIssue = true;

// This should no longer occur, but just in case it does...
progress.Report(new ProgressReport(ReportType.Log,
$"{nameof(percent)} was < 0 or > 100 ({percent}). {nameof(output)}: '{output}'. {nameof(videoLength)}: '{videoLength}'. {nameof(encodingTime)}: '{encodingTime}'. " +
$"Please report this as a bug: https://github.com/lay295/TwitchDownloader/issues/new/choose"));
}

progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Finalizing Video {percent}% [5/5]"));
progress.Report(new ProgressReport(percent));
}

/// <remarks>The <paramref name="cancellationTokenSource"/> may be canceled by this method.</remarks>
Expand Down Expand Up @@ -527,14 +532,11 @@ private static async Task DownloadVideoPartAsync(HttpClient httpClient, Uri base

private static Range GetStreamListCrop(IList<M3U8.Stream> streamList, VideoDownloadOptions downloadOptions)
{
var startCrop = TimeSpan.FromSeconds(downloadOptions.CropBeginningTime);
var endCrop = TimeSpan.FromSeconds(downloadOptions.CropEndingTime);

var startIndex = 0;
if (downloadOptions.CropBeginning)
{
var startTime = 0m;
var cropTotalSeconds = (decimal)startCrop.TotalSeconds;
var cropTotalSeconds = (decimal)downloadOptions.CropBeginningTime.TotalSeconds;
foreach (var videoPart in streamList)
{
if (startTime + videoPart.PartInfo.Duration > cropTotalSeconds)
Expand All @@ -549,7 +551,7 @@ private static Range GetStreamListCrop(IList<M3U8.Stream> streamList, VideoDownl
if (downloadOptions.CropEnding)
{
var endTime = streamList.Sum(x => x.PartInfo.Duration);
var cropTotalSeconds = (decimal)endCrop.TotalSeconds;
var cropTotalSeconds = (decimal)downloadOptions.CropEndingTime.TotalSeconds;
for (var i = streamList.Count - 1; i >= 0; i--)
{
if (endTime - streamList[i].PartInfo.Duration < cropTotalSeconds)
Expand Down
6 changes: 3 additions & 3 deletions TwitchDownloaderWPF/PageVodDownload.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,12 @@ public VideoDownloadOptions GetOptions(string filename, string folder)
checkEnd.IsChecked == true ? new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value) : vodLength,
viewCount.ToString(), game) + (comboQuality.Text.Contains("Audio", StringComparison.OrdinalIgnoreCase) ? ".m4a" : ".mp4")),
Oauth = TextOauth.Text,
Quality = GetQualityWithoutSize(comboQuality.Text).ToString(),
Quality = GetQualityWithoutSize(comboQuality.Text),
Id = currentVideoId,
CropBeginning = checkStart.IsChecked.GetValueOrDefault(),
CropBeginningTime = (int)(new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value).TotalSeconds),
CropBeginningTime = new TimeSpan((int)numStartHour.Value, (int)numStartMinute.Value, (int)numStartSecond.Value),
CropEnding = checkEnd.IsChecked.GetValueOrDefault(),
CropEndingTime = (int)(new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value).TotalSeconds),
CropEndingTime = new TimeSpan((int)numEndHour.Value, (int)numEndMinute.Value, (int)numEndSecond.Value),
FfmpegPath = "ffmpeg",
TempFolder = Settings.Default.TempPath
};
Expand Down
8 changes: 4 additions & 4 deletions TwitchDownloaderWPF/WindowQueueOptions.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,13 @@ private void btnQueue_Click(object sender, RoutedEventArgs e)
if (downloadOptions.CropBeginning)
{
chatOptions.CropBeginning = true;
chatOptions.CropBeginningTime = downloadOptions.CropBeginningTime;
chatOptions.CropBeginningTime = downloadOptions.CropBeginningTime.TotalSeconds;
}

if (downloadOptions.CropEnding)
{
chatOptions.CropEnding = true;
chatOptions.CropEndingTime = downloadOptions.CropEndingTime;
chatOptions.CropEndingTime = downloadOptions.CropEndingTime.TotalSeconds;
}

ChatDownloadTask chatTask = new ChatDownloadTask
Expand Down Expand Up @@ -475,8 +475,8 @@ private void EnqueueDataList()
: -1
};
downloadOptions.Filename = Path.Combine(folderPath, FilenameService.GetFilename(Settings.Default.TemplateVod, taskData.Title, taskData.Id, taskData.Time, taskData.Streamer,
downloadOptions.CropBeginning ? TimeSpan.FromSeconds(downloadOptions.CropBeginningTime) : TimeSpan.Zero,
downloadOptions.CropEnding ? TimeSpan.FromSeconds(downloadOptions.CropEndingTime) : TimeSpan.FromSeconds(taskData.Length),
downloadOptions.CropBeginning ? downloadOptions.CropBeginningTime : TimeSpan.Zero,
downloadOptions.CropEnding ? downloadOptions.CropEndingTime : TimeSpan.FromSeconds(taskData.Length),
taskData.Views.ToString(), taskData.Game) + ".mp4");

VodDownloadTask downloadTask = new VodDownloadTask
Expand Down

0 comments on commit d02269d

Please sign in to comment.