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