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:
@@ -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>;
|
||||
|
||||
+36
-35
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user