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; не критично, если не удалился.
}
}
}