diff --git a/Models/Source.cs b/Models/Source.cs index 7012be3..6025e3d 100644 --- a/Models/Source.cs +++ b/Models/Source.cs @@ -234,6 +234,16 @@ public class Source : SceneElement set => Set(ref _mediaType, value); } + private bool _isReplayLayer; + + /// TASK 65: this media layer shows saved replay clips rather than a chosen file. The VM + /// owns its path and playlist; the compositor needs to know nothing. + public bool IsReplayLayer + { + get => _isReplayLayer; + set => Set(ref _isReplayLayer, value); + } + private string? _mediaPath; public string? MediaPath { diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index 1ff071a..35091b4 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -117,7 +117,7 @@ public partial class LayoutStore : IDisposable TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted, ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness, - ChromaKeySpill, ChromaScreen + ChromaKeySpill, ChromaScreen, IsReplayLayer FROM Source ORDER BY SortOrder """; using var reader = cmd.ExecuteReader(); @@ -198,6 +198,8 @@ public partial class LayoutStore : IDisposable ChromaScreen = reader.IsDBNull(reader.GetOrdinal("ChromaScreen")) ? ChromaScreen.Green : (ChromaScreen)reader.GetInt32(reader.GetOrdinal("ChromaScreen")), + IsReplayLayer = !reader.IsDBNull(reader.GetOrdinal("IsReplayLayer")) + && reader.GetInt32(reader.GetOrdinal("IsReplayLayer")) != 0, }; if (!sourcesByScene.TryGetValue(sceneId, out var list)) sourcesByScene[sceneId] = list = new List<(Source, int)>(); diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index 03baaf0..ab598aa 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -48,6 +48,7 @@ public partial class LayoutStore : IDisposable ClipShape TEXT NOT NULL DEFAULT 'Traditional', IsMirrored INTEGER NOT NULL DEFAULT 0, IsBackground INTEGER NOT NULL DEFAULT 0, + IsReplayLayer INTEGER NOT NULL DEFAULT 0, CaptureKey TEXT, SortOrder INTEGER NOT NULL DEFAULT 0 ); @@ -320,6 +321,14 @@ public partial class LayoutStore : IDisposable cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';"; cmd.ExecuteNonQuery(); } + // TASK 65: a media layer that plays the replay clips instead of a chosen file. + if (!columns.Contains("IsReplayLayer")) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsReplayLayer INTEGER NOT NULL DEFAULT 0;"; + cmd.ExecuteNonQuery(); + } + if (!columns.Contains("ShowDesktop")) { using var cmd = _connection.CreateCommand(); diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index bb839cd..8d60d8e 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -106,7 +106,7 @@ public partial class LayoutStore : IDisposable TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic, TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted, ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, - ChromaKeySoftness, ChromaKeySpill, ChromaScreen) + ChromaKeySoftness, ChromaKeySpill, ChromaScreen, IsReplayLayer) VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, $x, $y, $w, $h, $opacity, $monitor, $clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort, @@ -118,7 +118,7 @@ public partial class LayoutStore : IDisposable $textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic, $textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted, $chromaEnabled, $chromaColor, $chromaSimilarity, $chromaSoftness, - $chromaSpill, $chromaScreen) + $chromaSpill, $chromaScreen, $isReplayLayer) """; cmd.Transaction = tx; var idP = cmd.Parameters.Add("$id", SqliteType.Text); @@ -158,6 +158,7 @@ public partial class LayoutStore : IDisposable var chromaSoftnessP = cmd.Parameters.Add("$chromaSoftness", SqliteType.Real); var chromaSpillP = cmd.Parameters.Add("$chromaSpill", SqliteType.Integer); var chromaScreenP = cmd.Parameters.Add("$chromaScreen", SqliteType.Integer); + var replayLayerP = cmd.Parameters.Add("$isReplayLayer", SqliteType.Integer); var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text); var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text); var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text); @@ -226,6 +227,7 @@ public partial class LayoutStore : IDisposable chromaSoftnessP.Value = source.ChromaKeySoftness; chromaSpillP.Value = source.ChromaKeySpill ? 1 : 0; chromaScreenP.Value = (int)source.ChromaScreen; + replayLayerP.Value = source.IsReplayLayer ? 1 : 0; mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString(); alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value; alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value; diff --git a/TASKS/task-65-replay-clips.md b/TASKS/task-65-replay-clips.md index a0aa23c..bfdb1e7 100644 --- a/TASKS/task-65-replay-clips.md +++ b/TASKS/task-65-replay-clips.md @@ -31,6 +31,11 @@ never the creator, who cannot reach a keyboard mid-moment). ## The replay layer +**Scope settled 2026-11-10:** the immediate replay of a fresh clip happens **only in the Live scene**. +While that replay is playing, **F10 is inert** ("replay in progress") — that is what replaces the +hall-of-mirrors exclusion, which is impossible with one encoder and two muxes: the clip *is* what the +stream got. + One layer type, added by the creator to the scenes they want; behaviour comes from the scene: | Scene | Behaviour | @@ -40,8 +45,9 @@ One layer type, added by the creator to the scenes they want; behaviour comes fr - **Audio off by default**, toggleable per layer. The show's sound design is the creator's; a replay should not re-mix it uninvited. -- **The replay layer is excluded from the clip's own composition.** Otherwise saving while a clip plays - records a video of a video (hall of mirrors). Viewers still see it live; the clip simply doesn't. +- **No exclusion, no second encode.** The clip is a second mux of the frames the stream already got, so + it contains whatever viewers saw. Double-clipping is prevented instead: F10 does nothing while the + Live replay is playing. ## Storage and retention