From 6be373476c9b98da382f1bd80dfb8a799b64dc27 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 29 Jul 2026 23:54:11 +0300 Subject: [PATCH] Implement background clip functionality for bumpers 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. --- .../Broadcast/Bumpers/BumperCommands.cs | 10 +- .../Broadcast/Bumpers/BumperDtos.cs | 4 +- .../Broadcast/Bumpers/BumperMapper.cs | 3 +- .../Broadcast/Bumpers/BumperSpecFactory.cs | 6 +- .../Broadcast/Bumpers/BumperSpecInputs.cs | 4 +- .../Broadcast/Bumpers/BumperSpecLoader.cs | 35 +- .../RenderBumperPreviewCommandHandler.cs | 67 +- .../UpdateBumperVariantCommandHandler.cs | 3 +- .../Common/Interfaces/IBumperRenderer.cs | 12 +- .../Common/Interfaces/IMediaStorage.cs | 6 + .../Programming/Planning/BumperResolver.cs | 67 +- .../TeleWave.Domain/Broadcast/BumperAsset.cs | 10 +- .../Broadcast/BumperBackground.cs | 6 + .../Broadcast/BumperTextVariant.cs | 17 +- .../Media/FfmpegBumperRenderer.cs | 22 +- .../Media/FileSystemMediaStorage.cs | 6 + ...729204622_BumperClipBackground.Designer.cs | 1428 +++++++++++++++++ .../20260729204622_BumperClipBackground.cs | 37 + .../Migrations/AppDbContextModelSnapshot.cs | 6 + .../Broadcast/BumperPreviewTests.cs | 4 +- .../Broadcast/BumperSpecLoaderTests.cs | 4 +- docs/tv-scheduler-architecture.md | 13 + .../components/BumperVariantEditor.tsx | 38 +- frontend/src/shared/api/types.ts | 4 +- frontend/src/shared/lib/locales/en.ts | 5 + frontend/src/shared/lib/locales/ru.ts | 5 + 26 files changed, 1798 insertions(+), 24 deletions(-) create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.cs diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs index 9517c98..1d1d18c 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperCommands.cs @@ -36,7 +36,9 @@ public sealed record BumperVariantInput( BumperTrigger Trigger, BumperBackground Background, int Weight, - IReadOnlyList Lines + IReadOnlyList Lines, + /// Ролик-подложка для фона . + 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); } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs index 094cb5b..124cdbc 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperDtos.cs @@ -13,7 +13,9 @@ public sealed record BumperTextVariantDto( BumperTrigger Trigger, BumperBackground Background, int Weight, - IReadOnlyList Lines + IReadOnlyList Lines, + /// Ролик-подложка для фона . + Guid? BackgroundClipShowId ); /// Блок заставки: оформление + звук + подблоки. — длина звука (сек). diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs index 6d69ce6..348a9ad 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperMapper.cs @@ -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() ); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs index c6a3324..2747fd3 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecFactory.cs @@ -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 ); } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs index 5786a4a..e0479f6 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecInputs.cs @@ -12,5 +12,7 @@ public sealed record BumperSpecInputs( string? AudioPath = null, /// Постер шоу как фон; подставляется, только если подблок его запросил. string? PosterAbsolutePath = null, - string? BackgroundAbsolutePath = null + string? BackgroundAbsolutePath = null, + /// Плейлист ролика-подложки, если текст пишется поверх готового видео. + string? ClipAbsolutePath = null ); diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs index 793cacc..b463e89 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperSpecLoader.cs @@ -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, IOptions 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 ) ); } + /// Плейлист ролика-подложки и его длительность; null — ролика уже нет или он не готов. + 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 ResolveShowPosterAsync( Guid showId, CancellationToken cancellationToken diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index 605a307..5e0da84 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -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, IOptions streamingOptions ) : ICommandHandler @@ -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(); } + /// Ролик-подложка: плейлист и длительность. + private sealed record ClipSource(string Path, int Seconds); + + /// Ролики-подложки подблоков блока, по шоу. Неготовые молча выпадают — превью не эфир. + private async Task> 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(); + 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( diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs index 04beb59..56ae11d 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperVariantCommandHandler.cs @@ -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( diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs index 45df867..887ab6c 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperRenderer.cs @@ -24,7 +24,17 @@ public sealed record BumperRenderSpec( string? BackgroundFile = null, string? MusicFile = null, /// Постер шоу как фон (используется, если подблок его запросил; затемняется). - string? PosterFile = null + string? PosterFile = null, + /// + /// Готовый ролик как подложка: плейлист ассета из библиотеки. Побеждает остальные источники — + /// это самый явный выбор из всех, его не подставляют молча. + /// + string? ClipFile = null, + /// + /// Брать звук из самого ролика. Ставится, когда у блока нет джингла: подкладывать под чужую + /// заставку синтезированный писк хуже, чем оставить её собственную дорожку. + /// + bool UseClipAudio = false ); /// Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки. diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs index 55f8db8..080c5e1 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -11,6 +11,12 @@ public interface IMediaStorage /// Свободное место на томе хранилища, байт. long GetAvailableFreeSpaceBytes(); + /// + /// Абсолютный путь к HLS-плейлисту готового ассета (assets/{id}/index.m3u8) — им ffmpeg + /// читает уже нарезанное медиа как обычный вход. Null, если каталога ассета нет на диске. + /// + string? ResolvePlaylistPath(Guid assetId); + /// /// Стримит загружаемый контент во временный файл в uploads/ без буферизации в память. /// Возвращает непрозрачный токен (имя временного файла) для последующего . diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs index abc176e..253bf4f 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs @@ -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]; } + /// + /// Готовые ассеты роликов-подложек по шоу. Берётся первая готовая серия: у ролика она всегда + /// одна, а необработанному в эфире взяться неоткуда. + /// + private async Task> LoadClipAssetsAsync( + IEnumerable 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); + } + /// Что нужно отрендерить для одной записи ленты. private sealed record BumperRequest( BumperTemplate Template, Guid VariantId, string Lines, Guid? PosterShowId, + Guid? BackgroundAssetId, string Signature ) { diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs index d04952f..daf00f3 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperAsset.cs @@ -27,6 +27,12 @@ public class BumperAsset /// Шоу, чей постер идёт фоном, или null (фон блока/градиент). public Guid? PosterShowId { get; private set; } + /// + /// Ассет ролика-подложки, если текст пишется поверх готового видео. Хранится именно ассет, + /// а не шоу: рендеру нужен конкретный файл, а перезалив ролика обязан дать новую сигнатуру. + /// + public Guid? BackgroundAssetId { get; private set; } + /// Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером). 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, }; diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs index 9cf6cb7..5fee6bb 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperBackground.cs @@ -14,4 +14,10 @@ public enum BumperBackground /// Постер предыдущего шоу. NowPoster = 2, + + /// + /// Готовый ролик из библиотеки: текст пишется поверх видео. Длину задаёт сам ролик, а не + /// джингл блока — подрезать чужую заставку под музыку значило бы обрывать её на полуслове. + /// + Clip = 3, } diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs index 2fec998..3885d13 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs @@ -19,6 +19,12 @@ public class BumperTextVariant /// Источник картинки под текстом. public BumperBackground Background { get; private set; } + /// + /// Ролик-подложка для : шоу-Interstitial из библиотеки. + /// Ссылка на шоу, а не на ассет: перезалив файла не должен рвать настройку подблока. + /// + public Guid? BackgroundClipShowId { get; private set; } + /// Вес при выборе подблока на переходе (0 — не выбирается никогда). 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; } /// diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs index 37b7250..5e417ff 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -183,7 +183,16 @@ public sealed class FfmpegBumperRenderer( // анимированный градиент. var inputs = new List(); 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 = diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index 908acd4..57c1592 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -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 SaveUploadAsync( Stream content, string extension, diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.Designer.cs new file mode 100644 index 0000000..969914a --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.Designer.cs @@ -0,0 +1,1428 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260729204622_BumperClipBackground")] + partial class BumperClipBackground + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BackgroundAssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("PosterShowId") + .HasColumnType("uuid"); + + b.Property("RenderedLinesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("Signature") + .IsUnique(); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Font") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Background") + .HasColumnType("integer"); + + b.Property("BackgroundClipShowId") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FranchiseExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FranchiseName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChoiceKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ChoiceWeight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId"); + + b.HasIndex("BumperVariantId"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxTotalSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 => + { + b1.Property("BumperTextVariantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Color"); + + b1.Property("Position"); + + b1.Property("Style"); + + b1.Property("Text") + .IsRequired() + .HasMaxLength(120); + + b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal"); + + b1.ToTable("BumperTextVariants"); + + b1 + .ToJson("Lines") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BumperTextVariantId"); + }); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany() + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null) + .WithMany() + .HasForeignKey("BumperVariantId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.cs new file mode 100644 index 0000000..ffba5a9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260729204622_BumperClipBackground.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class BumperClipBackground : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BackgroundClipShowId", + table: "BumperTextVariants", + type: "uuid", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "BackgroundAssetId", + table: "BumperAssets", + type: "uuid", + nullable: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "BackgroundClipShowId", table: "BumperTextVariants"); + + migrationBuilder.DropColumn(name: "BackgroundAssetId", table: "BumperAssets"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 51fb479..0dff446 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -164,6 +164,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("BackgroundAssetId") + .HasColumnType("uuid"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -262,6 +265,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Background") .HasColumnType("integer"); + b.Property("BackgroundClipShowId") + .HasColumnType("uuid"); + b.Property("BumperTemplateId") .HasColumnType("uuid"); diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs index 12a44b5..ba261f2 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs @@ -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(), storage ?? Substitute.For(), imageStore ?? Substitute.For(), + mediaStorage ?? Substitute.For(), Options.Create(new BumperOptions()), Options.Create(new StreamingOptions { SegmentSeconds = 5 }) ); diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs index f88fba1..f20f923 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperSpecLoaderTests.cs @@ -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(), images ?? Substitute.For(), + media ?? Substitute.For(), Options.Create(new BumperOptions()), Options.Create(new StreamingOptions { SegmentSeconds = 5 }) ); diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 632bd88..63e502c 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -601,6 +601,19 @@ BumperLine Источник фона становится явным полем подблока. Раньше постер шоу подставлялся молча и только в режиме `NowNext`; при свободных строках такой связи взяться неоткуда. +**Фон из готового ролика** (`background = clip`, `backgroundClipShowId`) — текст пишется поверх +видео из библиотеки, а не поверх картинки. Отличается от прочих источников тем, что задаёт время: + +- **длину заставки диктует ролик**, а не джингл блока. Подрезать чужую заставку под музыку значило бы + обрывать её на полуслове. Выравнивание по сегменту остаётся, хвост добирается стоп-кадром — + повтор первых секунд в конце заметнее любой заморозки; +- **звук**: назначен джингл — звучит он, подрезанный под ролик; не назначен — играет оригинальная + дорожка ролика. Синтезированный писк под чужую заставку не подкладывается; +- в сигнатуру кэша входит **ассет** ролика, а не шоу: перезалив файла обязан дать новый рендер. + В подблоке при этом хранится ссылка на шоу — перезалив не должен рвать настройку; +- ролик не готов или удалён — подблок молча отрабатывает по фону блока: ронять заставку, которая + уже стоит в ленте, нельзя. + **Плейсхолдеры** подставляются в момент планирования — там уже известны канал, пара соседей и точное время врезки: diff --git a/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx b/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx index c26f28b..1e74991 100644 --- a/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx +++ b/frontend/src/features/admin/bumpers/components/BumperVariantEditor.tsx @@ -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({ + {/* Ролик-подложка — только при своём источнике фона: в остальных случаях поле было бы + настройкой, которая ни на что не влияет. */} + {form.background === 'Clip' && ( +
+ + + + {t('admin.bumpers.backgroundClipHint')} + +
+ )}