using System.Globalization; using System.Text; using Microsoft.Extensions.Options; using TeleWave.Application.Common.Interfaces; using static TeleWave.Infrastructure.Media.FfmpegText; namespace TeleWave.Infrastructure.Media; /// /// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по /// : анимированный градиентный фон + текст «Сейчас/Далее» + короткий /// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и /// кратная сегменту, поэтому эфирная математика не отличает заставку от программы. /// public sealed class FfmpegBumperRenderer( MediaPathResolver paths, IOptions storageOptions, IOptions mediaOptions ) : IBumperRenderer { private readonly StorageOptions _storage = storageOptions.Value; private readonly MediaOptions _media = mediaOptions.Value; public async Task RenderAsync( Guid assetId, BumperRenderSpec spec, CancellationToken cancellationToken ) { var seg = Math.Max(1, _storage.SegmentSeconds); // Длительность — вверх до кратности сегменту (оркестратор обычно уже выровнял, страхуемся). var target = (int)(Math.Ceiling((double)Math.Max(seg, spec.DurationSeconds) / seg) * seg); var assetDir = paths.AssetDir(assetId); if (Directory.Exists(assetDir)) Directory.Delete(assetDir, recursive: true); Directory.CreateDirectory(assetDir); // Динамический текст (названия шоу / свободные строки) пишем в файлы и читаем через textfile= // с expansion=none — так произвольные символы/кириллица не ломают синтаксис фильтра. var nowFile = Path.Combine(assetDir, "now.txt"); var nextFile = Path.Combine(assetDir, "next.txt"); var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle; var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle; await File.WriteAllTextAsync(nowFile, line1, new UTF8Encoding(false), cancellationToken); await File.WriteAllTextAsync(nextFile, line2, new UTF8Encoding(false), cancellationToken); // Подписи «Сейчас/Далее» тоже пользователь-редактируемы (валидатор ограничивает только длину), // поэтому их так же читаем через textfile=, а не подставляем в text= инлайн: иначе запятая/`;`/`[`/`]` // в подписи ломают (или инъектируют звенья в) цепочку -filter_complex. Нужны лишь в режиме // «Сейчас/Далее» (не FreeText), где рисуются подписи. var nowLabelFile = Path.Combine(assetDir, "nowlabel.txt"); var nextLabelFile = Path.Combine(assetDir, "nextlabel.txt"); if (!spec.FreeText) { await File.WriteAllTextAsync( nowLabelFile, spec.NowLabel, new UTF8Encoding(false), cancellationToken ); await File.WriteAllTextAsync( nextLabelFile, spec.NextLabel, new UTF8Encoding(false), cancellationToken ); } try { var args = BuildArgs( assetDir, seg, target, nowFile, nextFile, nowLabelFile, nextLabelFile, spec ); var result = await ProcessRunner.RunAsync( _media.FfmpegPath, args, lowPriority: true, TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds), cancellationToken ); if (result.ExitCode != 0) throw new InvalidOperationException( $"ffmpeg (заставка) завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}" ); var playlist = Path.Combine(assetDir, "index.m3u8"); if (!File.Exists(playlist)) throw new InvalidOperationException( "ffmpeg не создал плейлист заставки index.m3u8." ); var segmentCount = Directory.GetFiles(assetDir, "seg*.ts").Length; if (segmentCount == 0) throw new InvalidOperationException( "ffmpeg не создал ни одного сегмента заставки." ); return new BumperRenderResult( TimeSpan.FromSeconds(target), seg, segmentCount, spec.Width, spec.Height, paths.AssetRelativePath(assetId) ); } finally { TryDelete(nowFile); TryDelete(nextFile); TryDelete(nowLabelFile); TryDelete(nextLabelFile); } } private List BuildArgs( string assetDir, int seg, int target, string nowFile, string nextFile, string nowLabelFile, string nextLabelFile, BumperRenderSpec spec ) { var w = spec.Width; var h = spec.Height; var titleSize = Math.Max(24, h / 10); var labelSize = Math.Max(14, h / 22); var gap = (int)(labelSize * 1.4); // Доступная ширина под текст (поля по 4% с каждой стороны) — под неё ужимаем длинные строки. var textWidth = (int)(w * 0.92); var nowLabelY = (int)(h * 0.22); var nowTitleY = nowLabelY + gap; var nextLabelY = (int)(h * 0.60); var nextTitleY = nextLabelY + gap; var font = EscapePath(spec.FontFile); var outStart = Math.Max(0, target - 1); // Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп), постер шоу (затемнённый) либо // анимированный градиент. var inputs = new List(); string videoPrefix; if (!string.IsNullOrEmpty(spec.BackgroundFile)) { if (IsImage(spec.BackgroundFile)) inputs.AddRange(["-loop", "1"]); else inputs.AddRange(["-stream_loop", "-1"]); inputs.AddRange(["-i", spec.BackgroundFile]); videoPrefix = $"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p"; } else if (!string.IsNullOrEmpty(spec.PosterFile)) { // Постер (портрет) растягиваем на кадр, размываем и затемняем, чтобы текст читался. inputs.AddRange(["-loop", "1", "-i", spec.PosterFile]); videoPrefix = $"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}" + ",boxblur=6:1,eq=brightness=-0.28:saturation=0.9" + ",fps=30,format=yuv420p"; } else { var gradient = $"gradients=s={w}x{h}:c0={spec.BackgroundColor}:c1={spec.BackgroundColor2}" + $":x0=0:y0=0:x1={w}:y1={h}:nb_colors=2:seed=42:speed=0.015:r=30:d={Fmt(target)}"; inputs.AddRange(["-f", "lavfi", "-i", gradient]); videoPrefix = "[0:v]format=yuv420p"; } // Вход 1 — звук: загруженная музыка (петля + loudnorm) либо синтезированный джингл. string audioChain; if (!string.IsNullOrEmpty(spec.MusicFile)) { inputs.AddRange(["-stream_loop", "-1", "-i", spec.MusicFile]); audioChain = $"[1:a]loudnorm=I={_media.LoudnessTargetLufs.ToString(CultureInfo.InvariantCulture)}:TP=-1.5:LRA=11" + $",afade=t=in:d=0.5,afade=t=out:st={Fmt(outStart)}:d=1.0[a]"; } else { var jingle = $"aevalsrc=exprs='0.14*sin(2*PI*523.25*t)+0.11*sin(2*PI*659.25*t)+0.09*sin(2*PI*783.99*t)'" + $":s=48000:d={Fmt(target)}"; inputs.AddRange(["-f", "lavfi", "-i", jingle]); audioChain = $"[1:a]tremolo=f=4:d=0.4,afade=t=in:d=0.5,afade=t=out:st={Fmt(outStart)}:d=1.0,volume=0.7[a]"; } var vchain = new StringBuilder(videoPrefix); if (spec.FreeText) { // Свободный текст: две центрированные строки (акцентная + основная), ужатые под ширину кадра. var line1Size = FitSize(spec.FreeLine1, labelSize + 4, textWidth); var line2Size = FitSize(spec.FreeLine2, titleSize, textWidth); var line1Y = (int)(h * 0.40); var line2Y = line1Y + (int)(line2Size * 1.2); vchain .Append(',') .Append(DrawTitle(font, nowFile, spec.AccentColor, line1Size, line1Y, 0.2)); vchain .Append(',') .Append(DrawTitle(font, nextFile, spec.TextColor, line2Size, line2Y, 0.5)); } else { var nowSize = FitSize(spec.NowTitle, titleSize, textWidth); var nextSize = FitSize(spec.NextTitle, titleSize, textWidth); vchain .Append(',') .Append(DrawLabel(font, nowLabelFile, spec.AccentColor, labelSize, nowLabelY, 0.2)); vchain .Append(',') .Append(DrawTitle(font, nowFile, spec.TextColor, nowSize, nowTitleY, 0.3)); vchain .Append(',') .Append( DrawLabel(font, nextLabelFile, spec.AccentColor, labelSize, nextLabelY, 1.0) ); vchain .Append(',') .Append(DrawTitle(font, nextFile, spec.TextColor, nextSize, nextTitleY, 1.1)); } vchain.Append("[v]"); var filterComplex = $"{vchain};{audioChain}"; var args = new List { "-hide_banner", "-nostdin", "-y" }; args.AddRange(inputs); args.AddRange([ "-filter_complex", filterComplex, "-map", "[v]", "-map", "[a]", "-threads", _media.TranscodeThreads.ToString(CultureInfo.InvariantCulture), "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-pix_fmt", "yuv420p", "-force_key_frames", $"expr:gte(t,n_forced*{seg.ToString(CultureInfo.InvariantCulture)})", "-sc_threshold", "0", "-c:a", "aac", "-b:a", "128k", "-ac", "2", "-ar", "48000", "-t", Fmt(target), "-f", "hls", "-hls_time", seg.ToString(CultureInfo.InvariantCulture), "-hls_playlist_type", "vod", "-hls_list_size", "0", "-hls_segment_filename", Path.Combine(assetDir, "seg%05d.ts"), Path.Combine(assetDir, "index.m3u8"), ]); return args; } /// /// Подбирает размер шрифта так, чтобы строка влезла в по ширине: /// drawtext не умеет авто-fit, поэтому оцениваем ширину как len × 0.62 × fontsize (кириллица /// шире латиницы) и ужимаем базовый размер, но не мельче трети от него. /// private static int FitSize(string text, int baseSize, int maxWidth) { var len = string.IsNullOrEmpty(text) ? 1 : text.Length; var fit = (int)(maxWidth / (len * 0.62)); return Math.Clamp(fit, Math.Max(12, baseSize / 3), baseSize); } private static readonly string[] ImageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".bmp"]; private static bool IsImage(string path) => ImageExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); private static string DrawTitle( string font, string textFile, string color, int size, int y, double fadeStart ) => $"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none" + $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}" + ":shadowcolor=black@0.6:shadowx=2:shadowy=2" + $":alpha='{FadeExpr(fadeStart)}'"; private static string DrawLabel( string font, string textFile, string color, int size, int y, double fadeStart ) => // Подпись читается из файла (textfile=) с expansion=none — произвольные символы подписи // не могут сломать/инъектировать цепочку filter_complex (см. запись файлов в RenderAsync). $"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none" + $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}" + ":shadowcolor=black@0.6:shadowx=1:shadowy=1" + $":alpha='{FadeExpr(fadeStart)}'"; private static string FadeExpr(double start) => $"if(lt(t,{Fmt(start)}),0,min(1,(t-{Fmt(start)})/0.5))"; /// Экранирование пути для значения опции фильтра (Windows-разделители → прямые слэши, /// двоеточие экранируется). На Linux (контейнере) — фактически no-op. private static string EscapePath(string path) => path.Replace('\\', '/').Replace(":", "\\:"); private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch (IOException) { // Файл-подсказка для drawtext; не критично, если не удалился. } } }