Implement background clip functionality for bumpers
Enhanced the bumper system to support a new background type, 'Clip', allowing text to be overlaid on existing video clips. This update includes the addition of a BackgroundClipShowId property in various records and classes, ensuring that the clip's duration dictates the bumper length. Updated validation rules, mapping, and rendering logic to accommodate this new feature. Localization strings were also updated to reflect the new background option and its implications for audio handling.
This commit is contained in:
@@ -36,7 +36,9 @@ public sealed record BumperVariantInput(
|
||||
BumperTrigger Trigger,
|
||||
BumperBackground Background,
|
||||
int Weight,
|
||||
IReadOnlyList<BumperLineDto> Lines
|
||||
IReadOnlyList<BumperLineDto> Lines,
|
||||
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
|
||||
Guid? BackgroundClipShowId = null
|
||||
);
|
||||
|
||||
public sealed record UpdateBumperVariantCommand(
|
||||
@@ -92,5 +94,11 @@ public sealed class UpdateBumperVariantCommandValidator
|
||||
RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines);
|
||||
RuleForEach(x => x.Input.Lines)
|
||||
.ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120));
|
||||
|
||||
// Фон из ролика без самого ролика — это не «пока не выбрал», а заставка, которая молча
|
||||
// отрендерится градиентом. Пусть выбор будет обязательным сразу.
|
||||
RuleFor(x => x.Input.BackgroundClipShowId)
|
||||
.NotNull()
|
||||
.When(x => x.Input.Background == BumperBackground.Clip);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ public sealed record BumperTextVariantDto(
|
||||
BumperTrigger Trigger,
|
||||
BumperBackground Background,
|
||||
int Weight,
|
||||
IReadOnlyList<BumperLineDto> Lines
|
||||
IReadOnlyList<BumperLineDto> Lines,
|
||||
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
|
||||
Guid? BackgroundClipShowId
|
||||
);
|
||||
|
||||
/// <summary>Блок заставки: оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||
|
||||
@@ -29,7 +29,8 @@ public static class BumperMapper
|
||||
v.Weight,
|
||||
v.Lines.OrderBy(l => l.Position)
|
||||
.Select(l => new BumperLineDto(l.Style, l.Color, l.Text))
|
||||
.ToList()
|
||||
.ToList(),
|
||||
v.BackgroundClipShowId
|
||||
))
|
||||
.ToList()
|
||||
);
|
||||
|
||||
@@ -27,6 +27,10 @@ public static class BumperSpecFactory
|
||||
inputs.Lines,
|
||||
inputs.BackgroundAbsolutePath,
|
||||
inputs.AudioPath,
|
||||
inputs.PosterAbsolutePath
|
||||
inputs.PosterAbsolutePath,
|
||||
inputs.ClipAbsolutePath,
|
||||
// Джингл назначен — он и звучит, подрезанный под ролик; не назначен — играет
|
||||
// оригинальная дорожка ролика.
|
||||
UseClipAudio: inputs.ClipAbsolutePath is not null && inputs.AudioPath is null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,5 +12,7 @@ public sealed record BumperSpecInputs(
|
||||
string? AudioPath = null,
|
||||
/// <summary>Постер шоу как фон; подставляется, только если подблок его запросил.</summary>
|
||||
string? PosterAbsolutePath = null,
|
||||
string? BackgroundAbsolutePath = null
|
||||
string? BackgroundAbsolutePath = null,
|
||||
/// <summary>Плейлист ролика-подложки, если текст пишется поверх готового видео.</summary>
|
||||
string? ClipAbsolutePath = null
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
@@ -16,6 +17,7 @@ public sealed class BumperSpecLoader(
|
||||
IAppDbContext dbContext,
|
||||
IBumperTemplateStorage bumperStorage,
|
||||
IImageStore imageStore,
|
||||
IMediaStorage mediaStorage,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
)
|
||||
@@ -47,8 +49,15 @@ public sealed class BumperSpecLoader(
|
||||
? await ResolveShowPosterAsync(showId, cancellationToken)
|
||||
: null;
|
||||
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
|
||||
var (clipPath, clipSeconds) = await ResolveClipAsync(
|
||||
cache.BackgroundAssetId,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// Длину задаёт ролик, если он есть: подрезать чужую заставку под джингл значило бы обрывать
|
||||
// её на полуслове. Выравнивание по сегменту остаётся — лента этого требует от любой записи.
|
||||
var aligned = BumperDuration.Aligned(
|
||||
BumperDuration.TemplateSeconds(template),
|
||||
clipSeconds ?? BumperDuration.TemplateSeconds(template),
|
||||
_segmentSeconds
|
||||
);
|
||||
|
||||
@@ -60,11 +69,33 @@ public sealed class BumperSpecLoader(
|
||||
BumperRenderedText.FromJson(cache.RenderedLinesJson),
|
||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||
posterPath,
|
||||
bgPath
|
||||
bgPath,
|
||||
clipPath
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Плейлист ролика-подложки и его длительность; null — ролика уже нет или он не готов.</summary>
|
||||
private async Task<(string? Path, int? Seconds)> ResolveClipAsync(
|
||||
Guid? assetId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (assetId is not { } id)
|
||||
return (null, null);
|
||||
|
||||
var duration = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => a.Id == id && a.Status == MediaAssetStatus.Ready)
|
||||
.Select(a => a.Duration)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (duration is not { } value || value <= TimeSpan.Zero)
|
||||
return (null, null);
|
||||
|
||||
var path = mediaStorage.ResolvePlaylistPath(id);
|
||||
return path is null ? (null, null) : (path, (int)Math.Ceiling(value.TotalSeconds));
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveShowPosterAsync(
|
||||
Guid showId,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
+65
-2
@@ -6,6 +6,7 @@ using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Domain.Programming;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
@@ -20,6 +21,7 @@ public sealed class RenderBumperPreviewCommandHandler(
|
||||
IBumperRenderer renderer,
|
||||
IBumperTemplateStorage storage,
|
||||
IImageStore imageStore,
|
||||
IMediaStorage mediaStorage,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
||||
@@ -58,6 +60,8 @@ public sealed class RenderBumperPreviewCommandHandler(
|
||||
);
|
||||
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||
|
||||
var clips = await ResolveClipsAsync(template, cancellationToken);
|
||||
|
||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||
{
|
||||
var lines = variant
|
||||
@@ -70,15 +74,22 @@ public sealed class RenderBumperPreviewCommandHandler(
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l.Text))
|
||||
.ToList();
|
||||
|
||||
// У ролика своя длина — в превью она тоже должна быть настоящей, иначе админ увидит
|
||||
// не ту заставку, которая пойдёт в эфир.
|
||||
var clip = variant.BackgroundClipShowId is { } clipShowId
|
||||
? clips.GetValueOrDefault(clipShowId)
|
||||
: null;
|
||||
|
||||
var spec = BumperSpecFactory.Build(
|
||||
_bumper,
|
||||
template,
|
||||
aligned,
|
||||
clip is null ? aligned : BumperDuration.Aligned(clip.Seconds, _segmentSeconds),
|
||||
new BumperSpecInputs(
|
||||
lines,
|
||||
audioPath,
|
||||
variant.Background == BumperBackground.Template ? null : posterPath,
|
||||
backgroundPath
|
||||
backgroundPath,
|
||||
clip?.Path
|
||||
)
|
||||
);
|
||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||
@@ -87,6 +98,58 @@ public sealed class RenderBumperPreviewCommandHandler(
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Ролик-подложка: плейлист и длительность.</summary>
|
||||
private sealed record ClipSource(string Path, int Seconds);
|
||||
|
||||
/// <summary>Ролики-подложки подблоков блока, по шоу. Неготовые молча выпадают — превью не эфир.</summary>
|
||||
private async Task<Dictionary<Guid, ClipSource>> ResolveClipsAsync(
|
||||
BumperTemplate template,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = template
|
||||
.Variants.Where(v => v.Background == BumperBackground.Clip)
|
||||
.Select(v => v.BackgroundClipShowId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var episodes = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.SelectMany(s => s.Episodes)
|
||||
.Where(e => showIds.Contains(e.ShowId))
|
||||
.OrderBy(e => e.Position)
|
||||
.Select(e => new { e.ShowId, e.MediaAssetId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||
var ready = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a =>
|
||||
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||
)
|
||||
.Select(a => new { a.Id, a.Duration })
|
||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||
|
||||
var result = new Dictionary<Guid, ClipSource>();
|
||||
foreach (var episode in episodes)
|
||||
{
|
||||
if (result.ContainsKey(episode.ShowId))
|
||||
continue;
|
||||
if (!ready.TryGetValue(episode.MediaAssetId, out var duration))
|
||||
continue;
|
||||
if (mediaStorage.ResolvePlaylistPath(episode.MediaAssetId) is not { } path)
|
||||
continue;
|
||||
|
||||
result[episode.ShowId] = new ClipSource(path, (int)Math.Ceiling(duration.TotalSeconds));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BumperContext BuildContext(Channel? channel, SampleShows samples)
|
||||
{
|
||||
var offset = TimeSpan.FromMinutes(
|
||||
|
||||
+2
-1
@@ -38,7 +38,8 @@ public sealed class UpdateBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||
command.Input.Name,
|
||||
command.Input.Trigger,
|
||||
command.Input.Background,
|
||||
command.Input.Weight
|
||||
command.Input.Weight,
|
||||
command.Input.BackgroundClipShowId
|
||||
);
|
||||
variant.SetLines(
|
||||
command.Input.Lines.Select(
|
||||
|
||||
@@ -24,7 +24,17 @@ public sealed record BumperRenderSpec(
|
||||
string? BackgroundFile = null,
|
||||
string? MusicFile = null,
|
||||
/// <summary>Постер шоу как фон (используется, если подблок его запросил; затемняется).</summary>
|
||||
string? PosterFile = null
|
||||
string? PosterFile = null,
|
||||
/// <summary>
|
||||
/// Готовый ролик как подложка: плейлист ассета из библиотеки. Побеждает остальные источники —
|
||||
/// это самый явный выбор из всех, его не подставляют молча.
|
||||
/// </summary>
|
||||
string? ClipFile = null,
|
||||
/// <summary>
|
||||
/// Брать звук из самого ролика. Ставится, когда у блока нет джингла: подкладывать под чужую
|
||||
/// заставку синтезированный писк хуже, чем оставить её собственную дорожку.
|
||||
/// </summary>
|
||||
bool UseClipAudio = false
|
||||
);
|
||||
|
||||
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
||||
|
||||
@@ -11,6 +11,12 @@ public interface IMediaStorage
|
||||
/// <summary>Свободное место на томе хранилища, байт.</summary>
|
||||
long GetAvailableFreeSpaceBytes();
|
||||
|
||||
/// <summary>
|
||||
/// Абсолютный путь к HLS-плейлисту готового ассета (<c>assets/{id}/index.m3u8</c>) — им ffmpeg
|
||||
/// читает уже нарезанное медиа как обычный вход. Null, если каталога ассета нет на диске.
|
||||
/// </summary>
|
||||
string? ResolvePlaylistPath(Guid assetId);
|
||||
|
||||
/// <summary>
|
||||
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
|
||||
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
|
||||
|
||||
@@ -57,6 +57,7 @@ public sealed class BumperResolver(
|
||||
tokens,
|
||||
cancellationToken
|
||||
);
|
||||
var clipAssets = await LoadClipAssetsAsync(templates.Values, cancellationToken);
|
||||
var requests = new List<(int Index, BumperRequest Request)>();
|
||||
|
||||
foreach (var (item, index) in reserved)
|
||||
@@ -91,6 +92,15 @@ public sealed class BumperResolver(
|
||||
_ => (Guid?)null,
|
||||
};
|
||||
|
||||
// Ролик-подложка: нет готового ассета — подблок работает как обычно, по фону блока.
|
||||
// Ронять заставку из-за необработанного ролика нельзя, она уже стоит в ленте.
|
||||
var clipAssetId =
|
||||
variant.Background == BumperBackground.Clip
|
||||
&& variant.BackgroundClipShowId is { } clipShowId
|
||||
&& clipAssets.TryGetValue(clipShowId, out var found)
|
||||
? found
|
||||
: (Guid?)null;
|
||||
|
||||
var linesJson = BumperRenderedText.ToJson(lines);
|
||||
requests.Add(
|
||||
(
|
||||
@@ -100,7 +110,8 @@ public sealed class BumperResolver(
|
||||
variant.Id,
|
||||
linesJson,
|
||||
posterShowId,
|
||||
ComputeSignature(template, variant.Id, linesJson, posterShowId)
|
||||
clipAssetId,
|
||||
ComputeSignature(template, variant.Id, linesJson, posterShowId, clipAssetId)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -138,7 +149,8 @@ public sealed class BumperResolver(
|
||||
request.Signature,
|
||||
request.Lines,
|
||||
request.PosterShowId,
|
||||
asset.Id
|
||||
asset.Id,
|
||||
request.BackgroundAssetId
|
||||
)
|
||||
);
|
||||
|
||||
@@ -214,22 +226,69 @@ public sealed class BumperResolver(
|
||||
BumperTemplate template,
|
||||
Guid variantId,
|
||||
string linesJson,
|
||||
Guid? posterShowId
|
||||
Guid? posterShowId,
|
||||
Guid? backgroundAssetId
|
||||
)
|
||||
{
|
||||
var raw = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{linesJson}"
|
||||
$"{template.Id}|{template.Revision}|{variantId}|{posterShowId}|{backgroundAssetId}|{linesJson}"
|
||||
);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..32];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Готовые ассеты роликов-подложек по шоу. Берётся первая готовая серия: у ролика она всегда
|
||||
/// одна, а необработанному в эфире взяться неоткуда.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<Guid, Guid>> LoadClipAssetsAsync(
|
||||
IEnumerable<BumperTemplate> templates,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var showIds = templates
|
||||
.SelectMany(t => t.Variants)
|
||||
.Where(v => v.Background == BumperBackground.Clip)
|
||||
.Select(v => v.BackgroundClipShowId)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (showIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var episodes = await dbContext
|
||||
.Shows.AsNoTracking()
|
||||
.SelectMany(s => s.Episodes)
|
||||
.Where(e => showIds.Contains(e.ShowId))
|
||||
.OrderBy(e => e.Position)
|
||||
.Select(e => new { e.ShowId, e.MediaAssetId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var ready = await dbContext
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a =>
|
||||
a.Status == MediaAssetStatus.Ready
|
||||
&& a.Duration != null
|
||||
&& episodes.Select(e => e.MediaAssetId).Contains(a.Id)
|
||||
)
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var readySet = ready.ToHashSet();
|
||||
return episodes
|
||||
.Where(e => readySet.Contains(e.MediaAssetId))
|
||||
.GroupBy(e => e.ShowId)
|
||||
.ToDictionary(g => g.Key, g => g.First().MediaAssetId);
|
||||
}
|
||||
|
||||
/// <summary>Что нужно отрендерить для одной записи ленты.</summary>
|
||||
private sealed record BumperRequest(
|
||||
BumperTemplate Template,
|
||||
Guid VariantId,
|
||||
string Lines,
|
||||
Guid? PosterShowId,
|
||||
Guid? BackgroundAssetId,
|
||||
string Signature
|
||||
)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user