test(core/browser/platform): 26 extreme RED/GREEN TDD cycles and full regression hardening (3,235 PASS)
Some checks failed
release / test (push) Failing after 14m5s
release / windows (push) Has been cancelled
release / android (push) Has been cancelled
release / linux (push) Has been cancelled
release / macos (push) Has been cancelled

This commit is contained in:
Yun Chan 2026-09-18 23:32:32 +09:00
parent bcd242691f
commit 8677853822
100 changed files with 4814 additions and 333 deletions

View file

@ -32,7 +32,13 @@ public static class ChromeInternalUrlRouter
return ChromeInternalAction.None;
var path = url.StartsWith("chrome://") ? url["chrome://".Length..] : url["paca://".Length..];
var clean = path.TrimEnd('/');
var clean = path.TrimStart('/').TrimEnd('/');
var queryOrFragIdx = clean.IndexOfAny(new[] { '?', '#' });
if (queryOrFragIdx >= 0)
{
clean = clean[..queryOrFragIdx].TrimEnd('/');
}
return clean switch
{

View file

@ -9,12 +9,15 @@ public sealed class MhtmlWebArchivePackager
{
public string Package(WebArchivePackageRequest request)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
var boundary = "----=_NextPart_" + Guid.NewGuid().ToString("N");
var sb = new StringBuilder();
sb.AppendLine("From: <Saved by PACA Browser>");
sb.AppendLine($"Snapshot-Content-Location: {request.Url}");
sb.AppendLine($"Subject: {request.Title}");
sb.AppendLine($"Snapshot-Content-Location: {request.Url ?? string.Empty}");
sb.AppendLine($"Subject: {request.Title ?? "Untitled"}");
sb.AppendLine($"Date: {DateTime.UtcNow:R}");
sb.AppendLine("MIME-Version: 1.0");
sb.AppendLine($"Content-Type: multipart/related; boundary=\"{boundary}\"; type=\"text/html\"");
@ -24,21 +27,26 @@ public sealed class MhtmlWebArchivePackager
sb.AppendLine($"--{boundary}");
sb.AppendLine("Content-Type: text/html; charset=\"utf-8\"");
sb.AppendLine("Content-Transfer-Encoding: 8bit");
sb.AppendLine($"Content-Location: {request.Url}");
sb.AppendLine($"Content-Location: {request.Url ?? string.Empty}");
sb.AppendLine();
sb.AppendLine(request.HtmlContent);
sb.AppendLine(request.HtmlContent ?? string.Empty);
sb.AppendLine();
// Resources
foreach (var res in request.Resources)
if (request.Resources != null)
{
sb.AppendLine($"--{boundary}");
sb.AppendLine($"Content-Type: {res.MimeType}");
sb.AppendLine("Content-Transfer-Encoding: base64");
sb.AppendLine($"Content-Location: {res.Url}");
sb.AppendLine();
sb.AppendLine(Convert.ToBase64String(res.Data));
sb.AppendLine();
foreach (var res in request.Resources)
{
if (res == null) continue;
var data = res.Data ?? Array.Empty<byte>();
sb.AppendLine($"--{boundary}");
sb.AppendLine($"Content-Type: {res.MimeType ?? "application/octet-stream"}");
sb.AppendLine("Content-Transfer-Encoding: base64");
sb.AppendLine($"Content-Location: {res.Url ?? string.Empty}");
sb.AppendLine();
sb.AppendLine(Convert.ToBase64String(data));
sb.AppendLine();
}
}
sb.AppendLine($"--{boundary}--");

View file

@ -41,6 +41,11 @@ public sealed class SiteAutoMutePolicy
var host = uri.Host.ToLowerInvariant();
foreach (var pattern in _patterns)
{
if (pattern == "*" || pattern == "*.*")
{
return true;
}
if (pattern.StartsWith("*.", StringComparison.Ordinal))
{
var suffix = pattern[2..];
@ -67,14 +72,23 @@ public sealed class SiteAutoMutePolicy
{
if (string.IsNullOrWhiteSpace(_configPath)) return;
var dir = Path.GetDirectoryName(_configPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
try
{
Directory.CreateDirectory(dir);
}
var dir = Path.GetDirectoryName(_configPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(_patterns.ToList(), new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_configPath, json);
var json = JsonSerializer.Serialize(_patterns.ToList(), new JsonSerializerOptions { WriteIndented = true });
var tmp = _configPath + ".tmp";
File.WriteAllText(tmp, json);
File.Move(tmp, _configPath, overwrite: true);
}
catch
{
// 디스크 오류 또는 권한 문제 시 예외를 전파하지 않고 메모리 정책으로 격리
}
}
private void Load()

View file

@ -1,4 +1,4 @@
namespace Paca.Browser;
namespace Paca.Browser;
public sealed record DualCuePair(string? PrimaryText, string? SecondaryText);
@ -28,6 +28,8 @@ public sealed class DualSubtitleEngine
public DualCuePair? GetDualCues(double currentSec)
{
if (double.IsNaN(currentSec) || double.IsInfinity(currentSec) || currentSec < 0) return null;
var pCue = _primaryEngine.GetActiveCue(currentSec);
var sCue = _secondaryEngine.GetActiveCue(currentSec);
@ -38,7 +40,7 @@ public sealed class DualSubtitleEngine
public void RegisterAudioTrack(AudioTrackInfo track)
{
if (track == null) return;
if (track == null || _audioTracks.Any(t => t.Id == track.Id)) return;
_audioTracks.Add(track);
if (track.IsDefault || _audioTracks.Count == 1)
{

View file

@ -39,9 +39,16 @@ public static class ExtensionCspValidator
violations.Add("Manifest V3 disallows 'unsafe-eval' in script-src.");
}
if (tokens.Any(t => t.Equals("'unsafe-inline'", StringComparison.OrdinalIgnoreCase)))
{
violations.Add("Manifest V3 disallows 'unsafe-inline' in script-src.");
}
if (tokens.Any(t => t.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("//", StringComparison.OrdinalIgnoreCase)))
t.StartsWith("//", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("blob:", StringComparison.OrdinalIgnoreCase)))
{
violations.Add("Manifest V3 disallows remote script sources.");
}
@ -50,6 +57,11 @@ public static class ExtensionCspValidator
normalizedList.Add(directive);
}
if (normalizedList.Count == 0)
{
return new CspValidationResult(true, Array.Empty<string>(), DefaultManifestV3Csp);
}
var normalizedCsp = string.Join("; ", normalizedList);
if (!normalizedCsp.EndsWith(';')) normalizedCsp += ";";

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
namespace Paca.Browser;
@ -60,6 +60,7 @@ public sealed class LiveCaptionEngine
public CaptionCue? GetActiveCue(double currentSec)
{
if (double.IsNaN(currentSec) || double.IsInfinity(currentSec) || currentSec < 0) return null;
return _cues.FirstOrDefault(c => currentSec >= c.StartSec && currentSec <= c.EndSec);
}

View file

@ -36,18 +36,21 @@ public static class AudioAlbumTrackSplitter
return $"-hide_banner -i \"{inputPath}\" -af silencedetect=noise={noiseDb.ToString("0.##", inv)}dB:d={durationSec.ToString("0.0", inv)} -f null -";
}
/// <summary>
/// <summary>
/// Parses ffmpeg stderr silencedetect output and returns calculated track intervals.
/// </summary>
public static List<AlbumTrackSegment> ParseSilenceToTracks(string silenceLog, double totalDurationSec, double minTrackDurationSec = 30.0)
{
var result = new List<AlbumTrackSegment>();
if (string.IsNullOrWhiteSpace(silenceLog) || totalDurationSec <= 0)
if (double.IsNaN(totalDurationSec) || double.IsInfinity(totalDurationSec) || totalDurationSec <= 0)
{
if (totalDurationSec > 0)
{
result.Add(new AlbumTrackSegment(1, 0.0, totalDurationSec, "Track 01"));
}
return result;
}
if (string.IsNullOrWhiteSpace(silenceLog))
{
result.Add(new AlbumTrackSegment(1, 0.0, totalDurationSec, "Track 01"));
return result;
}
@ -146,6 +149,15 @@ public static class AudioAlbumTrackSplitter
/// </summary>
public static string BuildSplitCommand(string inputPath, AlbumTrackSegment track, string outputPath)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (track == null)
throw new ArgumentNullException(nameof(track));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var inv = CultureInfo.InvariantCulture;
return $"-hide_banner -y -ss {track.StartSec.ToString("0.000", inv)} -to {track.EndSec.ToString("0.000", inv)} -i \"{inputPath}\" -c copy \"{outputPath}\"";
}
@ -155,6 +167,7 @@ public static class AudioAlbumTrackSplitter
/// </summary>
public static string GenerateM3uPlaylist(string albumTitle, IReadOnlyList<AlbumTrackSegment> tracks, string filenamePattern = "track_{0:D2}.mp3")
{
var safeAlbum = (albumTitle ?? "Album").Replace("\r", "").Replace("\n", " ").Trim();
var sb = new StringBuilder();
sb.AppendLine("#EXTM3U");
@ -162,8 +175,9 @@ public static class AudioAlbumTrackSplitter
{
foreach (var t in tracks)
{
var safeTitle = (t.Title ?? "Track").Replace("\r", "").Replace("\n", " ").Trim();
int durationRounded = (int)Math.Round(t.DurationSec);
sb.AppendLine($"#EXTINF:{durationRounded},{albumTitle} - {t.Title}");
sb.AppendLine($"#EXTINF:{durationRounded},{safeAlbum} - {safeTitle}");
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, filenamePattern, t.Index));
}
}

View file

@ -41,6 +41,13 @@ public static class AudioChannelDownmixer
DownmixMode mode,
bool copyVideo = true)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\"");
@ -48,7 +55,8 @@ public static class AudioChannelDownmixer
sb.Append(" -c:v copy");
var filter = BuildDownmixFilterArgs(mode);
sb.Append($" -filter:a \"{filter}\"");
if (!string.IsNullOrWhiteSpace(filter))
sb.Append($" -filter:a \"{filter}\"");
sb.Append(" -c:a aac -b:a 192k");
sb.Append($" \"{outputPath}\" -y");

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Globalization;
namespace Paca.Core;
@ -39,6 +39,19 @@ public static class AudioDynamicRangeCompressor
/// </summary>
public static string BuildCustomFilter(double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupDb, double limitPeakDb)
{
if (double.IsNaN(thresholdDb) || double.IsInfinity(thresholdDb))
throw new ArgumentOutOfRangeException(nameof(thresholdDb), "Threshold must be a finite number.");
if (double.IsNaN(ratio) || double.IsInfinity(ratio) || ratio <= 0)
throw new ArgumentOutOfRangeException(nameof(ratio), "Compression ratio must be a positive finite number.");
if (double.IsNaN(attackMs) || double.IsInfinity(attackMs) || attackMs < 0)
throw new ArgumentOutOfRangeException(nameof(attackMs), "Attack time must be a non-negative finite number.");
if (double.IsNaN(releaseMs) || double.IsInfinity(releaseMs) || releaseMs < 0)
throw new ArgumentOutOfRangeException(nameof(releaseMs), "Release time must be a non-negative finite number.");
if (double.IsNaN(makeupDb) || double.IsInfinity(makeupDb))
throw new ArgumentOutOfRangeException(nameof(makeupDb), "Makeup gain must be a finite number.");
if (double.IsNaN(limitPeakDb) || double.IsInfinity(limitPeakDb))
throw new ArgumentOutOfRangeException(nameof(limitPeakDb), "Peak limiter must be a finite number.");
var inv = CultureInfo.InvariantCulture;
return $"acompressor=threshold={thresholdDb.ToString("0.##", inv)}dB:ratio={ratio.ToString("0.##", inv)}:attack={attackMs.ToString("0.##", inv)}:release={releaseMs.ToString("0.##", inv)}:makeup={makeupDb.ToString("0.##", inv)}dB,alimiter=limit={limitPeakDb.ToString("0.##", inv)}dB";
}
@ -48,6 +61,16 @@ public static class AudioDynamicRangeCompressor
/// </summary>
public static string BuildFfmpegArgs(string inputPath, string outputPath, CompressorPreset preset, bool copyVideo = true, int audioBitrateKbps = 192)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
if (audioBitrateKbps <= 0)
audioBitrateKbps = 192;
string vCodec = copyVideo ? "-c:v copy" : "-c:v libx264 -crf 20 -preset fast";
var filter = BuildFilter(preset);
return $"-hide_banner -y -i \"{inputPath}\" {vCodec} -af \"{filter}\" -c:a aac -b:a {audioBitrateKbps}k \"{outputPath}\"";

View file

@ -17,6 +17,9 @@ public static class AudioPitchShiftEngine
/// </summary>
public static double ClampSemitones(double semitones)
{
if (double.IsNaN(semitones) || double.IsInfinity(semitones))
return 0.0;
return Math.Clamp(semitones, MinSemitones, MaxSemitones);
}
@ -34,6 +37,9 @@ public static class AudioPitchShiftEngine
/// </summary>
public static string BuildPitchShiftFilter(double semitones, int sampleRate = 44100)
{
if (sampleRate <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleRate), "Sample rate must be greater than zero.");
var scale = CalculatePitchScale(semitones);
var newRate = (int)Math.Round(sampleRate * scale);
var tempoFactor = 1.0 / scale;
@ -51,6 +57,13 @@ public static class AudioPitchShiftEngine
bool isAudioOnly = false,
int sampleRate = 44100)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var filter = BuildPitchShiftFilter(semitones, sampleRate);
var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\"");

View file

@ -46,9 +46,17 @@ public static class AudioVocalStemExtractor
AudioStemMode mode,
string audioCodec = "libmp3lame")
{
var filter = BuildFfmpegStemFilter(mode);
var bitrate = audioCodec.Contains("mp3", StringComparison.OrdinalIgnoreCase) ? "320k" : "192k";
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
return $"-i \"{inputPath}\" -filter:a \"{filter}\" -c:a {audioCodec} -b:a {bitrate} \"{outputPath}\" -y";
var filter = BuildFfmpegStemFilter(mode);
var safeCodec = string.IsNullOrWhiteSpace(audioCodec) ? "libmp3lame" : audioCodec;
var bitrate = safeCodec.Contains("mp3", StringComparison.OrdinalIgnoreCase) ? "320k" : "192k";
return $"-i \"{inputPath}\" -filter:a \"{filter}\" -c:a {safeCodec} -b:a {bitrate} \"{outputPath}\" -y";
}
}

View file

