Files
TeleWave/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs
T

355 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по
/// <see cref="BumperRenderSpec"/>: анимированный градиентный фон + текст «Сейчас/Далее» + короткий
/// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
/// кратная сегменту, поэтому эфирная математика не отличает заставку от программы.
/// </summary>
public sealed class FfmpegBumperRenderer(
MediaPathResolver paths,
IOptions<StorageOptions> storageOptions,
IOptions<MediaOptions> mediaOptions
) : IBumperRenderer
{
private readonly StorageOptions _storage = storageOptions.Value;
private readonly MediaOptions _media = mediaOptions.Value;
public async Task<BumperRenderResult> 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<string> 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>();
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<string> { "-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;
}
/// <summary>
/// Подбирает размер шрифта так, чтобы строка влезла в <paramref name="maxWidth"/> по ширине:
/// drawtext не умеет авто-fit, поэтому оцениваем ширину как len × 0.62 × fontsize (кириллица
/// шире латиницы) и ужимаем базовый размер, но не мельче трети от него.
/// </summary>
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))";
/// <summary>Экранирование пути для значения опции фильтра (Windows-разделители → прямые слэши,
/// двоеточие экранируется). На Linux (контейнере) — фактически no-op.</summary>
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; не критично, если не удалился.
}
}
}