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)
|
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapGet(
|
admin.MapGet(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/index.m3u8",
|
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
|
||||||
PreviewPlaylist
|
PreviewPlaylist
|
||||||
);
|
);
|
||||||
admin.MapGet(
|
admin.MapGet(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{file}",
|
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
|
||||||
PreviewSegment
|
PreviewSegment
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -452,10 +452,10 @@ public static class ChannelEndpoints
|
|||||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Плейлист превью: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
private static IResult PreviewPlaylist(Guid id, Guid templateId, MediaPathResolver paths)
|
private static IResult PreviewPlaylist(Guid id, Guid templateId, Guid variantId, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
var previewId = BumperPreview.AssetId(templateId);
|
var previewId = BumperPreview.AssetId(variantId);
|
||||||
string indexPath;
|
string indexPath;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -468,7 +468,7 @@ public static class ChannelEndpoints
|
|||||||
if (!File.Exists(indexPath))
|
if (!File.Exists(indexPath))
|
||||||
return Results.NotFound();
|
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();
|
var sb = new StringBuilder();
|
||||||
foreach (var line in File.ReadLines(indexPath))
|
foreach (var line in File.ReadLines(indexPath))
|
||||||
{
|
{
|
||||||
@@ -483,12 +483,18 @@ public static class ChannelEndpoints
|
|||||||
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
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))
|
if (!BumperSegmentFileName.IsMatch(file))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
var previewId = BumperPreview.AssetId(templateId);
|
var previewId = BumperPreview.AssetId(variantId);
|
||||||
string path;
|
string path;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ using TeleWave.Application.Common.Models;
|
|||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Синхронно рендерит пример заставки блока (с примерными названиями шоу) и возвращает id
|
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
|
||||||
/// ассета-превью. БД не меняет — это read-side генерация артефакта для предпросмотра.
|
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет — read-side генерация артефактов.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId)
|
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) : IQuery<Result>;
|
||||||
: IQuery<Result<Guid>>;
|
|
||||||
|
|||||||
+36
-35
@@ -15,7 +15,7 @@ public sealed class RenderBumperPreviewQueryHandler(
|
|||||||
IImageStore imageStore,
|
IImageStore imageStore,
|
||||||
IOptions<BumperOptions> bumperOptions,
|
IOptions<BumperOptions> bumperOptions,
|
||||||
IOptions<StreamingOptions> streamingOptions
|
IOptions<StreamingOptions> streamingOptions
|
||||||
) : IQueryHandler<RenderBumperPreviewQuery, Result<Guid>>
|
) : IQueryHandler<RenderBumperPreviewQuery, Result>
|
||||||
{
|
{
|
||||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
@@ -23,7 +23,7 @@ public sealed class RenderBumperPreviewQueryHandler(
|
|||||||
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
||||||
private const int DefaultBumperDurationSeconds = 8;
|
private const int DefaultBumperDurationSeconds = 8;
|
||||||
|
|
||||||
public async Task<Result<Guid>> Handle(
|
public async Task<Result> Handle(
|
||||||
RenderBumperPreviewQuery query,
|
RenderBumperPreviewQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
@@ -35,21 +35,17 @@ public sealed class RenderBumperPreviewQueryHandler(
|
|||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||||
if (channel is null)
|
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);
|
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||||
|
|
||||||
// Превью показываем по первому подблоку (стиль/звук блока + его текст).
|
|
||||||
var variant = template.Variants.OrderBy(v => v.Position).FirstOrDefault();
|
|
||||||
if (variant is null)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
|
|
||||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
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;
|
string? backgroundPath = null;
|
||||||
if (template.BackgroundImageId is { } bgId)
|
if (template.BackgroundImageId is { } bgId)
|
||||||
{
|
{
|
||||||
@@ -66,32 +62,37 @@ public sealed class RenderBumperPreviewQueryHandler(
|
|||||||
var aligned = (int)(
|
var aligned = (int)(
|
||||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
||||||
);
|
);
|
||||||
|
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||||
|
|
||||||
var spec = new BumperRenderSpec(
|
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
||||||
aligned,
|
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||||
_bumper.Width,
|
{
|
||||||
_bumper.Height,
|
var free = variant.Kind == BumperTextKind.Free;
|
||||||
template.BackgroundColor,
|
var spec = new BumperRenderSpec(
|
||||||
template.BackgroundColor2,
|
aligned,
|
||||||
template.AccentColor,
|
_bumper.Width,
|
||||||
template.TextColor,
|
_bumper.Height,
|
||||||
channel.BumperFont == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans,
|
template.BackgroundColor,
|
||||||
free ? "" : variant.NowLabel,
|
template.BackgroundColor2,
|
||||||
free ? "" : fromName,
|
template.AccentColor,
|
||||||
free ? "" : variant.NextLabel,
|
template.TextColor,
|
||||||
free ? "" : toName,
|
fontFile,
|
||||||
backgroundPath,
|
free ? "" : variant.NowLabel,
|
||||||
storage.AudioPath(template.Id, template.AudioExtension),
|
free ? "" : fromName,
|
||||||
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
|
free ? "" : variant.NextLabel,
|
||||||
null,
|
free ? "" : toName,
|
||||||
free,
|
backgroundPath,
|
||||||
variant.Line1,
|
audioPath,
|
||||||
variant.Line2
|
// Постер зависит от конкретного «следующего» шоу — в превью не подставляем.
|
||||||
);
|
null,
|
||||||
|
free,
|
||||||
|
variant.Line1,
|
||||||
|
variant.Line2
|
||||||
|
);
|
||||||
|
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var previewId = BumperPreview.AssetId(template.Id);
|
return Result.Success();
|
||||||
await renderer.RenderAsync(previewId, spec, cancellationToken);
|
|
||||||
return Result.Success(previewId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
|
/// <summary>Примерные названия «из/в» — берём первые два шоу канала, иначе заглушки.</summary>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
removeBumperVariant,
|
removeBumperVariant,
|
||||||
removeChannelAd,
|
removeChannelAd,
|
||||||
removeChannelShow,
|
removeChannelShow,
|
||||||
renderBumperPreview,
|
renderBumperPreviews,
|
||||||
setBumperTemplateBackground,
|
setBumperTemplateBackground,
|
||||||
updateBumperTemplate,
|
updateBumperTemplate,
|
||||||
updateBumperVariant,
|
updateBumperVariant,
|
||||||
@@ -694,7 +694,12 @@ function BumperTemplateEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
<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>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -871,19 +876,20 @@ function BumperVariantEditor({
|
|||||||
function BumperPreviewPlayer({
|
function BumperPreviewPlayer({
|
||||||
channelId,
|
channelId,
|
||||||
templateId,
|
templateId,
|
||||||
|
variants,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
channelId: string
|
channelId: string
|
||||||
templateId: string
|
templateId: string
|
||||||
|
variants: BumperTextVariantDto[]
|
||||||
onError: (e: unknown) => void
|
onError: (e: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
const [bust, setBust] = useState(0)
|
const [bust, setBust] = useState(0)
|
||||||
|
|
||||||
const render = useMutation({
|
const render = useMutation({
|
||||||
mutationFn: () => renderBumperPreview(channelId, templateId),
|
mutationFn: () => renderBumperPreviews(channelId, templateId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setBust(Date.now())
|
setBust(Date.now())
|
||||||
setReady(true)
|
setReady(true)
|
||||||
@@ -891,30 +897,6 @@ function BumperPreviewPlayer({
|
|||||||
onError,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -933,17 +915,56 @@ function BumperPreviewPlayer({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ready && (
|
{ready && (
|
||||||
<video
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
ref={videoRef}
|
{[...variants]
|
||||||
controls
|
.sort((a, b) => a.position - b.position)
|
||||||
playsInline
|
.map((v) => (
|
||||||
className="aspect-video w-full max-w-sm rounded-md border border-border bg-black"
|
<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({
|
function BumperBackgroundField({
|
||||||
channelId,
|
channelId,
|
||||||
templateId,
|
templateId,
|
||||||
|
|||||||
@@ -195,15 +195,15 @@ export function clearBumperTemplateBackground(id: string, templateId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Синхронно рендерит пример заставки блока (сервер собирает ffmpeg-клип). */
|
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||||
export function renderBumperPreview(id: string, templateId: string) {
|
export function renderBumperPreviews(id: string, templateId: string) {
|
||||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string) {
|
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/index.m3u8`
|
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OverrideBody = {
|
export type OverrideBody = {
|
||||||
|
|||||||
@@ -239,9 +239,9 @@ const resources = {
|
|||||||
bumperDefaultDuration: '≈8 с (джингл)',
|
bumperDefaultDuration: '≈8 с (джингл)',
|
||||||
bumperAudio: 'Звук',
|
bumperAudio: 'Звук',
|
||||||
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
|
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
|
||||||
bumperPreview: 'Отрендерить пример',
|
bumperPreview: 'Отрендерить примеры',
|
||||||
bumperPreviewRendering: 'Рендерим…',
|
bumperPreviewRendering: 'Рендерим…',
|
||||||
bumperPreviewHint: 'Пример со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
|
bumperPreviewHint: 'Примеры всех подблоков со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
|
||||||
bumperBackground: 'Фон-картинка',
|
bumperBackground: 'Фон-картинка',
|
||||||
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
|
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
|
||||||
bumperBackgroundPick: 'Выбрать из галереи',
|
bumperBackgroundPick: 'Выбрать из галереи',
|
||||||
@@ -559,9 +559,9 @@ const resources = {
|
|||||||
bumperDefaultDuration: '≈8 s (jingle)',
|
bumperDefaultDuration: '≈8 s (jingle)',
|
||||||
bumperAudio: 'Sound',
|
bumperAudio: 'Sound',
|
||||||
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
|
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
|
||||||
bumperPreview: 'Render sample',
|
bumperPreview: 'Render samples',
|
||||||
bumperPreviewRendering: 'Rendering…',
|
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',
|
bumperBackground: 'Background image',
|
||||||
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
|
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
|
||||||
bumperBackgroundPick: 'Pick from gallery',
|
bumperBackgroundPick: 'Pick from gallery',
|
||||||
|
|||||||
Reference in New Issue
Block a user