@ -30,11 +30,17 @@ public static class AudioVolumeNormalizer
private static readonly Regex InputThreshRegex = new(@"""input_thresh""\s*:\s*""?([^"",\s]+)""?", RegexOptions.Compiled);
private static readonly Regex TargetOffsetRegex = new(@"""target_offset""\s*:\s*""?([^"",\s]+)""?", RegexOptions.Compiled);
/// <summary>
/// Builds single-pass loudnorm audio filter string.
/// </summary>
/// <summary>
/// Builds single-pass loudnorm audio filter string.
/// </summary>
public static string BuildLoudnormFilterArgs(AudioNormalizerPreset preset)
{
if (preset == null)
throw new ArgumentNullException(nameof(preset));
var i = preset.TargetI.ToString("F1", CultureInfo.InvariantCulture);
var tp = preset.TargetTp.ToString("F1", CultureInfo.InvariantCulture);
var lra = preset.TargetLra.ToString("F1", CultureInfo.InvariantCulture);
@ -46,6 +52,15 @@ public static class AudioVolumeNormalizer
/// </summary>
public static string BuildNormalizeCommandLine(string inputPath, string outputPath, AudioNormalizerPreset preset, bool copyVideo = true)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
if (preset == null)
throw new ArgumentNullException(nameof(preset));
if (string.Equals(inputPath.Trim(), outputPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputPath));
var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\"");

View file

@ -46,6 +46,9 @@ public static class BandwidthScheduleProfileEngine
foreach (var rule in config.Rules)
{
if (rule == null || rule.Profile == null)
continue;
bool inWindow;
if (rule.StartTime <= rule.EndTime)
{
@ -61,6 +64,6 @@ public static class BandwidthScheduleProfileEngine
return rule.Profile;
}
return config.DefaultProfile;
return config.DefaultProfile ?? new BandwidthProfile("Default", 0, 4);
}
}

View file

@ -43,10 +43,16 @@ public static class WorkspaceConfigSanitizer
{
foreach (var name in names)
{
if (result.Count >= 20)
break;
if (!IsCorrupted(name))
{
var clean = name.Trim();
if (!result.Contains(clean))
if (clean.Length > 30)
clean = clean[..30].TrimEnd();
if (!string.IsNullOrEmpty(clean) && !result.Contains(clean))
result.Add(clean);
}
}

View file

@ -98,6 +98,7 @@ public static class DashParser
// t 속성은 타임라인을 해당 절대시각으로 재설정.
// #9 r=-1 은 "Period 끝까지 반복" — count=r+1=0 으로 0개를 내던 버그 수정.
var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
if (timescale <= 0) timescale = 1;
long endTime = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale) : long.MaxValue;
long number = startNumber; int idx = 0; long time = 0;
const long MaxSegs = 200000; // 폭주 방지 상한
@ -130,6 +131,7 @@ public static class DashParser
else
{
var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
if (timescale <= 0) timescale = 1;
var duration = (long?)tmpl.Attribute("duration") ?? 0;
if (duration > 0)
{
@ -196,7 +198,7 @@ public static class DashParser
return Uri.TryCreate(baseUri, rel, out var combined) ? combined : new Uri(rel, UriKind.RelativeOrAbsolute);
}
private static double ParseDuration(string? d)
internal static double ParseDuration(string? d)
{
if (string.IsNullOrWhiteSpace(d)) return 0;
try
@ -205,17 +207,40 @@ public static class DashParser
}
catch
{
// 폴백: 수동 파싱
var s = d.Trim();
if (!s.StartsWith("PT", StringComparison.OrdinalIgnoreCase)) return 0;
s = s.Substring(2);
double total = 0; var num = "";
// 폴백: ISO 8601 Duration (P[n]Y[n]M[n]DT[n]H[n]M[n]S) 수동 파싱
var s = d.Trim().ToUpperInvariant();
if (!s.StartsWith("P")) return 0;
s = s.Substring(1); // 'P' 제거
bool inTime = false;
double total = 0;
var num = "";
foreach (var ch in s)
{
if (char.IsDigit(ch) || ch == '.') num += ch;
else if (ch is 'H' or 'M' or 'S' && double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
if (ch == 'T')
{
total += ch == 'H' ? v * 3600 : ch == 'M' ? v * 60 : v;
inTime = true;
continue;
}
if (char.IsDigit(ch) || ch == '.')
{
num += ch;
}
else if (double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{
if (!inTime)
{
if (ch == 'D') total += v * 86400;
else if (ch == 'W') total += v * 604800;
else if (ch == 'Y') total += v * 31536000;
}
else
{
if (ch == 'H') total += v * 3600;
else if (ch == 'M') total += v * 60;
else if (ch == 'S') total += v;
}
num = "";
}
}

View file

@ -32,6 +32,16 @@ public static class AiHighlightClipper
Func<double, double>? sceneChangeProvider = null,
double windowSeconds = 30.0)
{
if (double.IsNaN(totalDurationSeconds) || totalDurationSeconds <= 0)
{
return new List<VideoHighlightWindow>();
}
if (double.IsNaN(windowSeconds) || windowSeconds <= 0)
{
windowSeconds = 30.0;
}
var windows = new List<VideoHighlightWindow>();
int count = (int)Math.Ceiling(totalDurationSeconds / windowSeconds);
@ -61,11 +71,29 @@ public static class AiHighlightClipper
public static List<string> BuildFfmpegShortsCommand(string inputPath, string outputPath, ShortsClipSpec spec)
{
if (spec == null)
throw new ArgumentNullException(nameof(spec));
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
var cleanInput = inputPath.Replace("\"", "").Trim();
var cleanOutput = outputPath.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Input and output path cannot refer to the exact same file (in-place overwrite prevention).");
}
double startSec = double.IsNaN(spec.StartSeconds) ? 0.0 : Math.Max(0.0, spec.StartSeconds);
double durSec = double.IsNaN(spec.DurationSeconds) || spec.DurationSeconds <= 0 ? 59.0 : spec.DurationSeconds;
var args = new List<string>
{
"-ss", spec.StartSeconds.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
"-t", spec.DurationSeconds.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
"-i", inputPath
"-ss", startSec.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
"-t", durSec.ToString("F2", System.Globalization.CultureInfo.InvariantCulture),
"-i", cleanInput
};
if (spec.VerticalCrop9x16)
@ -78,7 +106,7 @@ public static class AiHighlightClipper
args.AddRange(new[] { "-c", "copy" });
}
args.AddRange(new[] { "-y", outputPath });
args.AddRange(new[] { "-y", cleanOutput });
return args;
}
}

View file

@ -6,9 +6,22 @@ namespace Paca.Core.Detection;
public static class MediaDetector
{
/// <returns>HLS 또는 Direct, 감지 대상이 아니면 null</returns>
public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig config)
public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig? config)
{
var l = (uri ?? "").ToLowerInvariant();
if (config == null) return null;
if (string.IsNullOrWhiteSpace(uri)) return null;
if (len < -1) return null;
var trimmed = uri.Trim();
if (trimmed.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("blob:", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var l = trimmed.ToLowerInvariant();
var c = (contentType ?? "").ToLowerInvariant();
// 세그먼트 청크(.ts, .m4s, chunk, init.mp4 등)는 독립 미디어가 아니므로 목록 등록 차단

View file

@ -26,9 +26,16 @@ public static class NeuralShaderUpscaler
{
public static string GenerateHlslShaderCode(ShaderUpscaleConfig config)
{
config ??= new ShaderUpscaleConfig();
int targetWidth = config.TargetWidth > 0 ? config.TargetWidth : 3840;
int targetHeight = config.TargetHeight > 0 ? config.TargetHeight : 2160;
double sharpness = double.IsNaN(config.Sharpness) ? 1.0 : Math.Clamp(config.Sharpness, 0.0, 10.0);
string sharpnessStr = sharpness.ToString("F2", System.Globalization.CultureInfo.InvariantCulture);
return $@"
// Paca Neural Shader Upscaler Pipeline ({config.Algorithm})
// Target Resolution: {config.TargetWidth}x{config.TargetHeight}, Sharpness: {config.Sharpness}
// Target Resolution: {targetWidth}x{targetHeight}, Sharpness: {sharpnessStr}
Texture2D InputTexture : register(t0);
SamplerState LinearSampler : register(s0);
@ -41,13 +48,13 @@ float4 PS_NeuralUpscale(PS_INPUT input) : SV_Target {{
float2 uv = input.TexCoord;
float4 center = InputTexture.Sample(LinearSampler, uv);
// Neural weight accumulation pass
float4 n = InputTexture.Sample(LinearSampler, uv + float2(0, -1.0 / {config.TargetHeight}));
float4 s = InputTexture.Sample(LinearSampler, uv + float2(0, 1.0 / {config.TargetHeight}));
float4 e = InputTexture.Sample(LinearSampler, uv + float2(1.0 / {config.TargetWidth}, 0));
float4 w = InputTexture.Sample(LinearSampler, uv + float2(-1.0 / {config.TargetWidth}, 0));
float4 n = InputTexture.Sample(LinearSampler, uv + float2(0, -1.0 / {targetHeight}));
float4 s = InputTexture.Sample(LinearSampler, uv + float2(0, 1.0 / {targetHeight}));
float4 e = InputTexture.Sample(LinearSampler, uv + float2(1.0 / {targetWidth}, 0));
float4 w = InputTexture.Sample(LinearSampler, uv + float2(-1.0 / {targetWidth}, 0));
float4 edge = abs(n - s) + abs(e - w);
return center + (edge * {config.Sharpness:F2} * 0.15);
return center + (edge * {sharpnessStr} * 0.15);
}}
";
}

View file

@ -17,11 +17,37 @@ public static class SocialSiteDetector
private static readonly Regex TikTokPathRe = new(@"/@[^/]+/video/([0-9]+)", RegexOptions.Compiled);
private static readonly Regex FbPathRe = new(@"/(?:reel|videos)/([^/?#]+)", RegexOptions.Compiled);
/// <summary>문자열 URL이 소셜 영상 URL이면 정규화된 URL과 사이트 라벨을 반환.</summary>
public static bool TryMatch(string? urlString, out string normalized, out string label)
{
normalized = "";
label = "";
if (string.IsNullOrWhiteSpace(urlString)) return false;
urlString = urlString.Trim();
if (urlString.Contains('\0')) return false;
if (urlString.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("file:", StringComparison.OrdinalIgnoreCase) ||
urlString.StartsWith("blob:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri))
return false;
return TryMatch(uri, out normalized, out label);
}
/// <summary>소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환.</summary>
public static bool TryMatch(Uri url, out string normalized, out string label)
{
normalized = "";
label = "";
if (url == null || !url.IsAbsoluteUri) return false;
if (url.Scheme != "http" && url.Scheme != "https") return false;
var host = (url.Host ?? "").ToLowerInvariant();
var path = url.AbsolutePath ?? "";

View file

@ -262,10 +262,18 @@ public class HlsDownloader
_keys[keyUri] = key;
}
private byte[] DecryptSegment(byte[] data, Segment seg)
internal byte[] DecryptSegment(byte[] data, Segment seg)
{
var key = seg.Key!;
var keyBytes = _keys[key.KeyUri!];
if (!_keys.TryGetValue(key.KeyUri!, out var keyBytes) || keyBytes == null)
throw new InvalidDataException("HLS 복호화 키가 메모리에 로드되지 않았습니다.");
if (keyBytes.Length != 16)
throw new InvalidDataException($"HLS AES-128 키 크기 오류: 예상 16바이트, 실제 {keyBytes.Length}바이트");
if (data.Length % 16 != 0)
throw new InvalidDataException($"HLS 암호화 세그먼트 블록 크기 손상: 세그먼트 크기 {data.Length}B가 AES 블록 크기(16B)의 배수가 아닙니다.");
var iv = ResolveIv(key, seg.Sequence);
using var aes = Aes.Create();
aes.Key = keyBytes;
@ -276,13 +284,33 @@ public class HlsDownloader
return dec.TransformFinalBlock(data, 0, data.Length);
}
private static byte[] ResolveIv(KeyInfo key, long seq)
internal void SetPreloadedKeyForTesting(Uri uri, byte[] key) => _keys[uri] = key;
internal byte[] DecryptSegmentForTesting(byte[] data, Segment seg) => DecryptSegment(data, seg);
internal static byte[] ResolveIv(KeyInfo key, long seq)
{
var iv = key.Iv;
if (!string.IsNullOrEmpty(iv))
{
var hex = iv.Replace("0x", "").Replace(" ", "");
return Convert.FromHexString(hex.Length % 2 == 1 ? "0" + hex : hex);
var hex = iv.Replace("0x", "").Replace("0X", "").Replace(" ", "");
try
{
// RFC 8216 Section 5.2: 128-bit unsigned integer in big-endian format
// 32글자(16바이트) 미만일 경우 좌측에 0을 패딩
if (hex.Length < 32)
{
hex = hex.PadLeft(32, '0');
}
else if (hex.Length > 32)
{
hex = hex[^32..];
}
return Convert.FromHexString(hex);
}
catch
{
// 비정상 hex 포맷인 경우 시퀀스 번호 폴백
}
}
// IV 미지정 시 시퀀스 번호를 16바이트 big-endian 으로
var b = new byte[16];

View file

@ -31,9 +31,11 @@ public static class M3u8Parser
var variants = new List<Variant>();
var segments = new List<Segment>();
Uri? init = null;
long? initByteRangeLen = null;
long? initByteRangeOff = null;
bool isMaster = false;
if (string.IsNullOrWhiteSpace(text) || !text.Contains("#EXTM3U", StringComparison.OrdinalIgnoreCase))
if (baseUrl == null || string.IsNullOrWhiteSpace(text) || !text.Contains("#EXTM3U", StringComparison.OrdinalIgnoreCase))
{
return new ParsedPlaylist
{
@ -41,6 +43,8 @@ public static class M3u8Parser
Variants = variants,
Segments = segments,
InitSegment = init,
InitByteRangeLength = initByteRangeLen,
InitByteRangeOffset = initByteRangeOff,
};
}
@ -49,6 +53,11 @@ public static class M3u8Parser
long seq = 0;
int idx = 0;
long? pendingByteRangeLength = null;
long? pendingByteRangeOffset = null;
long prevByteRangeOffset = 0;
long prevByteRangeLength = 0;
using var reader = new StringReader(text);
string? raw;
while ((raw = reader.ReadLine()) != null)
@ -64,7 +73,7 @@ public static class M3u8Parser
if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:", StringComparison.OrdinalIgnoreCase))
{
var parts = line.Split(':', 2);
if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var s)) seq = s;
if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var s) && s >= 0) seq = s;
continue;
}
if (line.StartsWith("#EXT-X-KEY:", StringComparison.OrdinalIgnoreCase))
@ -78,11 +87,46 @@ public static class M3u8Parser
: null;
continue;
}
if (line.StartsWith("#EXT-X-BYTERANGE:", StringComparison.OrdinalIgnoreCase))
{
var val = line.Substring("#EXT-X-BYTERANGE:".Length).Trim();
var parts = val.Split('@', 2);
if (long.TryParse(parts[0].Trim(), out var len) && len > 0)
{
pendingByteRangeLength = len;
if (parts.Length > 1 && long.TryParse(parts[1].Trim(), out var off) && off >= 0)
{
pendingByteRangeOffset = off;
prevByteRangeOffset = off;
}
else
{
pendingByteRangeOffset = prevByteRangeOffset + prevByteRangeLength;
prevByteRangeOffset = pendingByteRangeOffset.Value;
}
prevByteRangeLength = len;
}
continue;
}
if (line.StartsWith("#EXT-X-MAP:", StringComparison.OrdinalIgnoreCase))
{
var a = ParseAttrs(line.Substring("#EXT-X-MAP:".Length));
if (a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u) && Uri.TryCreate(baseUrl, u.Trim('"'), out var mu))
init = mu;
if (a.TryGetValue("BYTERANGE", out var br) && !string.IsNullOrEmpty(br))
{
var cleanBr = br.Trim('"');
var brParts = cleanBr.Split('@', 2);
if (long.TryParse(brParts[0].Trim(), out var bLen) && bLen > 0)
{
initByteRangeLen = bLen;
if (brParts.Length > 1 && long.TryParse(brParts[1].Trim(), out var bOff) && bOff >= 0)
{
initByteRangeOff = bOff;
}
}
}
continue;
}
if (line.StartsWith("#")) continue;
@ -93,15 +137,18 @@ public static class M3u8Parser
if (pendingVariant != null)
{
int.TryParse(pendingVariant.GetValue("BANDWIDTH", "0"), out var bw);
variants.Add(new Variant(abs, bw,
variants.Add(new Variant(abs, Math.Max(0, bw),
pendingVariant.GetValue("RESOLUTION"), pendingVariant.GetValue("CODECS")));
pendingVariant = null;
isMaster = true;
}
else
{
segments.Add(new Segment(abs, idx, seq, curKey));
idx++; seq++;
segments.Add(new Segment(abs, idx, seq, curKey, pendingByteRangeLength, pendingByteRangeOffset));
pendingByteRangeLength = null;
pendingByteRangeOffset = null;
idx++;
if (seq < long.MaxValue) seq++;
}
}
@ -111,6 +158,8 @@ public static class M3u8Parser
Variants = variants,
Segments = segments,
InitSegment = init,
InitByteRangeLength = initByteRangeLen,
InitByteRangeOffset = initByteRangeOff,
};
}
}

