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.

This commit is contained in:
Leonid Pershin
2026-07-25 13:31:38 +03:00
parent a65bcf4258
commit 18325fdfcc
6 changed files with 116 additions and 89 deletions
@@ -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();
}
/// <summary>Плейлист превью: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, Guid templateId, MediaPathResolver paths)
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
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
{
@@ -4,8 +4,7 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Синхронно рендерит пример заставки блока (с примерными названиями шоу) и возвращает id
/// ассета-превью. БД не меняет — это read-side генерация артефакта для предпросмотра.
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет — read-side генерация артефактов.
/// </summary>
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId)
: IQuery<Result<Guid>>;
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) : IQuery<Result>;
@@ -15,7 +15,7 @@ public sealed class RenderBumperPreviewQueryHandler(
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : IQueryHandler<RenderBumperPreviewQuery, Result<Guid>>
) : IQueryHandler<RenderBumperPreviewQuery, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
@@ -23,7 +23,7 @@ public sealed class RenderBumperPreviewQueryHandler(
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result<Guid>> Handle(
public async Task<Result> 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<Guid>(ChannelErrors.NotFound);
return Result.Failure(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
if (template is null)
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
// Превью показываем по первому подблоку (стиль/звук блока + его текст).
var variant = template.Variants.OrderBy(v => v.Position).FirstOrDefault();
if (variant is null)
return Result.Failure<Guid>(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();
}
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
@@ -47,7 +47,7 @@ import {
removeBumperVariant,
removeChannelAd,
removeChannelShow,
renderBumperPreview,
renderBumperPreviews,
setBumperTemplateBackground,
updateBumperTemplate,
updateBumperVariant,
@@ -694,7 +694,12 @@ function BumperTemplateEditor({
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer channelId={channelId} templateId={template.id} onError={onError} />
<BumperPreviewPlayer
channelId={channelId}
templateId={template.id}
variants={template.variants}
onError={onError}
/>
</div>
<div className="flex justify-end">
@@ -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<HTMLVideoElement>(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 (
<>
<div className="flex flex-wrap items-center gap-2">
@@ -933,17 +915,56 @@ function BumperPreviewPlayer({
</span>
</div>
{ready && (
<video
ref={videoRef}
controls
playsInline
className="aspect-video w-full max-w-sm rounded-md border border-border bg-black"
/>
<div className="grid gap-3 sm:grid-cols-2">
{[...variants]
.sort((a, b) => a.position - b.position)
.map((v) => (
<div key={v.id} className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{v.name}</span>
<PreviewVideo
src={`${bumperPreviewPlaylistUrl(channelId, templateId, v.id)}?t=${bust}`}
/>
</div>
))}
</div>
)}
</>
)
}
/** Мини-плеер одного превью: грузит HLS через hls.js с Bearer-токеном (admin-роут под JWT). */
function PreviewVideo({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null)
useEffect(() => {
const video = videoRef.current
if (!video) return
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()
}
}, [src])
return (
<video
ref={videoRef}
controls
playsInline
className="aspect-video w-full rounded-md border border-border bg-black"
/>
)
}
function BumperBackgroundField({
channelId,
templateId,
+4 -4
View File
@@ -195,15 +195,15 @@ export function clearBumperTemplateBackground(id: string, templateId: string) {
})
}
/** Синхронно рендерит пример заставки блока (сервер собирает ffmpeg-клип). */
export function renderBumperPreview(id: string, templateId: string) {
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
export function renderBumperPreviews(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/index.m3u8`
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
}
export type OverrideBody = {
+4 -4
View File
@@ -239,9 +239,9 @@ const resources = {
bumperDefaultDuration: '≈8 с (джингл)',
bumperAudio: 'Звук',
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
bumperPreview: 'Отрендерить пример',
bumperPreview: 'Отрендерить примеры',
bumperPreviewRendering: 'Рендерим…',
bumperPreviewHint: 'Пример со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
bumperPreviewHint: 'Примеры всех подблоков со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
bumperBackground: 'Фон-картинка',
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
bumperBackgroundPick: 'Выбрать из галереи',
@@ -559,9 +559,9 @@ const resources = {
bumperDefaultDuration: '≈8 s (jingle)',
bumperAudio: 'Sound',
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
bumperPreview: 'Render sample',
bumperPreview: 'Render samples',
bumperPreviewRendering: 'Rendering…',
bumperPreviewHint: 'Sample with sound and animation (example show names). Uses saved settings.',
bumperPreviewHint: 'Samples of all sub-blocks with sound and animation (example show names). Uses saved settings.',
bumperBackground: 'Background image',
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
bumperBackgroundPick: 'Pick from gallery',