Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic.

This commit is contained in:
Leonid Pershin
2026-07-25 00:45:22 +03:00
parent 622bf1e440
commit 0cfc72166a
60 changed files with 5073 additions and 33 deletions
@@ -0,0 +1,251 @@
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
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");
await File.WriteAllTextAsync(nowFile, spec.NowTitle, new UTF8Encoding(false), cancellationToken);
await File.WriteAllTextAsync(nextFile, spec.NextTitle, new UTF8Encoding(false), cancellationToken);
try
{
var args = BuildArgs(assetDir, seg, target, nowFile, nextFile, spec);
var result = await ProcessRunner.RunAsync(
_media.FfmpegPath,
args,
lowPriority: true,
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);
}
}
private List<string> BuildArgs(
string assetDir,
int seg,
int target,
string nowFile,
string nextFile,
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);
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
{
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);
vchain.Append(',').Append(DrawLabel(font, spec.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2));
vchain.Append(',').Append(DrawTitle(font, nowFile, spec.TextColor, titleSize, nowTitleY, 0.3));
vchain.Append(',').Append(DrawLabel(font, spec.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0));
vchain.Append(',').Append(DrawTitle(font, nextFile, spec.TextColor, titleSize, 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;
}
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 text,
string color,
int size,
int y,
double fadeStart
) =>
$"drawtext=fontfile={font}:text={EscapeText(text)}: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(":", "\\:");
/// <summary>Экранирование литерального текста подписи внутри значения опции drawtext.</summary>
private static string EscapeText(string text) =>
text.Replace("\\", "\\\\").Replace(":", "\\:").Replace("'", "\\'");
private static string Fmt(double value) =>
value.ToString("0.###", CultureInfo.InvariantCulture);
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch (IOException)
{
// Файл-подсказка для drawtext; не критично, если не удалился.
}
}
private static string Tail(string text)
{
text = text.Trim();
const int max = 500;
return text.Length <= max ? text : text[^max..];
}
}