View file

@ -85,24 +85,31 @@ public class PartialChunkIntegrityCache
/// </summary>
public ChunkIntegrityState VerifyChunkFile(string filePath, long? expectedBytes = null, string? expectedSha256 = null)
{
if (!File.Exists(filePath))
return ChunkIntegrityState.Missing;
var fi = new FileInfo(filePath);
if (fi.Length == 0)
return ChunkIntegrityState.Corrupt;
if (expectedBytes.HasValue && expectedBytes.Value >= 0 && fi.Length != expectedBytes.Value)
return ChunkIntegrityState.Corrupt;
if (!string.IsNullOrWhiteSpace(expectedSha256))
try
{
var computed = ComputeFileSha256(filePath);
if (!string.Equals(computed, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase))
return ChunkIntegrityState.Corrupt;
}
if (!File.Exists(filePath))
return ChunkIntegrityState.Missing;
return ChunkIntegrityState.Verified;
var fi = new FileInfo(filePath);
if (fi.Length == 0)
return ChunkIntegrityState.Corrupt;
if (expectedBytes.HasValue && expectedBytes.Value >= 0 && fi.Length != expectedBytes.Value)
return ChunkIntegrityState.Corrupt;
if (!string.IsNullOrWhiteSpace(expectedSha256))
{
var computed = ComputeFileSha256(filePath);
if (!string.Equals(computed, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase))
return ChunkIntegrityState.Corrupt;
}
return ChunkIntegrityState.Verified;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return ChunkIntegrityState.Corrupt;
}
}
/// <summary>
@ -206,10 +213,21 @@ public class PartialChunkIntegrityCache
long verifiedBytes = 0;
var toDownload = new List<int>();
var fullPartsDir = Path.GetFullPath(partsDir);
for (int i = 0; i < totalSegments; i++)
{
var chunkName = getChunkName(i);
var chunkPath = Path.Combine(partsDir, chunkName);
var fullChunkPath = Path.GetFullPath(chunkPath);
// Path traversal 방어: partsDir 외부 경로 탈출 차단
if (!fullChunkPath.StartsWith(fullPartsDir, StringComparison.OrdinalIgnoreCase))
{
corruptedCount++;
toDownload.Add(i);
continue;
}
var tmpPath = chunkPath + ".tmp";
// 잔존 임시 파일(.tmp)은 비정상 중단 흔적이므로 안전 제거

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@ -123,6 +123,7 @@ public static class HlsManifestValidator
/// </summary>
public static string StripAdBlocks(string manifestContent, double maxAdDurationSec = 30.0)
{
if (manifestContent == null) return string.Empty;
if (string.IsNullOrWhiteSpace(manifestContent)) return manifestContent;
var lines = manifestContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);

View file

@ -50,7 +50,11 @@ public static class FftAudioWaveformEngine
public static string BuildFfmpegEqualizerFilter(List<EqualizerBand> bands)
{
var filters = bands.Select(b => $"equalizer=f={b.FrequencyHz}:t=q:w=1:g={b.GainDb:F1}");
if (bands == null || bands.Count == 0) return string.Empty;
var filters = bands
.Where(b => b != null)
.Select(b => $"equalizer=f={b.FrequencyHz.ToString(System.Globalization.CultureInfo.InvariantCulture)}:t=q:w=1:g={b.GainDb.ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}");
return string.Join(",", filters);
}
}

View file

@ -40,7 +40,19 @@ public static class HardwareTranscoderStudio
TranscodePresetSpec spec,
Platform.GpuCapabilities? capabilities = null)
{
var args = new List<string> { "-i", inputPath };
ArgumentNullException.ThrowIfNull(spec);
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 cleanInput = inputPath.Trim().Trim('"');
var cleanOutput = outputPath.Trim().Trim('"');
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Input path and output path cannot be identical.");
var args = new List<string> { "-i", cleanInput };
// Select Video Encoder
string vcodec;
@ -78,7 +90,9 @@ public static class HardwareTranscoderStudio
args.AddRange(new[] { "-b:v", $"{spec.TargetBitrateKbps}k" });
}
args.AddRange(new[] { "-c:a", spec.AudioCodec, "-b:a", $"{spec.AudioBitrateKbps}k", "-y", outputPath });
var audioCodec = string.IsNullOrWhiteSpace(spec.AudioCodec) ? "aac" : spec.AudioCodec.Trim();
var audioBitrate = spec.AudioBitrateKbps > 0 ? spec.AudioBitrateKbps : 128;
args.AddRange(new[] { "-c:a", audioCodec, "-b:a", $"{audioBitrate}k", "-y", cleanOutput });
return args;
}
}

View file

@ -42,13 +42,25 @@ public sealed class WebRtcCastingEngine
_iceServers.Add(new IceServerConfig());
}
public int ActiveSessionsCount
{
get
{
lock (_lock)
{
return _sessions.Count;
}
}
}
public WebRtcCastSession CreateCastSession(string targetDeviceName)
{
var cleanDevice = string.IsNullOrWhiteSpace(targetDeviceName) ? "Default Display" : targetDeviceName.Trim();
lock (_lock)
{
var session = new WebRtcCastSession
{
TargetDeviceName = targetDeviceName,
TargetDeviceName = cleanDevice,
IsConnected = true
};
_sessions[session.SessionId] = session;
@ -56,17 +68,32 @@ public sealed class WebRtcCastingEngine
}
}
public WebRtcCastSession? GetSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId)) return null;
lock (_lock)
{
return _sessions.GetValueOrDefault(sessionId);
}
}
public SdpSessionDescription GenerateOffer(string sessionId, int width = 1920, int height = 1080, int fps = 60)
{
var cleanSessionId = string.IsNullOrWhiteSpace(sessionId) ? "default" : sessionId.Trim();
var safeWidth = width <= 0 ? 1920 : width;
var safeHeight = height <= 0 ? 1080 : height;
var safeFps = fps <= 0 ? 60 : fps;
return new SdpSessionDescription
{
Type = "offer",
Sdp = $"v=0\r\no=PacaCast 0 0 IN IP4 127.0.0.1\r\ns=Paca UltraCast Session {sessionId}\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\na=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1\r\na=sendonly\r\n"
Sdp = $"v=0\r\no=PacaCast 0 0 IN IP4 127.0.0.1\r\ns=Paca UltraCast Session {cleanSessionId} ({safeWidth}x{safeHeight}@{safeFps})\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\na=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1\r\na=sendonly\r\n"
};
}
public bool TerminateSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId)) return false;
lock (_lock)
{
return _sessions.Remove(sessionId);

View file

@ -1,4 +1,4 @@
using System;
using System;
namespace Paca.Core;
@ -54,6 +54,10 @@ public static class MediaBitrateEstimator
public static BitrateEstimationResult CalculateBitrate(double durationSec, long targetSizeBytes, int audioBitrateKbps = 128, double overheadPercent = 3.0)
{
if (durationSec <= 0) durationSec = 1.0;
if (targetSizeBytes <= 0)
{
return new BitrateEstimationResult(false, MinVideoBitrateKbps, audioBitrateKbps, targetSizeBytes);
}
double usableBytes = targetSizeBytes * (1.0 - (overheadPercent / 100.0));
double totalBits = usableBytes * 8.0;
@ -74,9 +78,22 @@ public static class MediaBitrateEstimator
/// </summary>
public static (string Pass1Args, string Pass2Args) BuildTwoPassCommands(string inputPath, string outputPath, BitrateEstimationResult plan)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
var cleanInput = inputPath.Replace("\"", "").Trim();
var cleanOutput = outputPath.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Input and output path cannot refer to the exact same file (in-place overwrite prevention).");
}
string nullOutput = OperatingSystem.IsWindows() ? "NUL" : "/dev/null";
string pass1 = $"-hide_banner -y -i \"{inputPath}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 1 -an -f null {nullOutput}";
string pass2 = $"-hide_banner -y -i \"{inputPath}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 2 -c:a aac -b:a {plan.AudioBitrateKbps}k \"{outputPath}\"";
string pass1 = $"-hide_banner -y -i \"{cleanInput}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 1 -an -f null {nullOutput}";
string pass2 = $"-hide_banner -y -i \"{cleanInput}\" -c:v libx264 -b:v {plan.VideoBitrateKbps}k -pass 2 -c:a aac -b:a {plan.AudioBitrateKbps}k \"{cleanOutput}\"";
return (pass1, pass2);
}
}

View file

@ -81,12 +81,23 @@ public static class MediaMetadataTagWriter
/// </summary>
public static string BuildFfmpegArgs(MediaMetadataTag tag, string inputPath, string outputPath)
{
ArgumentNullException.ThrowIfNull(tag);
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("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\"");
sb.Append($"-i \"{cleanIn}\"");
if (!string.IsNullOrEmpty(tag.CoverArtworkPath))
{
sb.Append($" -i \"{tag.CoverArtworkPath}\" -map 0 -map 1 -c copy -disposition:v:1 attached_pic");
var cleanCover = tag.CoverArtworkPath.Trim().Replace("\"", "");
sb.Append($" -i \"{cleanCover}\" -map 0 -map 1 -c copy -disposition:v:1 attached_pic");
}
if (!string.IsNullOrEmpty(tag.Title))
@ -115,10 +126,10 @@ public static class MediaMetadataTagWriter
if (!string.IsNullOrEmpty(tag.Comment))
sb.Append($" -metadata comment=\"{SanitizeTagValue(tag.Comment)}\"");
if (outputPath.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
if (cleanOut.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
sb.Append(" -id3v2_version 3");
sb.Append($" \"{outputPath}\" -y");
sb.Append($" \"{cleanOut}\" -y");
return sb.ToString();
}
}

View file

@ -64,10 +64,23 @@ public static class MediaPlaybackSpeedPitchEngine
double speed,
bool isAudioOnly = false)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty.", nameof(inputPath));
if (string.IsNullOrWhiteSpace(outputPath))
throw new ArgumentException("Output path cannot be null or empty.", nameof(outputPath));
var cleanInput = inputPath.Replace("\"", "").Trim();
var cleanOutput = outputPath.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Input and output path cannot refer to the exact same file (in-place overwrite prevention).");
}
speed = ClampSpeed(speed);
var atempo = BuildAtempoFilterChain(speed);
var sb = new StringBuilder();
sb.Append($"-i \"{inputPath}\"");
sb.Append($"-i \"{cleanInput}\"");
if (!isAudioOnly)
{
@ -82,7 +95,7 @@ public static class MediaPlaybackSpeedPitchEngine
sb.Append(" -c:a libmp3lame -b:a 320k");
}
sb.Append($" \"{outputPath}\" -y");
sb.Append($" \"{cleanOutput}\" -y");
return sb.ToString();
}
}

View file

@ -10,8 +10,8 @@ public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs)
get
{
if (string.IsNullOrEmpty(Resolution)) return 0;
var parts = Resolution.Split('x', 'X');
return parts.Length == 2 && int.TryParse(parts[1], out int h) ? h : 0;
var parts = Resolution.Split(new[] { 'x', 'X', '@' }, StringSplitOptions.RemoveEmptyEntries);
return parts.Length >= 2 && int.TryParse(parts[1], out int h) && h > 0 ? h : 0;
}
}
@ -20,8 +20,8 @@ public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs)
get
{
if (string.IsNullOrEmpty(Resolution)) return 0;
var parts = Resolution.Split('x', 'X');
return parts.Length == 2 && int.TryParse(parts[0], out int w) ? w : 0;
var parts = Resolution.Split(new[] { 'x', 'X', '@' }, StringSplitOptions.RemoveEmptyEntries);
return parts.Length >= 1 && int.TryParse(parts[0], out int w) && w > 0 ? w : 0;
}
}
}
@ -30,7 +30,7 @@ public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs)
public record KeyInfo(Uri? KeyUri, string? Iv);
/// <summary>미디어 세그먼트</summary>
public record Segment(Uri Url, int Index, long Sequence, KeyInfo? Key);
public record Segment(Uri Url, int Index, long Sequence, KeyInfo? Key, long? ByteRangeLength = null, long? ByteRangeOffset = null);
/// <summary>m3u8 파싱 결과</summary>
public record ParsedPlaylist
@ -39,4 +39,6 @@ public record ParsedPlaylist
public IReadOnlyList<Variant> Variants { get; init; } = Array.Empty<Variant>();
public IReadOnlyList<Segment> Segments { get; init; } = Array.Empty<Segment>();
public Uri? InitSegment { get; init; } // #EXT-X-MAP (fMP4 초기화 세그먼트)
public long? InitByteRangeLength { get; init; }
public long? InitByteRangeOffset { get; init; }
}

View file

