From 18325fdfcc4a525e0380455e50c7c1ac429f9f82 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 13:31:38 +0300 Subject: [PATCH] Refactor bumper preview functionality: update API endpoints to support rendering previews for all bumper sub-blocks, modify related query and handler to generate individual asset previews, and enhance frontend components for improved user experience with variant-specific previews. Update translations for consistency in terminology. --- .../Endpoints/ChannelEndpoints.cs | 22 +++-- .../Bumpers/RenderBumperPreviewQuery.cs | 7 +- .../RenderBumperPreviewQueryHandler.cs | 71 +++++++-------- .../features/admin/channels/ChannelDetail.tsx | 89 ++++++++++++------- frontend/src/features/admin/channels/api.ts | 8 +- frontend/src/shared/lib/i18n.ts | 8 +- 6 files changed, 116 insertions(+), 89 deletions(-) diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 8b0a43b..6c2dd00 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -85,11 +85,11 @@ public static class ChannelEndpoints .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) .Produces(StatusCodes.Status204NoContent); admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/index.m3u8", + "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist ); admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/{file}", + "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment ); @@ -452,10 +452,10 @@ public static class ChannelEndpoints return result.IsSuccess ? Results.NoContent() : result.ToHttpResult(); } - /// Плейлист превью: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. - private static IResult PreviewPlaylist(Guid id, Guid templateId, MediaPathResolver paths) + /// Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. + private static IResult PreviewPlaylist(Guid id, Guid templateId, Guid variantId, MediaPathResolver paths) { - var previewId = BumperPreview.AssetId(templateId); + var previewId = BumperPreview.AssetId(variantId); string indexPath; try { @@ -468,7 +468,7 @@ public static class ChannelEndpoints if (!File.Exists(indexPath)) return Results.NotFound(); - var baseUrl = $"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/"; + var baseUrl = $"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/"; var sb = new StringBuilder(); foreach (var line in File.ReadLines(indexPath)) { @@ -483,12 +483,18 @@ public static class ChannelEndpoints return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); } - private static IResult PreviewSegment(Guid id, Guid templateId, string file, MediaPathResolver paths) + private static IResult PreviewSegment( + Guid id, + Guid templateId, + Guid variantId, + string file, + MediaPathResolver paths + ) { if (!BumperSegmentFileName.IsMatch(file)) return Results.NotFound(); - var previewId = BumperPreview.AssetId(templateId); + var previewId = BumperPreview.AssetId(variantId); string path; try { diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs index 571091b..b132b40 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs @@ -4,8 +4,7 @@ using TeleWave.Application.Common.Models; namespace TeleWave.Application.Broadcast.Bumpers; /// -/// Синхронно рендерит пример заставки блока (с примерными названиями шоу) и возвращает id -/// ассета-превью. БД не меняет — это read-side генерация артефакта для предпросмотра. +/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок — +/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет — read-side генерация артефактов. /// -public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) - : IQuery>; +public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) : IQuery; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs index 71beb54..698c3d0 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQueryHandler.cs @@ -15,7 +15,7 @@ public sealed class RenderBumperPreviewQueryHandler( IImageStore imageStore, IOptions bumperOptions, IOptions streamingOptions -) : IQueryHandler> +) : IQueryHandler { private readonly BumperOptions _bumper = bumperOptions.Value; private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds); @@ -23,7 +23,7 @@ public sealed class RenderBumperPreviewQueryHandler( /// Длительность заставки без загруженного звука (сек) — как в генераторе. private const int DefaultBumperDurationSeconds = 8; - public async Task> Handle( + public async Task Handle( RenderBumperPreviewQuery query, CancellationToken cancellationToken ) @@ -35,21 +35,17 @@ public sealed class RenderBumperPreviewQueryHandler( .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken); if (channel is null) - return Result.Failure(ChannelErrors.NotFound); + return Result.Failure(ChannelErrors.NotFound); var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId); if (template is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); - - // Превью показываем по первому подблоку (стиль/звук блока + его текст). - var variant = template.Variants.OrderBy(v => v.Position).FirstOrDefault(); - if (variant is null) - return Result.Failure(ChannelErrors.BumperTemplateNotFound); + return Result.Failure(ChannelErrors.BumperTemplateNotFound); var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken); - var free = variant.Kind == BumperTextKind.Free; + var fontFile = + channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans; - // Фон блока — из общего реестра по id. + // Фон блока — из общего реестра по id (общий для всех подблоков). string? backgroundPath = null; if (template.BackgroundImageId is { } bgId) { @@ -66,32 +62,37 @@ public sealed class RenderBumperPreviewQueryHandler( var aligned = (int)( Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds ); + var audioPath = storage.AudioPath(template.Id, template.AudioExtension); - var spec = new BumperRenderSpec( - aligned, - _bumper.Width, - _bumper.Height, - template.BackgroundColor, - template.BackgroundColor2, - template.AccentColor, - template.TextColor, - channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans, - free ? "" : variant.NowLabel, - free ? "" : fromName, - free ? "" : variant.NextLabel, - free ? "" : toName, - backgroundPath, - storage.AudioPath(template.Id, template.AudioExtension), - // Постер зависит от конкретного «следующего» шоу — в превью не подставляем. - null, - free, - variant.Line1, - variant.Line2 - ); + // Рендерим каждый подблок в свой ассет-превью (id по подблоку). + foreach (var variant in template.Variants.OrderBy(v => v.Position)) + { + var free = variant.Kind == BumperTextKind.Free; + var spec = new BumperRenderSpec( + aligned, + _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 + ); + await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken); + } - var previewId = BumperPreview.AssetId(template.Id); - await renderer.RenderAsync(previewId, spec, cancellationToken); - return Result.Success(previewId); + return Result.Success(); } /// Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки. diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index d929563..c601c3e 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -47,7 +47,7 @@ import { removeBumperVariant, removeChannelAd, removeChannelShow, - renderBumperPreview, + renderBumperPreviews, setBumperTemplateBackground, updateBumperTemplate, updateBumperVariant, @@ -694,7 +694,12 @@ function BumperTemplateEditor({
- +
@@ -871,19 +876,20 @@ function BumperVariantEditor({ function BumperPreviewPlayer({ channelId, templateId, + variants, onError, }: { channelId: string templateId: string + variants: BumperTextVariantDto[] onError: (e: unknown) => void }) { const { t } = useTranslation() - const videoRef = useRef(null) const [ready, setReady] = useState(false) const [bust, setBust] = useState(0) const render = useMutation({ - mutationFn: () => renderBumperPreview(channelId, templateId), + mutationFn: () => renderBumperPreviews(channelId, templateId), onSuccess: () => { setBust(Date.now()) setReady(true) @@ -891,30 +897,6 @@ function BumperPreviewPlayer({ onError, }) - // Грузим отрендеренный превью-плейлист через hls.js, добавляя Bearer-токен (admin-роут под JWT). - useEffect(() => { - if (!ready) return - const video = videoRef.current - if (!video) return - const src = `${bumperPreviewPlaylistUrl(channelId, templateId)}?t=${bust}` - let hls: Hls | null = null - if (Hls.isSupported()) { - hls = new Hls({ - xhrSetup: (xhr) => { - const token = getAccessToken() - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) - }, - }) - hls.loadSource(src) - hls.attachMedia(video) - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = src - } - return () => { - hls?.destroy() - } - }, [ready, bust, channelId, templateId]) - return ( <>
@@ -933,17 +915,56 @@ function BumperPreviewPlayer({
{ready && ( -