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:
Yun Chan 2026-09-14 00:39:44 +09:00
parent a88aafa1e5
commit 0e568f1f0a
975 changed files with 130593 additions and 16783 deletions

View 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";
}
}