@ -36,8 +36,16 @@ public static class MultiTrackMediaMuxer
/// </summary>
public static string BuildMuxCommandLine(MuxJobSpecification job)
{
if (job == null)
throw new ArgumentNullException(nameof(job));
ArgumentNullException.ThrowIfNull(job);
if (string.IsNullOrWhiteSpace(job.OutputPath))
throw new ArgumentException("Output path cannot be null or whitespace.", nameof(job));
int totalTracks = (job.VideoTrack != null ? 1 : 0) + job.AudioTracks.Count + job.SubtitleTracks.Count;
if (totalTracks == 0)
throw new InvalidOperationException("Mux job must specify at least one track.");
var cleanOut = job.OutputPath.Trim().Replace("\"", "");
var fullOut = System.IO.Path.GetFullPath(cleanOut);
var inputs = new List<string>();
var maps = new List<string>();
@ -45,9 +53,20 @@ public static class MultiTrackMediaMuxer
var disps = new List<string>();
var fileIndex = 0;
void CheckInputPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Track file path cannot be null or whitespace.");
var cleanIn = path.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), fullOut, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
}
if (job.VideoTrack != null)
{
inputs.Add($"-i \"{job.VideoTrack.FilePath}\"");
CheckInputPath(job.VideoTrack.FilePath);
var cleanV = job.VideoTrack.FilePath.Trim().Replace("\"", "");
inputs.Add($"-i \"{cleanV}\"");
maps.Add($"-map {fileIndex}:v:0");
fileIndex++;
}
@ -55,14 +74,19 @@ public static class MultiTrackMediaMuxer
for (var i = 0; i < job.AudioTracks.Count; i++)
{
var track = job.AudioTracks[i];
inputs.Add($"-i \"{track.FilePath}\"");
CheckInputPath(track.FilePath);
var cleanA = track.FilePath.Trim().Replace("\"", "");
inputs.Add($"-i \"{cleanA}\"");
maps.Add($"-map {fileIndex}:a:0");
if (!string.IsNullOrEmpty(track.Language))
metas.Add($"-metadata:s:a:{i} language={track.Language}");
if (!string.IsNullOrEmpty(track.Title))
metas.Add($"-metadata:s:a:{i} title=\"{track.Title}\"");
{
var cleanTitle = track.Title.Replace("\"", "");
metas.Add($"-metadata:s:a:{i} title=\"{cleanTitle}\"");
}
if (track.IsDefault)
disps.Add($"-disposition:a:{i} default");
@ -73,14 +97,19 @@ public static class MultiTrackMediaMuxer
for (var i = 0; i < job.SubtitleTracks.Count; i++)
{
var track = job.SubtitleTracks[i];
inputs.Add($"-i \"{track.FilePath}\"");
CheckInputPath(track.FilePath);
var cleanS = track.FilePath.Trim().Replace("\"", "");
inputs.Add($"-i \"{cleanS}\"");
maps.Add($"-map {fileIndex}:s:0");
if (!string.IsNullOrEmpty(track.Language))
metas.Add($"-metadata:s:s:{i} language={track.Language}");
if (!string.IsNullOrEmpty(track.Title))
metas.Add($"-metadata:s:s:{i} title=\"{track.Title}\"");
{
var cleanTitle = track.Title.Replace("\"", "");
metas.Add($"-metadata:s:s:{i} title=\"{cleanTitle}\"");
}
if (track.IsDefault)
disps.Add($"-disposition:s:{i} default");
@ -106,7 +135,7 @@ public static class MultiTrackMediaMuxer
}
sb.Append(" -c copy");
sb.Append($" \"{job.OutputPath}\" -y");
sb.Append($" \"{cleanOut}\" -y");
return sb.ToString();
}

View file

