Source access enforcement (the catalog): 10-source catalog + per-scene access matrix enforced by SceneAccessPolicy; Add-Source menu is one ItemsSource rebuilt per scene (Screen = Live-only, BG/Image/Text every scene), webcam row always present + greys out; Chatbox/Alerts/Socials are inert SourceType rows ready for their features; Text queued as a ship step (countdown + credits modes); Monetization notes socialBar free = YouTube + 1, unlimited with the paid key (79 tests passing)

This commit is contained in:
2026-08-10 12:17:03 -07:00
parent cc3b1c9234
commit ac22b1658f
8 changed files with 236 additions and 25 deletions
+11 -9
View File
@@ -4,7 +4,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:ytLive.ViewModels" xmlns:vm="clr-namespace:ytLive.ViewModels"
xmlns:models="clr-namespace:ytLive.Models"
xmlns:Helpers="clr-namespace:ytLive.Helpers" xmlns:Helpers="clr-namespace:ytLive.Helpers"
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding WindowTitle}" Height="720" Width="1280" Title="{Binding WindowTitle}" Height="720" Width="1280"
@@ -257,14 +256,17 @@
<Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0" <Button ToolTip="Add Source" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Click="AddSourceButton_Click"> Click="AddSourceButton_Click">
<Button.ContextMenu> <Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"> <ContextMenu ItemsSource="{Binding AddSourceMenuItems}"
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}" DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
IsEnabled="{Binding CanAddWebcamToActiveScene}" <ContextMenu.ItemContainerStyle>
ToolTip="One webcam at a time — it's already in your stream"/> <Style TargetType="MenuItem">
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.DisplayCapture}"/> <Setter Property="Header" Value="{Binding Header}"/>
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Background}"/> <Setter Property="Command" Value="{Binding Command}"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/> <Setter Property="CommandParameter" Value="{Binding Parameter}"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/> <Setter Property="IsEnabled" Value="{Binding IsEnabled}"/>
<Setter Property="ToolTip" Value="{Binding ToolTip}"/>
</Style>
</ContextMenu.ItemContainerStyle>
</ContextMenu> </ContextMenu>
</Button.ContextMenu> </Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2" <Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
+10 -1
View File
@@ -3,13 +3,22 @@ using ytLive.Helpers;
namespace ytLive.Models; namespace ytLive.Models;
/// <summary>
/// The source catalog (see TASKS.md). Chatbox / Alerts / Socials are inert
/// values until their features ship — the access-policy rows are live from
/// today, the menu items arrive with the features. Countdown and Credits are
/// NOT types: they're Text modes, gated by the same policy when Text ships.
/// </summary>
public enum SourceType public enum SourceType
{ {
DisplayCapture, DisplayCapture,
WindowCapture, WindowCapture,
Background, Background,
Image, Image,
TextOverlay TextOverlay,
Chatbox,
Alerts,
Socials
} }
public enum ClipShape public enum ClipShape
+32
View File
@@ -0,0 +1,32 @@
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// The per-scene source access matrix (see the source catalog in TASKS.md).
/// One source's availability in a scene is a product decision, enforced by
/// scene name on every place a source can be added. Countdown and Credits are
/// Text *modes*, not source types: their scene restrictions gate the mode when
/// the Text ship step lands. Chatbox / Alerts / Socials are inert values until
/// their features ship — the rows are live from today.
/// </summary>
public static class SceneAccessPolicy
{
public static bool Allows(string? sceneName, SourceType type)
{
if (!SceneCatalog.IsCanonical(sceneName)) return false;
return type switch
{
SourceType.DisplayCapture or SourceType.WindowCapture =>
SceneCatalog.Is(sceneName, SceneCatalog.Live),
SourceType.Background or SourceType.Image or SourceType.TextOverlay or
SourceType.Alerts or SourceType.Socials => true,
SourceType.Chatbox =>
SceneCatalog.Is(sceneName, SceneCatalog.Live)
|| SceneCatalog.Is(sceneName, SceneCatalog.Brb)
|| SceneCatalog.IsChat(sceneName),
_ => false,
};
}
}
+54 -14
View File
@@ -109,29 +109,55 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
12. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES) 12. ✅ The connected YouTube account's avatar/name shows in the top bar next to Start Stream (`SyncConnectedAccount`); the scenes list is content-height now (no dead space before SOURCES)
13.**Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending 13.**Window capture** — absorbed into the Screen picker (no separate source type); dedicated window-as-source work is pending
14.**Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1) 14.**Scene compositing** — the D3DImage/MediaElement preview compositor (this task's requirement 5; the output compositor ships as TASK 4 ship step 1)
15.**Text source** — live text ("Starting soon", "Back in 5", handle, callout) 15.**Text source** — live text ("Starting soon", "Back in 5", handle, callout); a queued ship step carries its **countdown** + **credits** (scroll) modes (see below)
16.**Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video 16.**Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video
17.**Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build 17.**Background removal (milestone 2)** — ONNX Runtime + DirectML, MediaPipe Selfie Segmentation — deliberately NOT in this build
18.**Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; **the one paid feature** (see Monetization in `ai.md`) 18.**Alerts** — Super Chat / membership / subscribe pop-ins; build after the six; unlocked by the one-time paid key (see Monetization in `ai.md`)
19.**The source catalog + access enforcement** — the 10-source catalog and per-scene matrix (the section below) live as `SceneAccessPolicy.Allows(sceneName, sourceType)`; the Add-Source menu is one `ItemsSource` (`AddSourceMenuItems`) rebuilt per scene from the policy (Screen is Live-only; BG/Image/Text every scene; the webcam row is always present and greys out once the active scene has one); Chatbox/Alerts/Socials are inert `SourceType` rows ready for their features — 79 tests passing
### The Minimal Source Set (design decision — do not expand casually) ### The Source Catalog & scene access (design decision — do not expand casually)
ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship ytLlive is YouTube-only and 90% of users are casual. OBS's long source list is off-putting; we ship
the hot few and nothing esoteric. If a user needs more, they've graduated to OBS. the hot few and nothing esoteric. If a user needs more, they've graduated to OBS.
1. **Webcam** — the face cam. Non-negotiable. 1. **Screen capture** — the main event (game, slides, browser) as the Live scene's permanent backdrop
2. **Screen** — the main event (game, slides, browser). One source; a picker chooses a monitor *or* a (`IsBackdrop`, schema v5/v6); the picker chooses a monitor *or* a window. **Live only** (policy).
window. (Window capture is absorbed here — no separate source type.) 2. **BG (Background)** — a full-canvas backdrop image. Fills the whole scene automatically, zero
3. **Background** — a full-canvas backdrop image. Fills the whole scene automatically, zero fiddling. fiddling. Kept separate from Image on purpose: same pixels, but this one needs no positioning.
Kept separate from Image on purpose: same pixels, but this one needs no positioning. 3. **Image** — a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned.
4. **Image**a floating graphic/logo overlay (watermark, badge, corner branding). Free-positioned. 4. **Text**live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this.
5. **Text** — live text ("Starting soon", "Back in 5", handle, callout). Casual streamers live on this. Ships with two **modes**: **Countdown** (Starting/BRB) and **Credits** (Ending) — see the queued
Text ship step below. No new source types (KISS).
5. **Webcam** — the face cam. Non-negotiable. A singleton resource (`WebcamSceneConfig`, not a `Source`
type) usable in any scene.
6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native. 6. **Chat box** — YouTube live chat rendered *on* the stream so viewers read along in-video. YT-native.
7. **Alerts** — Super Chat / membership / subscribe pop-ins. The dopamine source. **The one big lift** **Live / BRB / Chat scenes.**
(Super Chat event streaming + on-stream rendering/animation); build after the six. **Also the one 7. **Alerts** — Super Chat / membership / subscribe pop-ins. The dopamine source. **All scenes**
paid feature** — see Monetization in `ai.md`. alerts fire regardless of which scene is showing. Build after the six.
8. **socialBar** — the creator's social links as one bar (branded anchor icon + in-app drop-down).
**All scenes.** Free tier = the creator's YouTube channel + **one** more platform of their choice;
unlimited platforms come with the one-time paid key (see Monetization in `ai.md`).
Deliberately NOT supported: game capture, browser source, media playlist, VLC, color-key voodoo, MIDI. **Per-scene access matrix** — enforced by `SceneAccessPolicy.Allows(sceneName, sourceType)`:
| Source | Starting | Live | BRB | Chat | Ending |
|--------|:---:|:---:|:---:|:---:|:---:|
| Screen capture (backdrop) | ❌ | ✅ | ❌ | ❌ | ❌ |
| BG / Image / Text | ✅ | ✅ | ✅ | ✅ | ✅ |
| Webcam | ✅ | ✅ | ✅ | ✅ | ✅ |
| Countdown (Text mode) | ✅ | — | ✅ | — | — |
| Chat box | — | ✅ | ✅ | ✅ | — |
| Alerts | ✅ | ✅ | ✅ | ✅ | ✅ |
| Credits (Text mode) | — | — | — | — | ✅ |
| socialBar | ✅ | ✅ | ✅ | ✅ | ✅ |
Countdown and Credits are **Text modes** (no new source types): the scene restrictions gate the
*mode*, applied when the Text ship step lands. Chatbox / Alerts / socialBar are inert `SourceType`
values until their features ship — the policy rows are live from today, the menu items arrive with
the features.
Deliberately NOT supported: game capture (absorbed into Screen), browser source, media playlist, VLC,
color-key voodoo, MIDI.
### Source memory model (design decision) ### Source memory model (design decision)
@@ -187,6 +213,20 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
2. **Scenes list:** drag rows to reorder scenes 2. **Scenes list:** drag rows to reorder scenes
3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented 3. **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
#### Queued ship step — Text source (countdown + credits modes)
**Goal:** make Text real — content + font/size/color/alignment rendered in the preview AND the output
compositor — then its two modes on top. Status item 15 above.
1.**Text core**`SourceType.TextOverlay` gains content + styling; the XAML preview renders it and
the output compositor rasterizes it (preview via `FormattedText`, output via a `frameFor` frame,
the `StaticPixelCache` pattern)
2.**Countdown mode** — a Text bound to a countdown timer ("Live in 2:30"); the mode is gated to
Starting/BRB per the access matrix
3.**Credits mode** — a Text that scrolls up on the Ending scene
Ships only when queued (the Good Dog Rule: one integration test per PR, no multi-test branches).
--- ---
## TASK 4 — RTMP Ingest to YouTube ## TASK 4 — RTMP Ingest to YouTube
+31
View File
@@ -103,6 +103,7 @@ public class MainViewModel : ViewModelBase
public ObservableCollection<Scene> Scenes { get; } = new(); public ObservableCollection<Scene> Scenes { get; } = new();
public ObservableCollection<ChatMessage> ChatMessages { get; } = new(); public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
public ObservableCollection<DisplayInfo> Displays { get; } = new(); public ObservableCollection<DisplayInfo> Displays { get; } = new();
public ObservableCollection<SourceMenuItem> AddSourceMenuItems { get; } = new();
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" }; public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public Scene? ActiveScene public Scene? ActiveScene
@@ -123,6 +124,7 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(CanShowWebcamInActiveScene)); OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
UpdateActiveBackground(); UpdateActiveBackground();
UpdateBackdropImage(); UpdateBackdropImage();
RebuildAddSourceMenu();
} }
} }
} }
@@ -1189,6 +1191,7 @@ public class MainViewModel : ViewModelBase
foreach (SceneElement element in e.OldItems) foreach (SceneElement element in e.OldItems)
element.PropertyChanged -= OnElementPropertyChanged; element.PropertyChanged -= OnElementPropertyChanged;
OnPropertyChanged(nameof(CanAddWebcamToActiveScene)); OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
RebuildAddSourceMenu();
ScheduleSave(); ScheduleSave();
} }
@@ -1245,6 +1248,34 @@ public class MainViewModel : ViewModelBase
ActiveScene = Scenes.FirstOrDefault(); ActiveScene = Scenes.FirstOrDefault();
} }
// The Add-Source menu lists only what the active scene allows per the access
// matrix (SceneAccessPolicy). The webcam row is always present (webcams are
// allowed in every scene) but greys out once the active scene has one.
private void RebuildAddSourceMenu()
{
AddSourceMenuItems.Clear();
var sceneName = ActiveScene?.Name;
if (sceneName == null) return;
AddSourceMenuItems.Add(new SourceMenuItem(
"Webcam", AddWebcamCommand,
isEnabled: CanAddWebcamToActiveScene,
toolTip: "One webcam at a time — it's already in your stream"));
var menu = new[]
{
(Header: "Screen", Type: SourceType.DisplayCapture, Command: AddSourceCommand),
(Header: "Background", Type: SourceType.Background, Command: AddSourceCommand),
(Header: "Image", Type: SourceType.Image, Command: AddImageCommand),
(Header: "Text", Type: SourceType.TextOverlay, Command: AddSourceCommand),
};
foreach (var (header, type, command) in menu)
{
if (SceneAccessPolicy.Allows(sceneName, type))
AddSourceMenuItems.Add(new SourceMenuItem(header, command, type));
}
}
private void AddSource(object? parameter) private void AddSource(object? parameter)
{ {
var scene = ActiveScene; var scene = ActiveScene;
+28
View File
@@ -0,0 +1,28 @@
using System.Windows.Input;
namespace ytLive.ViewModels;
/// <summary>
/// One Add-Source menu entry: a label, its action, and an optional command
/// parameter. The collection is rebuilt per scene from
/// <see cref="Services.SceneAccessPolicy"/> so only the sources the active
/// scene allows appear in the menu.
/// </summary>
public sealed class SourceMenuItem
{
public SourceMenuItem(string header, ICommand command, object? parameter = null,
bool isEnabled = true, string? toolTip = null)
{
Header = header;
Command = command;
Parameter = parameter;
IsEnabled = isEnabled;
ToolTip = toolTip;
}
public string Header { get; }
public ICommand Command { get; }
public object? Parameter { get; }
public bool IsEnabled { get; }
public string? ToolTip { get; }
}
+3 -1
View File
@@ -379,7 +379,9 @@ delivery; we never own a server or a key shop):
~5s after go-live, only while live or recording. An always-on watermark can be cropped or covered; ~5s after go-live, only while live or recording. An always-on watermark can be cropped or covered;
an intermittent full-frame flash can't be cropped and is impractical to edit around on a live feed. an intermittent full-frame flash can't be cropped and is impractical to edit around on a live feed.
- **Paid (one-time):** branding flash removed (flips `BrandFlashEnabled` off) + **Alerts** - **Paid (one-time):** branding flash removed (flips `BrandFlashEnabled` off) + **Alerts**
(Super Chat / membership / subscribe pop-ins). (Super Chat / membership / subscribe pop-ins) + **unlimited socialBar platforms** (the free tier
is the creator's YouTube channel + one more; unlimited is the unlock — the teaser, per the source
catalog in `TASKS.md`).
Deliberately rejected: always-on watermark (obscurable — replaced by the flash), hard stream-time Deliberately rejected: always-on watermark (obscurable — replaced by the flash), hard stream-time
cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams cutoffs (the worst dead end — a stream dying mid-broadcast reads as broken, and YouTube streams
+67
View File
@@ -0,0 +1,67 @@
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// The per-scene source access matrix from the source catalog (TASKS.md).
/// One integration test: each canonical scene allows exactly its matrix set,
/// nothing more; non-canonical scenes allow nothing.
/// </summary>
public class SceneAccessPolicyTests
{
private static readonly SourceType[] All =
{
SourceType.DisplayCapture, SourceType.WindowCapture, SourceType.Background,
SourceType.Image, SourceType.TextOverlay, SourceType.Chatbox,
SourceType.Alerts, SourceType.Socials,
};
[Fact]
public void SceneAccessPolicy_Enforces_The_Full_Access_Matrix()
{
var allowed = new[]
{
(Scene: "Starting", Types: new[]
{
SourceType.Background, SourceType.Image, SourceType.TextOverlay,
SourceType.Alerts, SourceType.Socials,
}),
(Scene: "Live", Types: All),
(Scene: "BRB", Types: new[]
{
SourceType.Background, SourceType.Image, SourceType.TextOverlay,
SourceType.Chatbox, SourceType.Alerts, SourceType.Socials,
}),
(Scene: "Chat", Types: new[]
{
SourceType.Background, SourceType.Image, SourceType.TextOverlay,
SourceType.Chatbox, SourceType.Alerts, SourceType.Socials,
}),
(Scene: "Ending", Types: new[]
{
SourceType.Background, SourceType.Image, SourceType.TextOverlay,
SourceType.Alerts, SourceType.Socials,
}),
};
foreach (var (scene, types) in allowed)
{
foreach (var type in types)
Assert.True(
SceneAccessPolicy.Allows(scene, type),
$"{type} should be allowed in {scene}");
foreach (var type in All.Where(t => !types.Contains(t)))
Assert.False(
SceneAccessPolicy.Allows(scene, type),
$"{type} should NOT be allowed in {scene}");
}
Assert.False(SceneAccessPolicy.Allows(null, SourceType.Image));
Assert.False(SceneAccessPolicy.Allows("My Scene", SourceType.Image));
Assert.False(SceneAccessPolicy.Allows(" ", SourceType.TextOverlay));
}
}