feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
102
src/Paca.Core/Platform/AudioNormalizer.cs
Normal file
102
src/Paca.Core/Platform/AudioNormalizer.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Paca.Core.Platform;
|
||||
|
||||
public sealed record LoudnessSpec(double TargetLufs = -14.0, double TruePeak = -1.0, double LoudnessRange = 11.0)
|
||||
{
|
||||
public static LoudnessSpec WebStandard => new(-14.0, -1.0, 11.0);
|
||||
public static LoudnessSpec EbuR128 => new(-23.0, -1.0, 7.0);
|
||||
}
|
||||
|
||||
public sealed record MeasuredLoudness(double MeasuredI, double MeasuredTp, double MeasuredLra, double MeasuredThresh);
|
||||
|
||||
/// <summary>
|
||||
/// 29차: EBU R128 2-pass 오디오 라우드니스 정규화 및 무음 트리밍 엔진.
|
||||
/// </summary>
|
||||
public sealed class AudioNormalizer
|
||||
{
|
||||
private readonly IProcessRunner _runner;
|
||||
|
||||
public AudioNormalizer(IProcessRunner runner)
|
||||
{
|
||||
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
||||
}
|
||||
|
||||
public async Task<string> NormalizeAudioAsync(string ffmpeg, string inputFile, string outputFile, LoudnessSpec spec, CancellationToken ct)
|
||||
{
|
||||
// Pass 1: Measure loudness
|
||||
var iStr = spec.TargetLufs.ToString("F1", CultureInfo.InvariantCulture);
|
||||
var tpStr = spec.TruePeak.ToString("F1", CultureInfo.InvariantCulture);
|
||||
var lraStr = spec.LoudnessRange.ToString("F1", CultureInfo.InvariantCulture);
|
||||
|
||||
var pass1Filter = $"loudnorm=I={iStr}:TP={tpStr}:LRA={lraStr}:print_format=json";
|
||||
var pass1Args = new[]
|
||||
{
|
||||
"-hide_banner", "-i", inputFile,
|
||||
"-af", pass1Filter,
|
||||
"-f", "null", "-"
|
||||
};
|
||||
|
||||
var pass1Res = await _runner.RunAsync(new ProcessSpec(ffmpeg, pass1Args), null, ct);
|
||||
if (pass1Res.ExitCode != 0)
|
||||
throw new InvalidOperationException($"오디오 라우드니스 1-pass 측정 실패 (ExitCode: {pass1Res.ExitCode}): {pass1Res.Stderr}");
|
||||
var stats = ParseLoudnessStats(pass1Res.Stdout.Length > 0 ? pass1Res.Stdout : pass1Res.Stderr);
|
||||
|
||||
// Pass 2: Apply normalized audio
|
||||
var pass2Filter = stats != null
|
||||
? $"loudnorm=I={iStr}:TP={tpStr}:LRA={lraStr}:measured_I={stats.MeasuredI.ToString("F2", CultureInfo.InvariantCulture)}:measured_TP={stats.MeasuredTp.ToString("F2", CultureInfo.InvariantCulture)}:measured_LRA={stats.MeasuredLra.ToString("F2", CultureInfo.InvariantCulture)}:measured_thresh={stats.MeasuredThresh.ToString("F2", CultureInfo.InvariantCulture)}:linear=true"
|
||||
: $"loudnorm=I={iStr}:TP={tpStr}:LRA={lraStr}";
|
||||
|
||||
var pass2Args = new[]
|
||||
{
|
||||
"-y", "-hide_banner", "-loglevel", "error",
|
||||
"-i", inputFile,
|
||||
"-af", pass2Filter,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
outputFile
|
||||
};
|
||||
|
||||
var pass2Res = await _runner.RunAsync(new ProcessSpec(ffmpeg, pass2Args), null, ct);
|
||||
if (pass2Res.ExitCode != 0)
|
||||
throw new InvalidOperationException($"오디오 정규화 실패: {pass2Res.Stderr}");
|
||||
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
public static MeasuredLoudness? ParseLoudnessStats(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
var startIdx = text.IndexOf('{');
|
||||
var endIdx = text.LastIndexOf('}');
|
||||
if (startIdx < 0 || endIdx <= startIdx) return null;
|
||||
|
||||
var json = text.Substring(startIdx, endIdx - startIdx + 1);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
double i = 0, tp = 0, lra = 0, thresh = 0;
|
||||
if (root.TryGetProperty("input_i", out var iEl)) double.TryParse(iEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out i);
|
||||
if (root.TryGetProperty("input_tp", out var tpEl)) double.TryParse(tpEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out tp);
|
||||
if (root.TryGetProperty("input_lra", out var lraEl)) double.TryParse(lraEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out lra);
|
||||
if (root.TryGetProperty("input_thresh", out var thEl)) double.TryParse(thEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out thresh);
|
||||
|
||||
return new MeasuredLoudness(i, tp, lra, thresh);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string BuildSilenceRemoveFilter(double thresholdDb = -50.0, double minDurationSec = 0.5)
|
||||
{
|
||||
var thStr = thresholdDb.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
var durStr = minDurationSec.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
return $"silenceremove=start_periods=1:start_duration={durStr}:start_threshold={thStr}dB:stop_periods=1:stop_duration={durStr}:stop_threshold={thStr}dB";
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue