Refactor various components to improve code clarity and maintainability. Update ListUsersQueryHandler to utilize UserListFilter for parameter handling. Refactor BumperSpecFactory and related classes to encapsulate input parameters into dedicated records, enhancing readability. Adjust MediaAsset and Slot classes to streamline content updates with new content models. Improve BumperRenderBackgroundService and MediaProcessingBackgroundService to use MediaReadyInfo for asset readiness, ensuring consistent parameter management across the application.
ci / build-backend (push) Successful in 1m55s
ci / build-frontend (push) Successful in 45s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 6m28s

This commit is contained in:
Leonid Pershin
2026-07-27 00:56:04 +03:00
parent 419aff54fa
commit 19fa23b619
47 changed files with 556 additions and 423 deletions
+3 -3
View File
@@ -32,8 +32,8 @@ builder.Services.AddSerilog(
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
@@ -143,6 +143,6 @@ app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся `public partial class Program;` — чтобы WebApplicationTestFactory видела
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync();
@@ -12,13 +12,15 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
CancellationToken cancellationToken
) =>
identityService.ListUsersAsync(
query.Page,
query.PageSize,
query.Search,
query.RoleId,
query.IsBlocked,
query.Sort,
query.Desc,
new UserListFilter(
query.Page,
query.PageSize,
query.Search,
query.RoleId,
query.IsBlocked,
query.Sort,
query.Desc
),
cancellationToken
);
}
@@ -15,11 +15,7 @@ public static class BumperSpecFactory
BumperTemplate template,
BumperTextVariant variant,
int alignedDurationSeconds,
string fromName,
string toName,
string? audioPath,
string? posterAbsolutePath,
string? backgroundAbsolutePath
BumperSpecInputs inputs
)
{
var free = variant.Kind == BumperTextKind.Free;
@@ -33,12 +29,12 @@ public static class BumperSpecFactory
template.TextColor,
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
free ? "" : variant.NowLabel,
free ? "" : fromName,
free ? "" : inputs.FromName,
free ? "" : variant.NextLabel,
free ? "" : toName,
backgroundAbsolutePath,
audioPath,
posterAbsolutePath,
free ? "" : inputs.ToName,
inputs.BackgroundAbsolutePath,
inputs.AudioPath,
inputs.PosterAbsolutePath,
free,
variant.Line1,
variant.Line2
@@ -0,0 +1,15 @@
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их
/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а
/// <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
/// </summary>
public sealed record BumperSpecInputs(
string FromName,
string ToName,
string? AudioPath = null,
/// <summary>Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.</summary>
string? PosterAbsolutePath = null,
string? BackgroundAbsolutePath = null
);
@@ -72,11 +72,13 @@ public sealed class BumperSpecLoader(
template,
variant,
aligned,
names.GetValueOrDefault(cache.FromShowId, "…"),
names.GetValueOrDefault(cache.ToShowId, "…"),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath,
bgPath
new BumperSpecInputs(
names.GetValueOrDefault(cache.FromShowId, "…"),
names.GetValueOrDefault(cache.ToShowId, "…"),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath,
bgPath
)
);
}
@@ -43,9 +43,6 @@ public sealed class RenderBumperPreviewCommandHandler(
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var fontFile =
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
var seconds = template.AudioDurationSeconds is { } d and > 0
? d
@@ -55,18 +52,25 @@ public sealed class RenderBumperPreviewCommandHandler(
);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
var inputs = new BumperSpecInputs(
fromName,
toName,
audioPath,
PosterAbsolutePath: null,
backgroundPath
);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var spec = BuildSpec(
variant,
var spec = BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
fontFile,
backgroundPath,
audioPath,
fromName,
toName
inputs
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
@@ -92,45 +96,6 @@ public sealed class RenderBumperPreviewCommandHandler(
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
}
/// <summary>
/// Спецификация рендера одного подблока. В режиме <see cref="BumperTextKind.Free"/> подписи и
/// названия шоу гасятся: там на экране произвольные строки, а не «Сейчас/Далее».
/// </summary>
private BumperRenderSpec BuildSpec(
BumperTextVariant variant,
BumperTemplate template,
int alignedSeconds,
string fontFile,
string? backgroundPath,
string? audioPath,
string fromName,
string toName
)
{
var free = variant.Kind == BumperTextKind.Free;
return new BumperRenderSpec(
alignedSeconds,
_bumper.Width,
_bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
fontFile,
free ? "" : variant.NowLabel,
free ? "" : fromName,
free ? "" : variant.NextLabel,
free ? "" : toName,
backgroundPath,
audioPath,
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
null,
free,
variant.Line1,
variant.Line2
);
}
/// <summary>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
@@ -2,6 +2,7 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
@@ -31,11 +32,13 @@ public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContex
variant.Update(
command.Name.Trim(),
command.Kind,
command.NowLabel,
command.NextLabel,
command.Line1,
command.Line2,
new BumperTextContent(
command.Kind,
command.NowLabel,
command.NextLabel,
command.Line1,
command.Line2
),
command.Trigger,
command.Weight
);
@@ -57,13 +57,7 @@ public interface IIdentityService
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
Task<PagedList<UserSummaryDto>> ListUsersAsync(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
string? sort,
bool desc,
UserListFilter filter,
CancellationToken cancellationToken
);
}
@@ -0,0 +1,16 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Параметры выборки пользователей: страница, фильтры и сортировка. Отдельный тип, а не запрос
/// CQRS: список пользователей живёт в Identity (вне <c>IAppDbContext</c>), и порт не должен зависеть
/// от конкретной фичи.
/// </summary>
public sealed record UserListFilter(
int Page,
int PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked,
string? Sort = null,
bool Desc = false
);
@@ -39,16 +39,19 @@ public sealed class ListInterstitialBlocksQueryHandler(IAppDbContext dbContext)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken);
// Длительность шоу-ролика считаем один раз: одно и то же шоу встречается в нескольких блоках.
var clipSeconds = clips.ToDictionary(
pair => pair.Key,
pair => pair.Value.Sum((Guid assetId) => durations.GetValueOrDefault(assetId, 0d))
);
return collections
.Where(c => c.Items.Count > 0 && c.Items.All(i => clips.ContainsKey(i.ShowId)))
.Select(c => new InterstitialBlockDto(
c.Id,
c.Name,
c.Items.Count,
c.Items.Sum(i =>
clips[i.ShowId]
.Sum(assetId => durations.TryGetValue(assetId, out var d) ? d : 0)
)
c.Items.Sum(i => clipSeconds[i.ShowId])
))
.ToList();
}
@@ -25,7 +25,11 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
// ToLower().Contains переводится в LIKE lower(...) — регистронезависимо и без привязки к
// Npgsql-специфичному ILike (Application не ссылается на провайдер).
var term = query.Search.Trim().ToLower();
// CA1862 (Contains со StringComparison) здесь неприменим: это дерево выражений EF, а
// перегрузку с StringComparison провайдер в SQL не переводит — будет исключение в рантайме.
#pragma warning disable CA1862
q = q.Where(x => x.OriginalFileName.ToLower().Contains(term));
#pragma warning restore CA1862
}
var total = await q.CountAsync(cancellationToken);
@@ -142,13 +142,15 @@ public sealed class GridScheduleGenerator(
ToEntryKind(item.Kind),
item.StartsAtUtc,
item.EndsAtUtc,
item.ShowId,
item.UnitIndex,
item.SlotId,
item.Trace is null
? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
item.CollectionId
new ScheduleEntryOrigin(
item.ShowId,
item.UnitIndex,
item.SlotId,
item.Trace is null
? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
item.CollectionId
)
)
);
added++;
@@ -208,16 +208,18 @@ public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
slot.SnapToMinutes
);
copySlot.UpdateContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
)
);
}
@@ -66,16 +66,18 @@ public sealed class SlotWriter(IAppDbContext dbContext)
input.SnapToMinutes
);
target.UpdateContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
new SlotContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
)
);
return Result.Success();
@@ -0,0 +1,14 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Текстовое наполнение подблока заставки. Наборы полей взаимоисключающие: при
/// <see cref="BumperTextKind.NowNext"/> работают подписи, при <see cref="BumperTextKind.Free"/> —
/// произвольные строки; неиспользуемые просто хранятся, чтобы переключение режима не теряло ввод.
/// </summary>
public sealed record BumperTextContent(
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2
);
@@ -58,23 +58,14 @@ public class BumperTextVariant
CreatedAt = DateTimeOffset.UtcNow,
};
public void Update(
string name,
BumperTextKind kind,
string nowLabel,
string nextLabel,
string line1,
string line2,
BumperTrigger trigger,
int weight
)
public void Update(string name, BumperTextContent text, BumperTrigger trigger, int weight)
{
Name = name;
Kind = kind;
NowLabel = nowLabel;
NextLabel = nextLabel;
Line1 = line1;
Line2 = line2;
Kind = text.Kind;
NowLabel = text.NowLabel;
NextLabel = text.NextLabel;
Line1 = text.Line1;
Line2 = text.Line2;
Trigger = trigger;
Weight = Math.Max(0, weight);
}
@@ -47,11 +47,7 @@ public class ScheduleEntry
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
int? episodeIndex,
Guid? slotId,
string? traceJson,
Guid? collectionId = null
ScheduleEntryOrigin origin
) =>
new()
{
@@ -61,11 +57,11 @@ public class ScheduleEntry
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
SlotId = slotId,
TraceJson = traceJson,
CollectionId = collectionId,
ShowId = origin.ShowId,
EpisodeIndex = origin.EpisodeIndex,
SlotId = origin.SlotId,
TraceJson = origin.TraceJson,
CollectionId = origin.CollectionId,
};
public static ScheduleEntry Program(
@@ -0,0 +1,15 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Происхождение записи расписания: чем она порождена и в рамках чего. На эфирную математику не
/// влияет — нужно EPG (<see cref="ShowId"/>/<see cref="EpisodeIndex"/>) и отладке сетки
/// (<see cref="SlotId"/>/<see cref="TraceJson"/>).
/// </summary>
public sealed record ScheduleEntryOrigin(
Guid? ShowId,
int? EpisodeIndex,
Guid? SlotId,
string? TraceJson,
/// <summary>Коллекция (франшиза), частью которой шла запись, или null.</summary>
Guid? CollectionId = null
);
@@ -90,26 +90,17 @@ public class MediaAsset
Touch();
}
public void MarkReady(
TimeSpan duration,
int segmentSeconds,
int segmentCount,
int width,
int height,
string videoCodec,
string audioCodec,
string relativePath
)
public void MarkReady(MediaReadyInfo info)
{
Status = MediaAssetStatus.Ready;
Duration = duration;
SegmentSeconds = segmentSeconds;
SegmentCount = segmentCount;
Width = width;
Height = height;
VideoCodec = videoCodec;
AudioCodec = audioCodec;
RelativePath = relativePath;
Duration = info.Duration;
SegmentSeconds = info.SegmentSeconds;
SegmentCount = info.SegmentCount;
Width = info.Width;
Height = info.Height;
VideoCodec = info.VideoCodec;
AudioCodec = info.AudioCodec;
RelativePath = info.RelativePath;
ErrorMessage = null;
if (ProcessingStartedAt is { } startedAt)
{
@@ -0,0 +1,17 @@
namespace TeleWave.Domain.Media;
/// <summary>
/// Результат обработки ассета: что получилось после нарезки в HLS. Домен эти значения не вычисляет —
/// их приносит обработчик медиа (ffmpeg), а <see cref="MediaAsset.MarkReady"/> лишь фиксирует.
/// </summary>
public sealed record MediaReadyInfo(
TimeSpan Duration,
int SegmentSeconds,
int SegmentCount,
int Width,
int Height,
string VideoCodec,
string AudioCodec,
/// <summary>Путь к каталогу ассета относительно корня хранилища.</summary>
string RelativePath
);
+11 -22
View File
@@ -108,32 +108,21 @@ public class Slot
}
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
public void UpdateContent(
string title,
SlotKind slotKind,
Guid? groupId,
string? strategyJson,
string? repeatSourceJson,
SlotBlockMode blockMode,
int blockValue,
OverflowPolicy overflowPolicy,
Guid? junctionBetweenId = null,
Guid? junctionAfterId = null
)
public void UpdateContent(SlotContent content)
{
JunctionBetweenId = junctionBetweenId;
JunctionAfterId = junctionAfterId;
Title = title.Trim();
SlotKind = slotKind;
BlockMode = blockMode;
BlockValue = Math.Max(1, blockValue);
OverflowPolicy = overflowPolicy;
JunctionBetweenId = content.JunctionBetweenId;
JunctionAfterId = content.JunctionAfterId;
Title = content.Title.Trim();
SlotKind = content.SlotKind;
BlockMode = content.BlockMode;
BlockValue = Math.Max(1, content.BlockValue);
OverflowPolicy = content.OverflowPolicy;
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
GroupId = slotKind == SlotKind.Content ? groupId : null;
StrategyJson = slotKind == SlotKind.Content ? strategyJson : null;
RepeatSourceJson = slotKind == SlotKind.Repeat ? repeatSourceJson : null;
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
}
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
@@ -0,0 +1,22 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Наполнение слота: чем заполнять эфир, в каком объёме и с какими врезками. Отделено от расписания
/// слота (<see cref="Slot.UpdateTiming"/>) — это два независимых набора настроек, и правятся они
/// в редакторе тоже раздельно.
/// </summary>
public sealed record SlotContent(
string Title,
SlotKind SlotKind,
/// <summary>Группа контента — только для <see cref="SlotKind.Content"/>, иначе гасится.</summary>
Guid? GroupId,
string? StrategyJson,
string? RepeatSourceJson,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null
);
@@ -185,16 +185,12 @@ internal sealed class IdentityService(
}
public async Task<PagedList<UserSummaryDto>> ListUsersAsync(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
string? sort,
bool desc,
UserListFilter filter,
CancellationToken cancellationToken
)
{
var (page, pageSize, search, roleId, isBlocked, sort, desc) = filter;
var query =
from user in dbContext.Users
join userRole in dbContext.UserRoles on user.Id equals userRole.UserId into userRoles
@@ -60,14 +60,16 @@ internal sealed class BumperRenderBackgroundService(
job.AssetId,
asset =>
asset.MarkReady(
render.Duration,
render.SegmentSeconds,
render.SegmentCount,
render.Width,
render.Height,
"h264",
"aac",
render.RelativePath
new MediaReadyInfo(
render.Duration,
render.SegmentSeconds,
render.SegmentCount,
render.Width,
render.Height,
"h264",
"aac",
render.RelativePath
)
),
cancellationToken
);
@@ -21,6 +21,23 @@ public sealed class FfmpegBumperRenderer(
private readonly StorageOptions _storage = storageOptions.Value;
private readonly MediaOptions _media = mediaOptions.Value;
/// <summary>
/// Файлы с динамическим текстом заставки. Всё пользователь-редактируемое (названия шоу, подписи,
/// свободные строки) ffmpeg читает через <c>textfile=</c> с <c>expansion=none</c>: иначе запятая,
/// <c>;</c>, <c>[</c> или <c>]</c> в тексте ломают (или инъектируют звенья в) цепочку
/// <c>-filter_complex</c>.
/// </summary>
private sealed record TextFiles(string Now, string Next, string NowLabel, string NextLabel)
{
public static TextFiles In(string assetDir) =>
new(
Path.Combine(assetDir, "now.txt"),
Path.Combine(assetDir, "next.txt"),
Path.Combine(assetDir, "nowlabel.txt"),
Path.Combine(assetDir, "nextlabel.txt")
);
}
public async Task<BumperRenderResult> RenderAsync(
Guid assetId,
BumperRenderSpec spec,
@@ -36,31 +53,24 @@ public sealed class FfmpegBumperRenderer(
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 text = TextFiles.In(assetDir);
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);
await File.WriteAllTextAsync(text.Now, line1, new UTF8Encoding(false), cancellationToken);
await File.WriteAllTextAsync(text.Next, line2, new UTF8Encoding(false), cancellationToken);
// Подписи «Сейчас/Далее» тоже пользователь-редактируемы (валидатор ограничивает только длину),
// поэтому их так же читаем через textfile=, а не подставляем в text= инлайн: иначе запятая/`;`/`[`/`]`
// в подписи ломают (или инъектируют звенья в) цепочку -filter_complex. Нужны лишь в режиме
// «Сейчас/Далее» (не FreeText), где рисуются подписи.
var nowLabelFile = Path.Combine(assetDir, "nowlabel.txt");
var nextLabelFile = Path.Combine(assetDir, "nextlabel.txt");
// Подписи «Сейчас/Далее» нужны лишь в одноимённом режиме — в FreeText их не рисуют.
if (!spec.FreeText)
{
await File.WriteAllTextAsync(
nowLabelFile,
text.NowLabel,
spec.NowLabel,
new UTF8Encoding(false),
cancellationToken
);
await File.WriteAllTextAsync(
nextLabelFile,
text.NextLabel,
spec.NextLabel,
new UTF8Encoding(false),
cancellationToken
@@ -69,16 +79,7 @@ public sealed class FfmpegBumperRenderer(
try
{
var args = BuildArgs(
assetDir,
seg,
target,
nowFile,
nextFile,
nowLabelFile,
nextLabelFile,
spec
);
var args = BuildArgs(assetDir, seg, target, text, spec);
var result = await ProcessRunner.RunAsync(
_media.FfmpegPath,
args,
@@ -114,10 +115,10 @@ public sealed class FfmpegBumperRenderer(
}
finally
{
TryDelete(nowFile);
TryDelete(nextFile);
TryDelete(nowLabelFile);
TryDelete(nextLabelFile);
TryDelete(text.Now);
TryDelete(text.Next);
TryDelete(text.NowLabel);
TryDelete(text.NextLabel);
}
}
@@ -125,10 +126,7 @@ public sealed class FfmpegBumperRenderer(
string assetDir,
int seg,
int target,
string nowFile,
string nextFile,
string nowLabelFile,
string nextLabelFile,
TextFiles text,
BumperRenderSpec spec
)
{
@@ -211,29 +209,31 @@ public sealed class FfmpegBumperRenderer(
var line2Y = line1Y + (int)(line2Size * 1.2);
vchain
.Append(',')
.Append(DrawTitle(font, nowFile, spec.AccentColor, line1Size, line1Y, 0.2));
.Append(DrawTitle(font, text.Now, spec.AccentColor, line1Size, line1Y, 0.2));
vchain
.Append(',')
.Append(DrawTitle(font, nextFile, spec.TextColor, line2Size, line2Y, 0.5));
.Append(DrawTitle(font, text.Next, 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)
DrawLabel(font, text.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2)
);
vchain
.Append(',')
.Append(DrawTitle(font, nextFile, spec.TextColor, nextSize, nextTitleY, 1.1));
.Append(DrawTitle(font, text.Now, spec.TextColor, nowSize, nowTitleY, 0.3));
vchain
.Append(',')
.Append(
DrawLabel(font, text.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0)
);
vchain
.Append(',')
.Append(DrawTitle(font, text.Next, spec.TextColor, nextSize, nextTitleY, 1.1));
}
vchain.Append("[v]");
@@ -53,14 +53,16 @@ internal sealed class MediaProcessingBackgroundService(
job.AssetId,
asset =>
asset.MarkReady(
result.Duration,
result.SegmentSeconds,
result.SegmentCount,
result.Width,
result.Height,
result.VideoCodec,
result.AudioCodec,
result.RelativePath
new MediaReadyInfo(
result.Duration,
result.SegmentSeconds,
result.SegmentCount,
result.Width,
result.Height,
result.VideoCodec,
result.AudioCodec,
result.RelativePath
)
),
cancellationToken
);