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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user