@ -48,12 +48,37 @@ public static class HostRateLimiter
}
}
public static string NormalizeHost(string? hostOrUrl)
{
if (string.IsNullOrWhiteSpace(hostOrUrl)) return string.Empty;
var trimmed = hostOrUrl.Trim();
if (trimmed.Contains("://"))
{
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
return uri.Host.ToLowerInvariant();
}
if (Uri.TryCreate("http://" + trimmed, UriKind.Absolute, out var uri2))
{
return uri2.Host.ToLowerInvariant();
}
var colIdx = trimmed.IndexOf(':');
if (colIdx >= 0)
{
trimmed = trimmed.Substring(0, colIdx);
}
return trimmed.ToLowerInvariant();
}
public static int GetIntervalMs(string? host) => GetDefaultIntervalMs(host ?? string.Empty);
public static int GetDefaultIntervalMs(string host)
{
if (string.IsNullOrWhiteSpace(host)) return 200;
var h = host.ToLowerInvariant();
var h = NormalizeHost(host);
if (string.IsNullOrWhiteSpace(h)) return 200;
if (IsDomainOrSubdomain(h, "4cdn.org") || IsDomainOrSubdomain(h, "4chan.org") || IsDomainOrSubdomain(h, "4channel.org"))
return 1200; // 4chan 공식 API 룰: 초당 1회 이하

View file

@ -42,10 +42,17 @@ public sealed class MultiCdnMeshDownloader
_httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
}
private static CdnMirrorInfo CreateMirrorInfo(string key)
{
if (Uri.TryCreate(key, UriKind.Absolute, out var absUri))
return new CdnMirrorInfo(absUri);
return new CdnMirrorInfo(new Uri("http://localhost/" + key.TrimStart('/')));
}
public void RegisterMirror(Uri mirrorUri)
{
var key = GetMirrorKey(mirrorUri);
_mirrorStats.TryAdd(key, new CdnMirrorInfo(new Uri(key)));
_mirrorStats.TryAdd(key, CreateMirrorInfo(key));
}
public List<CdnMirrorInfo> GetRankedMirrors()
@ -53,7 +60,11 @@ public sealed class MultiCdnMeshDownloader
return _mirrorStats.Values.OrderByDescending(m => m.HealthScore).ToList();
}
private static string GetMirrorKey(Uri uri) => uri.GetLeftPart(UriPartial.Authority);
private static string GetMirrorKey(Uri? uri)
{
if (uri == null) return "unknown";
return uri.IsAbsoluteUri ? uri.GetLeftPart(UriPartial.Authority) : uri.ToString();
}
/// <summary>
/// 복수 CDN 미러 URL로 세그먼트 레이싱 다운로드를 수행합니다.
@ -63,12 +74,20 @@ public sealed class MultiCdnMeshDownloader
int staggerDelayMs = 40,
CancellationToken ct = default)
{
var uris = mirrorUris.ToList();
if (mirrorUris == null)
{
return new MultiCdnSegmentRaceResult { IsSuccess = false };
}
var uris = mirrorUris.Where(u => u != null).ToList();
if (uris.Count == 0)
{
return new MultiCdnSegmentRaceResult { IsSuccess = false };
}
if (staggerDelayMs < 0)
staggerDelayMs = 0;
if (uris.Count == 1)
{
return await DownloadSingleSegmentAsync(uris[0], ct).ConfigureAwait(false);
@ -91,7 +110,14 @@ public sealed class MultiCdnMeshDownloader
if (i > 0 && staggerDelayMs > 0)
{
await Task.Delay(staggerDelayMs, linkedCts.Token).ConfigureAwait(false);
try
{
await Task.Delay(staggerDelayMs, linkedCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
if (linkedCts.IsCancellationRequested) break;
@ -137,7 +163,7 @@ public sealed class MultiCdnMeshDownloader
{
var sw = Stopwatch.StartNew();
var key = GetMirrorKey(uri);
var stat = _mirrorStats.GetOrAdd(key, _ => new CdnMirrorInfo(new Uri(key)));
var stat = _mirrorStats.GetOrAdd(key, k => CreateMirrorInfo(k));
try
{

View file

@ -86,6 +86,10 @@ public sealed class P2pSwarmEngine
public static SwarmTorrentManifest CreateManifestFromBytes(string name, byte[] data, int pieceLength = 512 * 1024)
{
ArgumentNullException.ThrowIfNull(data);
name ??= "";
if (pieceLength < 16 * 1024) pieceLength = 512 * 1024;
var manifest = new SwarmTorrentManifest
{
Name = name,
@ -118,6 +122,7 @@ public sealed class P2pSwarmEngine
public void InitializeSession(SwarmTorrentManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
lock (_lock)
{
_manifest = manifest;
@ -142,15 +147,20 @@ public sealed class P2pSwarmEngine
public void AddPeer(SwarmPeer peer)
{
if (peer == null) return;
lock (_lock)
{
peer.Bitfield ??= Array.Empty<bool>();
if (peer.Bitfield.Length != _pieces.Count)
{
var bf = new bool[_pieces.Count];
Array.Copy(peer.Bitfield, bf, Math.Min(peer.Bitfield.Length, bf.Length));
peer.Bitfield = bf;
}
_peers[peer.PeerId] = peer;
if (!string.IsNullOrEmpty(peer.PeerId))
{
_peers[peer.PeerId] = peer;
}
}
}
@ -164,11 +174,15 @@ public sealed class P2pSwarmEngine
public bool IngestPieceData(int pieceIndex, byte[] data)
{
if (data == null) return false;
lock (_lock)
{
if (!_pieces.TryGetValue(pieceIndex, out var piece))
return false;
if (data.Length != piece.Length)
return false;
using var sha256 = SHA256.Create();
byte[] computedHash = sha256.ComputeHash(data);
string computedHex = Convert.ToHexString(computedHash).ToLowerInvariant();

View file

@ -8,5 +8,6 @@
<ItemGroup>
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<InternalsVisibleTo Include="Paca.Tests" />
</ItemGroup>
</Project>

View file

@ -25,6 +25,11 @@ public sealed class AudioNormalizer
public async Task<string> NormalizeAudioAsync(string ffmpeg, string inputFile, string outputFile, LoudnessSpec spec, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
if (string.Equals(Path.GetFullPath(inputFile), Path.GetFullPath(outputFile), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
// Pass 1: Measure loudness
var iStr = spec.TargetLufs.ToString("F1", CultureInfo.InvariantCulture);
var tpStr = spec.TruePeak.ToString("F1", CultureInfo.InvariantCulture);

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.IO;
using System.Text;
using Paca.Core.Util;
@ -68,25 +68,44 @@ public sealed class ChapterSplitter
public static string GetChapterFileName(int id, string title, string ext)
{
var cleanTitle = FileNameUtil.Sanitize(title);
var cleanExt = ext.TrimStart('.');
var cleanTitle = string.IsNullOrWhiteSpace(title) ? "Chapter" : FileNameUtil.Sanitize(title);
var cleanExt = string.IsNullOrWhiteSpace(ext) ? "mp4" : ext.TrimStart('.');
return $"{id:D2}_{cleanTitle}.{cleanExt}";
}
private static string EscapeFfmetadata(string text)
{
if (string.IsNullOrEmpty(text)) return "";
return text
.Replace(@"\", @"\\")
.Replace("=", @"\=")
.Replace(";", @"\;")
.Replace("#", @"\#")
.Replace("\r", "")
.Replace("\n", "\\\n");
}
public static string GenerateFfmetadata(IReadOnlyList<ChapterInfo> chapters, string? mainTitle = null)
{
var sb = new StringBuilder();
sb.AppendLine(";FFMETADATA1");
if (!string.IsNullOrWhiteSpace(mainTitle))
sb.AppendLine($"title={mainTitle}");
sb.AppendLine($"title={EscapeFfmetadata(mainTitle)}");
foreach (var ch in chapters)
if (chapters != null)
{
sb.AppendLine("[CHAPTER]");
sb.AppendLine("TIMEBASE=1/1000");
sb.AppendLine($"START={(long)(ch.StartSec * 1000)}");
sb.AppendLine($"END={(long)(ch.EndSec * 1000)}");
sb.AppendLine($"title={ch.Title}");
foreach (var ch in chapters)
{
if (ch == null) continue;
long startMs = double.IsNaN(ch.StartSec) || ch.StartSec < 0 ? 0 : (long)(ch.StartSec * 1000);
long endMs = double.IsNaN(ch.EndSec) || ch.EndSec < startMs / 1000.0 ? startMs : (long)(ch.EndSec * 1000);
sb.AppendLine("[CHAPTER]");
sb.AppendLine("TIMEBASE=1/1000");
sb.AppendLine($"START={startMs}");
sb.AppendLine($"END={endMs}");
sb.AppendLine($"title={EscapeFfmetadata(ch.Title ?? "Chapter")}");
}
}
return sb.ToString();

View file

@ -61,8 +61,9 @@ public sealed class HardwareEncoderDetector
HasAmfAv1: amfAv1);
}
public string GetBestH264Encoder(HardwareCapabilities caps)
public string GetBestH264Encoder(HardwareCapabilities? caps)
{
if (caps == null) return "libx264";
if (caps.HasNvidiaNvenc) return "h264_nvenc";
if (caps.HasAppleVideotoolbox) return "h264_videotoolbox";
if (caps.HasIntelQsv) return "h264_qsv";
@ -70,8 +71,9 @@ public sealed class HardwareEncoderDetector
return "libx264";
}
public string GetBestHevcEncoder(HardwareCapabilities caps)
public string GetBestHevcEncoder(HardwareCapabilities? caps)
{
if (caps == null) return "libx265";
if (caps.HasNvencHevc || caps.HasNvidiaNvenc) return "hevc_nvenc";
if (caps.HasAppleVideotoolbox) return "hevc_videotoolbox";
if (caps.HasQsvHevc || caps.HasIntelQsv) return "hevc_qsv";
@ -79,40 +81,45 @@ public sealed class HardwareEncoderDetector
return "libx265";
}
public string GetBestAv1Encoder(HardwareCapabilities caps)
public string GetBestAv1Encoder(HardwareCapabilities? caps)
{
if (caps == null) return "libsvtav1";
if (caps.HasNvencAv1) return "av1_nvenc";
if (caps.HasQsvAv1) return "av1_qsv";
if (caps.HasAmfAv1) return "av1_amf";
return "libsvtav1";
}
public static string[] BuildEncodingArgs(string encoder, int crfOrCqp = 19, string preset = "fast")
public static string[] BuildEncodingArgs(string? encoder, int crfOrCqp = 19, string? preset = "fast")
{
if (encoder.EndsWith("_nvenc", StringComparison.OrdinalIgnoreCase))
var cleanEncoder = string.IsNullOrWhiteSpace(encoder) ? "libx264" : encoder.Trim();
var cleanPreset = string.IsNullOrWhiteSpace(preset) ? "fast" : preset.Trim();
var safeCrf = Math.Clamp(crfOrCqp, 0, 63);
if (cleanEncoder.EndsWith("_nvenc", StringComparison.OrdinalIgnoreCase))
{
return new[]
{
"-c:v", encoder,
"-preset", preset,
"-cq", crfOrCqp.ToString()
"-c:v", cleanEncoder,
"-preset", cleanPreset,
"-cq", safeCrf.ToString()
};
}
if (encoder.EndsWith("_qsv", StringComparison.OrdinalIgnoreCase))
if (cleanEncoder.EndsWith("_qsv", StringComparison.OrdinalIgnoreCase))
{
return new[]
{
"-c:v", encoder,
"-global_quality", crfOrCqp.ToString()
"-c:v", cleanEncoder,
"-global_quality", safeCrf.ToString()
};
}
return new[]
{
"-c:v", encoder,
"-crf", crfOrCqp.ToString(),
"-preset", preset
"-c:v", cleanEncoder,
"-crf", safeCrf.ToString(),
"-preset", cleanPreset
};
}
}

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform;
namespace Paca.Core.Platform;
public enum ToneMapAlgorithm
{
@ -33,6 +33,8 @@ public sealed class HdrToneMapper
public static string BuildToneMapFilter(ToneMapAlgorithm algo, int targetNits = 100)
{
if (targetNits <= 0) targetNits = 100;
var algoStr = algo switch
{
ToneMapAlgorithm.Mobius => "mobius",
@ -46,33 +48,51 @@ public sealed class HdrToneMapper
public static string[] Build10BitPassthroughArgs(string inputFile, string outputFile)
{
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
return new[]
{
"-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile,
"-i", cleanInput,
"-c:v", "copy",
"-c:a", "copy",
outputFile
cleanOutput
};
}
public async Task<string> ConvertToSdrAsync(string ffmpeg, string inputFile, string outputFile, ToneMapAlgorithm algo, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var filter = BuildToneMapFilter(algo);
var args = new[]
{
"-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile,
"-i", cleanInput,
"-vf", filter,
"-c:v", "libx264", "-crf", "18", "-preset", "fast",
"-c:a", "copy",
outputFile
cleanOutput
};
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct);
if (res.ExitCode != 0)
throw new InvalidOperationException($"SDR 톤매핑 변환 실패: {res.Stderr}");
return outputFile;
return cleanOutput;
}
}

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
namespace Paca.Core.Platform;
@ -29,9 +29,11 @@ public sealed class HighlightShortsClipper
public static IReadOnlyList<HighlightClip> DetectHighlightWindows(IEnumerable<VolumeSample> samples, double windowDurationSec = 15.0, int topCount = 3)
{
if (samples == null) return Array.Empty<HighlightClip>();
if (samples == null || topCount <= 0) return Array.Empty<HighlightClip>();
if (double.IsNaN(windowDurationSec) || windowDurationSec <= 0) windowDurationSec = 15.0;
var sortedByVolume = samples.OrderByDescending(s => s.VolumeDb).ToList();
var valid = samples.Where(s => s != null && !double.IsNaN(s.VolumeDb) && !double.IsNaN(s.TimeSec)).ToList();
var sortedByVolume = valid.OrderByDescending(s => s.VolumeDb).ToList();
var selected = new List<HighlightClip>();
foreach (var s in sortedByVolume)
@ -51,7 +53,16 @@ public sealed class HighlightShortsClipper
public async Task<string> ExportShortsClipAsync(string ffmpeg, string inputFile, string outputFile, HighlightClip clip, bool isVerticalShorts, CancellationToken ct)
{
if (clip == null) throw new ArgumentNullException(nameof(clip));
if (!clip.IsValid()) throw new ArgumentException("유효하지 않은 클립 파라미터입니다.", nameof(clip));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var startStr = clip.StartSec.ToString("F2", CultureInfo.InvariantCulture);
var durStr = clip.DurationSec.ToString("F2", CultureInfo.InvariantCulture);
@ -61,7 +72,7 @@ public sealed class HighlightShortsClipper
"-y", "-hide_banner", "-loglevel", "error",
"-ss", startStr,
"-t", durStr,
"-i", inputFile
"-i", cleanInput
};
if (isVerticalShorts)

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
namespace Paca.Core.Platform;
@ -16,14 +16,23 @@ public sealed class LosslessSmartTrimmer
public static double FindNearestKeyframe(IEnumerable<double> keyframes, double targetSec)
{
if (keyframes == null || !keyframes.Any()) return targetSec;
double safeTarget = double.IsNaN(targetSec) || double.IsInfinity(targetSec) ? 0.0 : Math.Max(0.0, targetSec);
if (keyframes == null) return safeTarget;
return keyframes.OrderBy(k => Math.Abs(k - targetSec)).First();
var valid = keyframes.Where(k => !double.IsNaN(k) && !double.IsInfinity(k) && k >= 0.0).ToList();
if (valid.Count == 0) return safeTarget;
return valid.OrderBy(k => Math.Abs(k - safeTarget)).First();
}
public async Task<string> TrimLosslessAsync(string ffmpeg, string inputFile, string outputFile, double startSec, double endSec, CancellationToken ct)
{
if (startSec < 0 || endSec <= startSec)
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
if (string.Equals(Path.GetFullPath(inputFile), Path.GetFullPath(outputFile), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
if (double.IsNaN(startSec) || double.IsNaN(endSec) || double.IsInfinity(startSec) || double.IsInfinity(endSec) || startSec < 0 || endSec <= startSec)
throw new ArgumentException("시작 시간과 종료 시간이 유효하지 않습니다.");
var startStr = startSec.ToString("F3", CultureInfo.InvariantCulture);

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform;
namespace Paca.Core.Platform;
public sealed class MirrorHealthInfo
{
@ -16,65 +16,87 @@ public sealed class MultiSourceSegmentAccelerator
{
private readonly int _maxFailuresBeforeDrop;
private readonly List<MirrorHealthInfo> _mirrors = new();
private readonly object _syncLock = new();
public MultiSourceSegmentAccelerator(int maxFailuresBeforeDrop = 3)
{
_maxFailuresBeforeDrop = maxFailuresBeforeDrop;
}
public int MirrorCount => _mirrors.Count;
public int MirrorCount
{
get
{
lock (_syncLock) return _mirrors.Count;
}
}
public void AddMirror(string baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl)) return;
if (!_mirrors.Any(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)))
lock (_syncLock)
{
_mirrors.Add(new MirrorHealthInfo
if (!_mirrors.Any(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase)))
{
BaseUrl = baseUrl,
MaxFailures = _maxFailuresBeforeDrop
});
_mirrors.Add(new MirrorHealthInfo
{
BaseUrl = baseUrl,
MaxFailures = _maxFailuresBeforeDrop
});
}
}
}
public void RecordFailure(string baseUrl)
{
var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase));
if (mirror != null)
lock (_syncLock)
{
mirror.FailureCount++;
var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase));
if (mirror != null)
{
mirror.FailureCount++;
}
}
}
public void RecordLatency(string baseUrl, TimeSpan latency)
{
var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase));
if (mirror != null)
lock (_syncLock)
{
mirror.AverageLatencyMs = latency.TotalMilliseconds;
var mirror = _mirrors.FirstOrDefault(m => m.BaseUrl.Equals(baseUrl, StringComparison.OrdinalIgnoreCase));
if (mirror != null)
{
mirror.AverageLatencyMs = latency.TotalMilliseconds;
}
}
}
public string SelectBestMirror(int chunkIndex)
{
if (_mirrors.Count == 0) return "";
var active = _mirrors.Where(m => m.IsActive).ToList();
if (active.Count == 0)
lock (_syncLock)
{
// 모든 미러가 실패 상태이면 첫 번째 미러로 폴백
return _mirrors[0].BaseUrl;
}
if (_mirrors.Count == 0) return "";
int idx = (int)((uint)chunkIndex % active.Count);
return active[idx].BaseUrl;
var active = _mirrors.Where(m => m.IsActive).ToList();
if (active.Count == 0)
{
// 모든 미러가 실패 상태이면 첫 번째 미러로 폴백
return _mirrors[0].BaseUrl;
}
int idx = (int)((uint)chunkIndex % active.Count);
return active[idx].BaseUrl;
}
}
public string? GetFastestMirror()
{
var active = _mirrors.Where(m => m.IsActive).ToList();
if (active.Count == 0) return _mirrors.FirstOrDefault()?.BaseUrl;
lock (_syncLock)
{
var active = _mirrors.Where(m => m.IsActive).ToList();
if (active.Count == 0) return _mirrors.FirstOrDefault()?.BaseUrl;
return active.OrderBy(m => m.AverageLatencyMs).First().BaseUrl;
return active.OrderBy(m => m.AverageLatencyMs).First().BaseUrl;
}
}
}

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform;
namespace Paca.Core.Platform;
/// <summary>
/// 46차: 지능형 대역폭 예측 및 동적 청크 프리패칭 윈도우 엔진.
@ -9,6 +9,7 @@ public sealed class PredictiveChunkPrefetcher
private readonly int _maxWindowSize;
private readonly Queue<double> _speedSamples = new();
private readonly int _maxSamples;
private readonly object _syncLock = new();
public PredictiveChunkPrefetcher(int initialWindowSize = 3, int minWindowSize = 1, int maxWindowSize = 10, int maxSamples = 5)
{
@ -22,18 +23,23 @@ public sealed class PredictiveChunkPrefetcher
if (duration.TotalSeconds <= 0 || chunkSizeBytes <= 0) return;
double speed = chunkSizeBytes / duration.TotalSeconds;
_speedSamples.Enqueue(speed);
while (_speedSamples.Count > _maxSamples)
lock (_syncLock)
{
_speedSamples.Dequeue();
_speedSamples.Enqueue(speed);
while (_speedSamples.Count > _maxSamples)
{
_speedSamples.Dequeue();
}
}
}
public double GetEstimatedSpeedBytesPerSec()
{
if (_speedSamples.Count == 0) return 1_000_000.0; // 기본값 1MB/s
return _speedSamples.Average();
lock (_syncLock)
{
if (_speedSamples.Count == 0) return 1_000_000.0; // 기본값 1MB/s
return _speedSamples.Average();
}
}
public int CalculateOptimalPrefetchWindow()

View file

@ -32,14 +32,21 @@ public sealed class DesktopProcessRunner : IProcessRunner
System.Diagnostics.Process? proc = null;
try
{
var psi = new System.Diagnostics.ProcessStartInfo(spec.ExePath)
var exePath = (spec.ExePath ?? "").Trim().Trim('"');
var psi = new System.Diagnostics.ProcessStartInfo(exePath)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in spec.Args) psi.ArgumentList.Add(a);
if (spec.Args != null)
{
foreach (var a in spec.Args)
{
if (a != null) psi.ArgumentList.Add(a);
}
}
if (spec.EnvPrefix != null)
{
foreach (var (key, prefix) in spec.EnvPrefix)
@ -50,7 +57,7 @@ public sealed class DesktopProcessRunner : IProcessRunner
}
proc = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException($"{spec.ExePath} 시작 실패");
?? throw new InvalidOperationException($"{exePath} 시작 실패");
await using var _ = ct.Register(() =>
{
try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { }
@ -60,8 +67,18 @@ public sealed class DesktopProcessRunner : IProcessRunner
var stderr = new System.Text.StringBuilder();
var outTask = TapAsync(proc.StandardOutput, "out", stdout, onLine, ct);
var errTask = TapAsync(proc.StandardError, "err", stderr, onLine, ct);
await proc.WaitForExitAsync(ct);
await Task.WhenAll(outTask, errTask);
try
{
await proc.WaitForExitAsync(ct);
await Task.WhenAll(outTask, errTask);
}
catch (OperationCanceledException)
{
try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { }
throw;
}
ct.ThrowIfCancellationRequested();
return new ProcessResult(proc.ExitCode, stdout.ToString(), stderr.ToString());
}
finally
@ -74,11 +91,17 @@ public sealed class DesktopProcessRunner : IProcessRunner
private static async Task TapAsync(StreamReader reader, string stream, StringBuilder acc,
Action<string, string>? onLine, CancellationToken ct)
{
string? line;
while ((line = await reader.ReadLineAsync(ct)) != null)
try
{
acc.AppendLine(line);
if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line);
string? line;
while ((line = await reader.ReadLineAsync(ct)) != null)
{
acc.AppendLine(line);
if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line);
}
}
catch (OperationCanceledException) { }
catch (ObjectDisposedException) { }
catch (IOException) { }
}
}

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
namespace Paca.Core.Platform;
@ -11,26 +11,43 @@ public sealed class VideoDeduplicator
{
private readonly int _maxHammingThreshold;
private readonly Dictionary<string, ulong> _registry = new(StringComparer.OrdinalIgnoreCase);
private readonly object _syncLock = new();
public VideoDeduplicator(int maxHammingThreshold = 5)
{
_maxHammingThreshold = maxHammingThreshold;
_maxHammingThreshold = Math.Clamp(maxHammingThreshold, 0, 64);
}
public int RegisteredCount => _registry.Count;
public int RegisteredCount
{
get
{
lock (_syncLock) return _registry.Count;
}
}
public void RegisterVideo(string filePath, ulong pHash)
{
if (string.IsNullOrWhiteSpace(filePath)) return;
_registry[filePath] = pHash;
var cleanPath = filePath.Replace("\"", "").Trim();
lock (_syncLock)
{
_registry[cleanPath] = pHash;
}
}
public DuplicateMatch? FindDuplicate(ulong targetHash)
{
KeyValuePair<string, ulong>[] snapshot;
lock (_syncLock)
{
snapshot = _registry.ToArray();
}
DuplicateMatch? bestMatch = null;
int minDistance = int.MaxValue;
foreach (var (path, hash) in _registry)
foreach (var (path, hash) in snapshot)
{
var distance = ComputeHammingDistance(targetHash, hash);
if (distance <= _maxHammingThreshold && distance < minDistance)
@ -43,7 +60,10 @@ public sealed class VideoDeduplicator
return bestMatch;
}
public void Clear() => _registry.Clear();
public void Clear()
{
lock (_syncLock) _registry.Clear();
}
public static int ComputeHammingDistance(ulong a, ulong b)
{

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform;
namespace Paca.Core.Platform;
public enum DenoiseStrength
{
@ -72,25 +72,33 @@ public sealed class VideoDenoiseUpscaler
public async Task<string> EnhanceVideoAsync(string ffmpeg, string inputFile, string outputFile, DenoiseUpscaleSpec spec, CancellationToken ct)
{
if (spec == null) throw new ArgumentNullException(nameof(spec));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(Path.GetFullPath(cleanInput), Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var filter = BuildCombinedFilter(spec);
var args = new[]
{
"-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile,
"-i", cleanInput,
"-vf", filter,
"-c:v", "libx264",
"-preset", "medium",
"-crf", "18",
"-c:a", "copy",
outputFile
cleanOutput
};
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct);
if (res.ExitCode != 0)
throw new InvalidOperationException($"비디오 화질 개선 실패: {res.Stderr}");
return outputFile;
return cleanOutput;
}
}

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
namespace Paca.Core.Platform;
@ -32,7 +32,7 @@ public sealed class VideoWatermarkBurner
public static string BuildOverlayFilter(WatermarkPosition position, int margin = 10, double opacity = 1.0)
{
if (opacity < 0.0 || opacity > 1.0)
if (double.IsNaN(opacity) || opacity < 0.0 || opacity > 1.0)
throw new ArgumentOutOfRangeException(nameof(opacity), "투명도는 0.0과 1.0 사이여야 합니다.");
string posStr = position switch
@ -51,12 +51,23 @@ public sealed class VideoWatermarkBurner
public static string BuildDrawTextFilter(string text, int fontSize = 24, string fontColor = "white", int x = 10, int y = 10)
{
return $"drawtext=text='{text}':fontsize={fontSize}:fontcolor={fontColor}:x={x}:y={y}";
var escaped = (text ?? "")
.Replace("\r", "")
.Replace("\n", " ")
.Replace(@"\", @"\\")
.Replace("'", @"\'")
.Replace(":", @"\:")
.Replace("%", "%%");
return $"drawtext=text='{escaped}':fontsize={fontSize}:fontcolor={fontColor}:x={x}:y={y}";
}
public async Task<string> ApplyWatermarkAsync(string ffmpeg, string inputFile, string outputFile, WatermarkSpec spec, CancellationToken ct)
{
if (spec == null) throw new ArgumentNullException(nameof(spec));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
if (string.Equals(Path.GetFullPath(inputFile), Path.GetFullPath(outputFile), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var filterComplex = BuildOverlayFilter(spec.Position, spec.Margin, spec.Opacity);

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
namespace Paca.Core.Platform;
@ -67,6 +67,7 @@ public static class VoiceActivitySubtitleAdapter
int cueIndex = 1;
foreach (var seg in segments)
{
if (seg == null) continue;
sb.AppendLine(cueIndex.ToString());
sb.AppendLine($"{FormatTimecode(seg.StartSec)} --> {FormatTimecode(seg.EndSec)}");
sb.AppendLine($"[AI Speech Segment {cueIndex}]");
@ -79,6 +80,12 @@ public static class VoiceActivitySubtitleAdapter
public static string FormatTimecode(double totalSeconds)
{
if (double.IsNaN(totalSeconds) || totalSeconds <= 0.0)
return "00:00:00.000";
if (double.IsInfinity(totalSeconds) || totalSeconds >= 359999.999)
return "99:59:59.999";
var ts = TimeSpan.FromSeconds(totalSeconds);
return string.Format(CultureInfo.InvariantCulture, "{0:D2}:{1:D2}:{2:D2}.{3:D3}",
(int)ts.TotalHours,

View file

@ -1,4 +1,4 @@
namespace Paca.Core.Platform;
namespace Paca.Core.Platform;
public sealed record VrSpatialMetadata(
string Projection,
@ -40,21 +40,29 @@ public sealed class VrSpatialAudioEngine
public async Task<string> InjectVrMetadataAsync(string ffmpeg, string inputFile, string outputFile, VrSpatialMetadata meta, CancellationToken ct)
{
if (meta == null) throw new ArgumentNullException(nameof(meta));
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
var cleanInput = inputFile.Replace("\"", "").Trim();
var cleanOutput = outputFile.Replace("\"", "").Trim();
if (string.Equals(System.IO.Path.GetFullPath(cleanInput), System.IO.Path.GetFullPath(cleanOutput), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
var args = new[]
{
"-y", "-hide_banner", "-loglevel", "error",
"-i", inputFile,
"-i", cleanInput,
"-c", "copy",
"-metadata", $"spherical-video=projection:{meta.Projection}",
"-metadata", $"stereo-mode={meta.StereoMode}",
outputFile
"-metadata", $"spherical-video=projection:{meta.Projection ?? "equirectangular"}",
"-metadata", $"stereo-mode={meta.StereoMode ?? "mono"}",
cleanOutput
};
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), null, ct);
if (res.ExitCode != 0)
throw new InvalidOperationException($"VR 메타데이터 주입 실패: {res.Stderr}");
return outputFile;
return cleanOutput;
}
}

View file

@ -20,10 +20,32 @@ public sealed record ClipboardIntentResult(
public sealed class QuickActionClipboardDetector
{
private static readonly Regex MediaUrlRegex = new(
@"(?:https?://)?(?:www\.)?(?:youtube\.com|youtu\.be|twitter\.com|x\.com|tiktok\.com|instagram\.com|vimeo\.com|facebook\.com|twitch\.tv|bilibili\.com|soundcloud\.com)|(?:\.m3u8|\.mpd|\.mp4|\.m4a|\.webm)(?:\?|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled
);
private static readonly HashSet<string> KnownMediaHosts = new(StringComparer.OrdinalIgnoreCase)
{
"youtube.com", "youtu.be", "twitter.com", "x.com", "tiktok.com",
"instagram.com", "vimeo.com", "facebook.com", "fb.watch",
"twitch.tv", "bilibili.com", "soundcloud.com"
};
private static bool IsMediaHost(string host)
{
if (KnownMediaHosts.Contains(host)) return true;
foreach (var m in KnownMediaHosts)
{
if (host.EndsWith("." + m, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static bool HasMediaExtension(string path)
{
return path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".mpd", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".webm", StringComparison.OrdinalIgnoreCase);
}
private static readonly Regex MathExpressionRegex = new(
@"^[\s\d\.\+\-\*\/\(\)\^\%\=]+$",
@ -32,16 +54,34 @@ public sealed class QuickActionClipboardDetector
public ClipboardIntentResult DetectIntent(string? text)
{
if (string.IsNullOrWhiteSpace(text))
if (string.IsNullOrWhiteSpace(text) || text.Length > 8192)
return new ClipboardIntentResult(ClipboardIntentType.None, null, null, null);
var trimmed = text.Trim();
if (trimmed.Length > 4096)
return new ClipboardIntentResult(ClipboardIntentType.None, trimmed, null, null);
string urlCandidate = trimmed;
if (!urlCandidate.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!urlCandidate.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (urlCandidate.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("youtube.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("youtu.be", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("twitter.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("x.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("tiktok.com", StringComparison.OrdinalIgnoreCase) ||
urlCandidate.StartsWith("instagram.com", StringComparison.OrdinalIgnoreCase))
{
urlCandidate = "https://" + urlCandidate;
}
}
// 1. 미디어 스트림/동영상 URL 감지
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) &&
if (Uri.TryCreate(urlCandidate, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
if (MediaUrlRegex.IsMatch(trimmed))
if (IsMediaHost(uri.Host) || HasMediaExtension(uri.AbsolutePath))
{
return new ClipboardIntentResult(
ClipboardIntentType.MediaDownload,
@ -60,8 +100,9 @@ public sealed class QuickActionClipboardDetector
);
}
// 3. 인라인 수식 감지 (연산자가 포함되어 있고 3글자 이상인 경우)
// 3. 인라인 수식 감지 (숫자가 포함되어 있고 연산자가 포함되어 있는 경우)
if (trimmed.Length >= 3 &&
trimmed.Any(char.IsDigit) &&
(trimmed.Contains('+') || trimmed.Contains('-') || trimmed.Contains('*') || trimmed.Contains('/') || trimmed.Contains('^')) &&
MathExpressionRegex.IsMatch(trimmed))
{

View file

@ -34,28 +34,37 @@ public static class QuickActionFuzzyMatcher
if (string.IsNullOrEmpty(s)) return t?.Length ?? 0;
if (string.IsNullOrEmpty(t)) return s.Length;
if (s.Length > 512) s = s[..512];
if (t.Length > 512) t = t[..512];
string sLower = s.ToLowerInvariant();
string tLower = t.ToLowerInvariant();
int n = sLower.Length;
int m = tLower.Length;
int[,] d = new int[n + 1, m + 1];
for (int i = 0; i <= n; i++) d[i, 0] = i;
for (int j = 0; j <= m; j++) d[0, j] = j;
int[] prev = new int[m + 1];
int[] curr = new int[m + 1];
for (int j = 0; j <= m; j++) prev[j] = j;
for (int i = 1; i <= n; i++)
{
curr[0] = i;
char sChar = sLower[i - 1];
for (int j = 1; j <= m; j++)
{
int cost = (sLower[i - 1] == tLower[j - 1]) ? 0 : 1;
d[i, j] = Math.Min(
Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1),
d[i - 1, j - 1] + cost);
int cost = (sChar == tLower[j - 1]) ? 0 : 1;
curr[j] = Math.Min(
Math.Min(curr[j - 1] + 1, prev[j] + 1),
prev[j - 1] + cost);
}
Array.Copy(curr, prev, m + 1);
}
return d[n, m];
return prev[m];
}
public static int CalculateFuzzyScore(string? pattern, string? target)

View file

@ -41,8 +41,8 @@ public sealed class QuickActionInlineEvaluator
return true;
}
// Basic arithmetic: e.g. "12 + 34", "100 * 5", "2 ^ 10", "1000 - 350", "10 / 4"
var opMatch = Regex.Match(expr, @"^([0-9\.]+)\s*([\+\-\*\/\^])\s*([0-9\.]+)$");
// Basic arithmetic: e.g. "12 + 34", "-5 + 10", "100 * 5", "2 ^ 10", "1000 - 350", "10 / 4"
var opMatch = Regex.Match(expr, @"^([\+\-]?[0-9\.]+)\s*([\+\-\*\/\^])\s*([\+\-]?[0-9\.]+)$");
if (opMatch.Success &&
double.TryParse(opMatch.Groups[1].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double a) &&
double.TryParse(opMatch.Groups[3].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out double b))
@ -89,6 +89,10 @@ public sealed class QuickActionInlineEvaluator
{
byte[] bytes = Convert.FromBase64String(raw);
string text = Encoding.UTF8.GetString(bytes);
if (text.Contains('\0'))
{
text = $"[바이너리 데이터 ({bytes.Length} 바이트)] " + Convert.ToHexString(bytes);
}
result = new EvaluatorResult(text, "Base64 디코딩 결과", "dev.base64_decode");
return true;
}
@ -109,6 +113,10 @@ public sealed class QuickActionInlineEvaluator
{
string hex = query.StartsWith("hex ", StringComparison.OrdinalIgnoreCase) ? query[4..].Trim() : query.Trim();
hex = hex.TrimStart('#');
if (hex.Length == 3)
{
hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}";
}
if (hex.Length == 6 &&
byte.TryParse(hex[0..2], NumberStyles.HexNumber, null, out byte r) &&
byte.TryParse(hex[2..4], NumberStyles.HexNumber, null, out byte g) &&

View file

@ -73,8 +73,8 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE8AC",
Handler: ctx =>
{
ctx.ClipboardSetter?.Invoke(encoded);
ctx.ToastNotifier?.Invoke("Base64 복사 완료");
ctx?.ClipboardSetter?.Invoke(encoded);
ctx?.ToastNotifier?.Invoke("Base64 복사 완료");
return Task.FromResult(new QuickActionResult(true, OutputValue: encoded));
},
Description: $"'{text}' ➔ Base64로 인코딩하여 클립보드에 복사합니다."
@ -109,8 +109,8 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE8AC",
Handler: ctx =>
{
ctx.ClipboardSetter?.Invoke(decoded);
ctx.ToastNotifier?.Invoke("디코딩 복사 완료");
ctx?.ClipboardSetter?.Invoke(decoded);
ctx?.ToastNotifier?.Invoke("디코딩 복사 완료");
return Task.FromResult(new QuickActionResult(true, OutputValue: decoded));
},
Description: $"Base64 문자열을 디코딩하여 클립보드에 복사합니다."
@ -142,8 +142,8 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE943",
Handler: ctx =>
{
ctx.ClipboardSetter?.Invoke(hex);
ctx.ToastNotifier?.Invoke("SHA-256 해시 복사 완료");
ctx?.ClipboardSetter?.Invoke(hex);
ctx?.ToastNotifier?.Invoke("SHA-256 해시 복사 완료");
return Task.FromResult(new QuickActionResult(true, OutputValue: hex));
},
Description: $"'{text}'에 대한 전체 SHA-256 해시값을 클립보드에 복사합니다."
@ -167,7 +167,7 @@ public sealed class QuickActionParameterRouter
IconGlyph: "\uE70B",
Handler: ctx =>
{
ctx.ToastNotifier?.Invoke($"메모 저장됨: {text}");
ctx?.ToastNotifier?.Invoke($"메모 저장됨: {text}");
return Task.FromResult(new QuickActionResult(true, OutputValue: text));
},
Description: $"'{text}' 텍스트를 PACA 빠른 메모에 즉시 기록합니다."

View file

@ -36,38 +36,44 @@ public sealed class CrdtVaultSyncEngine<T>
public CrdtItemRecord<T> Set(string id, T value)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Item ID cannot be null or whitespace.", nameof(id));
lock (_lock)
{
_currentLamportClock++;
var record = new CrdtItemRecord<T>
{
Id = id,
Id = id.Trim(),
Value = value,
LamportTimestamp = _currentLamportClock,
ClientId = _clientId,
IsDeleted = false,
UtcTimestamp = DateTime.UtcNow
};
_items[id] = record;
_items[record.Id] = record;
return record;
}
}
public CrdtItemRecord<T> Delete(string id)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Item ID cannot be null or whitespace.", nameof(id));
lock (_lock)
{
_currentLamportClock++;
var record = new CrdtItemRecord<T>
{
Id = id,
Id = id.Trim(),
Value = default,
LamportTimestamp = _currentLamportClock,
ClientId = _clientId,
IsDeleted = true,
UtcTimestamp = DateTime.UtcNow
};
_items[id] = record;
_items[record.Id] = record;
return record;
}
}
@ -93,11 +99,18 @@ public sealed class CrdtVaultSyncEngine<T>
public int Merge(IEnumerable<CrdtItemRecord<T>> remoteRecords)
{
if (remoteRecords == null) return 0;
int appliedCount = 0;
lock (_lock)
{
foreach (var remote in remoteRecords)
{
if (remote == null || string.IsNullOrWhiteSpace(remote.Id))
continue;
if (remote.LamportTimestamp < 0)
remote.LamportTimestamp = 0;
if (!_items.TryGetValue(remote.Id, out var local))
{
_items[remote.Id] = remote;

View file

@ -25,7 +25,8 @@ public static class CredentialDataExchange
/// </summary>
public static byte[] ExportToEncryptedVault(IReadOnlyList<SiteCredentialRecord> records, string masterPassword)
{
if (string.IsNullOrEmpty(masterPassword))
ArgumentNullException.ThrowIfNull(records);
if (string.IsNullOrWhiteSpace(masterPassword))
throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword));
var exportItems = records.Select(r => new
@ -110,7 +111,7 @@ public static class CredentialDataExchange
byte[] tag = br.ReadBytes(TagSizeBytes);
int cipherLen = br.ReadInt32();
if (cipherLen <= 0 || cipherLen > 50 * 1024 * 1024) // 50MB 상한
if (cipherLen <= 0 || cipherLen > 50 * 1024 * 1024 || cipherLen > (ms.Length - ms.Position)) // 50MB 상한 및 잔여 바이트 검증
throw new InvalidDataException("암호문 크기가 유효 범위를 벗어났습니다.");
byte[] ciphertext = br.ReadBytes(cipherLen);
@ -179,15 +180,18 @@ public static class CredentialDataExchange
var sb = new StringBuilder();
sb.AppendLine("name,url,username,password,note,totp,category");
if (records == null) return sb.ToString();
foreach (var r in records)
{
string name = EscapeCsv(r.SiteName.Length > 0 ? r.SiteName : r.Domain);
string url = EscapeCsv(r.Url.Length > 0 ? r.Url : "https://" + r.Domain);
string user = EscapeCsv(r.Username);
string pass = EscapeCsv(!string.IsNullOrEmpty(r.DecryptedPassword) ? r.DecryptedPassword : r.EncryptedPassword);
string note = EscapeCsv(r.Notes);
string totp = EscapeCsv(r.TotpSecret ?? "");
string cat = EscapeCsv(r.Category);
if (r == null) continue;
string name = EscapeCsv(SanitizeCsvFormula(r.SiteName.Length > 0 ? r.SiteName : r.Domain));
string url = EscapeCsv(SanitizeCsvFormula(r.Url.Length > 0 ? r.Url : "https://" + r.Domain));
string user = EscapeCsv(SanitizeCsvFormula(r.Username));
string pass = EscapeCsv(SanitizeCsvFormula(!string.IsNullOrEmpty(r.DecryptedPassword) ? r.DecryptedPassword : r.EncryptedPassword));
string note = EscapeCsv(SanitizeCsvFormula(r.Notes));
string totp = EscapeCsv(SanitizeCsvFormula(r.TotpSecret ?? ""));
string cat = EscapeCsv(SanitizeCsvFormula(r.Category));
sb.AppendLine($"{name},{url},{user},{pass},{note},{totp},{cat}");
}
@ -216,13 +220,13 @@ public static class CredentialDataExchange
var cols = ParseCsvLine(line);
if (cols.Count < 3) continue;
string name = cols[0];
string url = cols[1];
string user = cols[2];
string pass = cols.Count > 3 ? cols[3] : "";
string note = cols.Count > 4 ? cols[4] : "";
string totp = cols.Count > 5 ? cols[5] : "";
string cat = cols.Count > 6 ? cols[6] : "General";
string name = UnsanitizeCsvFormula(cols[0]);
string url = UnsanitizeCsvFormula(cols[1]);
string user = UnsanitizeCsvFormula(cols[2]);
string pass = cols.Count > 3 ? UnsanitizeCsvFormula(cols[3]) : "";
string note = cols.Count > 4 ? UnsanitizeCsvFormula(cols[4]) : "";
string totp = cols.Count > 5 ? UnsanitizeCsvFormula(cols[5]) : "";
string cat = cols.Count > 6 ? UnsanitizeCsvFormula(cols[6]) : "General";
var rec = new SiteCredentialRecord
{
@ -241,6 +245,27 @@ public static class CredentialDataExchange
return list;
}
private static string UnsanitizeCsvFormula(string? text)
{
if (string.IsNullOrEmpty(text)) return "";
if (text.StartsWith("'") && text.Length > 1 && text[1] is '=' or '+' or '-' or '@' or '\t' or '\r')
{
return text[1..];
}
return text;
}
private static string SanitizeCsvFormula(string? text)
{
if (string.IsNullOrEmpty(text)) return "";
char first = text[0];
if (first is '=' or '+' or '-' or '@' or '\t' or '\r')
{
return "'" + text;
}
return text;
}
private static string EscapeCsv(string text)
{
if (string.IsNullOrEmpty(text)) return "";

View file

@ -31,18 +31,25 @@ public sealed record VaultSecurityAuditReport(
/// </summary>
public static class CredentialSecurityAuditor
{
public static VaultSecurityAuditReport PerformAudit(IReadOnlyList<SiteCredentialRecord> credentials, int oldPasswordDays = 90)
public static VaultSecurityAuditReport PerformAudit(IReadOnlyList<SiteCredentialRecord?>? credentials, int oldPasswordDays = 90)
{
var issues = new List<VaultAuditIssue>();
int total = credentials.Count;
if (total == 0)
if (credentials == null || credentials.Count == 0)
{
return new VaultSecurityAuditReport(100, 0, 0, 0, 0, 0, issues, DateTime.UtcNow);
}
var cleanList = credentials.Where(c => c != null).Cast<SiteCredentialRecord>().ToList();
if (cleanList.Count == 0)
{
return new VaultSecurityAuditReport(100, 0, 0, 0, 0, 0, issues, DateTime.UtcNow);
}
oldPasswordDays = Math.Max(1, oldPasswordDays);
int total = cleanList.Count;
// 1. 패스워드 재사용 검사
var passwordGroups = credentials
var passwordGroups = cleanList
.Where(c => !string.IsNullOrEmpty(c.DecryptedPassword) || !string.IsNullOrEmpty(c.EncryptedPassword))
.GroupBy(c => !string.IsNullOrEmpty(c.DecryptedPassword) ? c.DecryptedPassword : c.EncryptedPassword)
.Where(g => g.Count() > 1)
@ -66,7 +73,7 @@ public static class CredentialSecurityAuditor
// 2. 취약한 패스워드 검사
int weakCount = 0;
foreach (var item in credentials)
foreach (var item in cleanList)
{
if (!string.IsNullOrEmpty(item.DecryptedPassword))
{
@ -88,11 +95,11 @@ public static class CredentialSecurityAuditor
// 3. 오래된 비밀번호 검사 (기본 90일)
int oldCount = 0;
var thresholdDate = DateTime.UtcNow.AddDays(-oldPasswordDays);
foreach (var item in credentials)
foreach (var item in cleanList)
{
if (item.LastModifiedAt < thresholdDate || item.CreatedAt < thresholdDate)
DateTime effectiveDate = item.LastModifiedAt != default ? item.LastModifiedAt : item.CreatedAt;
if (effectiveDate < thresholdDate)
{
DateTime effectiveDate = item.CreatedAt < thresholdDate ? item.CreatedAt : item.LastModifiedAt;
oldCount++;
issues.Add(new VaultAuditIssue(
item.Id, item.Domain, item.Username,
@ -104,8 +111,8 @@ public static class CredentialSecurityAuditor
// 4. 2FA 미설정 검사 (금융/쇼핑/이메일 등 주요 사이트 또는 전체 미설정)
int missing2FaCount = 0;
bool hasSpecificHighSecurityCategory = credentials.Any(c => (c.Category is "Banking" or "Shopping" or "Email") || c.IsFavorite);
foreach (var item in credentials)
bool hasSpecificHighSecurityCategory = cleanList.Any(c => (c.Category is "Banking" or "Shopping" or "Email") || c.IsFavorite);
foreach (var item in cleanList)
{
if (string.IsNullOrEmpty(item.TotpSecret))
{

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@ -29,6 +30,10 @@ public static class E2eeRemoteBackupProvider
public static string EncryptPayload(string plainText, string masterPassword)
{
if (string.IsNullOrWhiteSpace(masterPassword))
throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword));
plainText ??= "";
byte[] salt = RandomNumberGenerator.GetBytes(SaltSizeBytes);
byte[] nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
byte[] key = Rfc2898DeriveBytes.Pbkdf2(
@ -42,9 +47,17 @@ public static class E2eeRemoteBackupProvider
byte[] cipherBytes = new byte[plainBytes.Length];
byte[] tag = new byte[TagSizeBytes];
using (var aesGcm = new AesGcm(key, TagSizeBytes))
try
{
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
using (var aesGcm = new AesGcm(key, TagSizeBytes))
{
aesGcm.Encrypt(nonce, plainBytes, cipherBytes, tag);
}
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plainBytes);
}
var header = new VaultBackupHeader
@ -60,13 +73,43 @@ public static class E2eeRemoteBackupProvider
public static string DecryptPayload(string backupJson, string masterPassword)
{
var header = JsonSerializer.Deserialize<VaultBackupHeader>(backupJson)
?? throw new InvalidOperationException("손상된 백업 파일 헤더입니다.");
if (string.IsNullOrWhiteSpace(backupJson))
throw new InvalidDataException("손상되었거나 비어있는 백업 페이로드입니다.");
byte[] salt = Convert.FromBase64String(header.SaltBase64);
byte[] nonce = Convert.FromBase64String(header.NonceBase64);
byte[] tag = Convert.FromBase64String(header.TagBase64);
byte[] cipherBytes = Convert.FromBase64String(header.CiphertextBase64);
if (string.IsNullOrWhiteSpace(masterPassword))
throw new ArgumentException("마스터 비밀번호를 입력해야 합니다.", nameof(masterPassword));
VaultBackupHeader header;
try
{
header = JsonSerializer.Deserialize<VaultBackupHeader>(backupJson)
?? throw new InvalidDataException("손상된 백업 파일 헤더입니다.");
}
catch (Exception ex) when (ex is not InvalidDataException)
{
throw new InvalidDataException("손상된 백업 파일 JSON 형식입니다.", ex);
}
if (header.Magic != "PACAVAULT")
throw new InvalidDataException("유효하지 않은 백업 매직 헤더입니다.");
byte[] salt, nonce, tag, cipherBytes;
try
{
salt = Convert.FromBase64String(header.SaltBase64 ?? "");
nonce = Convert.FromBase64String(header.NonceBase64 ?? "");
tag = Convert.FromBase64String(header.TagBase64 ?? "");
cipherBytes = Convert.FromBase64String(header.CiphertextBase64 ?? "");
}
catch (FormatException ex)
{
throw new InvalidDataException("손상된 Base64 인코딩 데이터입니다.", ex);
}
if (salt.Length != SaltSizeBytes || nonce.Length != NonceSizeBytes || tag.Length != TagSizeBytes)
{
throw new InvalidDataException("암호화 파라미터 규격이 일치하지 않습니다.");
}
byte[] key = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(masterPassword),
@ -76,11 +119,18 @@ public static class E2eeRemoteBackupProvider
KeySizeBytes);
byte[] plainBytes = new byte[cipherBytes.Length];
using (var aesGcm = new AesGcm(key, TagSizeBytes))
try
{
aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes);
using (var aesGcm = new AesGcm(key, TagSizeBytes))
{
aesGcm.Decrypt(nonce, cipherBytes, tag, plainBytes);
}
return Encoding.UTF8.GetString(plainBytes);
}
finally
{
CryptographicOperations.ZeroMemory(key);
CryptographicOperations.ZeroMemory(plainBytes);
}
return Encoding.UTF8.GetString(plainBytes);
}
}

View file

@ -145,7 +145,7 @@ public static class PasswordSecurityEngine
public static PasswordStrengthResult EvaluateStrength(string? password)
{
if (string.IsNullOrEmpty(password))
if (string.IsNullOrWhiteSpace(password))
{
return new PasswordStrengthResult(
0, PasswordGrade.VeryWeak, 0,
@ -224,6 +224,9 @@ public static class TotpEngine
DateTime? customTime = null,
TotpHashAlgorithm algorithm = TotpHashAlgorithm.Sha1)
{
if (timeStepSeconds <= 0) timeStepSeconds = 30;
digits = Math.Clamp(digits, 6, 8);
byte[] key = DecodeBase32(base32Secret);
if (key.Length == 0) return "".PadLeft(digits, '0');
@ -262,10 +265,11 @@ public static class TotpEngine
public static int GetRemainingSeconds(int timeStepSeconds = 30, DateTime? customTime = null)
{
if (timeStepSeconds <= 0) timeStepSeconds = 30;
DateTime time = customTime ?? DateTime.UtcNow;
long seconds = (long)(time - UnixEpoch).TotalSeconds;
int remaining = timeStepSeconds - (int)(seconds % timeStepSeconds);
return remaining == 0 ? timeStepSeconds : remaining;
return remaining <= 0 ? timeStepSeconds : remaining;
}
public static bool ValidateCode(

View file

@ -51,15 +51,18 @@ public class ThermalThrottlingGuard
if (IsThrottled(currentTemperatureCelsius))
return 1; // 과열 시 단일 세그먼트로 스로틀링
return requestedSegments;
return Math.Max(1, requestedSegments);
}
}
/// <summary>FFmpeg 트랜스코딩 프로파일 관리자</summary>
public class TranscodeProfileManager
{
public string GetFfmpegProfileArgs(string profileName)
public string GetFfmpegProfileArgs(string? profileName)
{
if (string.IsNullOrWhiteSpace(profileName))
return "-c copy";
return profileName.ToLowerInvariant() switch
{
"mp4_universal" => "-c:v libx264 -preset medium -crf 23 -c:a aac -b:a 192k -movflags +faststart",
@ -93,12 +96,25 @@ public class RemoteStreamProxyBuffer
CapacityBytes = capacityBytes;
}
public void Write(byte[] data)
public void Write(byte[]? data)
{
if (data == null || data.Length == 0) return;
lock (_lock)
{
_stream.Seek(0, SeekOrigin.End);
_stream.Write(data, 0, data.Length);
// CapacityBytes 초과 시 가장 오래된 앞부분 바이트 제거 (FIFO 한도 준수)
if (CapacityBytes > 0 && _stream.Length > CapacityBytes)
{
long excess = _stream.Length - CapacityBytes;
_stream.Seek(excess, SeekOrigin.Begin);
byte[] kept = new byte[CapacityBytes];
int read = _stream.Read(kept, 0, CapacityBytes);
_stream.SetLength(0);
_stream.Write(kept, 0, read);
}
}
}

View file

@ -28,12 +28,32 @@ public static class SubtitleBurnInEngine
/// </summary>
public static string BuildForceStyleString(SubtitleBurnInStyle style)
{
return $"FontName={style.FontName},FontSize={style.FontSize},PrimaryColour={style.PrimaryColor},OutlineColour={style.OutlineColor},Outline={style.OutlineWidth},MarginV={style.MarginV},Alignment={style.Alignment}";
if (style == null)
throw new ArgumentNullException(nameof(style));
if (style.FontSize <= 0)
throw new ArgumentOutOfRangeException(nameof(style), "Font size must be greater than zero.");
var safeFontName = (style.FontName ?? "Arial")
.Replace(",", "")
.Replace(";", "")
.Replace("'", "")
.Replace("\r", "")
.Replace("\n", "")
.Trim();
if (string.IsNullOrWhiteSpace(safeFontName))
safeFontName = "Arial";
var safePrimary = (style.PrimaryColor ?? "&H00FFFFFF").Replace(",", "").Replace(";", "").Trim();
var safeOutline = (style.OutlineColor ?? "&H00000000").Replace(",", "").Replace(";", "").Trim();
int safeOutlineWidth = Math.Max(0, style.OutlineWidth);
int safeMarginV = Math.Max(0, style.MarginV);
return $"FontName={safeFontName},FontSize={style.FontSize},PrimaryColour={safePrimary},OutlineColour={safeOutline},Outline={safeOutlineWidth},MarginV={safeMarginV},Alignment={style.Alignment}";
}
/// <summary>
/// Escapes Windows/POSIX file paths for use inside ffmpeg filter expressions.
/// Converts colons to '\:' and backslashes to forward slashes.
/// Converts colons to '\:', backslashes to forward slashes, and brackets to '\[' / '\]'.
/// </summary>
public static string EscapeSubtitlePathForFfmpeg(string path)
{
@ -43,7 +63,9 @@ public static class SubtitleBurnInEngine
return path
.Replace("\\", "/")
.Replace(":", "\\:")
.Replace("'", "\\'");
.Replace("'", "\\'")
.Replace("[", "\\[")
.Replace("]", "\\]");
}
/// <summary>
@ -55,6 +77,15 @@ public static class SubtitleBurnInEngine
string outputVideoPath,
SubtitleBurnInStyle? style = null)
{
if (string.IsNullOrWhiteSpace(inputVideoPath))
throw new ArgumentException("Input video path cannot be null or empty.", nameof(inputVideoPath));
if (string.IsNullOrWhiteSpace(subtitlePath))
throw new ArgumentException("Subtitle path cannot be null or empty.", nameof(subtitlePath));
if (string.IsNullOrWhiteSpace(outputVideoPath))
throw new ArgumentException("Output video path cannot be null or empty.", nameof(outputVideoPath));
if (string.Equals(inputVideoPath.Trim(), outputVideoPath.Trim(), StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Input and output paths cannot be identical.", nameof(outputVideoPath));
style ??= SubtitleBurnInStyle.Default;
var escapedPath = EscapeSubtitlePathForFfmpeg(subtitlePath);
var forceStyle = BuildForceStyleString(style);

View file

@ -25,38 +25,58 @@ public static class FileNameUtil
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
};
private static bool IsBidiOrInvisible(char c)
{
// Bidi override / isolate / embedding (Trojan Source CVE-2021-42574)
if (c is >= '\u200B' and <= '\u200F' or >= '\u202A' and <= '\u202E' or >= '\u2066' and <= '\u2069' or '\uFEFF')
return true;
return System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format;
}
private static string EnsureNotReserved(string name)
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(name);
var rootToken = name.Split('.')[0];
if (ReservedNames.Contains(nameWithoutExt) || ReservedNames.Contains(rootToken))
{
return "_" + name;
}
return name;
}
/// <summary>파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다.</summary>
public static string Sanitize(string? name, int maxLen = 120)
{
if (maxLen <= 0) return "video";
if (string.IsNullOrWhiteSpace(name)) return "video";
if (string.IsNullOrWhiteSpace(name.Trim().Trim('.'))) return "video";
var extra = new[] { '"', '<', '>', '|', '*', '?', ':', '/', '\\' };
var sb = new StringBuilder();
foreach (var c in name.Trim())
{
if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0 || char.IsControl(c)) sb.Append('_');
else sb.Append(c);
if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0 || char.IsControl(c) || IsBidiOrInvisible(c))
sb.Append('_');
else
sb.Append(c);
}
var result = sb.ToString().Trim().Trim('.');
if (string.IsNullOrWhiteSpace(result)) result = "video";
// Windows 장치 예약어 보호 (예: CON, aux.mp4, con.tar.gz)
var nameWithoutExt = Path.GetFileNameWithoutExtension(result);
var rootToken = result.Split('.')[0];
if (ReservedNames.Contains(nameWithoutExt) || ReservedNames.Contains(rootToken))
{
result = "_" + result;
}
if (result.Length > maxLen)
{
var cut = result[..maxLen];
// UTF-16 상위 서러게이트로 끝나면 1글자 줄여서 이모지/특수문자 파편화 방지
if (char.IsHighSurrogate(cut[^1]))
if (cut.Length > 0 && char.IsHighSurrogate(cut[^1]))
{
cut = cut[..^1];
}
return cut.Trim().Trim('.');
result = cut.Trim().Trim('.');
if (string.IsNullOrWhiteSpace(result)) result = "video";
}
// 절단 및 공백 정제 후 최종 윈도우 장치 예약어 보호 (예: CON_video[..3] -> CON -> _CON, con.tar.gz -> _con.tar.gz)
result = EnsureNotReserved(result);
return result;
}

View file

@ -1,4 +1,4 @@
using System.IO;
using System.IO;
namespace Paca.Core.Utils;
@ -9,6 +9,7 @@ public static class DiskSpaceGuardian
{
public static bool HasSufficientSpace(string targetPath, long requiredBytes)
{
if (requiredBytes <= 0 || string.IsNullOrWhiteSpace(targetPath)) return true;
try
{
var fullPath = Path.GetFullPath(targetPath);
@ -26,13 +27,41 @@ public static class DiskSpaceGuardian
}
}
public static bool IsDiskFullException(Exception ex)
public static bool IsDiskFullException(Exception? ex)
{
if (ex is not IOException ioEx) return false;
var msg = ioEx.Message.ToLowerInvariant();
return msg.Contains("not enough space") ||
msg.Contains("disk full") ||
msg.Contains("디스크 공간이 부족") ||
msg.Contains("공간이 부족");
if (ex == null) return false;
if (ex is AggregateException agg)
{
foreach (var inner in agg.Flatten().InnerExceptions)
{
if (IsDiskFullException(inner))
return true;
}
return false;
}
if (ex is IOException ioEx)
{
// Windows Win32 error codes: ERROR_DISK_FULL (112), ERROR_HANDLE_DISK_FULL (39)
int win32ErrorCode = ioEx.HResult & 0xFFFF;
if (win32ErrorCode is 112 or 39)
return true;
var msg = ioEx.Message.ToLowerInvariant();
if (msg.Contains("not enough space") ||
msg.Contains("disk full") ||
msg.Contains("디스크 공간이 부족") ||
msg.Contains("공간이 부족") ||
msg.Contains("espace disque insuffisant"))
{
return true;
}
}
if (ex.InnerException != null && IsDiskFullException(ex.InnerException))
return true;
return false;
}
}

View file

@ -47,9 +47,21 @@ public static class VideoAspectAutoCropper
int targetWidth = 1080,
int targetHeight = 1920)
{
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("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
targetWidth = Math.Max(2, targetWidth / 2 * 2);
targetHeight = Math.Max(2, targetHeight / 2 * 2);
var filter = BuildReframingFilterArgs(mode, targetWidth, targetHeight);
var filterFlag = mode == ReframingMode.BlurredBackground ? "-filter_complex" : "-vf";
return $"-i \"{inputPath}\" {filterFlag} \"{filter}\" -c:v libx264 -crf 20 -preset fast -c:a copy \"{outputPath}\" -y";
return $"-i \"{cleanIn}\" {filterFlag} \"{filter}\" -c:v libx264 -crf 20 -preset fast -c:a copy \"{cleanOut}\" -y";
}
}

View file

@ -56,9 +56,18 @@ public static class VideoDenoiseEnhancer
bool sharpen,
DenoisePreset preset = DenoisePreset.FastTemporalSpatial)
{
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("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
var filter = BuildEnhanceFilterChain(denoise, sharpen, preset);
var filterArg = !string.IsNullOrEmpty(filter) ? $"-vf \"{filter}\"" : string.Empty;
return $"-i \"{inputPath}\" {filterArg} -c:v libx264 -crf 20 -preset fast -c:a copy \"{outputPath}\" -y";
return $"-i \"{cleanIn}\" {filterArg} -c:v libx264 -crf 20 -preset fast -c:a copy \"{cleanOut}\" -y";
}
}

View file

@ -1,4 +1,4 @@
using System;
using System;
namespace Paca.Core;
@ -23,7 +23,7 @@ public static class VideoFrameRateConverter
/// </summary>
public static string BuildFpsFilter(int targetFps, FrameRateConversionMode mode)
{
if (targetFps <= 0) targetFps = 60;
targetFps = Math.Clamp(targetFps, 1, 240);
return mode switch
{
@ -38,7 +38,17 @@ public static class VideoFrameRateConverter
/// </summary>
public static string BuildFfmpegArgs(string inputPath, string outputPath, int targetFps, FrameRateConversionMode mode, int crf = 18)
{
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("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
crf = Math.Clamp(crf, 0, 51);
var filter = BuildFpsFilter(targetFps, mode);
return $"-hide_banner -y -i \"{inputPath}\" -vf \"{filter}\" -c:v libx264 -crf {crf} -preset fast -c:a copy \"{outputPath}\"";
return $"-hide_banner -y -i \"{cleanIn}\" -vf \"{filter}\" -c:v libx264 -crf {crf} -preset fast -c:a copy \"{cleanOut}\"";
}
}

View file

@ -79,9 +79,21 @@ public static class VideoIntroOutroTrimmer
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 \"{inputPath}\" -t {d} -c copy \"{outputPath}\" -y";
return $"-ss {s} -i \"{cleanIn}\" -t {d} -c copy \"{cleanOut}\" -y";
}
}

View file

@ -65,6 +65,15 @@ public static class VideoPosterExtractor
string outputImagePath,
PosterExtractionSpec spec)
{
if (string.IsNullOrWhiteSpace(inputVideoPath)) throw new ArgumentException("Input video path cannot be null or whitespace.", nameof(inputVideoPath));
if (string.IsNullOrWhiteSpace(outputImagePath)) throw new ArgumentException("Output image path cannot be null or whitespace.", nameof(outputImagePath));
var cleanIn = inputVideoPath.Trim().Replace("\"", "");
var cleanOut = outputImagePath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
var seekStr = spec.SeekTimeSec.ToString("F2", CultureInfo.InvariantCulture);
var scaleFilter = BuildScalingFilter(spec);
@ -75,6 +84,6 @@ public static class VideoPosterExtractor
_ => "-q:v 2"
};
return $"-ss {seekStr} -i \"{inputVideoPath}\" -vframes 1 -vf \"{scaleFilter}\" {encoderFlags} \"{outputImagePath}\" -y";
return $"-ss {seekStr} -i \"{cleanIn}\" -vframes 1 -vf \"{scaleFilter}\" {encoderFlags} \"{cleanOut}\" -y";
}
}

View file

@ -31,9 +31,19 @@ public static class VideoSpriteSheetGenerator
string outputJpgPath,
SpriteSheetConfig config)
{
if (string.IsNullOrWhiteSpace(inputVideoPath)) throw new ArgumentException("Input video path cannot be null or whitespace.", nameof(inputVideoPath));
if (string.IsNullOrWhiteSpace(outputJpgPath)) throw new ArgumentException("Output JPG path cannot be null or whitespace.", nameof(outputJpgPath));
var cleanIn = inputVideoPath.Trim().Replace("\"", "");
var cleanOut = outputJpgPath.Trim().Replace("\"", "");
if (string.Equals(System.IO.Path.GetFullPath(cleanIn), System.IO.Path.GetFullPath(cleanOut), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
if (config.IntervalSec <= 0) throw new ArgumentOutOfRangeException(nameof(config.IntervalSec), "Interval must be positive.");
if (config.Columns <= 0 || config.Rows <= 0) throw new ArgumentOutOfRangeException(nameof(config), "Columns and Rows must be positive.");
return $"-i \"{inputVideoPath}\" -vf \"fps=1/{config.IntervalSec},scale={config.ThumbWidth}:{config.ThumbHeight},tile={config.Columns}x{config.Rows}\" -frames:v 1 \"{outputJpgPath}\" -y";
return $"-i \"{cleanIn}\" -vf \"fps=1/{config.IntervalSec},scale={config.ThumbWidth}:{config.ThumbHeight},tile={config.Columns}x{config.Rows}\" -frames:v 1 \"{cleanOut}\" -y";
}
/// <summary>

View file

@ -87,6 +87,19 @@ public static class VideoTonemapEnhancer
string videoEncoder = "libx264",
string presetSpeed = "fast")
{
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("입력 파일과 출력 파일 경로가 동일할 수 없습니다.");
crf = Math.Clamp(crf, 0, 51);
videoEncoder = (videoEncoder ?? "libx264").Replace("\"", "").Replace(";", "").Trim();
presetSpeed = (presetSpeed ?? "fast").Replace("\"", "").Replace(";", "").Trim();
var filter = BuildCombinedFilter(algorithm, preset);
string encoderParams;
@ -107,6 +120,6 @@ public static class VideoTonemapEnhancer
encoderParams = $"-c:v {videoEncoder} -crf {crf} -preset {presetSpeed}";
}
return $"-hide_banner -y -i \"{inputPath}\" -vf \"{filter}\" {encoderParams} -pix_fmt yuv420p -c:a copy \"{outputPath}\"";
return $"-hide_banner -y -i \"{cleanIn}\" -vf \"{filter}\" {encoderParams} -pix_fmt yuv420p -c:a copy \"{cleanOut}\"";
}
}

View file

@ -1,4 +1,4 @@
using System.Security.Cryptography;
using System.Security.Cryptography;
namespace Paca.Server;
@ -12,7 +12,7 @@ public static class MediaLibrary
var result = new List<LibraryItemDto>();
try
{
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) return result;
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) return result;
foreach (var f in Directory.EnumerateFiles(folder).OrderByDescending(f => File.GetLastWriteTimeUtc(f)))
{
var ext = Path.GetExtension(f).ToLowerInvariant();
@ -29,9 +29,10 @@ public static class MediaLibrary
{
try
{
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) return null;
if (string.IsNullOrWhiteSpace(folder) || string.IsNullOrWhiteSpace(id) || !Directory.Exists(folder)) return null;
var cleanId = id.Trim();
foreach (var f in Directory.EnumerateFiles(folder))
if (IdOf(f) == id) return (f, Path.GetFileName(f));
if (IdOf(f) == cleanId) return (f, Path.GetFileName(f));
}
catch { }
return null;
@ -39,6 +40,7 @@ public static class MediaLibrary
private static string IdOf(string path)
{
if (string.IsNullOrWhiteSpace(path)) return string.Empty;
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(path)));
return Convert.ToHexString(hash)[..12].ToLowerInvariant();
}

View file

@ -16,7 +16,10 @@ public sealed class PositionStore
public PositionStore(string libraryFolder)
{
_path = Path.Combine(libraryFolder, FileName);
var folder = string.IsNullOrWhiteSpace(libraryFolder)
? Path.Combine(Path.GetTempPath(), "paca_positions")
: libraryFolder;
_path = Path.Combine(folder, FileName);
try
{
if (File.Exists(_path))
@ -28,16 +31,17 @@ public sealed class PositionStore
public double Get(string id)
{
lock (_gate) return _positions.GetValueOrDefault(id, 0);
if (string.IsNullOrWhiteSpace(id)) return 0;
lock (_gate) return _positions.GetValueOrDefault(id.Trim(), 0);
}
public void Set(string id, double positionSec)
{
if (string.IsNullOrEmpty(id) || positionSec < 0 || double.IsNaN(positionSec) || double.IsInfinity(positionSec))
if (string.IsNullOrWhiteSpace(id) || positionSec < 0 || double.IsNaN(positionSec) || double.IsInfinity(positionSec))
return;
lock (_gate)
{
_positions[id] = positionSec;
_positions[id.Trim()] = positionSec;
try
{
var dir = Path.GetDirectoryName(_path);

View file

@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using Paca.Core.Config;
using Paca.Core.Platform;
@ -16,6 +16,7 @@ public sealed partial class Thumbnailer
/// <summary>1초 지점 프레임 썸네일(320px 폭 JPEG). 실패 시 null. 캐시 히트 시 프로세스 없음.</summary>
public async Task<byte[]?> GetThumbnailAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(ffmpeg) || string.IsNullOrWhiteSpace(mediaPath)) return null;
var cache = mediaPath + ".thumb.jpg";
try
{
@ -34,6 +35,7 @@ public sealed partial class Thumbnailer
/// <summary>해상도("1920x1080"). ffmpeg -i stderr 의 Stream 라인에서 추출, .meta.json 캐시.</summary>
public async Task<string?> GetResolutionAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(ffmpeg) || string.IsNullOrWhiteSpace(mediaPath)) return null;
var cache = mediaPath + ".meta.json";
try
{