feat(65): the replay layer exists in the model; the exclusion problem is solved by the guard

Slice 5a, scaffolding only. 607 green.

- `Source.IsReplayLayer` + schema column + roundtrip: a media layer that shows saved clips instead of a
  chosen file. The compositor needs to know nothing — it is a media layer whose path the VM owns.
- **Two spec corrections from the creator (2026-11-10), recorded in the task:**
  1. The immediate replay of a fresh clip happens **only in the Live scene**. While it plays, **F10 is
     inert** ("replay in progress").
  2. That guard replaces the "layer excluded from its own clip" idea, which is **impossible** with one
     encoder and two muxes: the clip is a second mux of the same frames, so it necessarily contains what
     viewers saw. No second encode, no fiction about a clean feed.

Remaining for 65: the add-layer menu entry, `ClipSaved` → play (Live: once then hide; elsewhere: loop the
kept set), `SessionCompleted` → advance or hide, and silent-by-default audio.
This commit is contained in:
2026-11-10 12:48:41 -08:00
parent 28657918f9
commit a5c2061b55
5 changed files with 34 additions and 5 deletions
+10
View File
@@ -234,6 +234,16 @@ public class Source : SceneElement
set => Set(ref _mediaType, value);
}
private bool _isReplayLayer;
/// <summary>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.</summary>
public bool IsReplayLayer
{
get => _isReplayLayer;
set => Set(ref _isReplayLayer, value);
}
private string? _mediaPath;
public string? MediaPath
{
+3 -1
View File
@@ -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)>();
+9
View File
@@ -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();
+4 -2
View File
@@ -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;
+8 -2
View File
@@ -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