namespace VideoBrowser.Helpers { using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; using VideoBrowser.Common; /// /// Defines the . /// public class FFMpeg { #region Constants private const string RegexFindReportFile = "Report written to \"(.*)\""; private const string ReportFile = "ffreport-%t.log"; #endregion Constants #region Fields public static Dictionary EnvironmentVariables = new Dictionary() { { "FFREPORT", string.Format("file={0}:level=8", ReportFile) } }; /// /// Gets the path to FFMpeg executable. /// public static string FFMpegPath = Path.Combine(AppEnvironment.AppBinaryDirectory, "ffmpeg.exe"); #endregion Fields #region Methods /// /// Returns true if given file can be converted to a MP3 file, false otherwise. /// /// The file to check. /// The . public static FFMpegResult CanConvertToMP3(string file) { var hasAudioStream = false; var arguments = string.Format(Commands.GetFileInfo, file); var lines = new StringBuilder(); LogHeader(arguments); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { lines.AppendLine(line = line.Trim()); if (line.StartsWith("Stream #") && line.Contains("Audio")) { // File has audio stream hasAudioStream = true; } }, EnvironmentVariables); p.WaitForExit(); LogFooter(); if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); var errors = (List)CheckForErrors(reportFile); if (errors[0] != "At least one output file must be specified") return new FFMpegResult(p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(hasAudioStream); } /// /// Combines separate audio & video to a single MP4 file. /// /// The input video file. /// The input audio file. /// The output destination. /// The method to call when there is progress. Can be null. /// The . public static FFMpegResult Combine(string video, string audio, string output, Action reportProgress) { var started = false; var milliseconds = 0.0; var arguments = string.Format(Commands.Combine, video, audio, output); var lines = new StringBuilder(); LogHeader(arguments); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { lines.AppendLine(line = line.Trim()); // If reportProgress is null it can't be invoked. So skip code below if (reportProgress == null) return; if (line.StartsWith("Duration: ")) { var lineStart = "Duration: ".Length; var length = "00:00:00.00".Length; var time = line.Substring(lineStart, length); milliseconds = TimeSpan.Parse(time).TotalMilliseconds; } else if (line == "Press [q] to stop, [?] for help") { started = true; reportProgress.Invoke(0); } else if (started && line.StartsWith("frame=")) { var lineStart = line.IndexOf("time=") + 5; var length = "00:00:00.00".Length; var time = line.Substring(lineStart, length); var currentMilli = TimeSpan.Parse(time).TotalMilliseconds; var percentage = (currentMilli / milliseconds) * 100; reportProgress.Invoke(System.Convert.ToInt32(percentage)); } else if (started && line == string.Empty) { started = false; reportProgress.Invoke(100); } }, EnvironmentVariables); p.WaitForExit(); LogFooter(); if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(false, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(true); } /// /// Converts file to MP3. /// Possibly more formats in the future. /// /// The input file. /// The output destination. /// The ct. /// The method to call when there is progress. Can be null. /// The . public static FFMpegResult Convert(string input, string output, CancellationToken ct, Action reportProgress) { var isTempFile = false; if (input == output) { input = FFMpeg.RenameTemp(input); isTempFile = true; } var args = new string[] { input, GetBitRate(input).Value.ToString(), output }; var canceled = false; var started = false; var milliseconds = 0.0; var arguments = string.Format(Commands.Convert, args); var lines = new StringBuilder(); reportProgress?.Invoke(0, null); LogHeader(arguments); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { // Queued lines might still fire even after canceling process, don't actually know if (canceled) return; lines.AppendLine(line = line.Trim()); if (ct != null && ct.IsCancellationRequested) { process.Kill(); canceled = true; return; } // If reportProgress is null it can't be invoked. So skip code below if (reportProgress == null) return; if (line.StartsWith("Duration: ")) { var start = "Duration: ".Length; var length = "00:00:00.00".Length; var time = line.Substring(start, length); milliseconds = TimeSpan.Parse(time).TotalMilliseconds; } else if (line == "Press [q] to stop, [?] for help") { started = true; reportProgress.Invoke(0, null); } else if (started && line.StartsWith("size=")) { var start = line.IndexOf("time=") + 5; var length = "00:00:00.00".Length; var time = line.Substring(start, length); var currentMilli = TimeSpan.Parse(time).TotalMilliseconds; var percentage = (currentMilli / milliseconds) * 100; reportProgress.Invoke(System.Convert.ToInt32(percentage), null); } else if (started && line == string.Empty) { started = false; reportProgress.Invoke(100, null); } }, EnvironmentVariables); p.WaitForExit(); if (isTempFile) { FileHelper.DeleteFiles(input); } LogFooter(); if (canceled) { return new FFMpegResult(false); } else if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(false, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(true); } /// /// Converts file to MP3 and crops from start to end. /// /// The input file. /// The output destination. /// The crop start position. /// The method to call when there is progress. Can be null. /// The ct. /// . public static FFMpegResult ConvertCrop(string input, string output, TimeSpan cropStart, Action reportProgress, CancellationToken ct) { var isTempFile = false; if (input == output) { input = FFMpeg.RenameTemp(input); isTempFile = true; } var args = new string[] { string.Format("{0:00}:{1:00}:{2:00}.{3:000}", cropStart.Hours, cropStart.Minutes, cropStart.Seconds, cropStart.Milliseconds), input, GetBitRate(input).Value.ToString(), output }; var arguments = string.Format(Commands.ConvertCropFrom, args); var canceled = false; var started = false; var milliseconds = 0.0; var lines = new StringBuilder(); reportProgress?.Invoke(0, null); LogHeader(arguments); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { // Queued lines might still fire even after canceling process, don't actually know if (canceled) return; lines.AppendLine(line = line.Trim()); if (ct != null && ct.IsCancellationRequested) { process.Kill(); canceled = true; return; } // If reportProgress is null it can't be invoked. So skip code below if (reportProgress == null) { return; } if (line.StartsWith("Duration: ")) { // Get total milliseconds to track progress var start = "Duration: ".Length; var length = "00:00:00.00".Length; var time = line.Substring(start, length); milliseconds = TimeSpan.Parse(time).TotalMilliseconds - cropStart.TotalMilliseconds; } else if (line == "Press [q] to stop, [?] for help") { // Process has started started = true; reportProgress.Invoke(0, null); } else if (started && line.StartsWith("size=")) { // Track progress var start = line.IndexOf("time=") + 5; var length = "00:00:00.00".Length; var time = line.Substring(start, length); var currentMilli = TimeSpan.Parse(time).TotalMilliseconds; var percentage = (currentMilli / milliseconds) * 100; reportProgress.Invoke(System.Convert.ToInt32(percentage), null); } else if (started && line == string.Empty) { // Process has ended started = false; reportProgress.Invoke(100, null); } }, EnvironmentVariables); p.WaitForExit(); if (isTempFile) FileHelper.DeleteFiles(input); LogFooter(); if (canceled) { return new FFMpegResult(false); } else if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(false, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(true); } /// /// Converts file to MP3 and crops from start to end. /// /// The input file. /// The output destination. /// The crop start position. /// The crop end position. /// The method to call when there is progress. Can be null. /// The ct. /// . public static FFMpegResult ConvertCrop(string input, string output, TimeSpan cropStart, TimeSpan cropEnd, Action reportProgress, CancellationToken ct) { var isTempFile = false; if (input == output) { input = FFMpeg.RenameTemp(input); isTempFile = true; } var args = new string[] { string.Format("{0:00}:{1:00}:{2:00}.{3:000}", cropStart.Hours, cropStart.Minutes, cropStart.Seconds, cropStart.Milliseconds), input, string.Format("{0:00}:{1:00}:{2:00}.{3:000}", cropEnd.Hours, cropEnd.Minutes, cropEnd.Seconds, cropEnd.Milliseconds), GetBitRate(input).Value.ToString(), output }; var arguments = string.Format(Commands.ConvertCropFromTo, args); var canceled = false; var started = false; var milliseconds = cropEnd.TotalMilliseconds; var lines = new StringBuilder(); reportProgress?.Invoke(0, null); LogHeader(arguments); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { // Queued lines might still fire even after canceling process, don't actually know if (canceled) { return; } lines.AppendLine(line = line.Trim()); if (ct != null && ct.IsCancellationRequested) { process.Kill(); canceled = true; return; } // If reportProgress is null it can't be invoked. So skip code below if (reportProgress == null) { return; } if (line == "Press [q] to stop, [?] for help") { // Process has started started = true; reportProgress.Invoke(0, null); } else if (started && line.StartsWith("size=")) { // Track progress var start = line.IndexOf("time=") + 5; var length = "00:00:00.00".Length; var time = line.Substring(start, length); var currentMilli = TimeSpan.Parse(time).TotalMilliseconds; var percentage = (currentMilli / milliseconds) * 100; reportProgress.Invoke(System.Convert.ToInt32(percentage), null); } else if (started && line == string.Empty) { // Process has ended started = false; reportProgress.Invoke(100, null); } }, EnvironmentVariables); p.WaitForExit(); if (isTempFile) { FileHelper.DeleteFiles(input); } LogFooter(); if (canceled) { return new FFMpegResult(false); } else if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(false, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(true); } /// /// Crops file from given start position to end. /// /// The input file. /// The output destination. /// The crop start position. /// The method to call when there is progress. Can be null. /// The ct. /// The . public static FFMpegResult Crop(string input, string output, TimeSpan start, Action reportProgress, CancellationToken ct) { var isTempFile = false; if (input == output) { input = FFMpeg.RenameTemp(input); isTempFile = true; } var args = new string[] { string.Format("{0:00}:{1:00}:{2:00}.{3:000}", start.Hours, start.Minutes, start.Seconds, start.Milliseconds), input, output }; var arguments = string.Format(Commands.CropFrom, args); var canceled = false; var started = false; var milliseconds = 0.0; var lines = new StringBuilder(); reportProgress?.Invoke(0, null); LogHeader(arguments); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { // Queued lines might still fire even after canceling process, don't actually know if (canceled) return; lines.AppendLine(line = line.Trim()); if (ct != null && ct.IsCancellationRequested) { process.Kill(); canceled = true; return; } // If reportProgress is null it can't be invoked. So skip code below if (reportProgress == null) return; if (line.StartsWith("Duration: ")) { var lineStart = "Duration: ".Length; var length = "00:00:00.00".Length; var time = line.Substring(lineStart, length); milliseconds = TimeSpan.Parse(time).TotalMilliseconds; } else if (line == "Press [q] to stop, [?] for help") { started = true; reportProgress.Invoke(0, null); } else if (started && line.StartsWith("frame=")) { var lineStart = line.IndexOf("time=") + 5; var length = "00:00:00.00".Length; var time = line.Substring(lineStart, length); var currentMilli = TimeSpan.Parse(time).TotalMilliseconds; var percentage = (currentMilli / milliseconds) * 100; reportProgress.Invoke(System.Convert.ToInt32(percentage), null); } else if (started && line == string.Empty) { started = false; reportProgress.Invoke(100, null); } }, EnvironmentVariables); p.WaitForExit(); if (isTempFile) { FileHelper.DeleteFiles(input); } LogFooter(); if (canceled) { return new FFMpegResult(false); } else if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(false, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(true); } /// /// Crops file from given start position to given end position. /// /// The input file. /// The output destination. /// The crop start position. /// The crop end position. /// The method to call when there is progress. Can be null. /// The ct. /// The . public static FFMpegResult Crop(string input, string output, TimeSpan start, TimeSpan end, Action reportProgress, CancellationToken ct) { var isTempFile = false; if (input == output) { input = FFMpeg.RenameTemp(input); isTempFile = true; } var length = new TimeSpan(Math.Abs(start.Ticks - end.Ticks)); var args = new string[] { $"{start.Hours:00}:{start.Minutes:00}:{start.Seconds:00}.{start.Milliseconds:000}", input, $"{length.Hours:00}:{length.Minutes:00}:{length.Seconds:00}.{length.Milliseconds:000}", output }; var canceled = false; var started = false; var milliseconds = end.TotalMilliseconds; var arguments = string.Format(Commands.CropFromTo, args); var lines = new StringBuilder(); Process p = null; reportProgress?.Invoke(0, null); LogHeader(arguments); p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { // Queued lines might still fire even after canceling process, don't actually know if (canceled) return; lines.AppendLine(line = line.Trim()); if (ct != null && ct.IsCancellationRequested) { process.Kill(); canceled = true; return; } // If reportProgress is null it can't be invoked. So skip code below if (reportProgress == null) { return; } if (line == "Press [q] to stop, [?] for help") { started = true; reportProgress.Invoke(0, null); } else if (started && line.StartsWith("frame=")) { var lineStart = line.IndexOf("time=") + 5; var lineLength = "00:00:00.00".Length; var time = line.Substring(lineStart, lineLength); var currentMilli = TimeSpan.Parse(time).TotalMilliseconds; var percentage = (currentMilli / milliseconds) * 100; reportProgress.Invoke(System.Convert.ToInt32(percentage), null); } else if (started && line == string.Empty) { started = false; reportProgress.Invoke(100, null); } }, EnvironmentVariables); p.WaitForExit(); if (isTempFile) { FileHelper.DeleteFiles(input); } LogFooter(); if (canceled) { return new FFMpegResult(false); } else if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(false, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(true); } /// /// Fixes and optimizes final .ts file from .m3u8 playlist. /// /// The input. /// The output. /// The . public static FFMpegResult FixM3U8(string input, string output) { var lines = new StringBuilder(); var arguments = string.Format(Commands.FixM3U8, input, output); LogHeader(arguments); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { lines.Append(line); }, EnvironmentVariables); p.WaitForExit(); LogFooter(); if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(false, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(true); } /// /// Returns the bit rate of the given file. /// /// The file. /// The . public static FFMpegResult GetBitRate(string file) { var result = 128; // Default to 128k bitrate var regex = new Regex(@"^Stream\s#\d:\d.*\s(\d+)\skb\/s.*$", RegexOptions.Compiled); var lines = new StringBuilder(); var arguments = string.Format(Commands.GetFileInfo, file); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { lines.AppendLine(line = line.Trim()); if (line.StartsWith("Stream")) { Match m = regex.Match(line); if (m.Success) result = int.Parse(m.Groups[1].Value); } }, EnvironmentVariables); p.WaitForExit(); if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); var errors = (List)CheckForErrors(reportFile); if (errors[0] != "At least one output file must be specified") return new FFMpegResult(p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(result); } /// /// Returns the duration of the given file. /// /// The file to get duration from. /// The . public static FFMpegResult GetDuration(string file) { var result = TimeSpan.Zero; var lines = new StringBuilder(); var arguments = string.Format(Commands.GetFileInfo, file); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { lines.AppendLine(line = line.Trim()); // Example line, including whitespace: // Duration: 00:00:00.00, start: 0.000000, bitrate: *** kb/s if (line.StartsWith("Duration")) { var split = line.Split(' ', ','); result = TimeSpan.Parse(split[1]); } }, EnvironmentVariables); p.WaitForExit(); if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); var errors = (List)CheckForErrors(reportFile); if (errors[0] != "At least one output file must be specified") { return new FFMpegResult(p.ExitCode, CheckForErrors(reportFile)); } } return new FFMpegResult(result); } /// /// Returns the of the given file. /// /// The file to get from. /// The . public static FFMpegResult GetFileType(string file) { var result = FFMpegFileType.Error; var lines = new StringBuilder(); var arguments = string.Format(Commands.GetFileInfo, file); var p = ProcessHelper.StartProcess(FFMpegPath, arguments, null, delegate (Process process, string line) { lines.AppendLine(line = line.Trim()); // Example lines, including whitespace: // Stream #0:0(und): Video: h264 ([33][0][0][0] / 0x0021), yuv420p, 320x240 [SAR 717:716 DAR 239:179], q=2-31, 242 kb/s, 29.01 fps, 90k tbn, 90k tbc (default) // Stream #0:1(eng): Audio: vorbis ([221][0][0][0] / 0x00DD), 44100 Hz, stereo (default) if (line.StartsWith("Stream #")) { if (line.Contains("Video: ")) { // File contains video stream, so it's a video file, possibly without audio. result = FFMpegFileType.Video; } else if (line.Contains("Audio: ")) { // File contains audio stream. Keep looking for a video stream, // and if found it's probably a video file, or an audio file if not. result = FFMpegFileType.Audio; } } }, EnvironmentVariables); p.WaitForExit(); if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); var errors = (List)CheckForErrors(reportFile); if (errors[0] != "At least one output file must be specified") return new FFMpegResult(FFMpegFileType.Error, p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(result); } /// /// Returns list of errors, if any, from given FFMpeg report file. /// /// The report file to check. /// The . private static IEnumerable CheckForErrors(string filename) { var errors = new List(); using (var reader = new StreamReader(filename)) { var line = string.Empty; // Skip first 2 lines reader.ReadLine(); reader.ReadLine(); while ((line = reader.ReadLine()) != null) { errors.Add(line); } } return errors; } /// /// Find where the FFMpeg report file is from the output using Regex. /// /// The lines. /// The . private static string FindReportFile(string lines) { Match m; return (m = Regex.Match(lines, RegexFindReportFile)).Success ? Path.Combine(AppEnvironment.GetLogsDirectory(), m.Groups[1].Value) : string.Empty; } /// /// Gets current ffmpeg version. /// /// The . private static FFMpegResult GetVersion() { var version = string.Empty; var regex = new Regex("^ffmpeg version (.*) Copyright.*$", RegexOptions.Compiled); var lines = new StringBuilder(); var p = ProcessHelper.StartProcess(FFMpegPath, Commands.Version, null, delegate (Process process, string line) { lines.AppendLine(line); var match = regex.Match(line); if (match.Success) { version = match.Groups[1].Value.Trim(); } }, EnvironmentVariables); p.WaitForExit(); if (p.ExitCode != 0) { var reportFile = FindReportFile(lines.ToString()); return new FFMpegResult(p.ExitCode, CheckForErrors(reportFile)); } return new FFMpegResult(version); } /// /// Writes log footer to log. /// private static void LogFooter() { // Write log footer to stream. // Possibly write elapsed time and/or error in future. Logger.Info(Environment.NewLine); } /// /// Writes log header to log. /// /// The arguments. /// The caller. private static void LogHeader(string arguments, [CallerMemberName]string caller = "") { Logger.Info($"[{DateTime.Now}]"); Logger.Info($"function: {caller}"); Logger.Info($"cmd: {arguments}"); Logger.Info(); Logger.Info("OUTPUT"); } /// /// The RenameTemp. /// /// The input. /// The . private static string RenameTemp(string input) { var newInput = Path.Combine(Path.GetDirectoryName(input), Path.GetFileNameWithoutExtension(input)) + "_temp" + Path.GetExtension(input); File.Move(input, newInput); return newInput; } #endregion Methods /// /// Defines the . /// public static class Commands { #region Constants public const string Combine = " -report -y -i \"{0}\" -i \"{1}\" -c:v copy -c:a copy \"{2}\""; /* Convert options: * * -y - Overwrite output file without asking * -i - Input file name * -vn - Disables video recording. * -f - Forces file format, but isn't needed if output has .mp3 extensions * -b:a - Sets the audio bitrate. Output bitrate will not match exact. */ public const string Convert = " -report -y -i \"{0}\" -vn -f mp3 -b:a {1}k \"{2}\""; public const string ConvertCropFrom = " -report -y -ss {0} -i \"{1}\" -vn -f mp3 -b:a {2}k \"{3}\""; public const string ConvertCropFromTo = " -report -y -ss {0} -i \"{1}\" -to {2} -vn -f mp3 -b:a {3}k \"{4}\""; public const string CropFrom = " -report -y -ss {0} -i \"{1}\" -map 0 -acodec copy -vcodec copy \"{2}\""; public const string CropFromTo = " -report -y -ss {0} -i \"{1}\" -to {2} -map 0 -acodec copy -vcodec copy \"{3}\""; public const string FixM3U8 = " -report -y -i \"{0}\" -c copy -f mp4 -bsf:a aac_adtstoasc \"{1}\""; public const string GetFileInfo = " -report -i \"{0}\""; public const string Version = " -version"; #endregion Constants } } }