test(core/browser/platform): 26 extreme RED/GREEN TDD cycles and full regression hardening (3,235 PASS)
This commit is contained in:
parent
bcd242691f
commit
8677853822
100 changed files with 4814 additions and 333 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}--");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 += ";";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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}\"";
|
||||
|
|
|
|||
|
|
@ -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}\"");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}\"");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 등)는 독립 미디어가 아니므로 목록 등록 차단
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)은 비정상 중단 흔적이므로 안전 제거
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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회 이하
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<InternalsVisibleTo Include="Paca.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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 빠른 메모에 즉시 기록합니다."
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}\"";
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}\"";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue