Implement background clip functionality for bumpers
ci / build-backend (push) Successful in 2m14s
ci / build-frontend (push) Successful in 49s
ci / tests (push) Successful in 2m7s
ci / sonar (push) Successful in 7m39s

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:
Leonid Pershin
2026-07-29 23:54:11 +03:00
parent 35cb4b09dc
commit 6be373476c
26 changed files with 1798 additions and 24 deletions
@@ -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
@@ -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(
@@ -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
)
{
@@ -27,6 +27,12 @@ public class BumperAsset
/// <summary>Шоу, чей постер идёт фоном, или null (фон блока/градиент).</summary>
public Guid? PosterShowId { get; private set; }
/// <summary>
/// Ассет ролика-подложки, если текст пишется поверх готового видео. Хранится именно ассет,
/// а не шоу: рендеру нужен конкретный файл, а перезалив ролика обязан дать новую сигнатуру.
/// </summary>
public Guid? BackgroundAssetId { get; private set; }
/// <summary>Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером).</summary>
public Guid MediaAssetId { get; private set; }
@@ -40,7 +46,8 @@ public class BumperAsset
string signature,
string renderedLinesJson,
Guid? posterShowId,
Guid mediaAssetId
Guid mediaAssetId,
Guid? backgroundAssetId = null
) =>
new()
{
@@ -50,6 +57,7 @@ public class BumperAsset
Signature = signature,
RenderedLinesJson = renderedLinesJson,
PosterShowId = posterShowId,
BackgroundAssetId = backgroundAssetId,
MediaAssetId = mediaAssetId,
CreatedAt = DateTimeOffset.UtcNow,
};
@@ -14,4 +14,10 @@ public enum BumperBackground
/// <summary>Постер предыдущего шоу.</summary>
NowPoster = 2,
/// <summary>
/// Готовый ролик из библиотеки: текст пишется поверх видео. Длину задаёт сам ролик, а не
/// джингл блока — подрезать чужую заставку под музыку значило бы обрывать её на полуслове.
/// </summary>
Clip = 3,
}
@@ -19,6 +19,12 @@ public class BumperTextVariant
/// <summary>Источник картинки под текстом.</summary>
public BumperBackground Background { get; private set; }
/// <summary>
/// Ролик-подложка для <see cref="BumperBackground.Clip"/>: шоу-<c>Interstitial</c> из библиотеки.
/// Ссылка на шоу, а не на ассет: перезалив файла не должен рвать настройку подблока.
/// </summary>
public Guid? BackgroundClipShowId { get; private set; }
/// <summary>Вес при выборе подблока на переходе (0 — не выбирается никогда).</summary>
public int Weight { get; private set; } = DefaultWeight;
@@ -49,12 +55,21 @@ public class BumperTextVariant
CreatedAt = DateTimeOffset.UtcNow,
};
public void Update(string name, BumperTrigger trigger, BumperBackground background, int weight)
public void Update(
string name,
BumperTrigger trigger,
BumperBackground background,
int weight,
Guid? backgroundClipShowId = null
)
{
Name = name;
Trigger = trigger;
Background = background;
Weight = Math.Max(0, weight);
// Ссылка на ролик живёт только при своём источнике фона: иначе она осталась бы висеть
// после переключения на постер и всплыла бы при возврате уже неожиданно.
BackgroundClipShowId = background == BumperBackground.Clip ? backgroundClipShowId : null;
}
/// <summary>
@@ -183,7 +183,16 @@ public sealed class FfmpegBumperRenderer(
// анимированный градиент.
var inputs = new List<string>();
string videoPrefix;
if (!string.IsNullOrEmpty(spec.BackgroundFile))
if (!string.IsNullOrEmpty(spec.ClipFile))
{
// Готовый ролик как подложка. Не зацикливаем: длину задаёт он сам, а выравнивание по
// сегменту добираем стоп-кадром — повтор первых секунд в конце заметнее любой заморозки.
inputs.AddRange(["-i", spec.ClipFile]);
videoPrefix =
$"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
+ $",tpad=stop_mode=clone:stop_duration={Fmt(target)},fps=30,format=yuv420p";
}
else if (!string.IsNullOrEmpty(spec.BackgroundFile))
{
if (IsImage(spec.BackgroundFile))
inputs.AddRange(["-loop", "1"]);
@@ -212,8 +221,17 @@ public sealed class FfmpegBumperRenderer(
}
// Вход 1 — звук: загруженная музыка (петля + loudnorm) либо синтезированный джингл.
// У ролика-подложки без джингла звучит его собственная дорожка — второго входа не нужно.
string audioChain;
if (!string.IsNullOrEmpty(spec.MusicFile))
if (spec.UseClipAudio)
{
audioChain =
$"[0:a]loudnorm=I={_media.LoudnessTargetLufs.ToString(CultureInfo.InvariantCulture)}:TP=-1.5:LRA=11"
// apad добивает тишиной хвост, дорисованный стоп-кадром: без него поток звука
// кончается раньше видео и ffmpeg обрезает кадр по нему.
+ $",apad,afade=t=out:st={Fmt(outStart)}:d=1.0[a]";
}
else if (!string.IsNullOrEmpty(spec.MusicFile))
{
inputs.AddRange(["-stream_loop", "-1", "-i", spec.MusicFile]);
audioChain =
@@ -23,6 +23,12 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
}
}
public string? ResolvePlaylistPath(Guid assetId)
{
var path = Path.Combine(paths.AssetDir(assetId), "index.m3u8");
return File.Exists(path) ? path : null;
}
public async Task<string> SaveUploadAsync(
Stream content,
string extension,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperClipBackground : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "BackgroundClipShowId",
table: "BumperTextVariants",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "BackgroundAssetId",
table: "BumperAssets",
type: "uuid",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "BackgroundClipShowId", table: "BumperTextVariants");
migrationBuilder.DropColumn(name: "BackgroundAssetId", table: "BumperAssets");
}
}
}
@@ -164,6 +164,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid?>("BackgroundAssetId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -262,6 +265,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("Background")
.HasColumnType("integer");
b.Property<Guid?>("BackgroundClipShowId")
.HasColumnType("uuid");
b.Property<Guid>("BumperTemplateId")
.HasColumnType("uuid");
@@ -26,13 +26,15 @@ public class BumperPreviewTests
AppDbContext db,
IBumperRenderer? renderer = null,
IBumperTemplateStorage? storage = null,
IImageStore? imageStore = null
IImageStore? imageStore = null,
IMediaStorage? mediaStorage = null
) =>
new(
db,
renderer ?? Substitute.For<IBumperRenderer>(),
storage ?? Substitute.For<IBumperTemplateStorage>(),
imageStore ?? Substitute.For<IImageStore>(),
mediaStorage ?? Substitute.For<IMediaStorage>(),
Options.Create(new BumperOptions()),
Options.Create(new StreamingOptions { SegmentSeconds = 5 })
);
@@ -27,12 +27,14 @@ public class BumperSpecLoaderTests
private static BumperSpecLoader NewLoader(
Infrastructure.Persistence.AppDbContext db,
IBumperTemplateStorage? storage = null,
IImageStore? images = null
IImageStore? images = null,
IMediaStorage? media = null
) =>
new(
db,
storage ?? Substitute.For<IBumperTemplateStorage>(),
images ?? Substitute.For<IImageStore>(),
media ?? Substitute.For<IMediaStorage>(),
Options.Create(new BumperOptions()),
Options.Create(new StreamingOptions { SegmentSeconds = 5 })
);
+13
View File
@@ -601,6 +601,19 @@ BumperLine
Источник фона становится явным полем подблока. Раньше постер шоу подставлялся молча и только
в режиме `NowNext`; при свободных строках такой связи взяться неоткуда.
**Фон из готового ролика** (`background = clip`, `backgroundClipShowId`) — текст пишется поверх
видео из библиотеки, а не поверх картинки. Отличается от прочих источников тем, что задаёт время:
- **длину заставки диктует ролик**, а не джингл блока. Подрезать чужую заставку под музыку значило бы
обрывать её на полуслове. Выравнивание по сегменту остаётся, хвост добирается стоп-кадром —
повтор первых секунд в конце заметнее любой заморозки;
- **звук**: назначен джингл — звучит он, подрезанный под ролик; не назначен — играет оригинальная
дорожка ролика. Синтезированный писк под чужую заставку не подкладывается;
- в сигнатуру кэша входит **ассет** ролика, а не шоу: перезалив файла обязан дать новый рендер.
В подблоке при этом хранится ссылка на шоу — перезалив не должен рвать настройку;
- ролик не готов или удалён — подблок молча отрабатывает по фону блока: ронять заставку, которая
уже стоит в ленте, нельзя.
**Плейсхолдеры** подставляются в момент планирования — там уже известны канал, пара соседей
и точное время врезки:
@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
@@ -13,13 +13,15 @@ import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { listInterstitials } from '@/features/admin/interstitials/api'
import { qk } from '@/shared/api/query-keys'
import { removeBumperVariant, updateBumperVariant } from '../api'
import { unknownTokens } from '../placeholders'
import { BumperFramePreview } from './BumperFramePreview'
import { BumperLinesEditor } from './BumperLinesEditor'
const TRIGGERS: BumperTrigger[] = ['OnShowChange', 'BetweenEpisodes', 'Both']
const BACKGROUNDS: BumperBackground[] = ['Template', 'NextPoster', 'NowPoster']
const BACKGROUNDS: BumperBackground[] = ['Template', 'NextPoster', 'NowPoster', 'Clip']
/**
* Подблок: слева строки, справа постоянный предпросмотр кадра. Предпросмотр не опциональный —
@@ -39,10 +41,16 @@ export function BumperVariantEditor({
onError: (e: unknown) => void
}>) {
const { t } = useTranslation()
// Ролики грузим списком: их немного, а выбирать подложку удобнее из готового перечня.
const { data: clips } = useQuery({
queryKey: qk.interstitials.all,
queryFn: listInterstitials,
})
const [form, setForm] = useState({
name: variant.name,
trigger: variant.trigger,
background: variant.background,
backgroundClipShowId: variant.backgroundClipShowId,
weight: variant.weight,
lines: variant.lines,
})
@@ -52,6 +60,7 @@ export function BumperVariantEditor({
name: variant.name,
trigger: variant.trigger,
background: variant.background,
backgroundClipShowId: variant.backgroundClipShowId,
weight: variant.weight,
lines: variant.lines,
})
@@ -128,6 +137,31 @@ export function BumperVariantEditor({
</SelectContent>
</Select>
</div>
{/* Ролик-подложка — только при своём источнике фона: в остальных случаях поле было бы
настройкой, которая ни на что не влияет. */}
{form.background === 'Clip' && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.backgroundClip')}</Label>
<Select
value={form.backgroundClipShowId ?? ''}
onValueChange={(v) => set('backgroundClipShowId', v)}
>
<SelectTrigger>
<SelectValue placeholder={t('admin.bumpers.pickClip')} />
</SelectTrigger>
<SelectContent>
{(clips ?? []).map((clip) => (
<SelectItem key={clip.id} value={clip.id}>
{clip.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{t('admin.bumpers.backgroundClipHint')}
</span>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label>{t('admin.bumpers.weight')}</Label>
<Input
+3 -1
View File
@@ -510,7 +510,7 @@ export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
export type BumperLineStyle = 'Label' | 'Title' | 'Caption'
export type BumperLineColor = 'Accent' | 'Text'
/** Что за картинка под текстом: фон блока, постер следующего или предыдущего шоу. */
export type BumperBackground = 'Template' | 'NextPoster' | 'NowPoster'
export type BumperBackground = 'Template' | 'NextPoster' | 'NowPoster' | 'Clip'
/** Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами. */
export type BumperLineDto = {
@@ -529,6 +529,8 @@ export type BumperTextVariantDto = {
/** Вес при выборе подблока на переходе (0 — не выбирается никогда). */
weight: number
lines: BumperLineDto[]
/** Ролик-подложка для фона `Clip`: текст пишется поверх готового видео. */
backgroundClipShowId: string | null
}
/** Блок заставки — общий для всех каналов, как группа. */
+5
View File
@@ -875,7 +875,12 @@ export const en = {
Template: 'Block background',
NextPoster: 'Next show poster',
NowPoster: 'Previous show poster',
Clip: 'Existing clip',
},
backgroundClip: 'Backing clip',
pickClip: 'Pick a clip',
backgroundClipHint:
'The text is drawn over the video. The clip sets the bumper length; the blocks audio is trimmed to it, and without audio the clips own track plays.',
fileLoaded: 'loaded',
fileDefault: 'default',
upload: 'Upload',
+5
View File
@@ -870,7 +870,12 @@ export const ru = {
Template: 'Фон блока',
NextPoster: 'Постер следующего шоу',
NowPoster: 'Постер предыдущего шоу',
Clip: 'Готовый ролик',
},
backgroundClip: 'Ролик-подложка',
pickClip: 'Выберите ролик',
backgroundClipHint:
'Текст пишется поверх видео. Длину заставки задаёт сам ролик; звук блока подрезается под неё, а без звука играет оригинальная дорожка ролика.',
fileLoaded: 'загружено',
fileDefault: 'по умолчанию',
upload: 'Загрузить',