using System; using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; namespace Paca.Core; /// /// Detected interval of silence in an audio/video stream. /// public sealed record SilenceSpan(double StartSec, double DurationSec, double EndSec); /// /// Effective playback and export window skipping unwanted intro and outro sections. /// public sealed record TrimWindow(double StartSec, double EndSec, double DurationSec); /// /// Intelligent video trimmer parsing silencedetect logs and producing lossless cuts for intros/outros. /// public static class VideoIntroOutroTrimmer { private static readonly Regex SilenceStartRegex = new(@"silence_start:\s*([0-9.]+)", RegexOptions.Compiled); private static readonly Regex SilenceEndRegex = new(@"silence_end:\s*([0-9.]+)\s*\|\s*silence_duration:\s*([0-9.]+)", RegexOptions.Compiled); /// /// Parses ffmpeg silencedetect stderr output lines into structured silence spans. /// public static List ParseSilenceSpans(string ffmpegOutput) { var spans = new List(); if (string.IsNullOrWhiteSpace(ffmpegOutput)) return spans; var startMatches = SilenceStartRegex.Matches(ffmpegOutput); var endMatches = SilenceEndRegex.Matches(ffmpegOutput); var count = Math.Min(startMatches.Count, endMatches.Count); for (var i = 0; i < count; i++) { if (double.TryParse(startMatches[i].Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var start) && double.TryParse(endMatches[i].Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var end) && double.TryParse(endMatches[i].Groups[2].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var dur)) { spans.Add(new SilenceSpan(start, dur, end)); } } return spans; } /// /// Calculates safe playback trim window, guaranteeing at least 1 second duration. /// public static TrimWindow CalculateTrimWindow(double totalDurationSec, double skipIntroSec, double skipOutroSec) { if (totalDurationSec <= 0) return new TrimWindow(0, 1.0, 1.0); var start = Math.Max(0, skipIntroSec); var end = Math.Max(0, totalDurationSec - skipOutroSec); if (start >= end || (end - start) < 1.0) { start = 0; end = Math.Max(1.0, totalDurationSec); } var duration = end - start; return new TrimWindow(start, end, duration); } /// /// Builds ffmpeg CLI argument string for ultra-fast lossless video trimming. /// public static string BuildLosslessTrimCommandLine( string inputPath, string outputPath, double startSec, double durationSec) { if (string.IsNullOrWhiteSpace(inputPath)) throw new ArgumentException("Input path cannot be null or whitespace.", nameof(inputPath)); if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Output path cannot be null or whitespace.", nameof(outputPath)); var cleanIn = inputPath.Trim().Replace("\"", ""); var cleanOut = outputPath.Trim().Replace("\"", ""); if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다."); startSec = Math.Max(0.0, startSec); durationSec = Math.Max(0.1, durationSec); var s = startSec.ToString("F2", CultureInfo.InvariantCulture); var d = durationSec.ToString("F2", CultureInfo.InvariantCulture); return $"-ss {s} -i \"{cleanIn}\" -t {d} -c copy \"{cleanOut}\" -y"; } }