Compare commits
125
Commits
d195c29f79
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f08a478e | ||
|
|
ba60f9ab3d | ||
|
|
f4caea09ad | ||
|
|
70b5b01920 | ||
|
|
bd61e8df2a | ||
|
|
5bd750eaf5 | ||
|
|
1ce6b9c1b8 | ||
|
|
70fcf89f9e | ||
|
|
caf5d6130d | ||
|
|
6b4aa99863 | ||
|
|
b92569a8c7 | ||
|
|
838ba17eaf | ||
|
|
16abb1ddc1 | ||
|
|
4bc41c488c | ||
|
|
a5a10fb0b0 | ||
|
|
6c5cd2f66c | ||
|
|
a5dc2f494c | ||
|
|
d46e84562f | ||
|
|
aae6104666 | ||
|
|
cc75212997 | ||
|
|
1eb04597d1 | ||
|
|
c1b3868913 | ||
|
|
af6210082c | ||
|
|
1abc86358a | ||
|
|
514fb8480d | ||
|
|
bb3bc12784 | ||
|
|
b216cbc56f | ||
|
|
e654a16b60 | ||
|
|
5ae52808bb | ||
|
|
751d9b199c | ||
|
|
102cb96b28 | ||
|
|
47f409f0e4 | ||
|
|
7f89534309 | ||
|
|
f6fdab3bf2 | ||
|
|
ada8d2457d | ||
|
|
7acf044e4a | ||
|
|
b0f8263414 | ||
|
|
f2cab5ca0f | ||
|
|
93719a286d | ||
|
|
1708667602 | ||
|
|
700c7a8c23 | ||
|
|
37ef74eb8d | ||
|
|
1e3a4f7fe0 | ||
|
|
9c0c40c688 | ||
|
|
ff5c005bd9 | ||
|
|
2f5d0c1759 | ||
|
|
9621db29c9 | ||
|
|
24077e58cf | ||
|
|
2c5a3d8c11 | ||
|
|
4e5b4e3999 | ||
|
|
b265d42fb4 | ||
|
|
1d35a7400f | ||
|
|
e6c6913994 | ||
|
|
41064bdd13 | ||
|
|
b8eb2b4451 | ||
|
|
ff1c1965e0 | ||
|
|
c4da06b56b | ||
|
|
1c6fc3223d | ||
|
|
37791086ae | ||
|
|
b093609ad3 | ||
|
|
de787e0dad | ||
|
|
5fcfa545e5 | ||
|
|
a5c2061b55 | ||
|
|
28657918f9 | ||
|
|
f7d3d1301e | ||
|
|
5066d81aab | ||
|
|
b93744ec7f | ||
|
|
1a6a549e17 | ||
|
|
c257de1516 | ||
|
|
80ccdc9cb8 | ||
|
|
ec6a5ca466 | ||
|
|
42228c7aab | ||
|
|
4b3d3f2645 | ||
|
|
4855d5cf8d | ||
|
|
0fa3006da7 | ||
|
|
134103c08b | ||
|
|
b84802e657 | ||
|
|
b39439abdc | ||
|
|
555ff5c0ac | ||
|
|
bc62bd81ec | ||
|
|
92d875c343 | ||
|
|
ed32151136 | ||
|
|
e53f9974a5 | ||
|
|
4a43b3e229 | ||
|
|
a8f27db4c2 | ||
|
|
b4e952fda9 | ||
|
|
bef818f4a1 | ||
|
|
dbcb69278f | ||
|
|
30824c6d6e | ||
|
|
70e2c48136 | ||
|
|
3db964e5cf | ||
|
|
a6f15a4bc3 | ||
|
|
f7e1b76cdd | ||
|
|
0f08fd7ade | ||
|
|
3261293d57 | ||
|
|
a1d861608f | ||
|
|
0fade9702b | ||
|
|
27f655c8de | ||
|
|
9611cee199 | ||
|
|
dd51f7842a | ||
|
|
ec1091a170 | ||
|
|
d8c1bdba24 | ||
|
|
f2c43909c8 | ||
|
|
0de953ef66 | ||
|
|
3fde7fcfe6 | ||
|
|
72cb3ad55a | ||
|
|
a80108dd96 | ||
|
|
adf9883ae9 | ||
|
|
77eebdb2ce | ||
|
|
17d835353d | ||
|
|
3a56807ee3 | ||
|
|
5577aaaf91 | ||
|
|
69e595da25 | ||
|
|
0f4a035376 | ||
|
|
242a13ca30 | ||
|
|
e37215d9c4 | ||
|
|
a814bcd502 | ||
|
|
7bb1207fb2 | ||
|
|
ea24405ba2 | ||
|
|
21db8a17b8 | ||
|
|
2670ac022b | ||
|
|
776f84fb88 | ||
|
|
db563c8644 | ||
|
|
77d7208bfa | ||
|
|
785b5ff8f3 |
@@ -26,6 +26,10 @@ public partial class App : Application
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception", AppLogErrorClass.Class1);
|
||||
|
||||
AppLog.Write("App OnStartup begin");
|
||||
|
||||
// TASK 55·2: a crash leaves the same temp clip a clean exit would have deleted.
|
||||
var sweptClips = ytLive.Services.AlertClipCleanup.Sweep(System.IO.Path.GetTempPath());
|
||||
if (sweptClips > 0) AppLog.Write($"Temp alert clips removed at startup: {sweptClips}");
|
||||
// Attribution (2026-09-04): every take must be traceable to the exact binary
|
||||
// that produced it — the wordmark shows the release counter (BuildStamp.Release);
|
||||
// the per-build GUID is forensic only and lands here.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 423 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.5 MiB |
@@ -213,6 +213,31 @@
|
||||
<Button Content="+100" Width="34" Height="16" FontSize="9" Padding="0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="SyncPreset_Click" Tag="100"/>
|
||||
</StackPanel>
|
||||
<!-- Microphone-only delay: the voice waits for the camera's picture. The
|
||||
whole-mix slider above would drag desktop and alert audio with it. -->
|
||||
<Grid Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Background="{StaticResource Brush.SurfaceContainerHighest}" CornerRadius="{StaticResource Radius.Sm}" Padding="5,1">
|
||||
<TextBlock Text="MIC" FontSize="9" FontWeight="Bold" Foreground="{StaticResource Brush.OnSurfaceVariant}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Margin="6,0,0,0" FontSize="10" VerticalAlignment="Center"
|
||||
FontFamily="{StaticResource FontFamily.Mono}" TextAlignment="Right"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" Text="{Binding MicSyncOffsetLabel}"/>
|
||||
</Grid>
|
||||
<Slider Minimum="0" Maximum="500" SmallChange="5" LargeChange="25"
|
||||
TickFrequency="5" IsSnapToTickEnabled="True"
|
||||
Value="{Binding MicSyncOffsetMs, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,0" HorizontalAlignment="Right">
|
||||
<Button Content="off" Width="26" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="0"/>
|
||||
<Button Content="175" Width="30" Height="16" FontSize="9" Padding="0" Margin="0,0,4,0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="175"/>
|
||||
<Button Content="350" Width="30" Height="16" FontSize="9" Padding="0"
|
||||
Style="{StaticResource YtButtonSecondary}" Click="MicSyncPreset_Click" Tag="350"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -56,6 +56,13 @@ public partial class AudioDock : UserControl
|
||||
vm.SetSyncOffset(ms);
|
||||
}
|
||||
|
||||
private void MicSyncPreset_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not ViewModels.MainViewModel vm) return;
|
||||
if (sender is Button { Tag: string tag } && int.TryParse(tag, out var ms))
|
||||
vm.MicSyncOffsetMs = ms;
|
||||
}
|
||||
|
||||
private void TraxPlayPause_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm) vm.TraxLeftClick();
|
||||
|
||||
+233
-59
@@ -29,6 +29,7 @@
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<Helpers:LogInRangeConverter x:Key="LogInRange"/>
|
||||
<Helpers:ClosedBoxTemplateSelector x:Key="BorderColorTemplates"
|
||||
ClosedTemplate="{StaticResource BorderColorClosedTemplate}"
|
||||
RowTemplate="{StaticResource BorderColorRowTemplate}"/>
|
||||
@@ -37,11 +38,62 @@
|
||||
<Border Background="{StaticResource Brush.SurfaceContainer}" CornerRadius="6" Padding="12">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- ALERT STACK (creator design 2026-11-05): standing state on top, transient
|
||||
events beneath, newest first, pushing the panel's content down. Only red
|
||||
rows live here. Row 1 is the Steam licence indicator and is never displaced;
|
||||
it shows a pin while higher tiers are unavailable, and a close control once
|
||||
ownership is verified (that close is permanent). Clicking a row runs its
|
||||
action: the licence indicator opens About, an event opens the Logs tab with
|
||||
that event's lines painted. -->
|
||||
<!-- Alerts and the panel content are different kinds of thing (state vs. the
|
||||
scene you are editing), so they are separated by space AND a rule: without it
|
||||
the stack read as the first rows of the layer list (creator, 2026-11-05). -->
|
||||
<StackPanel Grid.Row="0" Visibility="{Binding HasAttentionAlerts, Converter={StaticResource BoolToVis}}">
|
||||
<ItemsControl ItemsSource="{Binding AttentionAlerts}" Margin="0,0,0,2">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="0,0,0,4" Padding="8,6" CornerRadius="4" Cursor="Hand"
|
||||
Background="{StaticResource Brush.ErrorContainer}"
|
||||
BorderBrush="{StaticResource Brush.Error}" BorderThickness="1">
|
||||
<Border.InputBindings>
|
||||
<MouseBinding MouseAction="LeftClick"
|
||||
Command="{Binding DataContext.ActivateAlertCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</Border.InputBindings>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button DockPanel.Dock="Right" VerticalAlignment="Top" Margin="6,0,0,0"
|
||||
Style="{StaticResource IconButton}" Width="20" Height="20"
|
||||
ToolTip="Dismiss"
|
||||
Command="{Binding DataContext.DismissAlertCommand, RelativeSource={RelativeSource AncestorType=UserControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
Visibility="{Binding CanDismiss, Converter={StaticResource BoolToVis}}">
|
||||
<Path Data="M4,4 L16,16 M16,4 L4,16" Stroke="{StaticResource Brush.Error}"
|
||||
StrokeThickness="2" Width="12" Height="12" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<!-- Pinned rows show the pin INSTEAD of a close control: the icon is
|
||||
the state ("standing indicator"), not a disabled button. -->
|
||||
<Path DockPanel.Dock="Right" Data="M12,2 L12,9 M8,9 H16 L14,15 H10 Z M10,15 L10,21 M14,15 L14,21"
|
||||
Stroke="{StaticResource Brush.Error}" StrokeThickness="1.6"
|
||||
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Top"
|
||||
Margin="6,2,2,0" ToolTip="Standing indicator"
|
||||
Visibility="{Binding ShowsPin, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding Text}" Style="{StaticResource AppLogEntryText}"
|
||||
TextWrapping="Wrap" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.Error}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Border Style="{StaticResource PropertyDivider}" Margin="0,4,0,10"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Edit mode (offline): layers + properties -->
|
||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
|
||||
<Grid Grid.Row="1" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
@@ -49,6 +101,7 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
@@ -70,6 +123,10 @@
|
||||
IsEnabled="{Binding CanAddWebcam}"
|
||||
ToolTip="{Binding WebcamAddToolTip}"/>
|
||||
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
||||
<MenuItem Header="Video File" Command="{Binding AddMediaCommand}"
|
||||
ToolTip="Play a video file in the scene (mp4, mov, avi, mkv, webm)"/>
|
||||
<MenuItem Header="Replay Clip" Command="{Binding AddReplayLayerCommand}"
|
||||
ToolTip="Plays saved replay clips: once in Live, looping in other scenes"/>
|
||||
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
||||
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
||||
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
|
||||
@@ -287,10 +344,28 @@
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<!-- TASK 35: the scene remembers the room. Three states on purpose:
|
||||
checked = mute on entry, unchecked = unmute on entry, indeterminate = leave the
|
||||
mix alone (the default, so a scene is passive until it is told). -->
|
||||
<StackPanel Grid.Row="3" Margin="0,10,0,0"
|
||||
Visibility="{Binding StagedScene, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock Text="WHEN THIS SCENE GOES LIVE" Style="{StaticResource SectionHeader}"/>
|
||||
<CheckBox Content="Mute the mic" IsThreeState="True" Margin="10,6,0,0"
|
||||
Style="{StaticResource PropertyPillCheck}"
|
||||
IsChecked="{Binding SceneMutesMic, Mode=TwoWay}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
ToolTip="Checked mutes on entry · Unchecked unmutes on entry · Indeterminate leaves the mix alone"/>
|
||||
<CheckBox Content="Mute desktop audio" IsThreeState="True" Margin="10,4,0,0"
|
||||
Style="{StaticResource PropertyPillCheck}"
|
||||
IsChecked="{Binding SceneMutesDesktop, Mode=TwoWay}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
ToolTip="Checked mutes on entry · Unchecked unmutes on entry · Indeterminate leaves the mix alone"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Resource alert: pinned to the bottom of the Layers panel.
|
||||
Red when a required resource (the one-and-only webcam)
|
||||
could not be locked; persists and re-polls until resolved. -->
|
||||
<Border Grid.Row="3" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
|
||||
<Border Grid.Row="4" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
|
||||
Background="{StaticResource Brush.ErrorContainer}" BorderBrush="{StaticResource Brush.Error}" BorderThickness="1"
|
||||
Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel>
|
||||
@@ -304,17 +379,17 @@
|
||||
|
||||
<!-- ELEMENTS panel: per-element config beneath Sources.
|
||||
Collapses entirely when no element is selected. -->
|
||||
<Rectangle Grid.Row="4" Height="1" Fill="{StaticResource Brush.Outline}" Margin="0,22,0,10"/>
|
||||
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<Rectangle Grid.Row="5" Height="1" Fill="{StaticResource Brush.Outline}" Margin="0,22,0,10"/>
|
||||
<StackPanel Grid.Row="6" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<Path Data="M12,8 A4,4 0 1 0 12.01,8 M19.4,15 L21,16.2 L19.4,19 L17.2,18.3 A7,7 0 0 1 15,19.4 L14.5,21.5 H9.5 L9,19.4 A7,7 0 0 1 6.8,18.3 L4.6,19 L3,16.2 L4.6,15 A7,7 0 0 1 4.6,9 L3,7.8 L4.6,5 L6.8,5.7 A7,7 0 0 1 9,4.6 L9.5,2.5 H14.5 L15,4.6 A7,7 0 0 1 17.2,5.7 L19.4,5 L21,7.8 L19.4,9 A7,7 0 0 1 19.4,15 Z"
|
||||
Stroke="{StaticResource Brush.Tertiary}" StrokeThickness="1.6" StrokeLineJoin="Round"
|
||||
Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Row="6" Text="Select an item to edit its properties" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
<TextBlock Grid.Row="7" Text="Select an item to edit its properties" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
Margin="8,4,0,0" TextWrapping="Wrap"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NullToVis}}"/>
|
||||
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
||||
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="7" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel x:Name="ElementsPanel" Margin="8,4,0,0">
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsTextOverlay, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
@@ -472,35 +547,6 @@
|
||||
Style="{StaticResource PropertyHint}" Margin="10,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
|
||||
keeps its OWN placement, so these fields edit the LIVE one —
|
||||
the placement the current tier shows. Gated on the VM's
|
||||
IsWebcamSelected (the same condition as the mirror/shape row
|
||||
above) so the webcam's border/background section stays the
|
||||
one panel bound to SelectedElement.IsWebcam. -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Style="{StaticResource PropertyHeader}"/>
|
||||
<!-- TASK 61: the four fields share ONE fixed width — wide enough
|
||||
for the largest geometry value (1920, plus a typed sign) and
|
||||
no wider, so Y/H no longer stretch to fill the row. -->
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Style="{StaticResource PropertyHeader}"/>
|
||||
@@ -688,23 +734,6 @@
|
||||
|
||||
<!-- Web source config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="URI" Style="{StaticResource PropertyHeader}"/>
|
||||
<DockPanel Margin="10,0,0,0">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
@@ -725,6 +754,99 @@
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Media (video file) config — TASK 21 -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsMediaSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<DockPanel Margin="0,0,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="▶ Play"
|
||||
Command="{Binding ReplayMediaCommand}"
|
||||
CommandParameter="{Binding SelectedElement}"
|
||||
Style="{StaticResource PropertyButton}" Padding="8,2" FontSize="11"
|
||||
ToolTip="Play this clip in the preview from the beginning"/>
|
||||
<TextBlock Text="Video File" Style="{StaticResource PropertyHeader}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<Grid Margin="10,0,0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="{Binding SelectedElement.MediaFileDisplay}"
|
||||
Style="{StaticResource PropertyFieldLabel}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
ToolTip="{Binding SelectedElement.MediaPath}"/>
|
||||
<Button Grid.Column="1" Content="Change…" Command="{Binding ChangeMediaFileCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource PropertyButton}"
|
||||
Margin="4,0,0,0" Padding="8,2" FontSize="11"/>
|
||||
</Grid>
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,4,0,4"
|
||||
Content="Loop" IsChecked="{Binding SelectedElement.MediaIsLooping, Mode=TwoWay}"
|
||||
ToolTip="Restart from the beginning at the end of the file — for starting-soon and BRB loops"/>
|
||||
<TextBlock Text="Clip finished — holding the last frame."
|
||||
Style="{StaticResource PropertyHint}"
|
||||
Visibility="{Binding SelectedElement.IsMediaFinished, Converter={StaticResource BoolToVis}}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<CheckBox DockPanel.Dock="Left" Style="{StaticResource PropertyPillCheck}"
|
||||
Content="Mute" IsChecked="{Binding SelectedElement.MediaMuted, Mode=TwoWay}"
|
||||
ToolTip="Silence this layer's sound. The picture keeps playing."/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Level" Style="{StaticResource PropertyFieldLabel}" Width="40"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.MediaVolume, StringFormat={}{0:P0}}"
|
||||
Style="{StaticResource PropertyFieldLabel}" Width="34" TextAlignment="Right"/>
|
||||
<Slider Minimum="0" Maximum="1" Width="120"
|
||||
Value="{Binding SelectedElement.MediaVolume, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Sound plays through the desktop audio controls, like game audio."
|
||||
Style="{StaticResource PropertyHint}"/>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!-- Green/Blue Screen (TASK 64): a video layer OR the alert box's
|
||||
own clip. The app offers, the creator accepts — never silent. -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.ShowsChromaKey, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<Border Style="{StaticResource PropertyDivider}"/>
|
||||
<TextBlock Text="Green/Blue Screen" Style="{StaticResource PropertyHeader}"/>
|
||||
|
||||
<Border Margin="10,0,0,4" Padding="6,5" CornerRadius="4"
|
||||
Background="{StaticResource Brush.Outline}"
|
||||
Visibility="{Binding SelectedElement.HasChromaSuggestion, Converter={StaticResource BoolToVis}}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Left" Width="12" Height="12" CornerRadius="2"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Background="{Binding SelectedElement.ChromaSuggestionColor}"
|
||||
ToolTip="The screen colour this clip was shot against"/>
|
||||
<Button DockPanel.Dock="Right" Content="Remove it"
|
||||
Style="{StaticResource PropertyButton}" Padding="8,2" FontSize="11"
|
||||
Command="{Binding AcceptChromaSuggestionCommand}"
|
||||
CommandParameter="{Binding SelectedElement}"/>
|
||||
<TextBlock Text="{Binding SelectedElement.ChromaSuggestionText}"
|
||||
Style="{StaticResource PropertyFieldLabel}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,0,0,4"
|
||||
Content="Remove the background" IsChecked="{Binding SelectedElement.ChromaKeyEnabled, Mode=TwoWay}"
|
||||
ToolTip="Key out the green/blue backdrop so the layers beneath show through"/>
|
||||
<TextBlock Text="Removed automatically — green screen detected."
|
||||
Style="{StaticResource PropertyHint}"
|
||||
Visibility="{Binding SelectedElement.HasAutoChromaKey, Converter={StaticResource BoolToVis}}"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Amount" Style="{StaticResource PropertyFieldLabel}" Width="52"/>
|
||||
<Slider Minimum="0" Maximum="0.5" Width="110"
|
||||
Value="{Binding SelectedElement.ChromaKeySimilarity, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Softness" Style="{StaticResource PropertyFieldLabel}" Width="52"/>
|
||||
<Slider Minimum="0" Maximum="0.5" Width="110"
|
||||
Value="{Binding SelectedElement.ChromaKeySoftness, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,0,0,4"
|
||||
Content="Fringe" IsChecked="{Binding SelectedElement.ChromaKeySpill, Mode=TwoWay}"
|
||||
ToolTip="Clean the coloured rim the screen leaves on hair and edges"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Chat box config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Chat Overlay" Style="{StaticResource PropertyHeader}"/>
|
||||
@@ -989,13 +1111,41 @@
|
||||
Margin="0,4,0,0"
|
||||
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
<!-- Geometry is ALWAYS the last section (creator ruling 2026-11-05):
|
||||
opacity sits at the top of the properties list and X/Y/W/H at the
|
||||
bottom, for every draggable element, so the eye can find them in
|
||||
the same place whatever layer is selected. For the webcam under
|
||||
the vertical tier this edits the LIVE placement (TASK 57). -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Style="{StaticResource PropertyHeader}"/>
|
||||
<!-- TASK 61: the four fields share ONE fixed width — wide enough
|
||||
for the largest geometry value (1920, plus a typed sign) and
|
||||
no wider, so Y/H no longer stretch to fill the row. -->
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Style="{StaticResource PropertyFieldLabel}" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Style="{StaticResource PropertyFieldLabel}" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
|
||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid Grid.Row="1" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
@@ -1117,12 +1267,36 @@
|
||||
VirtualizingPanel.VirtualizationMode="Recycling">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="6,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding TypeLabel}"
|
||||
Style="{StaticResource AppLogEntryText}" FontWeight="Bold"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBlock Text="{Binding Text}" Style="{StaticResource AppLogEntryText}"/>
|
||||
</DockPanel>
|
||||
<!-- An alert row is an entry point to its detail: clicking it opens
|
||||
this tab and paints the sequence range that event produced. -->
|
||||
<Border Padding="2,1" CornerRadius="3">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Value="True">
|
||||
<DataTrigger.Binding>
|
||||
<MultiBinding Converter="{StaticResource LogInRange}">
|
||||
<Binding Path="Sequence"/>
|
||||
<Binding Path="DataContext.HighlightedLogFrom"
|
||||
RelativeSource="{RelativeSource AncestorType=UserControl}"/>
|
||||
<Binding Path="DataContext.HighlightedLogTo"
|
||||
RelativeSource="{RelativeSource AncestorType=UserControl}"/>
|
||||
</MultiBinding>
|
||||
</DataTrigger.Binding>
|
||||
<Setter Property="Background" Value="{StaticResource Brush.ErrorContainer}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<DockPanel Margin="4,3">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding TypeLabel}"
|
||||
Style="{StaticResource AppLogEntryText}" FontWeight="Bold"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBlock Text="{Binding Text}" Style="{StaticResource AppLogEntryText}"
|
||||
TextWrapping="Wrap"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
@@ -178,8 +179,9 @@ public partial class LeftPanel : UserControl
|
||||
var position = e.GetPosition(listBox);
|
||||
if (!_isDragging)
|
||||
{
|
||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
if (!DragReorderMath.ExceededThreshold(
|
||||
position.X - _dragStartPoint.X, position.Y - _dragStartPoint.Y,
|
||||
SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance))
|
||||
return;
|
||||
_isDragging = true;
|
||||
listBox.CaptureMouse();
|
||||
@@ -193,12 +195,10 @@ public partial class LeftPanel : UserControl
|
||||
return;
|
||||
|
||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
||||
if (targetIndex < 0) return;
|
||||
var landed = DragReorderMath.TryMove(items, _dragIndex, targetIndex);
|
||||
if (landed < 0) return;
|
||||
|
||||
var dragged = items[_dragIndex];
|
||||
items.RemoveAt(_dragIndex);
|
||||
items.Insert(targetIndex, dragged);
|
||||
_dragIndex = targetIndex;
|
||||
_dragIndex = landed;
|
||||
_lastHoveredItem = targetItem;
|
||||
_dragReordered = true;
|
||||
}
|
||||
|
||||
+84
-35
@@ -15,7 +15,7 @@
|
||||
</Button.Template>
|
||||
</Button>
|
||||
|
||||
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="650" MaxHeight="700"
|
||||
<Border Background="{StaticResource Brush.Background}" CornerRadius="8" Width="820" MaxHeight="860"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Padding="28" BorderBrush="#333" BorderThickness="1">
|
||||
<Grid>
|
||||
@@ -76,42 +76,91 @@
|
||||
Background="{StaticResource Brush.SurfaceContainerLow}"
|
||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||
|
||||
<!-- Hub view -->
|
||||
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||
<TextBlock Text="{Binding AppVersionLabel}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
HorizontalAlignment="Center" Margin="0,4,0,0"/>
|
||||
<TextBlock Text="Live streaming for YouTube — simple enough that even the most right-brained person can intuit it."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" TextWrapping="Wrap" MaxWidth="380"
|
||||
TextAlignment="Center" Margin="0,16,0,0"/>
|
||||
<TextBlock x:Name="UpgradeCopy"
|
||||
Text="Free at 720p30. The paid version unlocks higher stream and recording quality. One-time purchase; no subscription. Launch price $79, then $99 on the announced date."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
TextWrapping="Wrap" MaxWidth="380" TextAlignment="Center" Margin="0,12,0,0"/>
|
||||
<Button x:Name="UpgradeLinkButton" Content="Learn more at llamacasty.com"
|
||||
Command="{Binding OpenPremiumCommand}" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Center" Margin="0,12,0,0"
|
||||
ToolTip="Opens llamacasty.com in your browser when clicked"/>
|
||||
<TextBlock Text="Made by gramps · llama chile shop" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
HorizontalAlignment="Center" Margin="0,16,0,8"/>
|
||||
<!-- Hub view — a conversion surface, not a credits page (creator, 2026-11-05):
|
||||
large logo, one tagline, the offer in three short blocks, one primary CTA,
|
||||
everything else demoted. A paying customer never sees the price. -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel>
|
||||
<Image Source="/LlamaCasty;component/Assets/llamacasty-logo.jpg"
|
||||
Width="520" Stretch="Uniform" HorizontalAlignment="Center" Margin="0,0,0,10"/>
|
||||
|
||||
<!-- Creator hub links -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,0,0,8">
|
||||
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"/>
|
||||
</StackPanel>
|
||||
<Button x:Name="ReleaseBadgeButton" Content="{Binding AppReleaseLabel}"
|
||||
Style="{StaticResource PillBadgeButton}"
|
||||
Command="{Binding OpenPremiumCommand}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,10"
|
||||
ToolTip="Pre-release — opens llamacasty.com when clicked"/>
|
||||
|
||||
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
|
||||
Margin="0,16,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="AboutTagline"
|
||||
Text="1-2-3, and go live to the world on YouTube!"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="17" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" MaxWidth="640" TextAlignment="Center"
|
||||
HorizontalAlignment="Center" Margin="0,4,0,18"/>
|
||||
|
||||
<Grid MaxWidth="640" HorizontalAlignment="Center" Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Free" Margin="0,0,14,4"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding FreeOfferText}" Margin="0,0,0,4"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Paid" Margin="0,0,14,4"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding PaidOfferText}" Margin="0,0,0,4"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="13" TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.ColumnSpan="2" Text="{Binding TrustLine}"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
TextWrapping="Wrap" Margin="0,6,0,0"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Free: the offer. Owned: thanks, no price. -->
|
||||
<StackPanel x:Name="UpgradeBlock"
|
||||
Visibility="{Binding IsAppOwned, Converter={StaticResource InverseBoolToVis}}"
|
||||
HorizontalAlignment="Center" Margin="0,18,0,0">
|
||||
<TextBlock x:Name="AboutPriceLine" Text="{Binding PriceLine}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="16" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding PriceNote}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
TextAlignment="Center" TextWrapping="Wrap" MaxWidth="520" Margin="0,2,0,10"/>
|
||||
<Button x:Name="UpgradeCtaButton" Content="{Binding UpgradeCtaLabel}"
|
||||
Command="{Binding OpenPremiumCommand}"
|
||||
Style="{StaticResource YtButton}"
|
||||
HorizontalAlignment="Center" Padding="22,10" FontSize="14"
|
||||
ToolTip="Opens llamacasty.com in your browser when clicked"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock x:Name="OwnedStateText" Text="{Binding LicenceStateLabel}"
|
||||
Foreground="{StaticResource Brush.Positive}" FontSize="13" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" Margin="0,18,0,0"
|
||||
Visibility="{Binding IsAppOwned, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<!-- Secondary destinations, one row, deliberately quieter than the CTA. -->
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,22,0,0">
|
||||
<Button Content="llama chile shop on YouTube" Command="{Binding OpenChannelCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
|
||||
<Button Content="Mastodon" Command="{Binding OpenMastodonCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
|
||||
<Button Content="Buy me a coffee" Command="{Binding OpenCoffeeCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0" FontSize="11" Padding="10,5"/>
|
||||
<Button Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" FontSize="11" Padding="10,5"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock x:Name="AboutCopyright" Text="{Binding CopyrightLabel}"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
HorizontalAlignment="Center" Margin="0,18,0,4"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Licensing/legal panel -->
|
||||
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
|
||||
|
||||
+74
-15
@@ -167,6 +167,17 @@
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Setter Property="Stretch" Value="Fill"/>
|
||||
</DataTrigger>
|
||||
|
||||
<!-- A video file layer is a frame source too. Without this trigger its Image stayed
|
||||
Collapsed and the clip was invisible in the preview while the decode ran — the
|
||||
same defect the alert box hit on 2026-09-26. -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsMediaSource}" Value="True"/>
|
||||
<Condition Binding="{Binding IsRectShape}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsWebcam}" Value="True"/>
|
||||
@@ -198,6 +209,15 @@
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
<!-- A round-clipped video layer uses the same unit-circle path, or it would
|
||||
render in the output and be invisible in the preview (2026-11-05). -->
|
||||
<MultiDataTrigger>
|
||||
<MultiDataTrigger.Conditions>
|
||||
<Condition Binding="{Binding IsMediaSource}" Value="True"/>
|
||||
<Condition Binding="{Binding IsRoundShape}" Value="True"/>
|
||||
</MultiDataTrigger.Conditions>
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</MultiDataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Image.Style>
|
||||
@@ -748,27 +768,66 @@
|
||||
<StackPanel Visibility="{Binding Ypp.IsSignedOut, Converter={StaticResource InverseBoolToVis}}">
|
||||
<TextBlock Text="{Binding Ypp.DisplayName}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"
|
||||
FontWeight="SemiBold" Margin="0,0,0,10"/>
|
||||
<TextBlock Text="SUBSCRIBERS — TIER 1 · FAN FUNDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,10">
|
||||
<!-- TASK 39·2 — the three metrics that count, with the threshold
|
||||
that applies TODAY (the long-form bar doubles 2027-02-01; that is
|
||||
data, not a code change). YouTube decides eligibility; these are
|
||||
estimates and the drawer says so. No red/green scoring. -->
|
||||
<TextBlock Text="SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.SubscribersText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
FontFamily="{StaticResource FontFamily.Mono}"/>
|
||||
<TextBlock Text="Ad revenue tier" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
|
||||
</DockPanel>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,10">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.Tier1SubsBarWidth}"/>
|
||||
Width="{Binding Ypp.SubscribersBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="SUBSCRIBERS — TIER 2 · AD REVENUE" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier2SubsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,10">
|
||||
|
||||
<TextBlock Text="LONG-FORM WATCH HOURS · 12 MONTHS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.WatchHoursText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
FontFamily="{StaticResource FontFamily.Mono}"/>
|
||||
<TextBlock Text="Public videos" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
|
||||
</DockPanel>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,4">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.Tier2SubsBarWidth}"/>
|
||||
Width="{Binding Ypp.WatchHoursBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="PUBLIC UPLOADS (90 DAYS)" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.UploadsText}" Foreground="{StaticResource Brush.OnSurface}" FontSize="13"/>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,4,0,6">
|
||||
<TextBlock Text="Bar rises to 8,000 h on 1 Feb 2027."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,10"/>
|
||||
|
||||
<TextBlock Text="SHORTS VIEWS · 90 DAYS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding Ypp.ShortsViewsText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12"
|
||||
FontFamily="{StaticResource FontFamily.Mono}"/>
|
||||
<TextBlock Text="Shorts feed" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"/>
|
||||
</DockPanel>
|
||||
<Border Height="8" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Margin="0,0,0,8">
|
||||
<Border Height="8" Background="{StaticResource Brush.Tertiary}" CornerRadius="4" HorizontalAlignment="Left"
|
||||
Width="{Binding Ypp.UploadsBarWidth}"/>
|
||||
Width="{Binding Ypp.ShortsViewsBarWidth}"/>
|
||||
</Border>
|
||||
<TextBlock Text="LONG-FORM WATCH HOURS + SHORTS VIEWS" Style="{StaticResource YtLabel}" Margin="0,10,0,2"/>
|
||||
<TextBlock Text="These arrive with the extended analytics update — subscriber + upload + standing progress is live now."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap"/>
|
||||
|
||||
<TextBlock Text="{Binding Ypp.CriteriaMetText}"
|
||||
Foreground="{StaticResource Brush.OnSurface}" FontSize="12" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="Either view route qualifies — subscribers plus watch hours, or subscribers plus Shorts views."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="{Binding Ypp.ThresholdsAsOfText}"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
|
||||
<TextBlock Text="{Binding Ypp.WatchMetricsMissingText}"
|
||||
Foreground="{StaticResource Brush.Attention}" FontSize="11" TextWrapping="Wrap"
|
||||
Margin="0,0,0,8"
|
||||
Visibility="{Binding Ypp.HasWatchMetrics, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
|
||||
<TextBlock Text="FAN FUNDING TIER · 500 SUBSCRIBERS" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
<TextBlock Text="{Binding Ypp.Tier1SubsText}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11"
|
||||
FontFamily="{StaticResource FontFamily.Mono}" Margin="0,0,0,10"/>
|
||||
|
||||
<TextBlock Text="COMPLIANCE CHECKLIST" Style="{StaticResource YtLabel}" Margin="0,14,0,4"/>
|
||||
<TextBlock Text="CHANNEL STANDING" Style="{StaticResource YtLabel}" Margin="0,0,0,2"/>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:models="clr-namespace:ytLive.Models"
|
||||
xmlns:services="clr-namespace:ytLive.Services">
|
||||
xmlns:services="clr-namespace:ytLive.Services"
|
||||
xmlns:notification="clr-namespace:Notification.Core;assembly=Notification.Core">
|
||||
<!-- App Settings, as a RIGHT-EDGE PULL-OUT (creator ruling 2026-11-01): the gear tab opens
|
||||
this drawer immediately, in the same animated-width pattern as Stream Settings / YPP /
|
||||
Socials / TEST. It used to be a centred modal overlay, which then needed its own scroll
|
||||
@@ -25,6 +26,24 @@
|
||||
IsReadOnly="True" Foreground="{StaticResource Brush.OnSurfaceVariant}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="Slide-out alerts" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
|
||||
<TextBlock Text="Position" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" Margin="10,0,0,8"
|
||||
SelectedValue="{Binding ToastPosition, Mode=TwoWay}" SelectedValuePath="Tag"
|
||||
ToolTip="Choose where notifications appear inside the app window.">
|
||||
<ComboBoxItem Content="Top left" Tag="{x:Static notification:NotificationPosition.TopLeft}"/>
|
||||
<ComboBoxItem Content="Top right" Tag="{x:Static notification:NotificationPosition.TopRight}"/>
|
||||
<ComboBoxItem Content="Center left" Tag="{x:Static notification:NotificationPosition.CenterLeft}"/>
|
||||
<ComboBoxItem Content="Center right" Tag="{x:Static notification:NotificationPosition.CenterRight}"/>
|
||||
<ComboBoxItem Content="Bottom left" Tag="{x:Static notification:NotificationPosition.BottomLeft}"/>
|
||||
<ComboBoxItem Content="Bottom right" Tag="{x:Static notification:NotificationPosition.BottomRight}"/>
|
||||
</ComboBox>
|
||||
<CheckBox Content="Keep alerts until dismissed" Style="{StaticResource PropertyPillCheck}"
|
||||
Margin="12,2,0,2" IsChecked="{Binding StickyAlertsUntilDismissed, Mode=TwoWay}"
|
||||
ToolTip="When on, every slide-out alert stays until you click it or close it."/>
|
||||
<TextBlock Text="Off: informational alerts use their normal timeouts; errors still stay until closed."
|
||||
Style="{StaticResource PropertyHint}" Margin="10,0,0,8"/>
|
||||
|
||||
<!-- ═══ BRAND ═══ -->
|
||||
<TextBlock Text="Brand logo" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
|
||||
<TextBlock Text="Image file (shown on every scene)" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
|
||||
@@ -63,6 +82,21 @@
|
||||
</Popup>
|
||||
|
||||
<!-- ═══ ZOOM ═══ -->
|
||||
<TextBlock Text="Clips" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
||||
<CheckBox Content="Save replay clips" Style="{StaticResource PropertyPillCheck}" Margin="12,2,0,4"
|
||||
IsChecked="{Binding ClipsEnabled, Mode=TwoWay}"
|
||||
ToolTip="F10 saves the last few seconds of the show. Off by default; nothing runs while it is off."/>
|
||||
<StackPanel Margin="12,0,0,4" IsEnabled="{Binding ClipsEnabled}">
|
||||
<TextBlock Text="Clip length" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||
<ComboBox ItemsSource="{Binding ClipLengthOptions}" SelectedItem="{Binding ClipLengthText, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="28" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="Keep" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="0,0,0,2"/>
|
||||
<ComboBox ItemsSource="{Binding ClipKeepOptions}" SelectedItem="{Binding ClipKeepText, Mode=TwoWay}"
|
||||
Style="{StaticResource YtComboBox}" Height="28"/>
|
||||
<TextBlock Text="A capture, not an editor: for trimming, captions or a library, use a video editor."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="10" TextWrapping="Wrap" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Zoom" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
|
||||
|
||||
<TextBlock Text="Zoom view shape" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
|
||||
@@ -79,6 +113,18 @@
|
||||
<TextBlock Text="Zoom box follows" Foreground="{StaticResource Brush.OnSurface}" FontSize="11" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- ═══ VIDEO CLIPS (TASK 64) ═══ -->
|
||||
<TextBlock Text="Video clips" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
|
||||
<DockPanel Margin="10,0,0,10">
|
||||
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding AutoChromaScreen, Mode=TwoWay}" VerticalAlignment="Center"
|
||||
ToolTip="On: a clip shot against a clear green or blue screen is keyed as soon as it is added. Off: the app asks first."/>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Auto Green/Blue Screen" Foreground="{StaticResource Brush.OnSurface}" FontSize="11"/>
|
||||
<TextBlock Text="Removes a detected screen without asking." Style="{StaticResource PropertyHint}"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<!-- ═══ SCENE TRANSITIONS ═══ -->
|
||||
<TextBlock Text="Scene transition" Style="{StaticResource SectionHeader}" Margin="0,6,0,4"/>
|
||||
<TextBlock Text="Type" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="11" Margin="10,0,0,2"/>
|
||||
@@ -96,6 +142,24 @@
|
||||
Value="{Binding TransitionDurationMs, Mode=TwoWay}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- ═══ LAYOUT DEFAULTS (TASK 37) ═══ -->
|
||||
<TextBlock Text="Editing" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
||||
<CheckBox IsChecked="{Binding LockEditWhileLive, Mode=TwoWay}" Margin="10,0,0,2"
|
||||
Style="{StaticResource PropertyPillCheck}"
|
||||
Content="Lock editing while recording or live"/>
|
||||
<TextBlock Text="Off: layers can be moved or resized during a show. On: going live turns EDIT off and stopping turns it back on."
|
||||
Style="{StaticResource PropertyHint}" Margin="28,0,0,10"/>
|
||||
|
||||
<TextBlock Text="Layout defaults" Style="{StaticResource SectionHeader}" Margin="0,10,0,4"/>
|
||||
<TextBlock Text="Replaces every scene and layer with this build’s factory layout. Your selected webcam stays the same."
|
||||
Style="{StaticResource PropertyHint}" Margin="10,0,0,6"/>
|
||||
<Button Content="Restore staged scene" Command="{Binding RestoreStagedSceneDefaultsCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="10,0,0,4" HorizontalAlignment="Left"/>
|
||||
<TextBlock Text="Resets only the scene currently staged in the preview."
|
||||
Style="{StaticResource PropertyHint}" Margin="10,0,0,8"/>
|
||||
<Button Content="Restore all scenes" Command="{Binding RestoreAllLayoutDefaultsCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="10,0,0,10" HorizontalAlignment="Left"/>
|
||||
|
||||
<!-- Development panel host. Empty in a Release build: the panel itself is excluded
|
||||
from the Release compile, and the code-behind only fills this inside #if DEBUG. -->
|
||||
<StackPanel x:Name="DeveloperPanelHost"/>
|
||||
|
||||
+174
-49
@@ -53,26 +53,6 @@
|
||||
</TextBlock>
|
||||
</Button>
|
||||
|
||||
<!-- EDIT: explicit toggle. Neutral = off (chat), blue = on (layers/properties). The
|
||||
trigger used to set the SAME colour as the resting state, so the button never
|
||||
looked pressed. Right edge aligns with the layers panel below. -->
|
||||
<ToggleButton Grid.Column="1" IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
|
||||
Cursor="Hand" Height="32" Padding="14,0" Margin="18,0,0,2"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
FontWeight="Bold" FontSize="12" Foreground="{StaticResource Brush.OnAccent}" BorderThickness="0">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="EditBody" Background="{StaticResource Brush.SurfaceContainerHigh}" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="EditBody" Property="Background" Value="{StaticResource Brush.Tertiary}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
</Grid>
|
||||
|
||||
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
|
||||
@@ -81,35 +61,174 @@
|
||||
reality line + End so nothing mid-session can be re-armed. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- IDLE: the mode switch + the action(s) of the armed world. The three
|
||||
controls share one height (build 77 feedback) so they read as one
|
||||
family — segments host, Start/Go Live, and Test all sit at 32. -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Visibility="{Binding ShowIdleWorld, Converter={StaticResource BoolToVis}}">
|
||||
<Border Background="{StaticResource Brush.SurfaceContainerLowest}" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ToggleButton Style="{StaticResource RecordPillToggle}"
|
||||
IsChecked="{Binding RecordPillOn}">
|
||||
<!-- the label follows the pill's own foreground: muted when armed-off,
|
||||
dark on red when armed (SegmentLabel pins its own colour) -->
|
||||
<TextBlock Text="REC" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource RecordPillToggle}"
|
||||
IsChecked="{Binding OnAirPillOn}">
|
||||
<TextBlock Text="ON-AIR" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ToggleButton}}"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Go face names the world: "Start Recording" vs "Go Live".
|
||||
(Signed-out ON-AIR Start routes to the sign-in — StartSession.) -->
|
||||
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
||||
Height="32" Margin="10,0,0,0" Command="{Binding PrimaryStartCommand}"
|
||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- No separate TEST button (creator ruling 2026-10-30): EDIT decides whether
|
||||
the ON-AIR start is the real livestream or the test one. -->
|
||||
</StackPanel>
|
||||
<!-- THE decision (creator ruling 2026-11-12): [ Build Scenes ][ Record / Go Live ].
|
||||
Build Scenes is the new edit mode — there is no separate EDIT button, so the
|
||||
question "which world am I in?" and "can I edit?" cannot disagree. Clicking the
|
||||
highlighted half re-enters it, which is how a creator gets back to the layers
|
||||
during a session. -->
|
||||
<!-- THREE FIXED SLOTS (creator, 2026-11-13): [Build Scenes] [YouTube] [Record]. The bar's
|
||||
shape NEVER changes — every transition happens inside one slot, so the neighbouring
|
||||
buttons cannot move. Each slot is its own little flow. -->
|
||||
<Border Background="{StaticResource Brush.SegmentTrack}" CornerRadius="12" Padding="4"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0"
|
||||
KeyboardNavigation.TabNavigation="Cycle" KeyboardNavigation.DirectionalNavigation="Cycle">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ══ SLOT 1: BUILD SCENES ══ -->
|
||||
<Button x:Name="BuildSegmentButton" Grid.Column="0" Style="{StaticResource SegmentHalf}"
|
||||
Tag="True" Command="{Binding SelectBuildCommand}"
|
||||
IsEnabled="{Binding IsBuildScenesEnabled}"
|
||||
ToolTip="{Binding BuildScenesTooltip}" FocusVisualStyle="{x:Null}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M3,4h8v6H3V4zM13,4h8v6h-8V4zM3,12h8v8H3V12zM13,12h8v8h-8V12z"/>
|
||||
<TextBlock Text="Build Scenes" FontSize="12" FontWeight="Bold" Margin="6,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- ══ SLOT 2: YOUTUBE ══ -->
|
||||
<Grid Grid.Column="1" Margin="4,0">
|
||||
<!-- State 0: idle -->
|
||||
<Button Style="{StaticResource SegmentHalf}" Tag="False"
|
||||
Command="{Binding OpenYouTubeCommand}" FocusVisualStyle="{x:Null}"
|
||||
Visibility="{Binding IsYouTubeIdle, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="Stream to YouTube">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
||||
<TextBlock Text="YouTube" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- State 1: choose privacy — grouped inside a bordered sub-pill -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
Visibility="{Binding IsYouTubeChoosingPrivacy, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding ChooseLiveCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Public livestream">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M8,5v14l11,-7z"/>
|
||||
<TextBlock Text="Live" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding ChoosePrivateCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Private stream — nothing public is published">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M7,10V7a5,5 0 0,1 10,0v3h1a1,1 0 0,1 1,1v9a1,1 0 0,1 -1,1H6a1,1 0 0,1 -1,-1v-9a1,1 0 0,1 1,-1h1zM9,10h6V7a3,3 0 0,0 -6,0v3z"/>
|
||||
<TextBlock Text="Private" FontSize="12" Margin="5,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- State 2: armed — amber border, because the next click goes on air -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
|
||||
Visibility="{Binding IsYouTubeArmed, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding StartYouTubeCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Start broadcasting now">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
|
||||
Fill="{StaticResource Brush.Attention}"/>
|
||||
<TextBlock Text="{Binding YouTubeArmLabel}" FontSize="12" FontWeight="Bold"
|
||||
Margin="5,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelYouTubeCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- State 3: streaming -->
|
||||
<Button Style="{StaticResource SegmentHalf}" Tag="True"
|
||||
Command="{Binding StopYouTubeCommand}" FocusVisualStyle="{x:Null}"
|
||||
Visibility="{Binding IsYouTubeStreaming, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="End the broadcast">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="True" Data="M6,6h12v12H6z"/>
|
||||
<TextBlock Text="{Binding YouTubeStopLabel}" FontSize="12" FontWeight="Bold"
|
||||
Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- ══ SLOT 3: RECORD ══ -->
|
||||
<Grid Grid.Column="2" Margin="4,0,0,0">
|
||||
<!-- State 0: idle -->
|
||||
<Button Style="{StaticResource SegmentHalf}" Tag="False"
|
||||
Command="{Binding OpenRecordCommand}" FocusVisualStyle="{x:Null}"
|
||||
Visibility="{Binding IsRecordIdle, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="Record to a local MP4">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource SegmentIcon}" Tag="False" Data="M12,2a10,10 0 1,0 0,20a10,10 0 1,0 0,-20zM12,4a8,8 0 1,1 0,16a8,8 0 1,1 0,-16zM12,7.5a4.5,4.5 0 1,1 0,9a4.5,4.5 0 1,1 0,-9z"/>
|
||||
<TextBlock Text="Record" FontSize="12" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- State 1: armed -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
BorderBrush="{StaticResource Brush.Attention}" BorderThickness="1"
|
||||
Visibility="{Binding IsRecordArmed, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding StartRecordingCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Start recording now">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
|
||||
Fill="{StaticResource Brush.Attention}"/>
|
||||
<TextBlock Text="Start Recording" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding CancelRecordCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Cancel">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M18.3,5.71L12,12l6.3,6.29 -1.41,1.42L10.59,13.41 4.29,19.71 2.88,18.29 9.17,12 2.88,5.71 4.29,4.29 10.59,10.59 16.89,4.29z" Width="11" Height="11"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- State 2: recording — green pulse, clock, stop -->
|
||||
<Border Background="{StaticResource Brush.SegmentActive}" CornerRadius="8" Padding="3"
|
||||
Visibility="{Binding IsRecordRecording, Converter={StaticResource BoolToVis}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="9" Height="9" VerticalAlignment="Center"
|
||||
Fill="{StaticResource Brush.RecordLive}"
|
||||
Opacity="{Binding RecDotOpacity, Mode=OneWay}"/>
|
||||
<TextBlock Text="{Binding RecordElapsedLabel}" FontSize="11" FontWeight="Bold"
|
||||
FontFamily="Consolas" Margin="5,0,8,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
<Button Style="{StaticResource OutputChip}" Command="{Binding StopRecordingCommand}"
|
||||
FocusVisualStyle="{x:Null}" ToolTip="Stop recording and save">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Style="{StaticResource OutputChipIcon}" Data="M6,6h12v12H6z"/>
|
||||
<TextBlock Text="Stop" FontSize="12" FontWeight="Bold" Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.SegmentTextActive}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
@@ -120,6 +239,12 @@
|
||||
Foreground="White" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding LiveElapsedText}" Foreground="White" FontSize="14"
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="8,0,0,0"/>
|
||||
<!-- TASK 32·2: measured facts only — elapsed seconds and which attempt. No countdown:
|
||||
YouTube's cutoff is not a constant we can promise. -->
|
||||
<TextBlock x:Name="ReconnectDetail" Text="{Binding ReconnectDetailText}"
|
||||
Foreground="{StaticResource Brush.Attention}" FontSize="13"
|
||||
FontFamily="Consolas" VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
Visibility="{Binding IsReconnecting, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
|
||||
Background="{StaticResource Brush.Primary}" Margin="14,0,0,0"
|
||||
Command="{Binding EndStreamCommand}"/>
|
||||
|
||||
@@ -19,6 +19,10 @@ public partial class TopBar : UserControl
|
||||
InitializeComponent();
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
||||
|
||||
// Keyboard enters the bar on Build scenes, and Tab cycles between the two halves only
|
||||
// (creator, build 729) — Tab must not walk off into the rest of the window from here.
|
||||
Loaded += (_, _) => BuildSegmentButton.Focus();
|
||||
}
|
||||
|
||||
/// <summary>Right-click on the LlamaCasty wordmark opens the app menu (Report Bug / Feature
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, static TextOverlay preview image from the shared cached text raster, text drawer, outgoing-picture transition overlay, right-edge Stream Info/YPP/Socials/TEST tabs and drawers, and live controls (TRAX / game / mic meters + sliders + speakers). The list-selected draggable layer is temporarily raised in preview z-order and custom hit-testing; deselection restores the scene's unchanged order (`SelectedLayerPreviewHitTestingTests`). **TASK 57:** dragging/resizing a webcam in the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` / `ResizeWebcamInVerticalTier` for the six-spot snap or Chat camera's two sizes. |
|
||||
| `SceneThumbnailStrip.xaml(.cs)` | Top row over the preview column: five fixed-size scene cards with scene name, hotkey, staged `PROGRAM` badge, orange staged/live border |
|
||||
| `LeftPanel.xaml(.cs)` | Row 2 Col 0 left panel: explicit EditingActive toggle on → Scene Layers+properties; off → YouTube chat, independent of stream status. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept; TextOverlay properties edit multiline content, font type/family, size, color via the shared palette, emphasis, and horizontal/vertical alignment with a static-text notice; sub-options are indented under their headings. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlays: App Settings / Report Bug / Feature Request / About; About includes the output-quality conversion copy and an explicit llamacasty.com link, while the licensing/legal panel remains in-app. App Settings groups recording, slide-out alert placement/stickiness, brand-logo, preview, transition, and staged/all-scene layout-default controls. `BackToAbout_Click` lives here |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
|
||||
+26
-13
@@ -62,19 +62,32 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
|
||||
| Partner support | `help.steampowered.com/en/wizard/HelpWithPublishing` |
|
||||
| Steamworks.NET (managed wrapper) | `nuget.org/packages/Steamworks.NET` |
|
||||
|
||||
1. **Steamworks account** → onboarding (NDA, identity, company details, tax interview, payout bank,
|
||||
then the **$100 Steam Direct fee** per app). The store page cannot be configured before the fee.
|
||||
2. **App IDs.** One paid app and its associated free Demo. The Demo has its own App ID; it checks
|
||||
ownership of the paid app with `BIsSubscribedApp(paidAppId)`.
|
||||
3. **Builds.** Keep one codebase. Steam gives the Demo a separate release checklist/build setup;
|
||||
shared depots can avoid duplicating common files once the paid app is released.
|
||||
4. **Steamworks SDK.** `./scripts/steam-sdk.sh ~/Downloads/steamworks_sdk_165.zip` extracts
|
||||
`steam_api64.dll` from `sdk/redistributable_bin/win64/` into `steam/` (gitignored) and stages
|
||||
`steam_appid.txt` = 480 — Valve's Spacewar app, so `SteamAPI.Init()` works locally before
|
||||
LlamaCasty has an App ID. Add `--contentbuilder` on the machine that uploads a depot. The csproj
|
||||
copies the DLL **beside the exe** (`Link`) with `ExcludeFromSingleFile`, and copies
|
||||
`steam_appid.txt` in Debug only — never into a depot. The managed side is the `Steamworks.NET`
|
||||
package (`SteamApps.BIsSubscribedApp`).
|
||||
### Status (updated 2026-11-05 — this is a WAIT on Valve, not a task of ours)
|
||||
|
||||
| Step | State |
|
||||
|---|---|
|
||||
| Partner account created | ✅ done by the creator |
|
||||
| Identity verification | ⏳ **pending** — Valve reviewing |
|
||||
| Tax interview | ⏳ submitted; Valve quotes **up to 30 business days** to review |
|
||||
| $100 Steam Direct fee | ✅ **paid** (creator, 2026-11-05) — a store page can be configured once identity clears |
|
||||
| App IDs (paid app + free Demo) | **not yet assigned** |
|
||||
| Store page | not created |
|
||||
| Build review | not started |
|
||||
|
||||
**Critical path from 2026-11-05**, using Valve's own published windows:
|
||||
|
||||
1. identity + tax review — up to **30 business days** (~6 calendar weeks) → mid-December
|
||||
2. fee paid, app IDs assigned, store page built and submitted
|
||||
3. store page review — **up to 12 business days** (Valve's current backlog; docs still say 3–5)
|
||||
4. page public **≥2 weeks before release** (Valve's rule)
|
||||
5. build review — **up to 12 business days**
|
||||
|
||||
**Earliest realistic launch: early-to-mid February 2027.** The launch-price deadline is no longer a
|
||||
date: it now ends **30 days after the store page goes live** (2026-11-05), so Valve's queue cannot make
|
||||
the public claim wrong.
|
||||
|
||||
Nothing below can start until step 1 clears; none of it blocks the app work.
|
||||
|
||||
5. **Store page** configured and made public **two weeks before release** — Valve's rule, and it is
|
||||
the window where wishlists accumulate.
|
||||
|
||||
|
||||
+31
-14
@@ -1,23 +1,40 @@
|
||||
# HANDOFF — 2026-11-04 (TASK 3·17 — static text layers)
|
||||
# HANDOFF — 2026-11-12 (bitrate + audio-clock fixes; sync number pending)
|
||||
|
||||
## State
|
||||
|
||||
- `main`; TASK 53 is complete and TASK 3·17 is complete in three commits: `60c4a00`, `58eb640`,
|
||||
and the final output/compositing phase.
|
||||
- Text layers persist independently, edit as static multiline text, and share one cached raster between
|
||||
preview and output. The frame pump only reads the raster cache; no WPF render occurs on its thread.
|
||||
- The app clean build has 0 warnings. 524 tests passed with the documented physical mouse-injection test
|
||||
and known audio test excluded; the new text renderer, preview/output, and pump-cache tests pass.
|
||||
- The four untracked `Assets/llamacasty-*` files remain untouched.
|
||||
- `main`; `b265d42` is the latest pushed commit. Everything after is local.
|
||||
- **Bitrate saga closed.** An @8 stream averaged 5.1 Mbps because the args were capped VBR, not CBR
|
||||
(measured: 9 kbps vs 1951 kbps at a 2000k target on a static frame). `700c7a8` adds `-rc cbr` for
|
||||
NVENC/AMF, nothing for QSV (infers CBR from bitrate == maxrate) or OpenH264 (no measurable gain).
|
||||
- Ladder `1708667` (12/10/6); floor `93719a2` (tier YouTube minimum); rate box `f2cab5c`; rate history +
|
||||
consent proposal `b0f8263`; pre-release 60 fps switch `1e3a4f7`.
|
||||
- **Audio drift closed** (`47f409f`, `102cb96`, `751d9b1`): the mix is driven by the monotonic clock,
|
||||
counts only audio whose write reached the pipe, and starts its clock at session open. Measured
|
||||
across three recordings: +2.1% long → 226 ms short → 137 ms short → **lengths now match (34 ms in
|
||||
13 s)**.
|
||||
- **Sync answer (measured, settled).** Mic delay ships at **0** and should stay off; with it off the audio
|
||||
path runs ~100 ms behind, so the **global** LIP-SYNC offset is **−130 ms**. Both offsets default to 0 —
|
||||
a correction belongs to one camera, never to the product (`AudioSyncSettingsTests` pins it).
|
||||
- **675 tests green, 0 warnings.** `scripts/avsync.py` measures any recording in seconds.
|
||||
|
||||
## Next action
|
||||
|
||||
- Next action: select the next queued task from `TASKS.md`.
|
||||
- Do not push absent a checkpoint request.
|
||||
- Creator: set the mic delay to 350, record ~15 s, run `./scripts/avsync.py "<clip>" --crop
|
||||
764x764+1032+159`. Target: `offset` inside ±30 ms and `lengths match`.
|
||||
- Then resume the queue: **40·A/C/D → Move → 9·5 → 14·16/17/18 → 66 → 67 → 68 → 69 → 70 → 36**.
|
||||
- Do not push app commits absent a checkpoint request.
|
||||
|
||||
## Landmines
|
||||
|
||||
- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
|
||||
mouse input. Do not run it while the desktop is in use.
|
||||
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
|
||||
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||
- `Assets/starting-background.jpg` is modified in the working tree, **awaiting the creator's visual
|
||||
check** — do not commit it with another unit.
|
||||
- **Never read a field the constructor assigns later** (`RefreshRateProposal()` at ctor 354 reads
|
||||
`_framePump`, built at 468 — 8 failing tests, `e654a16`).
|
||||
- **Never pipe a long job through `tail`** — it buffers, so a live gate looks dead, and verify's phase-3
|
||||
build writes to `/dev/null`. Use `vstest --logger:"console;verbosity=normal"`; it names the failure.
|
||||
- A killed test run leaves `testhost.exe` holding `LlamaCasty.dll`; every later build then fails on the
|
||||
copy. `taskkill.exe /F /IM testhost.exe` before rebuilding.
|
||||
- `ChromaKeyRealClipTests`, `AudioPipelineTests.Mix_*`, `AppLogRotationTests` and
|
||||
`NotificationAreaIntegrationTests` flake under load; each passes alone.
|
||||
- Every rate change is a full encoder relaunch; rates go through `MainViewModel.EffectiveQuality`.
|
||||
- `MainWindow` tests need a temporary `LayoutPathOverride` cleared in `finally`; read render numbers in Release.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The arithmetic behind dragging a row in the layer list, kept out of the window's code-behind so it
|
||||
/// can be tested without a mouse. It used to live inline in the preview-mouse handlers, and the only
|
||||
/// test that covered it injected REAL cursor movement (<c>SetCursorPos</c> + <c>mouse_event</c>) — which
|
||||
/// took over whatever machine ran the tests and failed whenever the desktop was busy. 2026-11-05: the
|
||||
/// arithmetic moved here (pure, tested), the injected-input test was deleted.
|
||||
/// </summary>
|
||||
public static class DragReorderMath
|
||||
{
|
||||
/// <summary>True once the pointer has travelled far enough to mean "drag", not "click". WPF's
|
||||
/// own thresholds are the caller's (<c>SystemParameters.MinimumHorizontal/VerticalDragDistance</c>).</summary>
|
||||
public static bool ExceededThreshold(double dx, double dy, double horizontal, double vertical)
|
||||
=> Math.Abs(dx) >= horizontal || Math.Abs(dy) >= vertical;
|
||||
|
||||
/// <summary>Moves one item to the position currently occupied by another. Returns the index the
|
||||
/// item actually landed on (the caller keeps dragging from there), or -1 when the move is a no-op
|
||||
/// or the indices are out of range.
|
||||
/// <para>Order matters: the item is removed first, so a downward move inserts at the target's old
|
||||
/// index and lands exactly under the pointer — this is the behaviour the mouse test used to pin.</para>
|
||||
/// </summary>
|
||||
public static int TryMove(IList items, int from, int to)
|
||||
{
|
||||
if (items == null || from < 0 || to < 0 || from >= items.Count || to >= items.Count) return -1;
|
||||
if (from == to) return -1;
|
||||
|
||||
var moved = items[from]!;
|
||||
items.RemoveAt(from);
|
||||
items.Insert(to, moved);
|
||||
return to;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Paces a frame stream to wall-clock deadlines instead of sleeping a fixed interval after each
|
||||
/// frame. The difference matters: a fixed sleep ADDS the frame's own decode/copy cost, so a 30 fps
|
||||
/// clip with 20 ms of work per frame plays at 1/(33+20) ≈ 19 fps — which is what a 1080p video layer
|
||||
/// did (measured 20.9 fps on a 30 fps clip, 2026-11-05). Here the target is <c>start + n × frame</c>,
|
||||
/// so work is absorbed by the interval when it fits, and a stream that has fallen behind resyncs
|
||||
/// instead of bursting to catch up.
|
||||
/// </summary>
|
||||
public sealed class FramePacer
|
||||
{
|
||||
private long _nextTicks = long.MinValue;
|
||||
|
||||
/// <summary>How long to wait after emitting the frame at <paramref name="now"/>. Returns zero
|
||||
/// when the frame is already due (or late).</summary>
|
||||
public TimeSpan DelayFor(TimeSpan frameDuration, TimeSpan now)
|
||||
{
|
||||
if (frameDuration <= TimeSpan.Zero) return TimeSpan.Zero;
|
||||
|
||||
if (_nextTicks == long.MinValue)
|
||||
{
|
||||
_nextTicks = now.Ticks + frameDuration.Ticks;
|
||||
return frameDuration;
|
||||
}
|
||||
|
||||
var remaining = TimeSpan.FromTicks(_nextTicks - now.Ticks);
|
||||
_nextTicks += frameDuration.Ticks;
|
||||
|
||||
if (remaining < TimeSpan.Zero)
|
||||
{
|
||||
// Late (the frame's work exceeded its interval): resync to now rather than emitting a
|
||||
// burst of frames back-to-back, which the viewer would see as a speed-up.
|
||||
_nextTicks = now.Ticks + frameDuration.Ticks;
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
// Never wait longer than one interval, even if the caller's clock stood still (a fake delay
|
||||
// in a test, or a suspended machine): a longer sleep would be a pause, not pacing.
|
||||
return remaining > frameDuration ? frameDuration : remaining;
|
||||
}
|
||||
|
||||
/// <summary>Restarts the schedule (a new playback pass begins at the top of the file).</summary>
|
||||
public void Reset() => _nextTicks = long.MinValue;
|
||||
}
|
||||
@@ -69,6 +69,18 @@ public static class InstanceProfile
|
||||
public static string? WebViewDataFolder => null;
|
||||
#endif
|
||||
|
||||
/// <summary>The audio pipe name for this process. The pipe server allows exactly ONE instance, so a
|
||||
/// shared name means the second app on the machine can never go live — it throws "All pipe instances
|
||||
/// are busy" (2026-11-06: Release recording, Debug trying to go live). Release keeps the plain name;
|
||||
/// a DEBUG build ALWAYS appends a suffix — its claimed id, or the process id when it has none — so a
|
||||
/// debug run launched straight from the IDE can never collide with the Release build beside it.</summary>
|
||||
public static string AudioPipeName(string baseName)
|
||||
#if DEBUG
|
||||
=> Id is { } id ? $"{baseName}_{id}" : $"{baseName}_pid{Environment.ProcessId}";
|
||||
#else
|
||||
=> baseName;
|
||||
#endif
|
||||
|
||||
/// <summary>The one real user profile: <c>%APPDATA%\ytLlive</c>. Note the historical
|
||||
/// double-L — do not "fix" it, users already have their layouts there.</summary>
|
||||
public static string DefaultRoot => Path.Combine(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// True when a log entry's sequence falls inside the range a clicked alert points at. Two numbers
|
||||
/// rather than a membership collection: the log list is virtualised, and rows that are already
|
||||
/// realised do not re-evaluate when a collection changes — but they do when a bound number changes.
|
||||
/// </summary>
|
||||
public sealed class LogInRangeConverter : IMultiValueConverter
|
||||
{
|
||||
public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (values.Length < 3) return false;
|
||||
if (values[0] is not long sequence) return false;
|
||||
if (values[1] is not long from || values[2] is not long to) return false;
|
||||
if (from == 0 && to == 0) return false;
|
||||
return sequence >= from && sequence <= to;
|
||||
}
|
||||
|
||||
public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
+41
-9
@@ -145,12 +145,44 @@
|
||||
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<Border x:Name="EntitlementErrorPanel" Margin="0,6,0,0" Padding="8,6"
|
||||
Background="{StaticResource Brush.ErrorContainer}"
|
||||
BorderBrush="{StaticResource Brush.Error}" BorderThickness="1" CornerRadius="4"
|
||||
Visibility="{Binding EntitlementErrorMessage, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock x:Name="EntitlementErrorText" Text="{Binding EntitlementErrorMessage}"
|
||||
Foreground="{StaticResource Brush.Error}" FontSize="11" TextWrapping="Wrap"/>
|
||||
<!-- The creator's own rate: the tier sets the geometry, this sets the bits.
|
||||
Guidance lives in the tooltip (YouTube's numbers, from RateLadder). -->
|
||||
<Grid Margin="0,6,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="RatedMbpsBox" Grid.Column="0" Style="{StaticResource YtTextBoxCompact}"
|
||||
Text="{Binding RatedMbps, UpdateSourceTrigger=LostFocus, StringFormat={}{0:0.#}}"
|
||||
ToolTip="{Binding RateHint}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/>
|
||||
</Grid>
|
||||
<!-- Between sessions only, never mid-show, never applied without a click: what
|
||||
the last few sessions actually held (Models/RateHistory). -->
|
||||
<Border Margin="0,6,0,0" Padding="8,6" CornerRadius="4"
|
||||
Background="{StaticResource Brush.SurfaceContainerHigh}">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasRateProposal}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding RateProposalText}" TextWrapping="Wrap" FontSize="11"
|
||||
Foreground="{StaticResource Brush.OnSurface}"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||
<Button Content="Use it" Command="{Binding AcceptRateProposalCommand}"
|
||||
Style="{StaticResource YtButton}"/>
|
||||
<Button Content="Keep mine" Command="{Binding DismissRateProposalCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="6,0,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -177,10 +209,10 @@
|
||||
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||
<controls:OverlayHost x:Name="AboutOverlay" Grid.RowSpan="3"/>
|
||||
|
||||
<!-- ═══ TOASTS: non-blocking notifications (bottom-right, above footer) ═══ -->
|
||||
<!-- ═══ TOASTS: non-blocking notifications (user-positioned, bottom-right by default) ═══ -->
|
||||
<notifications:NotificationArea x:Name="ToastArea"
|
||||
Grid.RowSpan="4"
|
||||
Position="BottomRight" MaxItems="4"
|
||||
Margin="0,0,12,96"/>
|
||||
Position="{Binding ToastPosition}" MaxItems="4"
|
||||
Margin="{Binding ToastAreaMargin}"/>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
+43
-1
@@ -147,8 +147,28 @@ public partial class MainWindow : Window
|
||||
_topBar.RefreshAvatar();
|
||||
}
|
||||
|
||||
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
private bool _shutdownComplete;
|
||||
|
||||
private async void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
if (_shutdownComplete)
|
||||
{
|
||||
SaveWindowGeometry();
|
||||
_viewModel.Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// A session must END before anything is disposed (2026-11-12: closing mid-recording used to
|
||||
// pull the pump out from under itself). Hold the window open, stop properly, then close again.
|
||||
if (_viewModel.IsLive || _viewModel.IsRecording)
|
||||
{
|
||||
e.Cancel = true;
|
||||
await _viewModel.StopBeforeShutdownAsync();
|
||||
_shutdownComplete = true;
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
SaveWindowGeometry();
|
||||
_viewModel.Shutdown();
|
||||
}
|
||||
@@ -181,6 +201,24 @@ public partial class MainWindow : Window
|
||||
{
|
||||
_viewModel.DismissZoomIfActive();
|
||||
}
|
||||
|
||||
// Esc leaves a sub-menu (creator ruling 2026-11-12): the Output world is a sub-menu, and its
|
||||
// back chevron is gone in favour of this. The bar says so under the switches.
|
||||
// Esc backs out of whichever slot is mid-flow (creator, 2026-11-13): the slots cancel in place
|
||||
// rather than moving the bar around.
|
||||
if (e.Key == System.Windows.Input.Key.Escape)
|
||||
{
|
||||
if (_viewModel.IsYouTubeChoosingPrivacy || _viewModel.IsYouTubeArmed)
|
||||
{
|
||||
_viewModel.CancelYouTubeCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (_viewModel.IsRecordArmed)
|
||||
{
|
||||
_viewModel.CancelRecordCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTaskbarOverlay()
|
||||
@@ -201,6 +239,10 @@ public partial class MainWindow : Window
|
||||
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
||||
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
||||
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
|
||||
// The Settings (gear) and Socials drawers live in the same host but were left out of this
|
||||
// guard, so clicking anywhere else left them open (reported 2026-11-05).
|
||||
_viewModel.CloseSettingsDrawerIfOpen();
|
||||
_viewModel.CloseSocialsDrawer();
|
||||
}
|
||||
if (_previewPane.IsClickInsidePreview(original)) return;
|
||||
if (_leftPanel.IsClickInside(original)) return;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row in the left panel's alert stack (creator design, 2026-11-05). The stack sits above the
|
||||
/// panel's content and pushes it down, so an alert is seen without hunting for a tab.
|
||||
/// <para><b>Pinned</b> rows are standing state, not errors to clear: they show a pin instead of a
|
||||
/// close control and are only removed when the state itself changes (the Steam licence indicator
|
||||
/// becomes dismissible — and its dismissal permanent — once ownership is verified).</para>
|
||||
/// <para>Clicking a row runs its action: the licence indicator opens About; an event alert opens the
|
||||
/// Logs tab with that event's lines highlighted.</para>
|
||||
/// <para>Rows are text only — a heading line above the message was redundant and pushed the stack past
|
||||
/// its two-line budget (creator, 2026-11-05).</para>
|
||||
/// </summary>
|
||||
public sealed record AttentionAlert(
|
||||
string Id,
|
||||
string Text,
|
||||
bool IsPinned,
|
||||
AttentionAlertAction Action,
|
||||
long LogFrom = 0,
|
||||
long LogTo = 0)
|
||||
{
|
||||
/// <summary>True while a close control should be shown (a dismissible row).</summary>
|
||||
public bool CanDismiss => !IsPinned;
|
||||
|
||||
/// <summary>True while the row should show the pin glyph instead of a close control.</summary>
|
||||
public bool ShowsPin => IsPinned;
|
||||
}
|
||||
|
||||
public enum AttentionAlertAction
|
||||
{
|
||||
/// <summary>Open the About panel (the licence indicator).</summary>
|
||||
About,
|
||||
|
||||
/// <summary>Open the Logs tab and highlight this event's lines.</summary>
|
||||
Logs,
|
||||
}
|
||||
+14
-1
@@ -19,6 +19,19 @@ public static class OutputProfile
|
||||
/// <summary>Free ceiling: 30 frames per second.</summary>
|
||||
public const int FreeMaxFps = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-release switch. <c>false</c> means the frame-rate boundary is not enforced yet, so every
|
||||
/// shipped tier is selectable by any build — the app is still being tested at 60 fps in
|
||||
/// <b>Release</b>, where <c>DevBuild.IsDevelopment</c> is false and the developer override cannot
|
||||
/// help. Flip to <c>true</c> when the paid edition ships; the rule itself already exists and its
|
||||
/// tests do not change.
|
||||
///
|
||||
/// <para><b>Why <c>static readonly</c>:</b> a <c>const</c> folds the consuming <c>||</c> at compile
|
||||
/// time and leaves an unreachable operand, which is a warning. The JIT folds a readonly field
|
||||
/// just as well, and the build stays at zero warnings.</para>
|
||||
/// </summary>
|
||||
public static readonly bool EntitlementEnforced = false;
|
||||
|
||||
/// <summary>The default free tier and fallback when no shipped tier is available.</summary>
|
||||
public const string FreeFallbackName = "1080p30";
|
||||
|
||||
@@ -51,6 +64,6 @@ public static class OutputProfile
|
||||
|
||||
// No shipped tier fits the budget: build the floor explicitly rather than trusting
|
||||
// the table. A build whose tiers were all redefined must still emit a legal frame.
|
||||
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 8.0, 1920, 1080);
|
||||
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 10.0, 1920, 1080);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One finished broadcast, as evidence about a tier's rate.</summary>
|
||||
/// <param name="Tier">The quality tier's display name, e.g. "1080p60".</param>
|
||||
/// <param name="Encoder">The probed encoder, e.g. "h264_nvenc" — history against one encoder is not
|
||||
/// evidence about another, so a GPU or driver change cannot import stale conclusions.</param>
|
||||
/// <param name="RatedMbps">The rate the session started at (the creator's or the tier default).</param>
|
||||
/// <param name="SettledMbps">The rate the session ended at after any step-down.</param>
|
||||
/// <param name="Steps">How many quality steps the session spent. Zero means the rate held.</param>
|
||||
public readonly record struct SessionRateResult(
|
||||
string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
|
||||
{
|
||||
public bool Stepped => Steps > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the app has learned about a connection, as pure policy — the only thing allowed to suggest a
|
||||
/// different starting rate, and it never applies one itself.
|
||||
///
|
||||
/// <para><b>High-water mark</b> is the highest rate that <i>finished</i> a session with zero steps, not
|
||||
/// the highest number ever seen: one easy session (a menu screen, a quiet game) must not set a target
|
||||
/// the machine cannot hold, which would make every later session look like a regression.</para>
|
||||
///
|
||||
/// <para><b>Asymmetric on purpose.</b> Raising needs a 10% improvement across five clean sessions, so
|
||||
/// fixing nothing is slow; lowering needs only repeated step-downs, because fixing damage should not
|
||||
/// wait. A creator may always override either way — see <c>MainViewModel.RatedMbps</c>.</para>
|
||||
/// </summary>
|
||||
public static class RateHistory
|
||||
{
|
||||
/// <summary>Sessions considered. Below this there is no evidence, only noise.</summary>
|
||||
public const int Window = 5;
|
||||
|
||||
/// <summary>How far above the current rate an upward proposal must sit to be worth asking.</summary>
|
||||
public const double RaiseThreshold = 0.10;
|
||||
|
||||
/// <summary>Step-downs in the window that make a downward proposal worth asking.</summary>
|
||||
public const int LowerAfterSteps = 3;
|
||||
|
||||
/// <summary>A suggested starting rate, and which way it moves.</summary>
|
||||
public readonly record struct Proposal(double Mbps, bool Raise);
|
||||
|
||||
/// <summary>
|
||||
/// The suggestion for one tier, or null when the evidence does not warrant one. <paramref name="encoder"/>
|
||||
/// null considers every encoder; callers pass the current one so a hardware change starts clean.
|
||||
/// </summary>
|
||||
public static Proposal? Suggest(
|
||||
IEnumerable<SessionRateResult> history, string tier, string? encoder, double currentMbps)
|
||||
{
|
||||
var window = history
|
||||
.Where(entry => entry.Tier == tier && (encoder is null || entry.Encoder == encoder))
|
||||
.TakeLast(Window)
|
||||
.ToList();
|
||||
|
||||
if (window.Count < Window) return null;
|
||||
|
||||
if (window.Count(entry => entry.Stepped) >= LowerAfterSteps)
|
||||
return new Proposal(Math.Round(Median(window.Select(entry => entry.SettledMbps)), 1), Raise: false);
|
||||
|
||||
var highWater = window.Max(entry => entry.Stepped ? 0 : entry.RatedMbps);
|
||||
return highWater >= currentMbps * (1 + RaiseThreshold)
|
||||
? new Proposal(Math.Round(highWater, 0), Raise: true)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static double Median(IEnumerable<double> values)
|
||||
{
|
||||
var sorted = values.OrderBy(value => value).ToArray();
|
||||
if (sorted.Length == 0) return 0;
|
||||
var middle = sorted.Length / 2;
|
||||
return sorted.Length % 2 == 1
|
||||
? sorted[middle]
|
||||
: (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
// ─── Storage form: one line per session, unit-separated fields ───
|
||||
|
||||
private const char Field = '\u001F';
|
||||
|
||||
public static string Format(SessionRateResult entry)
|
||||
=> string.Join(Field, entry.Tier, entry.Encoder,
|
||||
entry.RatedMbps.ToString("0.#"), entry.SettledMbps.ToString("0.#"), entry.Steps);
|
||||
|
||||
public static SessionRateResult? Parse(string? line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) return null;
|
||||
var parts = line.Split(Field);
|
||||
if (parts.Length != 5 || !double.TryParse(parts[2], out var rated)
|
||||
|| !double.TryParse(parts[3], out var settled) || !int.TryParse(parts[4], out var steps))
|
||||
return null;
|
||||
return new SessionRateResult(parts[0], parts[1], rated, settled, steps);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// YouTube's published H.264 ingestion numbers for a tier, in Mbps — the floor a step-down may walk
|
||||
/// to and the recommended rate a creator aims at.
|
||||
///
|
||||
/// <para><b>Source:</b> support.google.com/youtube/answer/2853702 (checked 2026-11-12): 1080p@60
|
||||
/// 6 min / 17 recommended, 1080p@30 5/14, 720p@30 and @60 3/8. The same page specifies CBR, which is
|
||||
/// why these numbers are a target the encoder actually holds (<c>FfmpegArgs.AddRateControl</c>).</para>
|
||||
///
|
||||
/// <para><b>Vertical is not a separate row.</b> 1080×1920 has the same pixel count as 1920×1080, so a
|
||||
/// portrait tier takes the band of its landscape pair — the frame rate alone decides which row.</para>
|
||||
/// </summary>
|
||||
public static class RateLadder
|
||||
{
|
||||
/// <summary>Minimum and recommended rates for a tier.</summary>
|
||||
public readonly record struct Band(double Minimum, double Recommended);
|
||||
|
||||
public static Band For(int width, int height, int fps)
|
||||
{
|
||||
var is1080 = Math.Max(width, height) >= 1920;
|
||||
return (is1080, fps > 30) switch
|
||||
{
|
||||
(true, true) => new Band(6.0, 17.0),
|
||||
(true, false) => new Band(5.0, 14.0),
|
||||
(_, _) => new Band(3.0, 8.0),
|
||||
};
|
||||
}
|
||||
|
||||
public static Band For(QualityOption tier) => For(tier.Width, tier.Height, tier.Fps);
|
||||
}
|
||||
@@ -72,6 +72,28 @@ public class Scene : INotifyPropertyChanged
|
||||
|
||||
public bool IsChatScene { get; init; }
|
||||
|
||||
private bool? _sceneMutesMic;
|
||||
|
||||
/// <summary>TASK 35: what this scene does to the mix when it takes the program output.
|
||||
/// <c>null</c> = leave it alone (the default; scenes are passive until told); true mutes the mic on
|
||||
/// entry. Applied once at entry, never while staging offline — auditioning a scene must not edit
|
||||
/// the stream's state.</summary>
|
||||
public bool? SceneMutesMic
|
||||
{
|
||||
get => _sceneMutesMic;
|
||||
set => Set(ref _sceneMutesMic, value);
|
||||
}
|
||||
|
||||
private bool? _sceneMutesDesktop;
|
||||
|
||||
/// <summary>TASK 35: as <see cref="SceneMutesMic"/>, for desktop/game audio.</summary>
|
||||
public bool? SceneMutesDesktop
|
||||
{
|
||||
get => _sceneMutesDesktop;
|
||||
set => Set(ref _sceneMutesDesktop, value);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Asset-table id of this scene's top-strip tile artwork. The tile is a
|
||||
/// separate image from the scene's background: the preview pane keeps
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One Super Chat or Super Sticker event, as YouTube reports it.</summary>
|
||||
public sealed record SuperChatEvent(
|
||||
string Id,
|
||||
ChatEventKind Kind,
|
||||
long AmountMicros,
|
||||
string Currency,
|
||||
string? DisplayAmount,
|
||||
DateTime PublishedAt);
|
||||
|
||||
/// <summary>
|
||||
/// TASK 3·20: what this session's rewards added up to — <b>in memory only</b>.
|
||||
/// <para>Derived on demand from <c>superChatEvents.list</c> rather than persisted: YouTube is the system
|
||||
/// of record for money, and keeping a local ledger would contradict the published promise that no
|
||||
/// viewer, member or donor information is retained after the session ends.</para>
|
||||
/// <para>Totals are kept <b>per currency</b> and never summed across currencies — a $5 and a €5 are not
|
||||
/// $10, and a single blended number would be a lie.</para>
|
||||
/// </summary>
|
||||
public sealed record SessionRewardSummary(
|
||||
int SuperChatCount,
|
||||
int SuperStickerCount,
|
||||
IReadOnlyDictionary<string, long> MicrosByCurrency,
|
||||
DateTime? FirstEventAt,
|
||||
DateTime? LastEventAt)
|
||||
{
|
||||
public static readonly SessionRewardSummary Empty = new(
|
||||
0, 0, new Dictionary<string, long>(), null, null);
|
||||
|
||||
public int TotalEvents => SuperChatCount + SuperStickerCount;
|
||||
|
||||
public bool HasEvents => TotalEvents > 0;
|
||||
|
||||
/// <summary>Filters to a session window and rolls the events up. Events outside
|
||||
/// [<paramref name="sinceUtc"/>, <paramref name="untilUtc"/>] are ignored, which is what makes a
|
||||
/// report "this session" rather than "this channel".</summary>
|
||||
public static SessionRewardSummary From(
|
||||
IEnumerable<SuperChatEvent> events, DateTime sinceUtc, DateTime? untilUtc = null)
|
||||
{
|
||||
var until = untilUtc ?? DateTime.MaxValue;
|
||||
var inWindow = events
|
||||
.Where(e => e.PublishedAt >= sinceUtc && e.PublishedAt <= until)
|
||||
.OrderBy(e => e.PublishedAt)
|
||||
.ToList();
|
||||
|
||||
if (inWindow.Count == 0) return Empty;
|
||||
|
||||
var byCurrency = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var e in inWindow)
|
||||
{
|
||||
var currency = string.IsNullOrWhiteSpace(e.Currency) ? "?" : e.Currency.ToUpperInvariant();
|
||||
byCurrency.TryGetValue(currency, out var total);
|
||||
byCurrency[currency] = total + Math.Max(0, e.AmountMicros);
|
||||
}
|
||||
|
||||
return new SessionRewardSummary(
|
||||
inWindow.Count(e => e.Kind == ChatEventKind.SuperChat),
|
||||
inWindow.Count(e => e.Kind == ChatEventKind.SuperSticker),
|
||||
byCurrency,
|
||||
inWindow[0].PublishedAt,
|
||||
inWindow[^1].PublishedAt);
|
||||
}
|
||||
|
||||
/// <summary>Money lines for display, e.g. "USD $12.34 · EUR €4.00". Empty when there is nothing to
|
||||
/// show — a creator who never monetized must never be shown a zero.</summary>
|
||||
public IReadOnlyList<string> MoneyLines()
|
||||
=> MicrosByCurrency
|
||||
.Where(pair => pair.Value > 0)
|
||||
.OrderByDescending(pair => pair.Value)
|
||||
.Select(pair => $"{pair.Key} {Format(pair.Key, pair.Value)}")
|
||||
.ToList();
|
||||
|
||||
private static string Format(string currency, long micros)
|
||||
{
|
||||
var amount = micros / 1_000_000m;
|
||||
var symbol = currency switch
|
||||
{
|
||||
"USD" => "$",
|
||||
"EUR" => "€",
|
||||
"GBP" => "£",
|
||||
"CAD" => "CA$",
|
||||
"AUD" => "A$",
|
||||
_ => string.Empty,
|
||||
};
|
||||
return symbol.Length > 0
|
||||
? symbol + amount.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
: amount.ToString("0.00", CultureInfo.InvariantCulture) + " " + currency;
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,17 @@ public enum SessionStartKind
|
||||
/// <para>Pure so the whole matrix is testable without a window.</para></summary>
|
||||
public static class SessionStartDecision
|
||||
{
|
||||
public static SessionStartKind For(bool recordArmed, bool onAirArmed, bool editing)
|
||||
/// <summary>
|
||||
/// Which kind of session the armed output starts. Since 2026-11-12 there is no inference from
|
||||
/// editing: the output stage offers "Private Test Stream" and "Review & Go Live" as separate
|
||||
/// buttons, so ON-AIR always means the real livestream and Test is something the creator asks for.
|
||||
/// </summary>
|
||||
public static SessionStartKind For(bool recordArmed, bool onAirArmed)
|
||||
{
|
||||
// Neither pill armed still starts something: local recording needs no account, and the
|
||||
// Neither output armed still starts something: local recording needs no account, and the
|
||||
// no-dead-ends rule forbids an inert primary button.
|
||||
if (recordArmed || !onAirArmed) return SessionStartKind.Record;
|
||||
return editing ? SessionStartKind.Test : SessionStartKind.Live;
|
||||
return SessionStartKind.Live;
|
||||
}
|
||||
|
||||
/// <summary>What pressing the button actually does, given whether YouTube is connected.
|
||||
|
||||
+181
-3
@@ -1,5 +1,6 @@
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
@@ -110,6 +111,7 @@ public class Source : SceneElement
|
||||
Raise(nameof(IsTextOverlay));
|
||||
Raise(nameof(IsScoreboard));
|
||||
Raise(nameof(IsTicker));
|
||||
Raise(nameof(ShowsChromaKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +120,8 @@ public class Source : SceneElement
|
||||
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
|
||||
/// (creator ruling 2026-11-01).</summary>
|
||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
|
||||
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker;
|
||||
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker
|
||||
or SourceType.MediaSource;
|
||||
|
||||
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
||||
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
||||
@@ -231,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
|
||||
{
|
||||
@@ -238,10 +251,17 @@ public class Source : SceneElement
|
||||
set
|
||||
{
|
||||
if (Set(ref _mediaPath, value))
|
||||
{
|
||||
Raise(nameof(IsMediaSource));
|
||||
Raise(nameof(MediaFileDisplay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The chosen file's name for the properties panel (path stays in the model).</summary>
|
||||
public string MediaFileDisplay =>
|
||||
string.IsNullOrWhiteSpace(_mediaPath) ? "No video chosen" : System.IO.Path.GetFileName(_mediaPath);
|
||||
|
||||
private bool _mediaIsLooping;
|
||||
public bool MediaIsLooping
|
||||
{
|
||||
@@ -256,6 +276,31 @@ public class Source : SceneElement
|
||||
set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0));
|
||||
}
|
||||
|
||||
private bool _mediaMuted;
|
||||
/// <summary>Silences this layer's soundtrack (the picture keeps playing). One decoder is
|
||||
/// shared per file, so the level/mute are the file's — the layer is the control.</summary>
|
||||
public bool MediaMuted
|
||||
{
|
||||
get => _mediaMuted;
|
||||
set => Set(ref _mediaMuted, value);
|
||||
}
|
||||
|
||||
private bool _isMediaRunning;
|
||||
/// <summary>True while the clip is decoding. False means it has played out and is holding its
|
||||
/// last frame — the panel then offers "Play again" instead of leaving a frozen picture with no
|
||||
/// explanation (the 2026-11-05 "clicking Loop did nothing" report).</summary>
|
||||
public bool IsMediaRunning
|
||||
{
|
||||
get => _isMediaRunning;
|
||||
set
|
||||
{
|
||||
if (Set(ref _isMediaRunning, value)) Raise(nameof(IsMediaFinished));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMediaFinished => !IsMediaRunning;
|
||||
|
||||
|
||||
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
|
||||
public MediaPlaybackState MediaPlaybackState
|
||||
{
|
||||
@@ -265,6 +310,133 @@ public class Source : SceneElement
|
||||
|
||||
public override bool IsMediaSource => Type == SourceType.MediaSource;
|
||||
|
||||
// ── Green/Blue Screen keying (TASK 64) ────────────────────────────────────────────
|
||||
// Settings live on the LAYER. The decoder is keyed by (path + these settings), so two
|
||||
// layers with the same look share one decode and two different looks get two decoders.
|
||||
|
||||
private bool _chromaKeyEnabled;
|
||||
public bool ChromaKeyEnabled
|
||||
{
|
||||
get => _chromaKeyEnabled;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeyEnabled, value))
|
||||
{
|
||||
Raise(nameof(ChromaKey));
|
||||
Raise(nameof(HasChromaSuggestion));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _chromaKeyColor = "#00B140";
|
||||
/// <summary>The screen colour as it was shot (the detector's median), not a canonical green.</summary>
|
||||
public string ChromaKeyColor
|
||||
{
|
||||
get => _chromaKeyColor;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeyColor, value)) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private double _chromaKeySimilarity = 0.12;
|
||||
/// <summary>Creator-facing "Amount": how far from the key colour still counts as screen.</summary>
|
||||
public double ChromaKeySimilarity
|
||||
{
|
||||
get => _chromaKeySimilarity;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeySimilarity, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private double _chromaKeySoftness = 0.08;
|
||||
/// <summary>Creator-facing "Softness": the width of the fade at the keyed edge.</summary>
|
||||
public double ChromaKeySoftness
|
||||
{
|
||||
get => _chromaKeySoftness;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeySoftness, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private bool _chromaKeySpill = true;
|
||||
/// <summary>Creator-facing "Fringe": clean the coloured rim the screen leaves on hair and edges.</summary>
|
||||
public bool ChromaKeySpill
|
||||
{
|
||||
get => _chromaKeySpill;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeySpill, value)) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private ChromaScreen _chromaScreen = ChromaScreen.Green;
|
||||
public ChromaScreen ChromaScreen
|
||||
{
|
||||
get => _chromaScreen;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaScreen, value)) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The look this layer asks its decoder for — the one place the settings become an
|
||||
/// ffmpeg chain and a session signature.</summary>
|
||||
public ChromaKeyOptions ChromaKey
|
||||
=> new(ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness, ChromaKeySpill, ChromaScreen);
|
||||
|
||||
private ChromaKeySuggestion _chromaSuggestion;
|
||||
/// <summary>The detector's offer for this clip. Transient by design — never persisted, because
|
||||
/// it is a hint about a file, not a setting the creator made.</summary>
|
||||
public ChromaKeySuggestion ChromaSuggestion
|
||||
{
|
||||
get => _chromaSuggestion;
|
||||
set
|
||||
{
|
||||
_chromaSuggestion = value;
|
||||
Raise(nameof(ChromaSuggestion));
|
||||
Raise(nameof(HasChromaSuggestion));
|
||||
Raise(nameof(ChromaSuggestionText));
|
||||
Raise(nameof(HasChromaSuggestionColor));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while there is an offer the creator has not accepted.</summary>
|
||||
public bool HasChromaSuggestion => !ChromaKeyEnabled && ChromaSuggestion.Screen != ChromaScreen.None;
|
||||
|
||||
private bool _chromaKeyAutoApplied;
|
||||
/// <summary>True when the app applied the key on its own (the "Auto Green/Blue Screen"
|
||||
/// setting). The panel says so, so an automatic change is never silent.</summary>
|
||||
public bool ChromaKeyAutoApplied
|
||||
{
|
||||
get => _chromaKeyAutoApplied;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeyAutoApplied, value)) Raise(nameof(HasAutoChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasAutoChromaKey => ChromaKeyAutoApplied && ChromaKeyEnabled;
|
||||
|
||||
/// <summary>Which layers get the Green/Blue Screen block: a video layer, or the alert box's
|
||||
/// own clip (TASK 64 — a clip is a clip).</summary>
|
||||
public bool ShowsChromaKey => IsMediaSource
|
||||
|| (Type == SourceType.AlertBox && !AlertUseDefaultVideo && !string.IsNullOrWhiteSpace(AlertVideoPath));
|
||||
|
||||
public string ChromaSuggestionText => ChromaSuggestion.Screen switch
|
||||
{
|
||||
ChromaScreen.Green => "Looks like a green screen.",
|
||||
ChromaScreen.Blue => "Looks like a blue screen.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>The detected colour, for the swatch next to the offer.</summary>
|
||||
public string ChromaSuggestionColor => ChromaSuggestion.ColorHex ?? "#00B140";
|
||||
|
||||
public bool HasChromaSuggestionColor => ChromaSuggestion.ColorHex != null;
|
||||
|
||||
// Alert video config (TASK 47): the shipped default clip lives in the Asset
|
||||
// table (seeded like image assets); the creator's replacement is a FILE PATH
|
||||
// reference only — never into the DB (creator ruling, 2026-09-26). Runtime
|
||||
@@ -280,14 +452,20 @@ public class Source : SceneElement
|
||||
public string? AlertVideoPath
|
||||
{
|
||||
get => _alertVideoPath;
|
||||
set => Set(ref _alertVideoPath, value);
|
||||
set
|
||||
{
|
||||
if (Set(ref _alertVideoPath, value)) Raise(nameof(ShowsChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private bool _alertUseDefaultVideo = true;
|
||||
public bool AlertUseDefaultVideo
|
||||
{
|
||||
get => _alertUseDefaultVideo;
|
||||
set => Set(ref _alertUseDefaultVideo, value);
|
||||
set
|
||||
{
|
||||
if (Set(ref _alertUseDefaultVideo, value)) Raise(nameof(ShowsChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
public bool AlertShowTicker
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>Why the policy acted. Reported in the toast and the session report, so it is never
|
||||
/// guessed at again after the fact.</summary>
|
||||
public enum StepCause
|
||||
{
|
||||
None,
|
||||
/// <summary>The machine could not produce the frame: stalls, late renders, pump drops.</summary>
|
||||
Machine,
|
||||
/// <summary>The frames were produced but the sink could not take them: encoder drops.</summary>
|
||||
Upload,
|
||||
/// <summary>Both kinds of evidence in the same window — doing nothing is the only safe move.</summary>
|
||||
Ambiguous,
|
||||
}
|
||||
|
||||
public enum StepAction
|
||||
{
|
||||
None,
|
||||
/// <summary>Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees.</summary>
|
||||
ShedCaptureCadence,
|
||||
LowerFrameRate,
|
||||
LowerResolution,
|
||||
LowerBitrate,
|
||||
RaiseFrameRate,
|
||||
RaiseResolution,
|
||||
RaiseBitrate,
|
||||
}
|
||||
|
||||
/// <summary>One statistics window as the pump and the encoder report it. Nothing here is derived
|
||||
/// from the network directly: congestion shows up as frames the encoder failed to produce in
|
||||
/// wall-clock time, and backpressure shows up as submit wait.</summary>
|
||||
public readonly record struct StepDownSample(
|
||||
double WindowSeconds,
|
||||
int PumpStalls,
|
||||
int PumpDrops,
|
||||
int EncoderDrops,
|
||||
double AvgRenderMs,
|
||||
double AvgSubmitWaitMs);
|
||||
|
||||
/// <summary>The decision and the numbers behind it. <paramref name="Why"/> is for the log; the
|
||||
/// creator-facing sentence is built in the VM from <see cref="Cause"/>.</summary>
|
||||
public readonly record struct StepDownDecision(StepCause Cause, StepAction Action, string? Why)
|
||||
{
|
||||
public bool Acted => Action != StepAction.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides when a session must give up quality, and why (TASK 33).
|
||||
///
|
||||
/// <para><b>Two levers, never mixed.</b> Machine starvation is answered with less work (60 → 30 fps,
|
||||
/// then 1080 → 720); upload congestion is answered with fewer bits, sized by the observed loss
|
||||
/// fraction. Resolution is never used as a bandwidth lever, because 1080p30 and 720p60 cost the same
|
||||
/// 6–8 Mbps: dropping pixels at an unchanged bitrate relieves nothing.</para>
|
||||
///
|
||||
/// <para><b>One-way by default.</b> A step-down that climbs back is an encoder restart, which is a
|
||||
/// visible hitch; the creator ruled that a show does not oscillate. The state machine still carries
|
||||
/// the ceiling, so enabling recovery later is a constructor flag rather than a rewrite.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class StepDownPolicy
|
||||
{
|
||||
/// <summary>Minimum evidence before acting: ~15 s of qualifying windows.</summary>
|
||||
internal const double QualifyingSeconds = 15.0;
|
||||
|
||||
/// <summary>A sample window shorter than this is ignored — the pump reports every 5 s.</summary>
|
||||
internal const double MinWindowSeconds = 4.0;
|
||||
|
||||
internal const double CooldownSeconds = 30.0;
|
||||
|
||||
/// <summary>Loss fraction at or above which the upload lever fires.</summary>
|
||||
internal const double UploadLossThreshold = 0.05;
|
||||
|
||||
/// <summary>Render time relative to the frame budget that counts as machine starvation.</summary>
|
||||
internal const double RenderBudgetFraction = 0.9;
|
||||
|
||||
internal const int StallThreshold = 5;
|
||||
|
||||
/// <summary>Clean seconds required before climbing when recovery is enabled.</summary>
|
||||
internal const double ClimbAfterCleanSeconds = 180.0;
|
||||
|
||||
/// <summary>A climb followed by a fall inside this window freezes the session at the low value.</summary>
|
||||
internal const double FlapWindowSeconds = 120.0;
|
||||
|
||||
private readonly bool _vertical;
|
||||
private readonly bool _allowClimb;
|
||||
private readonly int _ceilingFps;
|
||||
private readonly int _ceilingWidth;
|
||||
private readonly int _ceilingHeight;
|
||||
private readonly double _ceilingBitrateMbps;
|
||||
private readonly double _floorBitrateMbps;
|
||||
|
||||
private double _qualifyingSeconds;
|
||||
private double _cleanSeconds;
|
||||
private double _lastStepAt = double.NegativeInfinity;
|
||||
private double _lastRaiseAt = double.NegativeInfinity;
|
||||
private bool _frozen;
|
||||
private bool _shedCapture;
|
||||
|
||||
public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false)
|
||||
: this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Explicit geometry: the tier the creator actually chose, which is what decides the
|
||||
/// step budget (a 1080 start gets three, a 720 start gets one).</summary>
|
||||
/// <param name="floorBitrateMbps">The lowest rate a step-down may reach — the tier's YouTube
|
||||
/// minimum (<see cref="RateLadder"/>). Never below the starting rate, so an unusual start cannot
|
||||
/// begin already under its floor. Omitted, the historical 3/4 Mbps floor applies.</param>
|
||||
public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps,
|
||||
bool allowClimb = false, double? floorBitrateMbps = null)
|
||||
{
|
||||
_vertical = startHeight > startWidth;
|
||||
_allowClimb = allowClimb;
|
||||
_ceilingFps = startFps;
|
||||
_ceilingWidth = startWidth;
|
||||
_ceilingHeight = startHeight;
|
||||
_ceilingBitrateMbps = startBitrateMbps;
|
||||
_floorBitrateMbps = Math.Min(
|
||||
floorBitrateMbps ?? (_vertical ? 4.0 : 3.0),
|
||||
startBitrateMbps);
|
||||
|
||||
Fps = startFps;
|
||||
Width = startWidth;
|
||||
Height = startHeight;
|
||||
BitrateMbps = startBitrateMbps;
|
||||
|
||||
// Creator ruling 2026-11-09: a 1080p start may spend three steps; 720 and vertical get one.
|
||||
StepsAllowed = startWidth >= 1920 ? 3 : 1;
|
||||
}
|
||||
|
||||
public int Fps { get; private set; }
|
||||
public int Width { get; private set; }
|
||||
public int Height { get; private set; }
|
||||
public double BitrateMbps { get; private set; }
|
||||
public int StepsUsed { get; private set; }
|
||||
public int StepsAllowed { get; }
|
||||
public bool Frozen => _frozen;
|
||||
/// <summary>True when no lever has anything left: 30 fps, the floor geometry, the floor bitrate.</summary>
|
||||
public bool AtFloor => Fps <= 30
|
||||
&& (_vertical || Width <= 1280)
|
||||
&& BitrateMbps <= _floorBitrateMbps + 0.001;
|
||||
|
||||
public double FrameBudgetMs => 1000.0 / Math.Max(1, Fps);
|
||||
|
||||
/// <summary>Classifies one window and, when the evidence is strong enough and the budget allows,
|
||||
/// applies exactly one change. <paramref name="elapsed"/> is time since the session started.</summary>
|
||||
public StepDownDecision Observe(in StepDownSample sample, TimeSpan elapsed)
|
||||
{
|
||||
if (sample.WindowSeconds < MinWindowSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
|
||||
|
||||
var now = elapsed.TotalSeconds;
|
||||
var budget = FrameBudgetMs;
|
||||
|
||||
var machine = sample.PumpStalls >= StallThreshold
|
||||
|| sample.PumpDrops > 0
|
||||
|| sample.AvgRenderMs > budget * RenderBudgetFraction;
|
||||
|
||||
var loss = sample.EncoderDrops / Math.Max(1.0, Fps * sample.WindowSeconds);
|
||||
var upload = loss >= UploadLossThreshold;
|
||||
|
||||
if (machine && upload)
|
||||
{
|
||||
_qualifyingSeconds = 0;
|
||||
_cleanSeconds = 0;
|
||||
return new StepDownDecision(StepCause.Ambiguous, StepAction.None,
|
||||
$"render {sample.AvgRenderMs:0.0}ms, stalls {sample.PumpStalls}, loss {loss:P0} — not acting");
|
||||
}
|
||||
|
||||
if (!machine && !upload)
|
||||
{
|
||||
_qualifyingSeconds = 0;
|
||||
_cleanSeconds += sample.WindowSeconds;
|
||||
return TryClimb(sample, now);
|
||||
}
|
||||
|
||||
_cleanSeconds = 0;
|
||||
_qualifyingSeconds += sample.WindowSeconds;
|
||||
if (_qualifyingSeconds < QualifyingSeconds)
|
||||
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, null);
|
||||
|
||||
if (now - _lastStepAt < CooldownSeconds)
|
||||
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, "cooldown");
|
||||
|
||||
// Machine starvation: shed OUR cost before the viewer's tier is touched. Costs no step and is
|
||||
// not a quality change — if it does not fix the starvation, the fps ladder runs next window.
|
||||
if (machine && !_shedCapture)
|
||||
{
|
||||
_shedCapture = true;
|
||||
_qualifyingSeconds = 0;
|
||||
_lastStepAt = now;
|
||||
return new StepDownDecision(StepCause.Machine, StepAction.ShedCaptureCadence,
|
||||
"halving capture cost before lowering quality");
|
||||
}
|
||||
|
||||
if (StepsUsed >= StepsAllowed)
|
||||
return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None,
|
||||
"step budget spent — holding");
|
||||
|
||||
var decision = machine ? StepMachine() : StepUpload(loss);
|
||||
if (!decision.Acted) return decision;
|
||||
|
||||
if (now - _lastRaiseAt < FlapWindowSeconds) _frozen = true;
|
||||
|
||||
StepsUsed++;
|
||||
_lastStepAt = now;
|
||||
_qualifyingSeconds = 0;
|
||||
return decision;
|
||||
}
|
||||
|
||||
private StepDownDecision StepMachine()
|
||||
{
|
||||
if (Fps > 30)
|
||||
{
|
||||
Fps = 30;
|
||||
return new StepDownDecision(StepCause.Machine, StepAction.LowerFrameRate,
|
||||
$"frame rate 60 → 30 to give the machine its time back");
|
||||
}
|
||||
|
||||
// The vertical tier's floor IS its only geometry (1080×1920), so it has no resolution step.
|
||||
if (!_vertical && (Width > 1280 || Height > 720))
|
||||
{
|
||||
Width = 1280;
|
||||
Height = 720;
|
||||
return new StepDownDecision(StepCause.Machine, StepAction.LowerResolution,
|
||||
$"resolution {Width}×{Height} — fewer pixels to composite");
|
||||
}
|
||||
|
||||
return new StepDownDecision(StepCause.Machine, StepAction.None, "at the machine floor — holding");
|
||||
}
|
||||
|
||||
private StepDownDecision StepUpload(double loss)
|
||||
{
|
||||
var reduction = Math.Max(0.5, BitrateMbps * loss);
|
||||
var next = Math.Max(_floorBitrateMbps, BitrateMbps - reduction);
|
||||
if (next >= BitrateMbps - 0.05)
|
||||
return new StepDownDecision(StepCause.Upload, StepAction.None, "at the bitrate floor — holding");
|
||||
|
||||
var from = BitrateMbps;
|
||||
BitrateMbps = Math.Round(next, 1);
|
||||
return new StepDownDecision(StepCause.Upload, StepAction.LowerBitrate,
|
||||
$"{from:0.0} → {BitrateMbps:0.0} Mbps on {loss:P0} loss");
|
||||
}
|
||||
|
||||
private StepDownDecision TryClimb(in StepDownSample sample, double now)
|
||||
{
|
||||
if (!_allowClimb || _frozen) return new StepDownDecision(StepCause.None, StepAction.None, null);
|
||||
if (StepsUsed == 0) return new StepDownDecision(StepCause.None, StepAction.None, null);
|
||||
if (_cleanSeconds < ClimbAfterCleanSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
|
||||
if (now - _lastStepAt < CooldownSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
|
||||
|
||||
var (action, caused) = RaiseOneStep();
|
||||
if (!caused) return new StepDownDecision(StepCause.None, StepAction.None, null);
|
||||
|
||||
StepsUsed = Math.Max(0, StepsUsed - 1);
|
||||
_lastRaiseAt = now;
|
||||
_lastStepAt = now;
|
||||
_cleanSeconds = 0;
|
||||
return new StepDownDecision(StepCause.None, action, "conditions clean — one step back up");
|
||||
}
|
||||
|
||||
private (StepAction Action, bool Changed) RaiseOneStep()
|
||||
{
|
||||
if (Fps < _ceilingFps)
|
||||
{
|
||||
Fps = _ceilingFps;
|
||||
return (StepAction.RaiseFrameRate, true);
|
||||
}
|
||||
|
||||
if (Width < _ceilingWidth || Height < _ceilingHeight)
|
||||
{
|
||||
Width = _ceilingWidth;
|
||||
Height = _ceilingHeight;
|
||||
return (StepAction.RaiseResolution, true);
|
||||
}
|
||||
|
||||
if (BitrateMbps < _ceilingBitrateMbps - 0.05)
|
||||
{
|
||||
BitrateMbps = Math.Round(Math.Min(_ceilingBitrateMbps, BitrateMbps + 1.0), 1);
|
||||
return (StepAction.RaiseBitrate, true);
|
||||
}
|
||||
|
||||
return (StepAction.None, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 39·2: the three metrics that count towards YouTube Partner Program eligibility, as progress
|
||||
/// towards the ad-revenue thresholds.
|
||||
/// <para><b>YouTube decides.</b> This only reports what the published numbers say; it never claims
|
||||
/// eligibility, and it cannot see strikes, AdSense state, 2FA or the review decision.</para>
|
||||
/// <para><b>The view criteria are either/or</b> — 4,000 public watch hours <i>or</i> 10M Shorts views —
|
||||
/// so "2 of 3" is reported with that qualifier rather than implying all three must pass.</para>
|
||||
/// </summary>
|
||||
public sealed record YppProgress(
|
||||
long Subscribers,
|
||||
long SubscriberTarget,
|
||||
long WatchHours,
|
||||
long WatchHoursTarget,
|
||||
long ShortsViews,
|
||||
long ShortsViewsTarget)
|
||||
{
|
||||
public bool SubscribersMet => Subscribers >= SubscriberTarget;
|
||||
public bool WatchHoursMet => WatchHours >= WatchHoursTarget;
|
||||
public bool ShortsViewsMet => ShortsViews >= ShortsViewsTarget;
|
||||
|
||||
/// <summary>Criteria currently met (0–3). Two of three can mean eligible: subs plus EITHER view route.</summary>
|
||||
public int CriteriaMet => (SubscribersMet ? 1 : 0) + (WatchHoursMet ? 1 : 0) + (ShortsViewsMet ? 1 : 0);
|
||||
|
||||
/// <summary>True when the subscriber bar AND at least one view route are satisfied — the shape of the
|
||||
/// published rule. Still not a qualification claim: YouTube reviews it.</summary>
|
||||
public bool MeetsPublishedRule => SubscribersMet && (WatchHoursMet || ShortsViewsMet);
|
||||
|
||||
public double SubscribersFraction => Fraction(Subscribers, SubscriberTarget);
|
||||
public double WatchHoursFraction => Fraction(WatchHours, WatchHoursTarget);
|
||||
public double ShortsViewsFraction => Fraction(ShortsViews, ShortsViewsTarget);
|
||||
|
||||
public string SubscribersText => $"{Compact(Subscribers)} / {Compact(SubscriberTarget)}";
|
||||
public string WatchHoursText => $"{Compact(WatchHours)} / {Compact(WatchHoursTarget)} h";
|
||||
public string ShortsViewsText => $"{Compact(ShortsViews)} / {Compact(ShortsViewsTarget)}";
|
||||
|
||||
public string CriteriaMetText => CriteriaMet == 1
|
||||
? "1 of 3 criteria met — YouTube reviews eligibility."
|
||||
: $"{CriteriaMet} of 3 criteria met — YouTube reviews eligibility.";
|
||||
|
||||
private static double Fraction(long value, long target)
|
||||
=> target <= 0 ? 0 : Math.Clamp((double)value / target, 0, 1);
|
||||
|
||||
/// <summary>1,234 → "1,234"; 12,345 → "12.3k"; 1,234,567 → "1.23M". Mono-face friendly and stable
|
||||
/// in width, so the three bars do not jitter as values move.</summary>
|
||||
internal static string Compact(long value) => value switch
|
||||
{
|
||||
>= 1_000_000 => (value / 1_000_000d).ToString("0.##", CultureInfo.InvariantCulture) + "M",
|
||||
>= 10_000 => (value / 1_000d).ToString("0.#", CultureInfo.InvariantCulture) + "k",
|
||||
_ => value.ToString("N0", CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,10 @@ public static class YppThresholds
|
||||
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
|
||||
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
|
||||
|
||||
/// <summary>When these thresholds were last read from YouTube's published requirements. Shown in the
|
||||
/// drawer and asserted by a test, because YouTube moves the bar and a stale bar must not look current.</summary>
|
||||
public static readonly DateTime AsOfUtc = new(2026, 11, 5);
|
||||
|
||||
/// <summary>Fan-funding tier (before the full ad/YouTube Premium revenue tier).</summary>
|
||||
public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000);
|
||||
|
||||
|
||||
@@ -35,6 +35,61 @@ max 200.
|
||||
|
||||
## 🔬 Recipes registry
|
||||
|
||||
### A SETTING THAT "DIDN'T STICK" — CHECK THE DEBOUNCE FIRST (RECIPE, 2026-11-12)
|
||||
|
||||
**Symptom:** a value is set in the UI, behaves correctly for one session, and is gone after a
|
||||
restart. It looks like a wiring bug in the control; it is almost always `ScheduleSave()`.
|
||||
|
||||
**Cause:** `MainViewModel.ScheduleSave()` restarts a debounce timer that later calls `Save()`, which
|
||||
writes the whole layout. Anything changed in the last few seconds before the app closes is lost —
|
||||
and none of it is logged, so the next session reads a value the creator never chose.
|
||||
|
||||
**Quick fix:** for anything small and decisive (a sync offset, a toggle the creator just clicked),
|
||||
call the `_layoutStore.Save…()` method **in the setter itself**, before `ScheduleSave()`. Two rows
|
||||
cost nothing; a lost setting costs a whole reproduce-and-test cycle. Live example:
|
||||
`MainViewModel.Audio.cs` → `AudioSyncOffsetMs` / `MicSyncOffsetMs`.
|
||||
|
||||
**Check list when a remembered setting misbehaves:** grep the property for `ScheduleSave`, confirm a
|
||||
direct save exists, then reproduce with a *restart* (not just a new session) before debugging deeper.
|
||||
|
||||
**Bit us:** the lip-sync offset. `Audio.SyncOffsetMs` was set to −130, lost on close, and two clips
|
||||
were measured against a setting that was not in force — the numbers disagreed by ~65 ms and the
|
||||
disagreement was blamed on the audio path.
|
||||
|
||||
### A SYNC OFFSET IS NEVER A PRODUCT DEFAULT (2026-11-12)
|
||||
|
||||
**Mistake:** `MicSyncOffsetMs` shipped at **175 ms** because that suited one C920 on one desk. It
|
||||
shifted every creator's voice to correct one machine and hid whether a desync was the app's fault.
|
||||
|
||||
**Rule:** offsets ship at 0 and are opt-in. `AudioSyncSettingsTests` pins it. And the tool that
|
||||
measures them, `scripts/avsync.py`, varies **±35 ms between clips** on identical settings — it can
|
||||
answer "is this 100 ms out?" but never "is this 20 ms out?". Below ~50 ms the creator's judgement is
|
||||
the better instrument, so stop tuning rather than chasing the tool's noise.
|
||||
|
||||
### A LOOSE FILE IN `Assets/` IS NOT AN ASSET — THE CSProj LIST IS (RECIPE, 2026-11-04)
|
||||
|
||||
**Problem:** four untracked `Assets/llamacasty-*` exports sat in the tree for days, reported in
|
||||
`HANDOFF.md` as "untouched", and every session spent tokens re-litigating whether to commit them.
|
||||
|
||||
**What it actually is:** shipped art is embedded in the assembly via **`<Resource Include="Assets\…"/>`**
|
||||
in `ytLive.csproj` (icons, logo, backgrounds, fonts) or `<EmbeddedResource>` (the ONNX model), and the
|
||||
Store tiles in `Packaging/Assets/` are generated from `Assets/llamacasty-logo.jpg` by
|
||||
`scripts/make-store-logos.py`. The app reads art through `pack://`/`/LlamaCasty;component/Assets/…`
|
||||
URIs — it never loads a file from disk at runtime. So a loose `.jpg`/`.png` in `Assets/` that appears in
|
||||
**neither** the csproj nor any `Source=`/`Icon=`/script is a **source export**, not a dependency.
|
||||
|
||||
**The recipe (30 seconds, do it before mentioning the file):**
|
||||
1. `git status --short` → the untracked list.
|
||||
2. For each file: `grep -rIn -F "<name>" --exclude-dir=.git --exclude-dir=bin --exclude-dir=obj .`
|
||||
plus `grep -n 'Assets' ytLive.csproj`. **No hit in either = dead.** (Check `scripts/` too — a
|
||||
generator can be the only consumer.)
|
||||
3. Referenced → add it to `<Resource>` **and** commit it in the same unit. Unreferenced → `rm` it and
|
||||
commit the deletion; do not park it in `HANDOFF.md`.
|
||||
|
||||
**Deleted 2026-11-04 by this rule:** `llamacasty-icon.jpg`, `llamacasty-logo-white.jpg`,
|
||||
`llamacasty-logo-white-nobg.png`, `llamacasty-logo-white-nobg-dim.png` — the shipped brand art is
|
||||
`llamacasty-icon.ico`/`.png` and `llamacasty-logo.jpg`, all three already tracked and embedded.
|
||||
|
||||
### DEPLOYING llamacasty.com TO THE DROPLET (RECIPE, 2026-10-30)
|
||||
|
||||
**Problem:** "upload the site" burned a session: `ssh -p 2214 gramps@llamachile.tube` hung on a
|
||||
@@ -565,6 +620,17 @@ packages, high-quality downscale via `TransformedBitmap`. Screenshots compress
|
||||
|
||||
**Result:** 3.2MB screenshot → 1400×794 → **188KB** `ytLlive-preview.jpg` in `docs/`.
|
||||
|
||||
**Faster route (2026-11-09): the bundled ffmpeg does the same job in one command** — no throwaway
|
||||
project, and it is already the tool used for every other asset:
|
||||
|
||||
```bash
|
||||
./ffmpeg/ffmpeg.exe -hide_banner -loglevel error -y -i "C:\path\shot.png" \
|
||||
-vf "scale=1400:-2:flags=lanczos" -q:v 4 "C:\...\docs\ytLlive-preview.jpg"
|
||||
```
|
||||
|
||||
`-q:v 4` is the sweet spot for UI screenshots (text stays crisp); q3 runs ~340 KB, q6 ~230 KB.
|
||||
The WPF recipe above stays as the fallback if ffmpeg is unavailable.
|
||||
|
||||
---
|
||||
|
||||
### Measuring audio-video A/V sync from a recording (no ears needed) — RECIPE
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ kept locally, and what is never collected.
|
||||
| Your layout: scenes, sources, assets, settings | `%APPDATA%\ytLlive\ytLlive.db` | So the app looks the same next time |
|
||||
| YouTube sign-in token | `%APPDATA%\ytLlive\ytLlive.auth`, encrypted with Windows DPAPI (CurrentUser) | So you stay signed in; only your Windows account can decrypt it |
|
||||
| Diagnostic log | `%APPDATA%\ytLlive\startup.log`, capped at 5 MB | To diagnose crashes you report |
|
||||
| Recordings | the folder you choose | Your files; the app never uploads them unless you press Publish |
|
||||
| Recordings | the folder you choose | Your files; the app never uploads them |
|
||||
| Replay clips (if you use them) | `clips\` beside your recordings | Only the three most recent are kept; older ones are deleted automatically. Local only, never uploaded |
|
||||
|
||||
Nothing else is written to disk. There is no analytics service, no crash
|
||||
reporter, no advertising SDK and no telemetry of any kind.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 55·2: the default alert clip is materialised into <c>%TEMP%</c> so ffmpeg can read it as a file.
|
||||
/// Nothing used to delete it, so every profile that ever played an alert left an orphan behind.
|
||||
///
|
||||
/// <para>Two touches, deliberately simple: delete this session's file on close, and sweep anything the
|
||||
/// prefix matches at startup (a crash leaves the same debris a clean exit does not). Only files this app
|
||||
/// created are touched — the prefix is the contract, and the sweep never deletes a directory.</para>
|
||||
/// </summary>
|
||||
public static class AlertClipCleanup
|
||||
{
|
||||
public const string Prefix = "ytLive-alert-";
|
||||
|
||||
/// <summary>Deletes stale alert clips left in <paramref name="directory"/>. Returns how many went.
|
||||
/// Missing directory is not an error.</summary>
|
||||
public static int Sweep(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory)) return 0;
|
||||
var removed = 0;
|
||||
foreach (var path in Directory.EnumerateFiles(directory, Prefix + "*.mp4"))
|
||||
{
|
||||
if (TryDelete(path)) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write(ex, "Alert clip sweep failed");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes one clip if it exists. Safe on null/blank and on a locked file.</summary>
|
||||
public static bool Delete(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return false;
|
||||
return TryDelete(path);
|
||||
}
|
||||
|
||||
private static bool TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write(ex, $"Alert clip delete failed: {path}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-177
@@ -1,6 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Helpers;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
@@ -11,10 +10,10 @@ namespace ytLive.Services;
|
||||
/// same file, both paced to real time. The alert box owns a decoder per event
|
||||
/// (start at play, dispose at drain) — unlike MediaSource's app-wide refcounted
|
||||
/// session, an alert REPLAYS the same clip each event, so a fresh decode per
|
||||
/// play is required. Video is rawvideo BGRA scaled to the box rect (the
|
||||
/// <see cref="MediaVideoSource"/> shape); audio is ffmpeg <c>f32le</c> at the
|
||||
/// mixer's native 48 kHz stereo (so the mixer ring needs no resampler). The
|
||||
/// fade is applied by the alert layer, not here.
|
||||
/// play is required. The fade is applied by the alert layer, not here.
|
||||
/// <para>Since TASK 21's audio slice this is a thin wrapper over the shared
|
||||
/// <see cref="VideoClipPipe"/> (the same pipe a media layer uses); the alert case
|
||||
/// simply never sets <c>Looping</c>.</para>
|
||||
/// </summary>
|
||||
public interface IAlertClipDecoder : IDisposable
|
||||
{
|
||||
@@ -35,30 +34,14 @@ public interface IAlertClipDecoder : IDisposable
|
||||
event Action? Completed;
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IAlertClipDecoder"/>: two ffmpeg subprocesses on
|
||||
/// the same file. Video via <see cref="RawVideoFrameReader"/> on a <c>rawvideo
|
||||
/// bgra</c> pipe (-an), audio via an <c>f32le -ac 2 -ar 48000</c> pipe (-vn).
|
||||
/// The seams (locator, process factory, frame-rate probe, delay) let tests drive
|
||||
/// a fake decoder without a real codec or Windows.</summary>
|
||||
/// <summary>Default <see cref="IAlertClipDecoder"/>: the shared clip pipe with looping off.</summary>
|
||||
public sealed class AlertClipDecoder : IAlertClipDecoder
|
||||
{
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action<AudioSample>? AudioReady;
|
||||
public event Action? Completed;
|
||||
|
||||
private const int AudioSampleRate = 48000;
|
||||
private const int AudioChannels = 2;
|
||||
private const int AudioChunkFloats = 4800; // 50ms of stereo
|
||||
|
||||
private readonly string _path;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _processFactory;
|
||||
private readonly IFrameRateProbe? _probe;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _run;
|
||||
private readonly VideoClipPipe _clip;
|
||||
|
||||
public AlertClipDecoder(
|
||||
string path,
|
||||
@@ -67,161 +50,20 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
|
||||
IFfmpegLocator locator,
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null,
|
||||
ChromaKeyOptions? chromaKey = null)
|
||||
{
|
||||
_path = path;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
_probe = frameRateProbe;
|
||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||
_clip = new VideoClipPipe(
|
||||
path, width, height, locator, processFactory, frameRateProbe, delay,
|
||||
audio: true, chromaKey: chromaKey);
|
||||
_clip.FrameAvailable += frame => FrameAvailable?.Invoke(frame);
|
||||
_clip.AudioAvailable += sample => AudioReady?.Invoke(sample);
|
||||
_clip.Completed += () => Completed?.Invoke();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_run != null) return;
|
||||
_run = Task.Run(RunAsync, CancellationToken.None);
|
||||
}
|
||||
public void Start() => _ = _clip.StartAsync();
|
||||
|
||||
public void Stop() => _cts.Cancel();
|
||||
public void Stop() => _ = _clip.StopAsync();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
var videoFrames = 0;
|
||||
var audioChunks = 0;
|
||||
var failed = false;
|
||||
try
|
||||
{
|
||||
double? frameRate = null;
|
||||
if (_probe != null)
|
||||
frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false);
|
||||
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var videoTask = RunVideoAsync(ffmpegPath, frameDuration, ct);
|
||||
var audioTask = RunAudioAsync(ffmpegPath, ct);
|
||||
videoFrames = await videoTask.ConfigureAwait(false);
|
||||
audioChunks = await audioTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
failed = true;
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed decode is not fatal: the alert layer falls back to the six
|
||||
// animations (or the frame it already holds) exactly as a missing file.
|
||||
// Logged so a silent blank box traces back to a decode failure.
|
||||
failed = true;
|
||||
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})", AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
AppLog.Write($"Alert clip end: {Path.GetFileName(_path)} frames={videoFrames} audioChunks={audioChunks} failed={failed}");
|
||||
Completed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
|
||||
{
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
|
||||
using var process = _processFactory();
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var reader = new RawVideoFrameReader(_width, _height);
|
||||
var buffer = new byte[65536];
|
||||
var stream = process.StandardOutput;
|
||||
var frames = 0;
|
||||
while (!ct.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
var chunk = new byte[read];
|
||||
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
|
||||
foreach (var frame in reader.Feed(chunk))
|
||||
{
|
||||
frames++;
|
||||
FrameAvailable?.Invoke(frame);
|
||||
if (frameDuration > TimeSpan.Zero)
|
||||
await _delay(frameDuration, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
private async Task<int> RunAudioAsync(string ffmpegPath, CancellationToken ct)
|
||||
{
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -";
|
||||
using var process = _processFactory();
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var stream = process.StandardOutput;
|
||||
var raw = new byte[65536];
|
||||
var carry = new byte[0];
|
||||
var floatScratch = new float[AudioChunkFloats];
|
||||
var floatIndex = 0;
|
||||
var chunks = 0;
|
||||
// The pipe is byte-delimited only: a read may split a float, so leftover
|
||||
// bytes are carried into the next read instead of dropped (a dropped
|
||||
// half-float would desync the entire stream).
|
||||
while (!ct.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
var total = carry.Length + read;
|
||||
var bytes = total <= raw.Length ? raw : new byte[total];
|
||||
if (carry.Length > 0)
|
||||
{
|
||||
Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length);
|
||||
Buffer.BlockCopy(raw, 0, bytes, carry.Length, read);
|
||||
}
|
||||
var floatsTo = total - (total % 4);
|
||||
for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4)
|
||||
floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i);
|
||||
carry = total > floatsTo ? bytes[floatsTo..total] : new byte[0];
|
||||
|
||||
while (floatIndex == floatScratch.Length)
|
||||
{
|
||||
chunks++;
|
||||
AudioReady?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
|
||||
// Pace the NEXT chunk so the whole clip's audio lands at real-time
|
||||
// cadence (the fade in the alert layer tracks the same wall clock).
|
||||
await _delay(TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)), ct).ConfigureAwait(false);
|
||||
floatScratch = new float[AudioChunkFloats];
|
||||
floatIndex = 0;
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
public void Dispose() => _clip.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// How many frames the mix must produce <i>now</i>, derived from the monotonic clock rather than from a
|
||||
/// tick count.
|
||||
///
|
||||
/// <para><b>Why this exists (2026-11-12):</b> the live loop used to write a fixed 480 frames per
|
||||
/// iteration and trust that every iteration was 10 ms. It is not: `Task.Delay` plus processing lands
|
||||
/// near 9.8 ms under a raised timer resolution, and 102 ticks per second where 100 are expected is
|
||||
/// 102% of an audio second per wall second. FFmpeg stamps audio by sample count, so the track comes out
|
||||
/// longer than the show and the sound slides later against a correct picture — measured on a 9.9 s
|
||||
/// recording: video 9.888 s (right), audio 10.112 s (+2.1%, 224 ms).</para>
|
||||
///
|
||||
/// <para>The video side never had this bug because the pump paces off a stopwatch. This is the audio
|
||||
/// equivalent: ask the clock what should exist, produce the difference, pad on underrun. Pure, so the
|
||||
/// drift case is a test rather than a listening test.</para>
|
||||
/// </summary>
|
||||
public static class AudioClock
|
||||
{
|
||||
/// <summary>
|
||||
/// Frames still owed at <paramref name="elapsedSeconds"/>: expected-minus-produced, clamped to
|
||||
/// 0..<paramref name="maxFrames"/>. Zero means "not due yet"; the cap keeps a stalled loop from
|
||||
/// dumping a huge burst in one write (the deficit stays owed and is paid off over the next ticks).
|
||||
/// </summary>
|
||||
public static int FramesDue(double elapsedSeconds, long framesProduced, int sampleRate, int maxFrames)
|
||||
{
|
||||
if (elapsedSeconds <= 0 || sampleRate <= 0 || maxFrames <= 0) return 0;
|
||||
var expected = (long)Math.Round(elapsedSeconds * sampleRate);
|
||||
var debt = expected - framesProduced;
|
||||
return (int)Math.Clamp(debt, 0L, maxFrames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the produced count for frames that actually reached the encoder. A write that went
|
||||
/// nowhere (ffmpeg had not connected yet) must stay owed: counting it would shorten the track by
|
||||
/// that gap — measured 2026-11-12 on the first clock-driven recording, where 27 start-up drops made
|
||||
/// the audio 226 ms <i>short</i> after the same bug had made it 224 ms long.
|
||||
/// </summary>
|
||||
public static long Advance(long framesProduced, int frames, bool wrote)
|
||||
=> framesProduced + (wrote ? frames : 0);
|
||||
}
|
||||
+117
-34
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
@@ -34,6 +35,9 @@ public sealed class AudioMixer : IDisposable
|
||||
private readonly TimeSpan _mixInterval;
|
||||
private readonly Func<int>? _syncOffsetMs;
|
||||
private readonly AudioSyncDelay _syncDelay;
|
||||
private readonly AudioSyncDelay _micSyncDelay;
|
||||
private readonly Func<int>? _micSyncOffsetMs;
|
||||
private float[]? _delayedMic;
|
||||
private int _advanceSamplesRemaining;
|
||||
private bool _started;
|
||||
|
||||
@@ -97,7 +101,8 @@ public sealed class AudioMixer : IDisposable
|
||||
Func<double>? micGain = null,
|
||||
Func<double>? loopbackGain = null,
|
||||
Func<int>? syncOffsetMs = null,
|
||||
TimeSpan? mixInterval = null)
|
||||
TimeSpan? mixInterval = null,
|
||||
Func<int>? micSyncOffsetMs = null)
|
||||
{
|
||||
_mic = mic;
|
||||
_loopback = loopback;
|
||||
@@ -108,20 +113,28 @@ public sealed class AudioMixer : IDisposable
|
||||
_loopbackGain = loopbackGain;
|
||||
_syncOffsetMs = syncOffsetMs;
|
||||
_syncDelay = new AudioSyncDelay(OutputSampleRate);
|
||||
// The microphone alone, mono: a camera's frames reach the compositor later than the mic's
|
||||
// samples reach the pipe, so the voice has to wait for the picture. Loopback and alert audio
|
||||
// are not tied to a camera frame and must not move with it.
|
||||
_micSyncOffsetMs = micSyncOffsetMs;
|
||||
_micSyncDelay = new AudioSyncDelay(OutputSampleRate, channels: 1);
|
||||
_mixInterval = mixInterval ?? DefaultMixInterval;
|
||||
|
||||
var seconds = _mixInterval.TotalSeconds;
|
||||
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
|
||||
// A tick normally owes ~10 ms; after a stall it may owe more, and the clock-based loop pays it
|
||||
// off in one write. Buffers therefore size to the largest chunk the planner can return.
|
||||
var maxChunkFrames = Math.Max(framesPerTick, OutputSampleRate / 5);
|
||||
_micBuffer = new AudioRingBuffer(OutputSampleRate * 2);
|
||||
_loopbackBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 2);
|
||||
_alertBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 8);
|
||||
_voiceChain = new VoiceFilterChain(OutputSampleRate);
|
||||
_ducker = new AutoDucker();
|
||||
_micChunk = new float[framesPerTick];
|
||||
_loopbackChunk = new float[framesPerTick * 2];
|
||||
_alertChunk = new float[framesPerTick * 2];
|
||||
_mixBuffer = new float[framesPerTick * 2];
|
||||
_delayedMix = new float[framesPerTick * 2];
|
||||
_micChunk = new float[maxChunkFrames];
|
||||
_loopbackChunk = new float[maxChunkFrames * 2];
|
||||
_alertChunk = new float[maxChunkFrames * 2];
|
||||
_mixBuffer = new float[maxChunkFrames * 2];
|
||||
_delayedMix = new float[maxChunkFrames * 2];
|
||||
|
||||
_mic.Started += OnMicStarted;
|
||||
_mic.SampleReady += OnMicSample;
|
||||
@@ -222,7 +235,9 @@ public sealed class AudioMixer : IDisposable
|
||||
_liveCts = cts;
|
||||
_pipe = new NamedPipeAudioWriter();
|
||||
_pipe.Start(pipeName);
|
||||
_log?.Invoke($"Audio live: started, pipe '{pipeName}'");
|
||||
// Prove in the log which mic delay is actually in force: the value lives in the layout DB and a
|
||||
// preset click is invisible in a recording.
|
||||
_log?.Invoke($"Audio live: started, pipe '{pipeName}', mic delay {_micSyncOffsetMs?.Invoke() ?? 0} ms");
|
||||
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
|
||||
}
|
||||
|
||||
@@ -283,6 +298,25 @@ public sealed class AudioMixer : IDisposable
|
||||
LoopbackLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
/// <summary>Drops a MEDIA clip's audio into the desktop/game ring (TASK 21's audio slice).
|
||||
/// It joins that group on purpose: the desktop fader and the mic auto-ducker then apply
|
||||
/// exactly as they do to game audio and TRAX, so a BRB bed ducks under the creator's
|
||||
/// voice without a second audio knob. The layer's own level/mute are applied upstream
|
||||
/// by <see cref="MediaVideoSourceManager"/>.</summary>
|
||||
public void EnqueueDesktopMediaAudio(AudioSample sample)
|
||||
{
|
||||
var data = sample.Samples;
|
||||
if (data.Length == 0) return;
|
||||
if (sample.SampleRate != OutputSampleRate)
|
||||
data = ResampleLoopback(data, sample.SampleRate);
|
||||
if (sample.Channels == 1)
|
||||
data = UpmixToStereo(data);
|
||||
_loopbackBuffer.Write(data);
|
||||
|
||||
var level = _loopbackMeter.Push(new AudioSample(data, OutputSampleRate, sample.Channels));
|
||||
LoopbackLevelChanged?.Invoke(level);
|
||||
}
|
||||
|
||||
/// <summary>Drops the alert clip's audio into its own ring (TASK 47 — the
|
||||
/// stream-alert video's soundtrack, pre-scaled by the alert layer to
|
||||
/// volume × fade). Mixed at unity into the live output — NO auto-duck: an
|
||||
@@ -365,12 +399,44 @@ public sealed class AudioMixer : IDisposable
|
||||
var mixBuffer = _mixBuffer!;
|
||||
var micChunk = _micChunk!;
|
||||
var loopbackChunk = _loopbackChunk!;
|
||||
|
||||
// The audio clock is the monotonic clock, not the tick count. A fixed chunk per iteration turns
|
||||
// any scheduler error into a permanent A/V rate error (measured 2026-11-12: 2.1% fast, 224 ms
|
||||
// of drift in ten seconds); asking the clock how much is owed and producing exactly that keeps
|
||||
// the track the same length as the show.
|
||||
// The clock runs from the moment the session opens, NOT from the first successful write. Starting
|
||||
// it at connect made the track begin that much later than the picture: measured 2026-11-12, the
|
||||
// writes began 160 ms after ffmpeg started reading and the audio stream came out 137 ms shorter
|
||||
// than the video, so every word sat 160 ms late. Owed time is paid out of the capture ring when
|
||||
// the pipe connects, which is the audio that was missed — real audio, not padding.
|
||||
var clock = Stopwatch.StartNew();
|
||||
long framesProduced = 0;
|
||||
var maxChunkFrames = micChunk.Length;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var nextTick = DateTime.UtcNow + _mixInterval;
|
||||
try
|
||||
{
|
||||
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer);
|
||||
// Nowhere for audio to go until ffmpeg is reading: wait without producing, so the debt
|
||||
// stays owed and is paid from the ring the moment the pipe connects.
|
||||
if (!pipe.IsConnected)
|
||||
{
|
||||
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var frames = AudioClock.FramesDue(
|
||||
clock.Elapsed.TotalSeconds, framesProduced, OutputSampleRate, maxChunkFrames);
|
||||
if (frames <= 0)
|
||||
{
|
||||
// Nothing owed yet — sooner than the tick interval, so a short yield, not a spin.
|
||||
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer, frames);
|
||||
var samples = frames * 2;
|
||||
|
||||
// Negative offset: skip the armed |N| ms of stream head. The pipe
|
||||
// writer tolerates a partial chunk (any float length is valid);
|
||||
@@ -379,17 +445,19 @@ public sealed class AudioMixer : IDisposable
|
||||
var writeFrom = 0;
|
||||
if (_advanceSamplesRemaining > 0)
|
||||
{
|
||||
var drop = Math.Min(_advanceSamplesRemaining, mixBuffer.Length);
|
||||
var drop = Math.Min(_advanceSamplesRemaining, samples);
|
||||
_advanceSamplesRemaining -= drop;
|
||||
writeFrom = drop;
|
||||
}
|
||||
|
||||
if (writeFrom < mixBuffer.Length)
|
||||
await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom), cancellationToken).ConfigureAwait(false);
|
||||
var wrote = writeFrom >= samples
|
||||
|| await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom, samples - writeFrom),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
framesProduced = AudioClock.Advance(framesProduced, frames, wrote);
|
||||
|
||||
_micDrainedTotal += micDrained;
|
||||
_loopDrainedTotal += loopDrained;
|
||||
for (var i = writeFrom; i < mixBuffer.Length; i++)
|
||||
for (var i = writeFrom; i < samples; i++)
|
||||
{
|
||||
var abs = Math.Abs(mixBuffer[i]);
|
||||
if (abs > _peakMix) _peakMix = abs;
|
||||
@@ -447,17 +515,18 @@ public sealed class AudioMixer : IDisposable
|
||||
/// Returns the pre-duck mic RMS and how many samples each buffer contributed
|
||||
/// (the 5s live-loop telemetry reads those to name whether capture or the
|
||||
/// pipe starved a silent recording).</summary>
|
||||
private (float MicRms, int MicDrained, int LoopDrained) FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
|
||||
private (float MicRms, int MicDrained, int LoopDrained) FillAndMix(
|
||||
float[] micChunk, float[] loopbackChunk, float[] mix, int frames)
|
||||
{
|
||||
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
|
||||
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
|
||||
var micCount = _micBuffer.Read(micChunk, frames);
|
||||
var loopCount = _loopbackBuffer.Read(loopbackChunk, frames * 2);
|
||||
var alertChunk = _alertChunk!;
|
||||
var alertCount = _alertBuffer.Read(alertChunk, alertChunk.Length);
|
||||
var alertCount = _alertBuffer.Read(alertChunk, frames * 2);
|
||||
|
||||
float micRms = 0;
|
||||
if (micCount == 0)
|
||||
{
|
||||
Array.Clear(micChunk, 0, micChunk.Length);
|
||||
Array.Clear(micChunk, 0, frames);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -465,56 +534,70 @@ public sealed class AudioMixer : IDisposable
|
||||
for (var i = 0; i < micCount; i++)
|
||||
sumSquares += micChunk[i] * micChunk[i];
|
||||
micRms = (float)Math.Sqrt(sumSquares / micCount);
|
||||
if (micCount < micChunk.Length)
|
||||
Array.Clear(micChunk, micCount, micChunk.Length - micCount);
|
||||
if (micCount < frames)
|
||||
Array.Clear(micChunk, micCount, frames - micCount);
|
||||
}
|
||||
|
||||
if (loopCount == 0)
|
||||
{
|
||||
Array.Clear(loopbackChunk, 0, loopbackChunk.Length);
|
||||
Array.Clear(loopbackChunk, 0, frames * 2);
|
||||
}
|
||||
else if (loopCount < loopbackChunk.Length)
|
||||
else if (loopCount < frames * 2)
|
||||
{
|
||||
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
|
||||
Array.Clear(loopbackChunk, loopCount, frames * 2 - loopCount);
|
||||
}
|
||||
|
||||
if (alertCount == 0)
|
||||
{
|
||||
Array.Clear(alertChunk, 0, alertChunk.Length);
|
||||
Array.Clear(alertChunk, 0, frames * 2);
|
||||
}
|
||||
else if (alertCount < alertChunk.Length)
|
||||
else if (alertCount < frames * 2)
|
||||
{
|
||||
Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount);
|
||||
Array.Clear(alertChunk, alertCount, frames * 2 - alertCount);
|
||||
}
|
||||
|
||||
// Mic-only delay: applied to the source, before the mix, so the desktop and alert audio keep
|
||||
// their timing. Reconfigured every tick like the mix line above (same 2026-09-12 trap: a
|
||||
// reallocating Configure would discard the buffered voice every 10 ms).
|
||||
var micDelayMs = _micSyncOffsetMs?.Invoke() ?? 0;
|
||||
if (micDelayMs > 0)
|
||||
{
|
||||
_micSyncDelay.Configure(micDelayMs);
|
||||
var delayedMic = _delayedMic;
|
||||
if (delayedMic is null || delayedMic.Length < frames)
|
||||
delayedMic = _delayedMic = new float[frames];
|
||||
_micSyncDelay.Process(micChunk.AsSpan(0, frames), delayedMic.AsSpan(0, frames));
|
||||
Array.Copy(delayedMic, micChunk, frames);
|
||||
}
|
||||
|
||||
var duck = _ducker.Update(micRms);
|
||||
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
|
||||
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
|
||||
|
||||
var frames = micChunk.Length;
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var m = micChunk[i] * micGain;
|
||||
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain + alertChunk[i * 2];
|
||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain + alertChunk[i * 2 + 1];
|
||||
}
|
||||
_masterLimiter.Process(mix);
|
||||
var samples = frames * 2;
|
||||
_masterLimiter.Process(mix.AsSpan(0, samples));
|
||||
|
||||
_syncDelay.Configure(_syncOffsetMs?.Invoke() ?? 0);
|
||||
var delayed = _delayedMix;
|
||||
if (delayed is null || delayed.Length < mix.Length)
|
||||
delayed = _delayedMix = new float[mix.Length];
|
||||
_syncDelay.Process(mix, delayed);
|
||||
Array.Copy(delayed, mix, mix.Length);
|
||||
if (delayed is null || delayed.Length < samples)
|
||||
delayed = _delayedMix = new float[samples];
|
||||
_syncDelay.Process(mix.AsSpan(0, samples), delayed.AsSpan(0, samples));
|
||||
Array.Copy(delayed, mix, samples);
|
||||
|
||||
if (MonoOutput) Helpers.AudioFold.ToMono(mix.AsSpan(0, mix.Length), 2);
|
||||
MeasureMaster(mix);
|
||||
if (MonoOutput) Helpers.AudioFold.ToMono(mix.AsSpan(0, samples), 2);
|
||||
MeasureMaster(mix.AsSpan(0, samples));
|
||||
return (micRms, micCount, loopCount);
|
||||
}
|
||||
|
||||
/// <summary>One pass over the finished mix: 1024 samples per channel, so the cost is noise
|
||||
/// beside the mixer it sits behind.</summary>
|
||||
private void MeasureMaster(float[] mix)
|
||||
private void MeasureMaster(ReadOnlySpan<float> mix)
|
||||
{
|
||||
double left = 0, right = 0;
|
||||
var frames = mix.Length / 2;
|
||||
|
||||
@@ -3,8 +3,8 @@ using System;
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 22: a fixed end-to-end audio delay applied to the interleaved stereo
|
||||
/// mix in <see cref="AudioMixer"/>. This is OBS's documented fix for the
|
||||
/// TASK 22: a fixed end-to-end audio delay applied either to the interleaved
|
||||
/// stereo mix or to one mono source in <see cref="AudioMixer"/>. This is OBS's documented fix for the
|
||||
/// "audio is ahead of video" lip-sync case: delay the audio by a few
|
||||
/// milliseconds so it lands on the video. It is a pure delay line — input in,
|
||||
/// the same audio N ms later out — so it is trivially testable and carries no
|
||||
@@ -20,14 +20,19 @@ namespace ytLive.Services.Audio;
|
||||
public sealed class AudioSyncDelay
|
||||
{
|
||||
private readonly int _sampleRate;
|
||||
private readonly int _channels;
|
||||
|
||||
private float[] _buffer = Array.Empty<float>();
|
||||
private int _delaySamples;
|
||||
private int _writePos;
|
||||
|
||||
public AudioSyncDelay(int sampleRate)
|
||||
/// <param name="channels">Samples per frame in the chunks passed to <see cref="Process"/>: 2 for
|
||||
/// the interleaved stereo mix, 1 for a mono source such as the microphone. The offset is always
|
||||
/// specified in milliseconds, so the frame count — not the sample count — is what stays constant.</param>
|
||||
public AudioSyncDelay(int sampleRate, int channels = 2)
|
||||
{
|
||||
_sampleRate = Math.Max(1, sampleRate);
|
||||
_channels = Math.Clamp(channels, 1, 2);
|
||||
Configure(0);
|
||||
}
|
||||
|
||||
@@ -45,13 +50,13 @@ public sealed class AudioSyncDelay
|
||||
var delaySamples = Frames(delayMs);
|
||||
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
|
||||
_delaySamples = delaySamples;
|
||||
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 1024];
|
||||
_buffer = new float[(_delaySamples + _sampleRate) * _channels + 1024];
|
||||
_writePos = 0;
|
||||
}
|
||||
|
||||
/// <summary>Delays an interleaved stereo chunk by the configured offset.
|
||||
/// The returned length equals <paramref name="input"/>'s; the head is
|
||||
/// silence until the line fills.</summary>
|
||||
/// <summary>Delays one interleaved chunk (stereo or mono, per the constructor) by the configured
|
||||
/// offset. The returned length equals <paramref name="input"/>'s; the head is silence until the
|
||||
/// line fills.</summary>
|
||||
public void Process(ReadOnlySpan<float> input, Span<float> output)
|
||||
{
|
||||
if (output.Length < input.Length)
|
||||
@@ -87,8 +92,8 @@ public sealed class AudioSyncDelay
|
||||
private int Frames(int delayMs)
|
||||
{
|
||||
var clamped = Math.Clamp(delayMs, 0, 500);
|
||||
// Interleaved stereo: each frame is two samples (L, R), so the delay
|
||||
// in buffer positions is twice the frame count.
|
||||
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * 2;
|
||||
// A frame is `_channels` samples, so the delay in buffer positions is
|
||||
// the frame count times the channel count.
|
||||
return (int)Math.Round(_sampleRate / 1000.0 * clamped) * _channels;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class MasterLimiter
|
||||
/// <summary>Scales the frame by the current gain. Gain drops instantly to
|
||||
/// <c>ceiling/peak</c> when the frame exceeds the ceiling and recovers
|
||||
/// toward unity slowly.</summary>
|
||||
public void Process(float[] samples)
|
||||
public void Process(Span<float> samples)
|
||||
{
|
||||
var peak = 0f;
|
||||
foreach (var s in samples)
|
||||
|
||||
@@ -23,8 +23,10 @@ public interface IAudioPipeWriter : IDisposable
|
||||
/// connected to the pipe.</summary>
|
||||
long DroppedWrites { get; }
|
||||
|
||||
/// <summary>Writes interleaved float samples; dropped until the client connects.</summary>
|
||||
Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
|
||||
/// <summary>Writes interleaved float samples. Returns false when nothing was written because the
|
||||
/// client has not connected or the pipe is closed — the caller must not count that audio as
|
||||
/// produced, or a start-up gap silently shortens the track.</summary>
|
||||
Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Closes the server end — ffmpeg sees EOF and ends the audio input.</summary>
|
||||
void Stop();
|
||||
@@ -65,7 +67,7 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> WriteAsync(ReadOnlyMemory<float> samples, CancellationToken cancellationToken = default)
|
||||
{
|
||||
NamedPipeServerStream? pipe;
|
||||
lock (_sync)
|
||||
@@ -76,11 +78,12 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
{
|
||||
if (pipe?.IsConnected == false || pipe == null)
|
||||
DroppedWrites++;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
|
||||
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 32·3 — naming the broadcast that resumes a dropped show. A resumed stream is a NEW broadcast
|
||||
/// with a new VOD (YouTube cannot resume a completed one), so the title has to say so; otherwise the
|
||||
/// channel grows two identically named videos and the creator has to explain it.
|
||||
/// <para>Pure, because the interesting cases are the boring ones: a title already marked, a second drop
|
||||
/// in the same session, and YouTube's 100-character limit.</para>
|
||||
/// </summary>
|
||||
public static class BroadcastTitle
|
||||
{
|
||||
public const int MaxLength = 100;
|
||||
|
||||
private static readonly Regex ResumeMarker =
|
||||
new(@"\s*\(resumed(?: \d+)?\)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>The title for resume number <paramref name="resumeCount"/> (1 = first re-air).
|
||||
/// Any existing marker is replaced rather than stacked, and the base is truncated so the whole
|
||||
/// title fits YouTube's limit.</summary>
|
||||
public static string ForResume(string? title, int resumeCount)
|
||||
{
|
||||
var baseTitle = ResumeMarker.Replace(title ?? string.Empty, string.Empty).Trim();
|
||||
if (baseTitle.Length == 0) baseTitle = "LlamaCasty";
|
||||
|
||||
var marker = resumeCount <= 1 ? " (resumed)" : $" (resumed {Math.Max(2, resumeCount)})";
|
||||
var room = MaxLength - marker.Length;
|
||||
if (baseTitle.Length > room) baseTitle = baseTitle[..room].TrimEnd();
|
||||
return baseTitle + marker;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>What a clip's background looks like, as far as keying goes.</summary>
|
||||
public enum ChromaScreen
|
||||
{
|
||||
/// <summary>No usable backdrop — say nothing to the creator.</summary>
|
||||
None,
|
||||
Green,
|
||||
Blue,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: "this clip was shot against a green screen". <c>Coverage</c> × <c>Tightness</c>:
|
||||
/// how much of the frame is the colour, and how consistent that colour is. A painted screen
|
||||
/// scores near 1; a lawn or a football pitch scores low because the greens disagree.
|
||||
/// </summary>
|
||||
public readonly record struct ChromaKeySuggestion(
|
||||
ChromaScreen Screen,
|
||||
string? ColorHex,
|
||||
double Confidence)
|
||||
{
|
||||
/// <summary>The screen is uniform enough to key without the creator touching a slider.</summary>
|
||||
public bool IsHighConfidence => Screen != ChromaScreen.None && Confidence >= HighConfidenceFloor;
|
||||
|
||||
public const double HighConfidenceFloor = 0.85;
|
||||
public const double SuggestFloor = 0.65;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: finds a green or blue screen in a decoded frame — the "clueless creator" half of
|
||||
/// the feature. Pure and allocation-light (it only samples an outer ring and a coarse grid), so
|
||||
/// it runs on the probe frame without touching ffmpeg twice.
|
||||
/// <para><b>It never decides anything.</b> A suggestion is offered to the creator; auto-apply is
|
||||
/// an explicit setting and only fires at <see cref="ChromaKeySuggestion.IsHighConfidence"/>.
|
||||
/// Wrongly keying a lawn is destructive, so "None" is a normal, expected answer.</para>
|
||||
/// </summary>
|
||||
public static class ChromaKeyDetector
|
||||
{
|
||||
// Hue windows in degrees. Green sits 75–165 (a real screen reads ~120–145), blue 185–265.
|
||||
private const double GreenHueFrom = 75, GreenHueTo = 165;
|
||||
private const double BlueHueFrom = 185, BlueHueTo = 265;
|
||||
|
||||
// A screen is saturated and not dark. These also exclude letterbox bars (near-black,
|
||||
// no saturation) and blown-out highlights from the denominator, so black bars do not
|
||||
// count against an otherwise perfect screen.
|
||||
private const double MinSaturation = 0.35;
|
||||
private const double MinValue = 0.15;
|
||||
|
||||
// No upper brightness bound ON PURPOSE (found on a real clip 2026-11-05): a clean painted
|
||||
// screen is fully saturated, so its value sits at ~1.0 and a 0.98 ceiling rejected the
|
||||
// greenest possible screen. White highlights are excluded by the SATURATION floor instead,
|
||||
// which is the property that actually separates them from a screen.
|
||||
|
||||
// The screen's own pixels must agree with each other: a painted surface is flat, a lawn
|
||||
// is not. This is what keeps "green field" from being keyed.
|
||||
private const double HueTightness = 12.0;
|
||||
|
||||
/// <summary>Samples an outer ring (the subject sits in the middle, the screen fills the
|
||||
/// edges) and reports what it saw.</summary>
|
||||
public static ChromaKeySuggestion Detect(VideoFrame frame)
|
||||
{
|
||||
if (frame.Width < 8 || frame.Height < 8) return Empty;
|
||||
|
||||
var ring = Math.Max(2, (int)Math.Round(Math.Min(frame.Width, frame.Height) * 0.15));
|
||||
var step = Math.Max(1, Math.Min(frame.Width, frame.Height) / 96);
|
||||
|
||||
var hues = new List<double>();
|
||||
var sameHue = 0;
|
||||
double hueSum = 0;
|
||||
long rSum = 0, gSum = 0, bSum = 0;
|
||||
var considered = 0;
|
||||
var green = 0;
|
||||
var blue = 0;
|
||||
|
||||
for (var y = 0; y < frame.Height; y += step)
|
||||
{
|
||||
var inVerticalRing = y < ring || y >= frame.Height - ring;
|
||||
for (var x = 0; x < frame.Width; x += step)
|
||||
{
|
||||
if (!inVerticalRing && x >= ring && x < frame.Width - ring) continue;
|
||||
|
||||
var i = y * frame.Stride + x * 4;
|
||||
var b = frame.BgraPixels[i];
|
||||
var g = frame.BgraPixels[i + 1];
|
||||
var r = frame.BgraPixels[i + 2];
|
||||
if (frame.BgraPixels[i + 3] < 8) continue; // transparent: not the backdrop
|
||||
|
||||
var (hue, saturation, value) = ToHsv(r, g, b);
|
||||
if (saturation < MinSaturation || value < MinValue) continue;
|
||||
|
||||
considered++;
|
||||
if (hue is >= GreenHueFrom and <= GreenHueTo) green++;
|
||||
else if (hue is >= BlueHueFrom and <= BlueHueTo) blue++;
|
||||
else continue;
|
||||
|
||||
hues.Add(hue);
|
||||
hueSum += hue;
|
||||
rSum += r;
|
||||
gSum += g;
|
||||
bSum += b;
|
||||
}
|
||||
}
|
||||
|
||||
if (considered < 64) return Empty;
|
||||
|
||||
var screen = green >= blue
|
||||
? (green == 0 ? ChromaScreen.None : ChromaScreen.Green)
|
||||
: ChromaScreen.Blue;
|
||||
if (screen == ChromaScreen.None) return Empty;
|
||||
|
||||
var accepted = hues.Count;
|
||||
var coverage = (double)accepted / considered;
|
||||
|
||||
// Tint stability: how many of the accepted pixels sit within ±HueTightness of the
|
||||
// median hue. A painted screen ≈ 1.0; grass, foliage and sunlight ≈ far less.
|
||||
hues.Sort();
|
||||
var median = hues[hues.Count / 2];
|
||||
foreach (var hue in hues)
|
||||
{
|
||||
var delta = Math.Abs(hue - median);
|
||||
if (delta > 180) delta = 360 - delta;
|
||||
if (delta <= HueTightness) sameHue++;
|
||||
}
|
||||
var tightness = (double)sameHue / accepted;
|
||||
|
||||
var confidence = coverage * tightness;
|
||||
if (confidence < ChromaKeySuggestion.SuggestFloor) return Empty;
|
||||
|
||||
// The keyed colour is the screen as it was actually shot, not a canonical green:
|
||||
// similarity is a distance, so the median of the sampled pixels keys far better.
|
||||
var keyed = Math.Max(1, accepted);
|
||||
var color = $"#{rSum / keyed:X2}{gSum / keyed:X2}{bSum / keyed:X2}";
|
||||
return new ChromaKeySuggestion(screen, color, confidence);
|
||||
}
|
||||
|
||||
private static readonly ChromaKeySuggestion Empty = new(ChromaScreen.None, null, 0);
|
||||
|
||||
private static (double Hue, double Saturation, double Value) ToHsv(byte r, byte g, byte b)
|
||||
{
|
||||
var rf = r / 255.0;
|
||||
var gf = g / 255.0;
|
||||
var bf = b / 255.0;
|
||||
var max = Math.Max(rf, Math.Max(gf, bf));
|
||||
var min = Math.Min(rf, Math.Min(gf, bf));
|
||||
var delta = max - min;
|
||||
|
||||
double hue = 0;
|
||||
if (delta > 1e-6)
|
||||
{
|
||||
if (max == rf) hue = 60 * (((gf - bf) / delta) % 6);
|
||||
else if (max == gf) hue = 60 * (((bf - rf) / delta) + 2);
|
||||
else hue = 60 * (((rf - gf) / delta) + 4);
|
||||
}
|
||||
if (hue < 0) hue += 360;
|
||||
|
||||
var saturation = max <= 0 ? 0 : delta / max;
|
||||
return (hue, saturation, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: reads ONE frame out of a video file so the app can look at its background before
|
||||
/// the creator says anything. Kept behind an interface so tests never spawn ffmpeg, and separate
|
||||
/// from the decode pipe because this is a read, not a playback session.
|
||||
/// </summary>
|
||||
public interface IChromaProbe
|
||||
{
|
||||
/// <summary>The file's first usable frame at a small size, or null when it cannot be read
|
||||
/// (bad path, unsupported file, ffmpeg gave nothing). Null is a normal answer: the layer
|
||||
/// simply gets no suggestion.</summary>
|
||||
Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IChromaProbe"/>: one ffmpeg pass, scaled to 160×90 so the look is
|
||||
/// cheap and the detector sees plenty of the backdrop.</summary>
|
||||
public sealed class FfmpegChromaProbe : IChromaProbe
|
||||
{
|
||||
private const int ProbeWidth = 160;
|
||||
private const int ProbeHeight = 90;
|
||||
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _processFactory;
|
||||
|
||||
public FfmpegChromaProbe(IFfmpegLocator locator, Func<IDecodeProcess> processFactory)
|
||||
{
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
}
|
||||
|
||||
public async Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
try
|
||||
{
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{path}\" -frames:v 1 -an " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf scale={ProbeWidth}:{ProbeHeight} -";
|
||||
|
||||
using var process = _processFactory();
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var frames = await Task.Run(() =>
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
process.StandardOutput.CopyTo(buffer);
|
||||
var reader = new RawVideoFrameReader(ProbeWidth, ProbeHeight);
|
||||
var fed = reader.Feed(buffer.ToArray());
|
||||
return fed;
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return frames.Count > 0 ? frames[0] : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write($"ChromaProbe: '{Path.GetFileName(path)}' unreadable: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 65: where clips live and how many survive.
|
||||
///
|
||||
/// <para><b>Not clip management.</b> There is no gallery, no naming, no per-clip decision. A clip is
|
||||
/// written to <c>clips\</c>, the oldest is deleted when the next one lands, and the only knobs are the
|
||||
/// length (10 or 15 s) and how many to keep (3, 4 or 5). Anything richer belongs to a video editor.</para>
|
||||
/// </summary>
|
||||
public sealed class ClipLibrary
|
||||
{
|
||||
public const string Extension = ".mp4";
|
||||
public const string FilePrefix = "clip-";
|
||||
|
||||
private readonly Func<string> _folder;
|
||||
|
||||
public ClipLibrary(Func<string> folder) => _folder = folder;
|
||||
|
||||
/// <summary>Bytes the ring should hold for a length and bitrate, with 25% headroom for bitrate
|
||||
/// movement. This is a memory cap, not a duration — the ring trims whole fragments.</summary>
|
||||
public static long RingBytesFor(int seconds, int bitrateKbps)
|
||||
=> Math.Max(1, seconds) * (long)bitrateKbps * 1000 / 8 * 5 / 4;
|
||||
|
||||
/// <summary>Writes a clip and prunes to <paramref name="keep"/>; returns the file path, or null when
|
||||
/// there was nothing to write (an empty snapshot must never become a zero-byte file).</summary>
|
||||
public string? Save(byte[] mp4Bytes, DateTime nowLocal, int keep)
|
||||
{
|
||||
if (mp4Bytes.Length == 0) return null;
|
||||
try
|
||||
{
|
||||
var folder = EnsureFolder();
|
||||
var path = Path.Combine(folder, $"{FilePrefix}{nowLocal:yyyyMMdd-HHmmss}{Extension}");
|
||||
var unique = path;
|
||||
for (var n = 2; File.Exists(unique); n++)
|
||||
unique = Path.Combine(folder, $"{FilePrefix}{nowLocal:yyyyMMdd-HHmmss}-{n}{Extension}");
|
||||
|
||||
File.WriteAllBytes(unique, mp4Bytes);
|
||||
Prune(keep);
|
||||
return unique;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write(ex, "Clip save failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Newest first. The replay layer's playlist is this list, so its order matters.</summary>
|
||||
public IReadOnlyList<string> Recent()
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = EnsureFolder();
|
||||
return Directory.EnumerateFiles(folder, FilePrefix + "*" + Extension)
|
||||
.OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write(ex, "Clip list failed");
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes everything past the retention count. Missing folders are not an error.</summary>
|
||||
public int Prune(int keep)
|
||||
{
|
||||
if (keep < 1) keep = 1;
|
||||
var removed = 0;
|
||||
foreach (var stale in Recent().Skip(keep))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(stale);
|
||||
removed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write(ex, $"Clip prune failed: {stale}");
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>The folder beside the recordings, created on demand.</summary>
|
||||
public string EnsureFolder()
|
||||
{
|
||||
var folder = Path.Combine(_folder(), "clips");
|
||||
Directory.CreateDirectory(folder);
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 65: the replay buffer's memory. ffmpeg writes fragmented MP4 to a pipe; the app keeps the last
|
||||
/// N seconds of those fragments here and hands them back whole.
|
||||
///
|
||||
/// <para><b>Why encoded bytes and not frames:</b> a 1080p BGRA composite frame is 8.3 MB, so fifteen
|
||||
/// seconds at 60 fps is ~7.4 GB of raw frames. The 15 MB figure is the compressed stream, and only that
|
||||
/// fits in RAM. <b>Why whole fragments:</b> a fragment is the smallest thing that can be replayed, so the
|
||||
/// cap is a byte cap and the clip may be slightly shorter than the requested seconds — never corrupt.</para>
|
||||
/// </summary>
|
||||
public sealed class ClipRing
|
||||
{
|
||||
private readonly long _capBytes;
|
||||
private readonly Queue<byte[]> _fragments = new();
|
||||
private byte[]? _init;
|
||||
private long _bytes;
|
||||
|
||||
public ClipRing(long capBytes)
|
||||
{
|
||||
if (capBytes <= 0) throw new ArgumentOutOfRangeException(nameof(capBytes));
|
||||
_capBytes = capBytes;
|
||||
}
|
||||
|
||||
/// <summary>The stream's initialisation segment (ftyp + moov). Every replayable file needs it, and
|
||||
/// it does not rotate — it is written once per encoder start.</summary>
|
||||
public void SetInit(byte[] init)
|
||||
{
|
||||
_init = init ?? throw new ArgumentNullException(nameof(init));
|
||||
RecomputeBytes();
|
||||
Trim();
|
||||
}
|
||||
|
||||
public void Append(byte[] fragment)
|
||||
{
|
||||
if (fragment is null || fragment.Length == 0) return;
|
||||
_fragments.Enqueue(fragment);
|
||||
_bytes += fragment.Length;
|
||||
Trim();
|
||||
}
|
||||
|
||||
public long Bytes => _bytes;
|
||||
public int FragmentCount => _fragments.Count;
|
||||
public bool IsEmpty => _init is null || _fragments.Count == 0;
|
||||
|
||||
/// <summary>The bytes of a playable clip, init first. Empty array when nothing has been buffered —
|
||||
/// the caller must not write a zero-byte file (a failed save is better than a file that won't open).</summary>
|
||||
public byte[] Snapshot()
|
||||
{
|
||||
if (IsEmpty) return Array.Empty<byte>();
|
||||
var total = _init!.Length;
|
||||
foreach (var fragment in _fragments) total += fragment.Length;
|
||||
|
||||
var output = new byte[total];
|
||||
var offset = 0;
|
||||
Buffer.BlockCopy(_init, 0, output, offset, _init.Length);
|
||||
offset += _init.Length;
|
||||
foreach (var fragment in _fragments)
|
||||
{
|
||||
Buffer.BlockCopy(fragment, 0, output, offset, fragment.Length);
|
||||
offset += fragment.Length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_fragments.Clear();
|
||||
_init = null;
|
||||
_bytes = 0;
|
||||
}
|
||||
|
||||
private void RecomputeBytes()
|
||||
{
|
||||
_bytes = _init?.Length ?? 0;
|
||||
foreach (var fragment in _fragments) _bytes += fragment.Length;
|
||||
}
|
||||
|
||||
/// <summary>Drop oldest fragments until the ring fits. Newest data always wins: a clip that ends at
|
||||
/// the moment the creator pressed the key is the whole point.</summary>
|
||||
private void Trim()
|
||||
{
|
||||
while (_fragments.Count > 0 && _bytes > _capBytes)
|
||||
{
|
||||
_bytes -= _fragments.Dequeue().Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 55·3: a recording that dies of a full disk is a trust breach, not a support ticket. The guard is
|
||||
/// a pure threshold policy so the numbers are visible in one place and testable without a drive.
|
||||
///
|
||||
/// <para>Thresholds chosen 2026-11-10 for a 1080p stream at 8 Mbps (~3.6 GB/hour): refusing to start
|
||||
/// under <b>1 GB</b> keeps the creator from beginning something that cannot finish; a warning under
|
||||
/// <b>5 GB</b> says "you have about eighty minutes" while there is still time to act; and one mid-show
|
||||
/// warning at <b>2 GB</b> is the last useful moment before the encoder fails for a reason it will not
|
||||
/// explain.</para>
|
||||
/// </summary>
|
||||
public static class DiskSpaceGuard
|
||||
{
|
||||
public const long BlockStartBytes = 1L * 1024 * 1024 * 1024;
|
||||
public const long WarnStartBytes = 5L * 1024 * 1024 * 1024;
|
||||
public const long WarnDuringBytes = 2L * 1024 * 1024 * 1024;
|
||||
|
||||
public enum StartDecision
|
||||
{
|
||||
Ok,
|
||||
Warn,
|
||||
Block,
|
||||
}
|
||||
|
||||
public static StartDecision ForStart(long freeBytes)
|
||||
=> freeBytes < BlockStartBytes ? StartDecision.Block
|
||||
: freeBytes < WarnStartBytes ? StartDecision.Warn
|
||||
: StartDecision.Ok;
|
||||
|
||||
/// <summary>One warning per session, once the drive falls below the mid-recording threshold.</summary>
|
||||
public static bool ShouldWarnDuring(long freeBytes) => freeBytes < WarnDuringBytes;
|
||||
|
||||
/// <summary>Free bytes on the drive that holds <paramref name="folder"/>, or null when it cannot be
|
||||
/// read (a removable drive that just went away must not block a recording that is already running).</summary>
|
||||
public static long? FreeBytes(string folder)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = System.IO.Path.GetPathRoot(System.IO.Path.GetFullPath(folder));
|
||||
if (string.IsNullOrEmpty(root)) return null;
|
||||
return new System.IO.DriveInfo(root).AvailableFreeSpace;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ public sealed class EncoderOptions
|
||||
/// <summary>Name of the mixer's audio pipe (default <see cref="DefaultAudioPipeName"/>).</summary>
|
||||
public string AudioPipeName { get; init; } = DefaultAudioPipeName;
|
||||
|
||||
/// <summary>TASK 65: when set, the encoder muxes a SECOND video output into this ring (fragmented
|
||||
/// MP4 on stdout) for the replay buffer. Null = clips off, and the ffmpeg command line is exactly
|
||||
/// what it was before the feature existed.</summary>
|
||||
public Services.ClipRing? ClipBuffer { get; init; }
|
||||
|
||||
/// <summary>GOP in frames = Fps × 4s — the YouTube keyframe ≤ 4s compliance bound.</summary>
|
||||
public int GopSize => Fps * 4;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,28 @@ public static class FfmpegArgs
|
||||
AddVideoTags(args, options, videoEncoder);
|
||||
args.AddRange(["-c:a", "aac",
|
||||
"-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
|
||||
// TASK 32·4: FRAGMENTED mp4. A plain MP4 writes its index (moov) at the end, so a
|
||||
// power cut or a killed process leaves the video bytes on disk in a file nothing can
|
||||
// open — the whole recording, not the tail. Fragmented writes the index first and
|
||||
// flushes a playable fragment at every keyframe, so an interrupted file still plays up
|
||||
// to the last fragment. Record block only: the FLV stream output has no index.
|
||||
args.AddRange(["-movflags", "+frag_keyframe+empty_moov+default_base_moof"]);
|
||||
args.AddRange(["-f", "mp4", options.RecordPath!]);
|
||||
}
|
||||
|
||||
// TASK 65: the replay buffer is a third mux of the same encoded frames. Fragmented MP4 on
|
||||
// stdout (stats already own stderr) — one fragment per second, so a clip can start anywhere
|
||||
// inside the chosen window instead of at the last 4-second keyframe.
|
||||
if (options.ClipBuffer != null)
|
||||
{
|
||||
AddVideoTags(args, options, videoEncoder);
|
||||
args.AddRange(["-c:a", "aac",
|
||||
"-ar", options.AudioSampleRate.ToString(), "-ac", options.AudioChannels.ToString()]);
|
||||
args.AddRange(["-movflags", "+frag_keyframe+empty_moov+default_base_moof"]);
|
||||
args.AddRange(["-frag_duration", "1000000"]);
|
||||
args.AddRange(["-f", "mp4", "pipe:1"]);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -58,9 +77,29 @@ public static class FfmpegArgs
|
||||
args.AddRange(["-vf", $"scale={options.Width}:{options.Height}:flags=bilinear,setsar=1"]);
|
||||
}
|
||||
args.AddRange(["-c:v", videoEncoder]);
|
||||
AddRateControl(args, videoEncoder);
|
||||
args.AddRange(["-b:v", $"{options.BitrateKbps}k", "-maxrate", $"{options.BitrateKbps}k",
|
||||
"-bufsize", $"{options.BitrateKbps * 2}k"]);
|
||||
args.AddRange(["-g", gop.ToString(), "-keyint_min", gop.ToString(),
|
||||
"-sc_threshold", "0", "-bf", "0", "-pix_fmt", "yuv420p"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constant bitrate, spelled the one way each encoder understands.
|
||||
///
|
||||
/// <para><b>Why this is not a shared flag:</b> the <c>-b:v/-maxrate/-bufsize</c> triad is capped VBR,
|
||||
/// not CBR — measured 2026-11-12 on the bundled BtbN build, a static frame at <c>-b:v 2000k</c>
|
||||
/// produced <b>9 kbps</b> with NVENC's default rate control and <b>1951 kbps</b> with <c>-rc cbr</c>.
|
||||
/// That is the gap behind an @8 stream that averaged 5.1 Mbps.</para>
|
||||
///
|
||||
/// <para>Per encoder: NVENC and AMF take an explicit mode. QSV has no <c>-rc</c> and already selects
|
||||
/// CBR when <c>bitrate == maxrate</c> (<c>libavcodec/qsvenc.c</c>, <c>select_rc_mode</c>). OpenH264's
|
||||
/// <c>-rc_mode bitrate</c> made no measurable difference on this build, so it is deliberately not
|
||||
/// passed. YouTube's encoder spec requires CBR (support.google.com/youtube/answer/2853702).</para>
|
||||
/// </summary>
|
||||
private static void AddRateControl(List<string> args, string videoEncoder)
|
||||
{
|
||||
if (videoEncoder is "h264_nvenc" or "h264_amf")
|
||||
args.AddRange(["-rc", "cbr"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
private IEncoderProcess? _process;
|
||||
private EncoderOptions? _options;
|
||||
private StreamHealth _health = new() { Status = StreamStatus.Offline };
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? VideoEncoderName { get; private set; }
|
||||
private Task? _stderrLoop;
|
||||
private bool _stopRequested;
|
||||
|
||||
@@ -82,6 +85,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var encoder = options.VideoEncoder ?? await ProbeEncoderAsync(ffmpegPath, cancellationToken).ConfigureAwait(false);
|
||||
VideoEncoderName = encoder;
|
||||
var args = FfmpegArgs.Build(options, encoder);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
@@ -118,6 +122,11 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
_stopRequested = false;
|
||||
_stderrLoop = RunStderrLoopAsync(process);
|
||||
StartDrainLoop(process);
|
||||
|
||||
// TASK 65: the clip mux is a side channel. It reads its own thread, never blocks the frame
|
||||
// path, and a fault disables clips instead of touching the stream.
|
||||
if (options.ClipBuffer is { } clipRing)
|
||||
_ = Task.Run(() => ReadClipOutputAsync(process, clipRing));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -239,6 +248,24 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
/// ArrayPool may return a larger buffer), returned to the pool after the write
|
||||
/// copies into the pipe, and stdin is closed (EOF) once the queue drains.
|
||||
/// </summary>
|
||||
private static async Task ReadClipOutputAsync(IEncoderProcess process, Services.ClipRing ring)
|
||||
{
|
||||
var splitter = new Fmp4FragmentSplitter(ring);
|
||||
var buffer = new byte[64 * 1024];
|
||||
try
|
||||
{
|
||||
var stdout = process.StandardOutputBytes;
|
||||
int read;
|
||||
while ((read = await stdout.ReadAsync(buffer).ConfigureAwait(false)) > 0)
|
||||
splitter.Write(buffer.AsSpan(0, read));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Clips stop; the broadcast does not.
|
||||
AppLog.Write(ex, "Clip buffer reader failed — replay clips disabled for this session");
|
||||
}
|
||||
}
|
||||
|
||||
private void StartDrainLoop(IEncoderProcess process)
|
||||
{
|
||||
_drainTask = Task.Run(async () =>
|
||||
|
||||
@@ -21,6 +21,8 @@ public sealed class FfmpegEncoderProcess : IEncoderProcess
|
||||
}
|
||||
|
||||
public Stream StandardInput => _process.StandardInput.BaseStream;
|
||||
|
||||
public Stream StandardOutputBytes => _process.StandardOutput.BaseStream;
|
||||
public TextReader StandardOutput => _process.StandardOutput;
|
||||
public TextReader StandardError => _process.StandardError;
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 65: turns the fragmented-MP4 byte stream ffmpeg writes to stdout into a playable ring.
|
||||
///
|
||||
/// <para>MP4 boxes are length-prefixed, so this needs no muxer cooperation: <c>ftyp</c>+<c>moov</c> are
|
||||
/// the initialisation segment, and each <c>moof</c>+<c>mdat</c> pair is one fragment. The ring only ever
|
||||
/// sees whole fragments, which is what makes a snapshot playable and a trim safe.</para>
|
||||
///
|
||||
/// <para>Deliberately forgiving: an unparseable header clears the buffer and waits for the next one. A
|
||||
/// clip that fails must never take the encoder down with it — the stream is running through the same
|
||||
/// process.</para>
|
||||
/// </summary>
|
||||
public sealed class Fmp4FragmentSplitter
|
||||
{
|
||||
private readonly ClipRing _ring;
|
||||
private readonly List<byte> _pending = new(capacity: 64 * 1024);
|
||||
private readonly MemoryStream _init = new();
|
||||
private readonly MemoryStream _fragment = new();
|
||||
private bool _inFragment;
|
||||
|
||||
public Fmp4FragmentSplitter(ClipRing ring) => _ring = ring;
|
||||
|
||||
public void Write(ReadOnlySpan<byte> data)
|
||||
{
|
||||
foreach (var b in data) _pending.Add(b);
|
||||
Drain();
|
||||
}
|
||||
|
||||
private void Drain()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_pending.Count < 8) return;
|
||||
|
||||
long size = ((long)_pending[0] << 24) | ((long)_pending[1] << 16) | ((long)_pending[2] << 8) | _pending[3];
|
||||
var type = Encoding.ASCII.GetString(_pending.GetRange(4, 4).ToArray());
|
||||
var header = 8;
|
||||
|
||||
if (size == 1)
|
||||
{
|
||||
if (_pending.Count < 16) return;
|
||||
size = 0;
|
||||
for (var i = 8; i < 16; i++) size = (size << 8) | _pending[i];
|
||||
header = 16;
|
||||
}
|
||||
|
||||
if (size < header || size > int.MaxValue)
|
||||
{
|
||||
// Not a box boundary any more; drop what we have and resync on the next write.
|
||||
_pending.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pending.Count < size) return;
|
||||
|
||||
var box = _pending.GetRange(0, (int)size).ToArray();
|
||||
_pending.RemoveRange(0, (int)size);
|
||||
Route(type, box);
|
||||
}
|
||||
}
|
||||
|
||||
private void Route(string type, byte[] box)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "ftyp":
|
||||
_init.Write(box);
|
||||
break;
|
||||
case "moov":
|
||||
_init.Write(box);
|
||||
_ring.SetInit(_init.ToArray()); // init is complete once moov lands
|
||||
break;
|
||||
case "moof":
|
||||
FlushFragment();
|
||||
_fragment.Write(box);
|
||||
_inFragment = true;
|
||||
break;
|
||||
case "mdat":
|
||||
if (!_inFragment) return; // mdat without a moof is not replayable
|
||||
_fragment.Write(box);
|
||||
FlushFragment();
|
||||
break;
|
||||
default:
|
||||
if (_inFragment) _fragment.Write(box);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushFragment()
|
||||
{
|
||||
if (_inFragment && _fragment.Length > 0)
|
||||
_ring.Append(_fragment.ToArray());
|
||||
_fragment.SetLength(0);
|
||||
_inFragment = false;
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,10 @@ public sealed class FramePump : IDisposable
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
|
||||
/// <summary>The encoder this session is running, or null before the first start. The view model
|
||||
/// files each broadcast's rate history under it.</summary>
|
||||
public string? EncoderName => _encoder?.VideoEncoderName;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pumpTask;
|
||||
private bool _started;
|
||||
@@ -129,6 +133,14 @@ public sealed class FramePump : IDisposable
|
||||
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
|
||||
public event EventHandler<string>? Failed;
|
||||
|
||||
/// <summary>TASK 32·1: the ENCODER died (the push dropped) — recoverable, unlike a compositor fault.
|
||||
/// Raised before the pump stops, so the session can relaunch the push instead of ending.</summary>
|
||||
public event EventHandler<string>? EncoderFailed;
|
||||
|
||||
/// <summary>TASK 33: the same 5-second window the stats line reports, structured — so a policy can
|
||||
/// tell machine starvation from encoder backpressure without parsing prose.</summary>
|
||||
public event EventHandler<FramePumpStats>? StatsReported;
|
||||
|
||||
public FramePump(
|
||||
Func<Scene?> sceneProvider,
|
||||
Func<SceneElement, VideoFrame?> frameResolver,
|
||||
@@ -240,6 +252,28 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>TASK 33: relaunch the pump and encoder at a new tier, over the same URL and named audio pipe.
|
||||
///
|
||||
/// <para>Full stop + start rather than an encoder swap, deliberately: <see cref="StopAsync"/> awaits
|
||||
/// the loop and flushes the encoder, so no in-flight submit can land on a disposed process. A bare
|
||||
/// swap races exactly that and would fault the loop — which ends the broadcast, the one thing a
|
||||
/// quality step-down must never do. The audio pipe must be closed before the pump stops and freshly
|
||||
/// opened before it starts again; otherwise ffmpeg waits on the still-open audio input, then the new
|
||||
/// process tries to attach to a broken server. The cost is a short freeze, already accepted by the ladder.</para>
|
||||
/// </summary>
|
||||
public async Task<bool> ChangeEncoderAsync(Action stopAudio, Action startAudio,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stopAudio);
|
||||
ArgumentNullException.ThrowIfNull(startAudio);
|
||||
stopAudio();
|
||||
await StopAsync(cancellationToken);
|
||||
if (cancellationToken.IsCancellationRequested) return false;
|
||||
startAudio();
|
||||
await StartAsync(cancellationToken);
|
||||
return IsRunning;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
IFfmpegEncoder? encoder;
|
||||
@@ -422,6 +456,21 @@ public sealed class FramePump : IDisposable
|
||||
_log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}");
|
||||
_timings.Reset();
|
||||
|
||||
// Structured twin of the line above, raised before the counters reset. `dropped` is the
|
||||
// ENCODER's count (ffmpeg blocked); a renderer that duplicates frames is already visible
|
||||
// as render time + stalls, and FramesRendered is ambiguous by design (paste cache).
|
||||
StatsReported?.Invoke(this, new FramePumpStats(
|
||||
WindowSeconds: 5,
|
||||
FramesSubmitted: statFrames,
|
||||
FramesRendered: statRenders,
|
||||
AvgRenderMs: statRenders > 0 ? renderTicks / freq * 1000 / statRenders : 0,
|
||||
AvgSubmitMs: statFrames > 0 ? submitTicks / freq * 1000 / statFrames : 0,
|
||||
AvgWaitMs: statRenders > 0 ? waitTicks / freq * 1000 / statRenders : 0,
|
||||
WorstRenderMs: worstRender / freq * 1000,
|
||||
WorstSubmitMs: worstSubmit / freq * 1000,
|
||||
Dropped: dropped,
|
||||
Stalls: stalls));
|
||||
|
||||
worstRender = 0;
|
||||
worstSubmit = 0;
|
||||
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
|
||||
@@ -969,7 +1018,9 @@ public sealed class FramePump : IDisposable
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||
Failed?.Invoke(this, message);
|
||||
// A dead push is not a dead session: the broadcast may still be live, so this is the
|
||||
// recoverable signal (TASK 32·1). Fatal pump faults still go through Failed.
|
||||
EncoderFailed?.Invoke(this, message);
|
||||
_ = StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>One 5-second window of pump and encoder evidence, structured.
|
||||
///
|
||||
/// <para>The stats line in the log is for humans; this is for the policy that has to decide whether
|
||||
/// the machine or the connection is the problem. Note which counter is which:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Stalls"/> and <see cref="AvgRenderMs"/> are the MACHINE's evidence — the pump
|
||||
/// could not produce a frame in time.</item>
|
||||
/// <item><see cref="Dropped"/> is the ENCODER's — frames submitted but not accepted downstream. It
|
||||
/// rises when ffmpeg is blocked, which under CBR means the socket, not the renderer.</item>
|
||||
/// <item><see cref="FramesRendered"/> is deliberately NOT used for classification: a static scene
|
||||
/// legitimately reuses the paste cache and renders few frames while completely healthy.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public readonly record struct FramePumpStats(
|
||||
double WindowSeconds,
|
||||
int FramesSubmitted,
|
||||
int FramesRendered,
|
||||
double AvgRenderMs,
|
||||
double AvgSubmitMs,
|
||||
double AvgWaitMs,
|
||||
double WorstRenderMs,
|
||||
double WorstSubmitMs,
|
||||
int Dropped,
|
||||
int Stalls);
|
||||
@@ -17,6 +17,10 @@ public interface IEncoderProcess : IDisposable
|
||||
Stream StandardInput { get; }
|
||||
|
||||
TextReader StandardOutput { get; }
|
||||
|
||||
/// <summary>TASK 65: the same stdout as raw bytes — the replay-buffer mux writes fragmented MP4
|
||||
/// here while <c>-stats</c> keeps stderr for itself.</summary>
|
||||
Stream StandardOutputBytes { get; }
|
||||
TextReader StandardError { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
|
||||
@@ -19,6 +19,11 @@ public interface IFfmpegEncoder : IDisposable
|
||||
/// <summary>Fires when the subprocess dies unexpectedly (non-zero exit while live).</summary>
|
||||
event EventHandler<string>? ProcessFailed;
|
||||
|
||||
/// <summary>Which encoder the probe chose for this session ("h264_nvenc", "libopenh264", …). Null
|
||||
/// before the first start. The rate history is keyed by it: evidence about one encoder says nothing
|
||||
/// about another.</summary>
|
||||
string? VideoEncoderName { get; }
|
||||
|
||||
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
|
||||
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class SteamEntitlement : IEntitlement
|
||||
if (_paidAppId == 0)
|
||||
{
|
||||
_cached = false;
|
||||
_errorMessage = "The paid Steam app ID is not configured for this build. Contact the creator or support.";
|
||||
_errorMessage = "Steam licensing is not configured in this build.";
|
||||
return false;
|
||||
}
|
||||
if (_cached is { } known) return known;
|
||||
|
||||
@@ -16,6 +16,8 @@ public enum HotkeyId
|
||||
ToggleMicMute = 7,
|
||||
ToggleGameMute = 8,
|
||||
TraxPlayPause = 9,
|
||||
/// <summary>TASK 65: snapshot the replay ring and save it as a clip.</summary>
|
||||
SaveClip = 10,
|
||||
}
|
||||
|
||||
/// <summary>Registers/revokes the OS-level hotkeys. Seam so tests never touch
|
||||
@@ -65,6 +67,7 @@ public sealed class GlobalHotkeyManager
|
||||
[HotkeyId.ToggleMicMute] = new HotkeyChord(0, 0x76), // F7
|
||||
[HotkeyId.ToggleGameMute] = new HotkeyChord(0, 0x77), // F8
|
||||
[HotkeyId.TraxPlayPause] = new HotkeyChord(0, 0x78), // F9
|
||||
[HotkeyId.SaveClip] = new HotkeyChord(0, 0x79), // F10
|
||||
};
|
||||
|
||||
private readonly IHotkeyRegistrar _registrar;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ytLive.Services.Audio;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
@@ -17,6 +19,11 @@ public interface IMediaFrameSource : IDisposable
|
||||
/// <summary>Raised (on a worker thread) for each decoded frame.</summary>
|
||||
event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
/// <summary>Raised (on a worker thread) per chunk of the file's own soundtrack
|
||||
/// (48 kHz stereo float). The manager applies the layer's level/mute and hands it
|
||||
/// to the mixer; a source with no audio simply never raises it.</summary>
|
||||
event Action<AudioSample>? AudioAvailable;
|
||||
|
||||
/// <summary>Raised when the file finishes decoding (natural EOF) — not raised
|
||||
/// while <see cref="Looping"/> is true.</summary>
|
||||
event Action? Completed;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 32·5: can we actually reach YouTube's ingest right now? One TCP connect, nothing else.
|
||||
/// <para>This is prevention, not recovery: "I forgot the VPN" used to produce a broadcast that YouTube
|
||||
/// created and nobody ever saw — the app looked live while the push had nowhere to go. Checking the
|
||||
/// socket before the API call means the failure is a message, not a ghost broadcast.</para>
|
||||
/// </summary>
|
||||
public interface IIngestProbe
|
||||
{
|
||||
/// <summary>True when the ingest host accepted a TCP connection. False on any failure — the
|
||||
/// point is a fast, unambiguous answer, not a diagnosis.</summary>
|
||||
Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Default probe: parse the RTMP URL, connect to the host on its port (1935 for rtmp) with a
|
||||
/// short timeout. Failures are logged and reported as "not reachable".</summary>
|
||||
public sealed class TcpIngestProbe : IIngestProbe
|
||||
{
|
||||
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly TimeSpan _timeout;
|
||||
|
||||
public TcpIngestProbe(TimeSpan? timeout = null) => _timeout = timeout ?? DefaultTimeout;
|
||||
|
||||
public async Task<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryParse(rtmpUrl, out var host, out var port))
|
||||
{
|
||||
AppLog.Write($"Ingest probe: '{rtmpUrl}' is not a usable RTMP URL");
|
||||
return false;
|
||||
}
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, timeoutCts.Token).ConfigureAwait(false);
|
||||
return client.Connected;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AppLog.Write($"Ingest probe: {host}:{port} did not answer within {_timeout.TotalSeconds:0.#} s");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Ingest probe: {host}:{port} unreachable: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>rtmp://a.rtmp.youtube.com/live2/key → (a.rtmp.youtube.com, 1935).
|
||||
/// rtmps uses 443, which is what YouTube's secure ingest listens on.</summary>
|
||||
internal static bool TryParse(string? rtmpUrl, out string host, out int port)
|
||||
{
|
||||
host = string.Empty;
|
||||
port = 1935;
|
||||
if (string.IsNullOrWhiteSpace(rtmpUrl)) return false;
|
||||
if (!Uri.TryCreate(rtmpUrl, UriKind.Absolute, out var uri)) return false;
|
||||
if (string.IsNullOrWhiteSpace(uri.Host)) return false;
|
||||
|
||||
host = uri.Host;
|
||||
port = uri.IsDefaultPort
|
||||
? (uri.Scheme.Equals("rtmps", StringComparison.OrdinalIgnoreCase) ? 443 : 1935)
|
||||
: uri.Port;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,20 @@ public partial class LayoutStore : IDisposable
|
||||
return (string)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
/// <summary>True when the asset already holds exactly these bytes. Used to decide whether a
|
||||
/// bundled image needs re-seeding: comparing bytes cannot go stale the way a version constant
|
||||
/// can (the constant and the embedded resource were built at different moments twice).</summary>
|
||||
public bool AssetMatches(string assetId, byte[] data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return false;
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data));
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1 FROM Asset WHERE Id = $id AND Hash = $hash;";
|
||||
cmd.Parameters.AddWithValue("$id", assetId);
|
||||
cmd.Parameters.AddWithValue("$hash", hash);
|
||||
return cmd.ExecuteScalar() != null;
|
||||
}
|
||||
|
||||
// ─── License (TASK 10 — Polar billing) ───
|
||||
|
||||
/// <summary>The saved license key, or null if never entered.</summary>
|
||||
|
||||
@@ -16,7 +16,7 @@ public partial class LayoutStore : IDisposable
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder";
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SceneMutesMic, SceneMutesDesktop FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
@@ -29,6 +29,8 @@ public partial class LayoutStore : IDisposable
|
||||
HasBackground = reader.GetInt32(4) != 0,
|
||||
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||
ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null,
|
||||
SceneMutesMic = reader.FieldCount > 7 && !reader.IsDBNull(7) ? reader.GetInt32(7) != 0 : null,
|
||||
SceneMutesDesktop = reader.FieldCount > 8 && !reader.IsDBNull(8) ? reader.GetInt32(8) != 0 : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -113,7 +115,9 @@ public partial class LayoutStore : IDisposable
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
|
||||
ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness,
|
||||
ChromaKeySpill, ChromaScreen, IsReplayLayer
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -183,6 +187,19 @@ public partial class LayoutStore : IDisposable
|
||||
|| !Enum.IsDefined((TextLayerVerticalAlignment)reader.GetInt32(49))
|
||||
? TextLayerVerticalAlignment.Top
|
||||
: (TextLayerVerticalAlignment)reader.GetInt32(49),
|
||||
MediaMuted = reader.GetInt32(reader.GetOrdinal("MediaMuted")) != 0,
|
||||
ChromaKeyEnabled = reader.GetInt32(reader.GetOrdinal("ChromaKeyEnabled")) != 0,
|
||||
ChromaKeyColor = reader.IsDBNull(reader.GetOrdinal("ChromaKeyColor"))
|
||||
? "#00B140"
|
||||
: reader.GetString(reader.GetOrdinal("ChromaKeyColor")),
|
||||
ChromaKeySimilarity = reader.GetDouble(reader.GetOrdinal("ChromaKeySimilarity")),
|
||||
ChromaKeySoftness = reader.GetDouble(reader.GetOrdinal("ChromaKeySoftness")),
|
||||
ChromaKeySpill = reader.GetInt32(reader.GetOrdinal("ChromaKeySpill")) != 0,
|
||||
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)>();
|
||||
|
||||
@@ -16,6 +16,8 @@ public partial class LayoutStore : IDisposable
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
HasBackground INTEGER NOT NULL DEFAULT 1,
|
||||
SceneMutesMic INTEGER NULL,
|
||||
SceneMutesDesktop INTEGER NULL,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
@@ -46,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
|
||||
);
|
||||
@@ -139,10 +142,12 @@ public partial class LayoutStore : IDisposable
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSceneThumbnailColumn();
|
||||
MigrateSceneAudioColumns();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
EnsureLayoutSnapshotSchema();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 10;";
|
||||
@@ -154,6 +159,40 @@ public partial class LayoutStore : IDisposable
|
||||
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
|
||||
// CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
|
||||
// get the columns here instead.
|
||||
/// <summary>TASK 35: scene-linked audio. NULL means "leave the mix alone", so a scene is passive
|
||||
/// until the creator says otherwise — including every layout written before this column existed.</summary>
|
||||
private void MigrateSceneAudioColumns()
|
||||
{
|
||||
var sceneColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
sceneColumns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (!sceneColumns.Contains("SceneMutesMic"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesMic INTEGER NULL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!sceneColumns.Contains("SceneMutesDesktop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN SceneMutesDesktop INTEGER NULL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// BRB ships muted (creator spec), applied only where the creator has never expressed an opinion.
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "UPDATE Scene SET SceneMutesMic = 1 WHERE SceneMutesMic IS NULL AND LOWER(TRIM(Name)) = 'brb';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
private void MigrateSourceTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -283,6 +322,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();
|
||||
@@ -378,6 +425,50 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("MediaMuted"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaMuted INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 64: green/blue screen settings per layer (applied in the decode pipe).
|
||||
if (!columns.Contains("ChromaKeyEnabled"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyEnabled INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeyColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyColor TEXT NOT NULL DEFAULT '#00B140';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeySimilarity"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySimilarity REAL NOT NULL DEFAULT 0.12;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeySoftness"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySoftness REAL NOT NULL DEFAULT 0.08;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeySpill"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySpill INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaScreen"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaScreen INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 47: alert video config. The default clip asset id + the creator's
|
||||
// replacement path (a FILE reference, never a stored BLOB) + the pill,
|
||||
|
||||
@@ -54,8 +54,10 @@ public partial class LayoutStore : IDisposable
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail, $sort)
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId,
|
||||
SceneMutesMic, SceneMutesDesktop, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail,
|
||||
$mutesMic, $mutesDesktop, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -65,6 +67,8 @@ public partial class LayoutStore : IDisposable
|
||||
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
|
||||
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||
var thumbnailP = cmd.Parameters.Add("$thumbnail", SqliteType.Text);
|
||||
var mutesMicP = cmd.Parameters.Add("$mutesMic", SqliteType.Integer);
|
||||
var mutesDesktopP = cmd.Parameters.Add("$mutesDesktop", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
@@ -77,6 +81,12 @@ public partial class LayoutStore : IDisposable
|
||||
backgroundP.Value = scene.HasBackground ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value;
|
||||
mutesMicP.Value = scene.SceneMutesMic.HasValue
|
||||
? (object)(scene.SceneMutesMic.Value ? 1 : 0)
|
||||
: DBNull.Value;
|
||||
mutesDesktopP.Value = scene.SceneMutesDesktop.HasValue
|
||||
? (object)(scene.SceneMutesDesktop.Value ? 1 : 0)
|
||||
: DBNull.Value;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
@@ -94,7 +104,9 @@ public partial class LayoutStore : IDisposable
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment)
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
|
||||
ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity,
|
||||
ChromaKeySoftness, ChromaKeySpill, ChromaScreen, IsReplayLayer)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
@@ -104,7 +116,9 @@ public partial class LayoutStore : IDisposable
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
|
||||
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
|
||||
$textUnderline, $textAlignment, $textVerticalAlignment)
|
||||
$textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted,
|
||||
$chromaEnabled, $chromaColor, $chromaSimilarity, $chromaSoftness,
|
||||
$chromaSpill, $chromaScreen, $isReplayLayer)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -137,6 +151,14 @@ public partial class LayoutStore : IDisposable
|
||||
var mediaPathP = cmd.Parameters.Add("$mediaPath", SqliteType.Text);
|
||||
var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
|
||||
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
|
||||
var mediaMutedP = cmd.Parameters.Add("$mediaMuted", SqliteType.Integer);
|
||||
var chromaEnabledP = cmd.Parameters.Add("$chromaEnabled", SqliteType.Integer);
|
||||
var chromaColorP = cmd.Parameters.Add("$chromaColor", SqliteType.Text);
|
||||
var chromaSimilarityP = cmd.Parameters.Add("$chromaSimilarity", SqliteType.Real);
|
||||
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);
|
||||
@@ -198,6 +220,14 @@ public partial class LayoutStore : IDisposable
|
||||
mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
|
||||
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
|
||||
mediaVolumeP.Value = source.MediaVolume;
|
||||
mediaMutedP.Value = source.MediaMuted ? 1 : 0;
|
||||
chromaEnabledP.Value = source.ChromaKeyEnabled ? 1 : 0;
|
||||
chromaColorP.Value = source.ChromaKeyColor;
|
||||
chromaSimilarityP.Value = source.ChromaKeySimilarity;
|
||||
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;
|
||||
@@ -404,10 +434,16 @@ public partial class LayoutStore : IDisposable
|
||||
DELETE FROM Asset WHERE Id NOT IN (
|
||||
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT AssetId FROM DefaultSource WHERE AssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT ThumbnailAssetId FROM DefaultScene WHERE ThumbnailAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT AlertVideoAssetId FROM DefaultSource WHERE AlertVideoAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
|
||||
UNION
|
||||
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
|
||||
|
||||
@@ -1,11 +1,96 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Notification.Core;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public NotificationPosition LoadNotificationPosition()
|
||||
{
|
||||
var value = GetSetting("Notifications.Position");
|
||||
return Enum.TryParse(value, true, out NotificationPosition position) && IsSupportedNotificationPosition(position)
|
||||
? position
|
||||
: NotificationPosition.BottomRight;
|
||||
}
|
||||
|
||||
public void SaveNotificationPosition(NotificationPosition position)
|
||||
{
|
||||
if (!IsSupportedNotificationPosition(position)) position = NotificationPosition.BottomRight;
|
||||
UpsertSetting("Notifications.Position", position.ToString());
|
||||
}
|
||||
|
||||
/// <summary>May editing be used while a session runs? Off by default: the creator's ruling
|
||||
/// (2026-11-12) is that moving a layer or resizing a webcam mid-show is theirs to do. Turning it on
|
||||
/// restores the old gun-safety — go-live flips EDIT off and stop flips it back.</summary>
|
||||
public bool LoadLockEditWhileLive() => GetSetting("App.LockEditWhileLive") == "1";
|
||||
|
||||
public void SaveLockEditWhileLive(bool locked)
|
||||
=> UpsertSetting("App.LockEditWhileLive", locked ? "1" : "0");
|
||||
|
||||
public bool LoadStickyAlertsUntilDismissed()
|
||||
=> GetSetting("Notifications.StickyUntilDismissed") == "1";
|
||||
|
||||
public void SaveStickyAlertsUntilDismissed(bool enabled)
|
||||
=> UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0");
|
||||
|
||||
/// <summary>The creator's own video rate for one tier, in Mbps, or null to use the shipped default.
|
||||
/// Per tier because each has its own ceiling; the rate is a target the encoder is asked to hold,
|
||||
/// so a stored number means the same thing on every run (CBR).</summary>
|
||||
public double? LoadRatedMbps(string tierDisplay)
|
||||
=> double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
|
||||
|
||||
/// <summary>Finished sessions, oldest first — the evidence behind any rate suggestion. Capped so the
|
||||
/// row never grows without bound: 40 sessions is several weeks of streaming for anyone.</summary>
|
||||
public List<SessionRateResult> LoadRateHistory()
|
||||
{
|
||||
var raw = GetSetting("Video.RateHistory");
|
||||
if (string.IsNullOrWhiteSpace(raw)) return new List<SessionRateResult>();
|
||||
return raw.Split('\n')
|
||||
.Select(RateHistory.Parse)
|
||||
.Where(entry => entry is not null)
|
||||
.Select(entry => entry!.Value)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void AppendRateHistory(SessionRateResult entry)
|
||||
{
|
||||
var history = LoadRateHistory();
|
||||
history.Add(entry);
|
||||
if (history.Count > RateHistoryCap)
|
||||
history.RemoveRange(0, history.Count - RateHistoryCap);
|
||||
UpsertSetting("Video.RateHistory", string.Join('\n', history.Select(RateHistory.Format)));
|
||||
}
|
||||
|
||||
private const int RateHistoryCap = 40;
|
||||
|
||||
/// <summary>The proposed rate the creator already refused for this tier, or zero for none. Stored as the
|
||||
/// number rather than a flag, so a materially different suggestion may still be asked.</summary>
|
||||
public double LoadDismissedRateProposal(string tierDisplay)
|
||||
=> double.TryParse(GetSetting($"Video.RateProposalDismissed.{tierDisplay}"), out var mbps) ? mbps : 0;
|
||||
|
||||
public void SaveDismissedRateProposal(string tierDisplay, double mbps)
|
||||
=> UpsertSetting($"Video.RateProposalDismissed.{tierDisplay}", mbps.ToString("0.#"));
|
||||
|
||||
public void SaveRatedMbps(string tierDisplay, double? mbps)
|
||||
{
|
||||
if (mbps is not > 0)
|
||||
{
|
||||
using var delete = _connection.CreateCommand();
|
||||
delete.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
delete.Parameters.AddWithValue("$key", $"Video.Rate.{tierDisplay}");
|
||||
delete.ExecuteNonQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
UpsertSetting($"Video.Rate.{tierDisplay}", mbps.Value.ToString("0.#"));
|
||||
}
|
||||
|
||||
private static bool IsSupportedNotificationPosition(NotificationPosition position)
|
||||
=> position is NotificationPosition.TopLeft or NotificationPosition.TopRight
|
||||
or NotificationPosition.CenterLeft or NotificationPosition.CenterRight
|
||||
or NotificationPosition.BottomLeft or NotificationPosition.BottomRight;
|
||||
|
||||
/// <summary>The last picked mic source name, or null if never chosen. Restored
|
||||
/// at startup so the sound bar reconnects to the same, already-vetted device.</summary>
|
||||
public string? LoadMicSourceName()
|
||||
@@ -40,6 +125,26 @@ public partial class LayoutStore : IDisposable
|
||||
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shipped default for the microphone-only delay: **zero**. A sync offset is a correction for one
|
||||
/// camera on one desk, never a product default — shipping a number would shift every creator's audio
|
||||
/// to fix one machine's camera, and would hide whether the fault is theirs or ours.
|
||||
/// </summary>
|
||||
public const int DefaultMicSyncOffsetMs = 0;
|
||||
|
||||
/// <summary>Microphone-only sync delay (0..500 ms), so the voice can wait for the camera's picture
|
||||
/// without moving desktop or alert audio. Absent means the shipped default.</summary>
|
||||
public int LoadMicSyncOffsetMs()
|
||||
{
|
||||
var value = GetSetting("Audio.MicSyncOffsetMs");
|
||||
return int.TryParse(value, out var ms)
|
||||
? Math.Clamp(ms, 0, 500)
|
||||
: DefaultMicSyncOffsetMs;
|
||||
}
|
||||
|
||||
public void SaveMicSyncOffsetMs(int ms)
|
||||
=> UpsertSetting("Audio.MicSyncOffsetMs", Math.Clamp(ms, 0, 500).ToString());
|
||||
|
||||
public void SaveAudioSyncOffsetMs(int ms)
|
||||
{
|
||||
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
|
||||
@@ -48,6 +153,42 @@ public partial class LayoutStore : IDisposable
|
||||
/// <summary>Zoom box follow mode (creator ruling 2026-11-01): false = ANCHOR (the capture
|
||||
/// stays on the point that was clicked, the default), true = FOLLOW (the capture re-anchors
|
||||
/// to the box centre while it is dragged).</summary>
|
||||
/// <summary>TASK 64: key a detected green/blue screen without asking. Off by default — the
|
||||
/// app offers, the creator accepts; this switch is for the creator who is tired of clicking.</summary>
|
||||
/// <summary>Creator closed the Steam licence indicator while ownership was verified. Honoured only
|
||||
/// while Steam keeps confirming ownership — losing it brings the indicator back (2026-11-05).</summary>
|
||||
public bool LoadSteamLicenceIndicatorDismissed() => GetSetting("Steam.LicenceIndicatorDismissed") == "1";
|
||||
|
||||
public void SaveSteamLicenceIndicatorDismissed(bool dismissed)
|
||||
=> UpsertSetting("Steam.LicenceIndicatorDismissed", dismissed ? "1" : "0");
|
||||
|
||||
/// <summary>TASK 3·20: show money in the app's own analytics surfaces. OFF by default — most
|
||||
/// creators never monetize, and a $0.00 report is a spit in the face (creator ruling 2026-11-05).</summary>
|
||||
public bool LoadShowMonetaryMetrics() => GetSetting("Metrics.ShowMonetary") == "1";
|
||||
|
||||
public void SaveShowMonetaryMetrics(bool show) => UpsertSetting("Metrics.ShowMonetary", show ? "1" : "0");
|
||||
|
||||
/// <summary>TASK 65: replay clips. Off by default — the ring costs memory and the feature is opt-in.</summary>
|
||||
public bool LoadClipsEnabled() => GetSetting("Clips.Enabled") == "1";
|
||||
|
||||
public void SaveClipsEnabled(bool enabled) => UpsertSetting("Clips.Enabled", enabled ? "1" : "0");
|
||||
|
||||
/// <summary>Clip length in seconds: 10 or 15 (default 10).</summary>
|
||||
public int LoadClipSeconds()
|
||||
=> int.TryParse(GetSetting("Clips.Seconds"), out var s) && (s == 10 || s == 15) ? s : 10;
|
||||
|
||||
public void SaveClipSeconds(int seconds) => UpsertSetting("Clips.Seconds", seconds.ToString());
|
||||
|
||||
/// <summary>How many clips survive on disk: 3, 4 or 5 (default 3).</summary>
|
||||
public int LoadClipKeep()
|
||||
=> int.TryParse(GetSetting("Clips.Keep"), out var k) && k >= 3 && k <= 5 ? k : 3;
|
||||
|
||||
public void SaveClipKeep(int keep) => UpsertSetting("Clips.Keep", keep.ToString());
|
||||
|
||||
public bool LoadAutoChromaScreen() => GetSetting("Video.AutoChromaScreen") == "1";
|
||||
|
||||
public void SaveAutoChromaScreen(bool enabled) => UpsertSetting("Video.AutoChromaScreen", enabled ? "1" : "0");
|
||||
|
||||
public bool LoadZoomFollowBox() => GetSetting("Zoom.FollowBox") == "1";
|
||||
|
||||
public void SaveZoomFollowBox(bool follow) => UpsertSetting("Zoom.FollowBox", follow ? "1" : "0");
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
private static readonly string[] SnapshotTables =
|
||||
{
|
||||
"Scene", "Source", "Webcam", "WebcamSceneConfig", "Socials", "SocialEntry", "Music",
|
||||
};
|
||||
|
||||
private const string DefaultsInitializedKey = "DefaultsInitialized";
|
||||
private const string CurrentBuildIdKey = "CurrentBuildId";
|
||||
|
||||
private void EnsureLayoutSnapshotSchema()
|
||||
{
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS LayoutSnapshotState (
|
||||
Key TEXT PRIMARY KEY,
|
||||
Value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS DefaultLayoutSetting (
|
||||
Key TEXT PRIMARY KEY,
|
||||
Value TEXT NOT NULL
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var table in SnapshotTables)
|
||||
{
|
||||
var snapshotTable = "Default" + table;
|
||||
using (var create = _connection.CreateCommand())
|
||||
{
|
||||
create.CommandText = $"CREATE TABLE IF NOT EXISTS {snapshotTable} AS SELECT * FROM {table} WHERE 0;";
|
||||
create.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var snapshotColumns = ReadColumnNames(snapshotTable);
|
||||
foreach (var (name, type, defaultValue) in ReadColumnDefinitions(table))
|
||||
{
|
||||
if (snapshotColumns.Contains(name, StringComparer.OrdinalIgnoreCase)) continue;
|
||||
var definition = string.IsNullOrWhiteSpace(type) ? "BLOB" : type;
|
||||
if (defaultValue != null) definition += " DEFAULT " + defaultValue;
|
||||
using var add = _connection.CreateCommand();
|
||||
add.CommandText = $"ALTER TABLE {snapshotTable} ADD COLUMN {QuoteIdentifier(name)} {definition};";
|
||||
add.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Captures the installed layout once as the immutable branded default. Existing databases
|
||||
/// are snapshotted before MainViewModel loads them; a fresh database calls
|
||||
/// <see cref="CaptureDefaultsIfMissing"/> after its factory scenes have been seeded.</summary>
|
||||
public void PrepareLayoutSnapshots(string buildId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(buildId);
|
||||
var defaultsInitialized = GetSnapshotState(DefaultsInitializedKey) == "1";
|
||||
if (!defaultsInitialized && HasStoredScenes())
|
||||
{
|
||||
CaptureDefaults();
|
||||
defaultsInitialized = true;
|
||||
}
|
||||
|
||||
var currentBuildId = GetSnapshotState(CurrentBuildIdKey);
|
||||
if (defaultsInitialized && !string.Equals(currentBuildId, buildId, StringComparison.Ordinal))
|
||||
RestoreDefaults(buildId);
|
||||
else if (!defaultsInitialized && string.IsNullOrWhiteSpace(currentBuildId))
|
||||
SetSnapshotState(CurrentBuildIdKey, buildId);
|
||||
}
|
||||
|
||||
/// <summary>Seeds the default snapshot after the first-run factory layout has been persisted.</summary>
|
||||
public void CaptureDefaultsIfMissing()
|
||||
{
|
||||
if (GetSnapshotState(DefaultsInitializedKey) != "1")
|
||||
CaptureDefaults();
|
||||
}
|
||||
|
||||
/// <summary>Restores one existing scene by its current ID. Unknown scenes have no branded default
|
||||
/// and are left untouched.</summary>
|
||||
public bool RestoreDefaultScene(string sceneId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sceneId);
|
||||
|
||||
string? defaultSceneId;
|
||||
using (var find = _connection.CreateCommand())
|
||||
{
|
||||
find.CommandText = """
|
||||
SELECT Id FROM DefaultScene
|
||||
WHERE Name = (SELECT Name FROM Scene WHERE Id = $sceneId) COLLATE NOCASE
|
||||
LIMIT 1;
|
||||
""";
|
||||
find.Parameters.AddWithValue("$sceneId", sceneId);
|
||||
defaultSceneId = find.ExecuteScalar() as string;
|
||||
}
|
||||
if (defaultSceneId == null) return false;
|
||||
|
||||
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
|
||||
var webcamId = GetCurrentWebcamId();
|
||||
using var tx = _connection.BeginTransaction();
|
||||
|
||||
using (var clearBackgroundSettings = _connection.CreateCommand())
|
||||
{
|
||||
clearBackgroundSettings.Transaction = tx;
|
||||
clearBackgroundSettings.CommandText = """
|
||||
DELETE FROM Settings
|
||||
WHERE Key IN (
|
||||
SELECT 'BackgroundPath_' || Id FROM Source WHERE SceneId = $sceneId
|
||||
UNION SELECT 'BackgroundUseDefault_' || Id FROM Source WHERE SceneId = $sceneId
|
||||
UNION SELECT 'BackgroundPath_' || $sceneId
|
||||
UNION SELECT 'BackgroundUseDefault_' || $sceneId
|
||||
);
|
||||
""";
|
||||
clearBackgroundSettings.Parameters.AddWithValue("$sceneId", sceneId);
|
||||
clearBackgroundSettings.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var table in new[] { "WebcamSceneConfig", "Source" })
|
||||
{
|
||||
using var delete = _connection.CreateCommand();
|
||||
delete.Transaction = tx;
|
||||
delete.CommandText = $"DELETE FROM {table} WHERE SceneId = $sceneId;";
|
||||
delete.Parameters.AddWithValue("$sceneId", sceneId);
|
||||
delete.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var sceneColumns = columnsByTable["Scene"]
|
||||
.Where(column => column != "Id" && column != "SortOrder").ToArray();
|
||||
var assignments = string.Join(", ", sceneColumns.Select(column =>
|
||||
$"{QuoteIdentifier(column)} = (SELECT {QuoteIdentifier(column)} FROM DefaultScene WHERE Id = $defaultSceneId)"));
|
||||
using (var updateScene = _connection.CreateCommand())
|
||||
{
|
||||
updateScene.Transaction = tx;
|
||||
updateScene.CommandText = $"UPDATE Scene SET {assignments} WHERE Id = $sceneId;";
|
||||
updateScene.Parameters.AddWithValue("$sceneId", sceneId);
|
||||
updateScene.Parameters.AddWithValue("$defaultSceneId", defaultSceneId);
|
||||
updateScene.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
CopyDefaultSceneRows("Source", sceneId, defaultSceneId, null, tx, columnsByTable["Source"]);
|
||||
if (webcamId != null)
|
||||
CopyDefaultSceneRows("WebcamSceneConfig", sceneId, defaultSceneId, webcamId, tx,
|
||||
columnsByTable["WebcamSceneConfig"]);
|
||||
|
||||
using (var copyBackgroundSettings = _connection.CreateCommand())
|
||||
{
|
||||
copyBackgroundSettings.Transaction = tx;
|
||||
copyBackgroundSettings.CommandText = """
|
||||
INSERT INTO Settings (Key, Value)
|
||||
SELECT Key, Value FROM DefaultLayoutSetting
|
||||
WHERE Key IN (
|
||||
SELECT 'BackgroundPath_' || Id FROM DefaultSource WHERE SceneId = $defaultSceneId
|
||||
UNION SELECT 'BackgroundUseDefault_' || Id FROM DefaultSource WHERE SceneId = $defaultSceneId
|
||||
UNION SELECT 'BackgroundPath_' || $defaultSceneId
|
||||
UNION SELECT 'BackgroundUseDefault_' || $defaultSceneId
|
||||
);
|
||||
""";
|
||||
copyBackgroundSettings.Parameters.AddWithValue("$defaultSceneId", defaultSceneId);
|
||||
copyBackgroundSettings.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Restores the full factory snapshot without changing the active build stamp.</summary>
|
||||
public bool RestoreAllDefaults()
|
||||
{
|
||||
if (GetSnapshotState(DefaultsInitializedKey) != "1") return false;
|
||||
var buildId = GetSnapshotState(CurrentBuildIdKey);
|
||||
if (string.IsNullOrWhiteSpace(buildId)) return false;
|
||||
RestoreDefaults(buildId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string? GetCurrentWebcamId()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Id FROM Webcam LIMIT 1;";
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
private void CopyDefaultSceneRows(string table, string sceneId, string defaultSceneId,
|
||||
string? webcamId, SqliteTransaction tx, IReadOnlyList<string> columns)
|
||||
{
|
||||
var columnList = string.Join(", ", columns.Select(QuoteIdentifier));
|
||||
var values = string.Join(", ", columns.Select(column => column switch
|
||||
{
|
||||
"SceneId" => "$sceneId",
|
||||
"WebcamId" when webcamId != null => "$webcamId",
|
||||
_ => QuoteIdentifier(column),
|
||||
}));
|
||||
|
||||
using var copy = _connection.CreateCommand();
|
||||
copy.Transaction = tx;
|
||||
copy.CommandText = $"INSERT INTO {table} ({columnList}) SELECT {values} FROM Default{table} WHERE SceneId = $defaultSceneId;";
|
||||
copy.Parameters.AddWithValue("$sceneId", sceneId);
|
||||
copy.Parameters.AddWithValue("$defaultSceneId", defaultSceneId);
|
||||
if (webcamId != null) copy.Parameters.AddWithValue("$webcamId", webcamId);
|
||||
copy.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void CaptureDefaults()
|
||||
{
|
||||
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
|
||||
using var tx = _connection.BeginTransaction();
|
||||
foreach (var table in SnapshotTables.Reverse())
|
||||
{
|
||||
using var delete = _connection.CreateCommand();
|
||||
delete.Transaction = tx;
|
||||
delete.CommandText = $"DELETE FROM Default{table};";
|
||||
delete.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var table in SnapshotTables)
|
||||
{
|
||||
var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier));
|
||||
using var copy = _connection.CreateCommand();
|
||||
copy.Transaction = tx;
|
||||
copy.CommandText = $"INSERT INTO Default{table} ({columns}) SELECT {columns} FROM {table};";
|
||||
copy.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var clearSettings = _connection.CreateCommand())
|
||||
{
|
||||
clearSettings.Transaction = tx;
|
||||
clearSettings.CommandText = "DELETE FROM DefaultLayoutSetting;";
|
||||
clearSettings.ExecuteNonQuery();
|
||||
}
|
||||
using (var copySettings = _connection.CreateCommand())
|
||||
{
|
||||
copySettings.Transaction = tx;
|
||||
copySettings.CommandText = """
|
||||
INSERT INTO DefaultLayoutSetting (Key, Value)
|
||||
SELECT Key, Value FROM Settings
|
||||
WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';
|
||||
""";
|
||||
copySettings.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var state = _connection.CreateCommand())
|
||||
{
|
||||
state.Transaction = tx;
|
||||
state.CommandText = """
|
||||
INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, '1')
|
||||
ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
|
||||
""";
|
||||
state.Parameters.AddWithValue("$key", DefaultsInitializedKey);
|
||||
state.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
private void RestoreDefaults(string buildId)
|
||||
{
|
||||
var columnsByTable = SnapshotTables.ToDictionary(table => table, ReadColumnNames);
|
||||
var currentWebcamId = GetCurrentWebcamId();
|
||||
using var tx = _connection.BeginTransaction();
|
||||
|
||||
foreach (var table in SnapshotTables.Reverse())
|
||||
{
|
||||
if (table == "Webcam") continue;
|
||||
using var delete = _connection.CreateCommand();
|
||||
delete.Transaction = tx;
|
||||
delete.CommandText = $"DELETE FROM {table};";
|
||||
delete.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var table in SnapshotTables)
|
||||
{
|
||||
if (table == "Webcam" || (table == "WebcamSceneConfig" && currentWebcamId == null)) continue;
|
||||
var columns = string.Join(", ", columnsByTable[table].Select(QuoteIdentifier));
|
||||
var values = string.Join(", ", columnsByTable[table].Select(column =>
|
||||
column == "WebcamId" && table == "WebcamSceneConfig"
|
||||
? "$webcamId"
|
||||
: QuoteIdentifier(column)));
|
||||
using var copy = _connection.CreateCommand();
|
||||
copy.Transaction = tx;
|
||||
copy.CommandText = $"INSERT INTO {table} ({columns}) SELECT {values} FROM Default{table};";
|
||||
if (table == "WebcamSceneConfig") copy.Parameters.AddWithValue("$webcamId", currentWebcamId!);
|
||||
copy.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var clearSettings = _connection.CreateCommand())
|
||||
{
|
||||
clearSettings.Transaction = tx;
|
||||
clearSettings.CommandText = "DELETE FROM Settings WHERE Key LIKE 'BackgroundPath_%' OR Key LIKE 'BackgroundUseDefault_%';";
|
||||
clearSettings.ExecuteNonQuery();
|
||||
}
|
||||
using (var copySettings = _connection.CreateCommand())
|
||||
{
|
||||
copySettings.Transaction = tx;
|
||||
copySettings.CommandText = "INSERT INTO Settings (Key, Value) SELECT Key, Value FROM DefaultLayoutSetting;";
|
||||
copySettings.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
SetSnapshotState(CurrentBuildIdKey, buildId, tx);
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
private bool HasStoredScenes()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT EXISTS(SELECT 1 FROM Scene LIMIT 1);";
|
||||
return Convert.ToInt32(cmd.ExecuteScalar()) != 0;
|
||||
}
|
||||
|
||||
private string? GetSnapshotState(string key)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM LayoutSnapshotState WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
private void SetSnapshotState(string key, string value, SqliteTransaction? transaction = null)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.Transaction = transaction;
|
||||
cmd.CommandText = """
|
||||
INSERT INTO LayoutSnapshotState (Key, Value) VALUES ($key, $value)
|
||||
ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
cmd.Parameters.AddWithValue("$value", value);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private List<string> ReadColumnNames(string table)
|
||||
=> ReadColumnDefinitions(table).Select(column => column.Name).ToList();
|
||||
|
||||
private List<(string Name, string Type, string? DefaultValue)> ReadColumnDefinitions(string table)
|
||||
{
|
||||
var columns = new List<(string, string, string?)>();
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA table_info({table});";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add((reader.GetString(1), reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
reader.IsDBNull(4) ? null : reader.GetString(4)));
|
||||
return columns;
|
||||
}
|
||||
|
||||
private static string QuoteIdentifier(string identifier)
|
||||
=> "\"" + identifier.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
+27
-116
@@ -1,38 +1,35 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: decodes a local media file (any format ffmpeg handles) into
|
||||
/// <see cref="VideoFrame"/>s for a MediaSource. Implements
|
||||
/// <see cref="IMediaFrameSource"/>: raises <see cref="FrameAvailable"/> per
|
||||
/// frame and <see cref="IMediaFrameSource.Completed"/> on natural EOF (unless
|
||||
/// <see cref="IMediaFrameSource.Looping"/>). Spawns ffmpeg with
|
||||
/// <c>-f rawvideo -pix_fmt bgra</c> and drains the raw BGRA pipe via
|
||||
/// <see cref="RawVideoFrameReader"/>. The decoder process and emitters are seams
|
||||
/// so tests run without a real codec or audio device.
|
||||
/// <see cref="VideoFrame"/>s for a MediaSource. This is the <see cref="IMediaFrameSource"/>
|
||||
/// shape over the shared <see cref="VideoClipPipe"/> — the same pipe an alert clip uses
|
||||
/// (TASK 47), so the two features cannot drift apart again in how they decode.
|
||||
/// <para>Video AND the file's own soundtrack are decoded. The manager scales the audio by
|
||||
/// the layer's level/mute and hands it to the mixer's desktop group.</para>
|
||||
/// <para><b>Keying (TASK 64) is a decode option</b>: pass <see cref="ChromaKeyOptions"/> and the
|
||||
/// green/blue screen is removed inside ffmpeg's pipe, so the preview bitmap and the composited
|
||||
/// frame both carry the keyed alpha. Two looks = two sources; identical looks share one.</para>
|
||||
/// </summary>
|
||||
public sealed class MediaVideoSource : IMediaFrameSource
|
||||
{
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action<AudioSample>? AudioAvailable;
|
||||
public event Action? Completed;
|
||||
|
||||
public string Key => _path;
|
||||
|
||||
public bool Looping { get; set; }
|
||||
public bool Looping
|
||||
{
|
||||
get => _clip.Looping;
|
||||
set => _clip.Looping = value;
|
||||
}
|
||||
|
||||
private readonly string _path;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _processFactory;
|
||||
private readonly IFrameRateProbe? _probe;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private IDisposable? _run;
|
||||
private IDecodeProcess? _active;
|
||||
private readonly VideoClipPipe _clip;
|
||||
|
||||
public MediaVideoSource(
|
||||
string path,
|
||||
@@ -41,107 +38,21 @@ public sealed class MediaVideoSource : IMediaFrameSource
|
||||
IFfmpegLocator locator,
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null,
|
||||
ChromaKeyOptions? chromaKey = null)
|
||||
{
|
||||
_path = path;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
_probe = frameRateProbe;
|
||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||
_clip = new VideoClipPipe(
|
||||
path, width, height, locator, processFactory, frameRateProbe, delay,
|
||||
audio: true, chromaKey: chromaKey);
|
||||
_clip.FrameAvailable += frame => FrameAvailable?.Invoke(frame);
|
||||
_clip.AudioAvailable += sample => AudioAvailable?.Invoke(sample);
|
||||
_clip.Completed += () => Completed?.Invoke();
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_run != null) return Task.CompletedTask;
|
||||
_run = _cts.Token.Register(StopInternal);
|
||||
_ = Task.Run(RunAsync, CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task StartAsync(CancellationToken cancellationToken = default) => _clip.StartAsync(cancellationToken);
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_run = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task StopAsync() => _clip.StopAsync();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_active?.Dispose();
|
||||
}
|
||||
|
||||
private void StopInternal() => KillProcess();
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
double? frameRate = null;
|
||||
if (_probe != null)
|
||||
frameRate = await _probe.ProbeAsync(_path, _cts.Token).ConfigureAwait(false);
|
||||
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(_cts.Token).ConfigureAwait(false);
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
do
|
||||
{
|
||||
using (var process = _processFactory())
|
||||
{
|
||||
_active = process;
|
||||
process.Start(startInfo);
|
||||
|
||||
var reader = new RawVideoFrameReader(_width, _height);
|
||||
var buffer = new byte[65536];
|
||||
var stream = process.StandardOutput;
|
||||
while (!_cts.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
foreach (var frame in reader.Feed(buffer.AsSpan(0, read).ToArray()))
|
||||
{
|
||||
FrameAvailable?.Invoke(frame);
|
||||
if (frameDuration > TimeSpan.Zero)
|
||||
await _delay(frameDuration, _cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
_active = null;
|
||||
}
|
||||
while (Looping && !_cts.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Fail($"Media decode failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cts.IsCancellationRequested)
|
||||
Completed?.Invoke();
|
||||
}
|
||||
|
||||
private void Fail(string message) => Console.Error.WriteLine(message);
|
||||
|
||||
private void KillProcess()
|
||||
{
|
||||
try { _active?.Kill(); } catch { /* already exited */ }
|
||||
}
|
||||
public void Dispose() => _clip.Dispose();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services.Audio;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
@@ -23,8 +24,13 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
public string Key { get; }
|
||||
public IMediaFrameSource Source { get; }
|
||||
public Action<VideoFrame>? FrameHandler;
|
||||
public Action<AudioSample>? AudioHandler;
|
||||
public Action? CompletedHandler;
|
||||
public bool Finished;
|
||||
public int RefCount;
|
||||
public bool Started;
|
||||
public bool AudioMuted;
|
||||
public double AudioVolume = 1.0;
|
||||
public WriteableBitmap? PreviewBitmap;
|
||||
public VideoFrame? LatestFrame;
|
||||
public bool FramePending;
|
||||
@@ -37,8 +43,10 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
|
||||
// (file path, the layer's look) → decoder. The SESSION key is separate: path + look.
|
||||
private readonly Func<string, ChromaKeyOptions?, IMediaFrameSource?> _sourceFactory;
|
||||
private readonly Dispatcher? _uiDispatcher;
|
||||
private readonly Func<string, bool>? _loopProvider;
|
||||
private readonly Dictionary<string, MediaSession> _sessions = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
@@ -48,14 +56,28 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
/// <summary>Raised when a media decoder cannot be created (bad path).</summary>
|
||||
public event Action<string, string>? MediaFailed;
|
||||
|
||||
public MediaVideoSourceManager(Func<string, IMediaFrameSource?> sourceFactory, Dispatcher? uiDispatcher = null)
|
||||
/// <summary>Raised (worker thread) per audio chunk of a file's own soundtrack, already
|
||||
/// scaled by the session's level and dropped when muted.</summary>
|
||||
public event Action<string, AudioSample>? AudioAvailable;
|
||||
|
||||
public MediaVideoSourceManager(
|
||||
Func<string, ChromaKeyOptions?, IMediaFrameSource?> sourceFactory,
|
||||
Dispatcher? uiDispatcher = null,
|
||||
Func<string, bool>? loopProvider = null)
|
||||
{
|
||||
_sourceFactory = sourceFactory;
|
||||
_uiDispatcher = uiDispatcher;
|
||||
_loopProvider = loopProvider;
|
||||
}
|
||||
|
||||
/// <summary>Increments the refcount for a file, starting the decoder the first time.</summary>
|
||||
public async Task<bool> AcquireAsync(string key)
|
||||
/// <summary>Increments the refcount for a file, starting the decoder the first time.
|
||||
/// <paramref name="keyOptions"/> is the layer's look (TASK 64): the caller keys sessions by
|
||||
/// path + look, so two looks on one file are two decoders and identical looks share one.</summary>
|
||||
public async Task<bool> AcquireAsync(string key, ChromaKeyOptions? keyOptions = null)
|
||||
=> await AcquireAsync(key, key, keyOptions).ConfigureAwait(false);
|
||||
|
||||
/// <summary>Acquires by session key, decoding <paramref name="path"/> with the given look.</summary>
|
||||
public async Task<bool> AcquireAsync(string key, string path, ChromaKeyOptions? keyOptions = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return false;
|
||||
|
||||
@@ -74,7 +96,7 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
IMediaFrameSource? source;
|
||||
try
|
||||
{
|
||||
source = _sourceFactory(key);
|
||||
source = _sourceFactory(path, keyOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -90,6 +112,14 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
session = new MediaSession(key, source);
|
||||
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
||||
session.Source.FrameAvailable += session.FrameHandler;
|
||||
session.AudioHandler = sample => OnAudioAvailable(session, sample);
|
||||
session.Source.AudioAvailable += session.AudioHandler;
|
||||
session.CompletedHandler = () => OnSessionCompleted(session);
|
||||
session.Source.Completed += session.CompletedHandler;
|
||||
// Loop intent lives on the Source (per scene), so the caller supplies the
|
||||
// per-path answer ("any live reference loops"). Applied before Start so a
|
||||
// zero-length or instantly-EOF file cannot slip past a later assignment.
|
||||
session.Source.Looping = _loopProvider?.Invoke(key) ?? false;
|
||||
_sessions[key] = session;
|
||||
shouldStart = true;
|
||||
}
|
||||
@@ -108,6 +138,8 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
lock (_gate)
|
||||
_sessions.Remove(key);
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler;
|
||||
if (session.CompletedHandler != null) session.Source.Completed -= session.CompletedHandler;
|
||||
AppLog.Write($"MediaSource: failed to start decoder '{key}': {ex.Message}");
|
||||
MediaFailed?.Invoke(key, ex.Message);
|
||||
await SafeStopAsync(session.Source);
|
||||
@@ -128,6 +160,8 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
|
||||
if (toStop == null) return;
|
||||
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||
if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler;
|
||||
if (toStop.CompletedHandler != null) toStop.Source.Completed -= toStop.CompletedHandler;
|
||||
await SafeStopAsync(toStop.Source);
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
@@ -146,6 +180,8 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
|
||||
if (toStop == null) return;
|
||||
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
||||
if (toStop.AudioHandler != null) toStop.Source.AudioAvailable -= toStop.AudioHandler;
|
||||
if (toStop.CompletedHandler != null) toStop.Source.Completed -= toStop.CompletedHandler;
|
||||
await SafeStopAsync(toStop.Source);
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
@@ -158,6 +194,120 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
return _sessions.TryGetValue(key, out var session) ? session.LatestFrame : null;
|
||||
}
|
||||
|
||||
/// <summary>Raised when a file plays out (natural end, no loop) — the layer is then holding its
|
||||
/// last frame and the creator can start it again.</summary>
|
||||
public event Action<string>? SessionCompleted;
|
||||
|
||||
/// <summary>True while a decode session exists for the file — the caller uses this to
|
||||
/// keep one acquire per path instead of stacking refcounts on every layout pass.</summary>
|
||||
public bool HasSession(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <summary>The files with a live session, for reconciling a layout load.</summary>
|
||||
public IReadOnlyList<string> GetActiveKeys()
|
||||
{
|
||||
lock (_gate)
|
||||
return new List<string>(_sessions.Keys);
|
||||
}
|
||||
|
||||
/// <summary>Applies a loop change to a running session (the creator flipped the
|
||||
/// toggle). No-op when the file has no session.</summary>
|
||||
/// <summary>False once the file has played to the end. A finished session keeps its last frame
|
||||
/// (and its refcount), so "play again" is a restart, not a re-acquire.</summary>
|
||||
public bool IsRunning(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(key, out var session) && session.Started && !session.Finished;
|
||||
}
|
||||
|
||||
/// <summary>Restarts a finished file from the top (the Loop switch or "Play again"). The session is
|
||||
/// kept, so the shared preview bitmap and the frame consumers stay valid.</summary>
|
||||
public async Task<bool> RestartAsync(string key)
|
||||
{
|
||||
MediaSession? session;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_sessions.TryGetValue(key, out session)) return false;
|
||||
session.Finished = false;
|
||||
session.Started = false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await session.Source.StartAsync().ConfigureAwait(false);
|
||||
session.Started = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"MediaSource: restarting '{key}' failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLooping(string key, bool looping)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sessions.TryGetValue(key, out var session))
|
||||
session.Source.Looping = looping;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The layer's sound controls for this file. One decoder is shared per path,
|
||||
/// so the level/mute are per SESSION: whichever layer the creator last touched wins —
|
||||
/// the alternative (one decoder per layer) would play the same file twice.</summary>
|
||||
public void SetAudioSettings(string key, double volume, bool muted)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_sessions.TryGetValue(key, out var session)) return;
|
||||
session.AudioVolume = Math.Clamp(volume, 0, 1);
|
||||
session.AudioMuted = muted;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionCompleted(MediaSession session)
|
||||
{
|
||||
if (!TryGetActiveSession(session)) return;
|
||||
lock (_gate)
|
||||
{
|
||||
if (session.Source.Looping) return; // it will restart itself
|
||||
session.Finished = true;
|
||||
}
|
||||
SessionCompleted?.Invoke(session.Key);
|
||||
}
|
||||
|
||||
private void OnAudioAvailable(MediaSession session, AudioSample sample)
|
||||
{
|
||||
if (!TryGetActiveSession(session)) return;
|
||||
|
||||
double volume;
|
||||
bool muted;
|
||||
lock (_gate)
|
||||
{
|
||||
volume = session.AudioVolume;
|
||||
muted = session.AudioMuted;
|
||||
}
|
||||
if (muted || volume <= 0) return;
|
||||
|
||||
if (volume < 0.999)
|
||||
{
|
||||
// Copy before scaling: the source owns its buffer and may recycle it.
|
||||
var scaled = new float[sample.Samples.Length];
|
||||
var gain = (float)volume;
|
||||
for (var i = 0; i < scaled.Length; i++)
|
||||
scaled[i] = sample.Samples[i] * gain;
|
||||
AudioAvailable?.Invoke(session.Key, new AudioSample(scaled, sample.SampleRate, sample.Channels));
|
||||
return;
|
||||
}
|
||||
|
||||
AudioAvailable?.Invoke(session.Key, sample);
|
||||
}
|
||||
|
||||
/// <summary>The shared preview bitmap for a file (so a config created after the
|
||||
/// first frame can adopt the running bitmap), or null.</summary>
|
||||
public WriteableBitmap? GetPreviewBitmap(string key)
|
||||
@@ -178,6 +328,8 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
if (session.AudioHandler != null) session.Source.AudioAvailable -= session.AudioHandler;
|
||||
if (session.CompletedHandler != null) session.Source.Completed -= session.CompletedHandler;
|
||||
_ = SafeStopAsync(session.Source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ using ytLive.Helpers;
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Toast notifications rendered inside the main window's <c>ToastArea</c>
|
||||
/// (bottom-right, above the footer). Info/success auto-dismiss after ~4s,
|
||||
/// warnings ~8s, errors stay until dismissed. Calls from any thread are
|
||||
/// Toast notifications rendered inside the main window's <c>ToastArea</c>, whose
|
||||
/// position is configurable in App Settings (bottom-right by default). Info/success auto-dismiss after ~4s,
|
||||
/// warnings ~8s, errors stay until dismissed. App Settings can make all alerts sticky and
|
||||
/// dismissible by clicking the card. Calls from any thread are
|
||||
/// marshalled to the captured dispatcher.
|
||||
/// </summary>
|
||||
public sealed class NotificationService : INotificationService
|
||||
@@ -28,35 +29,49 @@ public sealed class NotificationService : INotificationService
|
||||
|
||||
private readonly NotificationManager _manager = new();
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly Func<bool> _stickyAlertsUntilDismissed;
|
||||
|
||||
public NotificationService()
|
||||
: this(Application.Current?.Dispatcher)
|
||||
: this(Application.Current?.Dispatcher, static () => false)
|
||||
{
|
||||
}
|
||||
|
||||
internal NotificationService(Dispatcher? dispatcher)
|
||||
: this(dispatcher, static () => false)
|
||||
{
|
||||
}
|
||||
|
||||
internal NotificationService(Dispatcher? dispatcher, Func<bool> stickyAlertsUntilDismissed)
|
||||
{
|
||||
_dispatcher = dispatcher ?? Dispatcher.CurrentDispatcher;
|
||||
_stickyAlertsUntilDismissed = stickyAlertsUntilDismissed;
|
||||
NotificationConstants.NotificationCornerRadius = new CornerRadius(8);
|
||||
NotificationConstants.KeepNotificationVisibleOnMouseOver = true;
|
||||
}
|
||||
|
||||
public void Show(string title, string message, AppNotificationSeverity severity)
|
||||
{
|
||||
var request = BuildRequest(title, message, severity);
|
||||
_dispatcher.Invoke(() => _manager.Show(request));
|
||||
_dispatcher.Invoke(() => _manager.Show(BuildRequestForCurrentSettings(title, message, severity)));
|
||||
}
|
||||
|
||||
internal static NotificationRequest BuildRequest(string title, string message, AppNotificationSeverity severity)
|
||||
internal NotificationRequest BuildRequestForCurrentSettings(string title, string message,
|
||||
AppNotificationSeverity severity)
|
||||
=> BuildRequest(title, message, severity, _stickyAlertsUntilDismissed());
|
||||
|
||||
internal static NotificationRequest BuildRequest(string title, string message,
|
||||
AppNotificationSeverity severity, bool stickyUntilDismissed = false)
|
||||
{
|
||||
var (backgroundHex, expirationSeconds) = Cards[severity];
|
||||
var builder = NotificationBuilder
|
||||
.Create(title, message)
|
||||
.InArea(AreaName)
|
||||
.WithBackground(NotificationColor.FromHex(backgroundHex))
|
||||
.WithForeground(NotificationColor.FromHex(ThemeBrush.Hex("Brush.OnSurface")));
|
||||
.WithForeground(NotificationColor.FromHex(ThemeBrush.Hex("Brush.OnSurface")))
|
||||
.CloseOnClick(stickyUntilDismissed);
|
||||
|
||||
if (severity == AppNotificationSeverity.Error)
|
||||
if (stickyUntilDismissed)
|
||||
builder.NeverExpires();
|
||||
else if (severity == AppNotificationSeverity.Error)
|
||||
builder.NeverExpires();
|
||||
else
|
||||
builder.ExpiresInSeconds(expirationSeconds);
|
||||
|
||||
@@ -7,19 +7,30 @@ namespace ytLive.Services;
|
||||
/// frame is emitted whenever that many bytes have accumulated. Pure and fully
|
||||
/// deterministic, so it is tested with a synthetic byte stream — no ffmpeg, no
|
||||
/// process, no audio device needed.
|
||||
/// <para><b>Fill a preallocated frame, do not re-concatenate a pending buffer</b> (2026-11-05).
|
||||
/// The previous version copied the whole accumulated partial frame on every <c>Feed</c>, so a
|
||||
/// 1080p frame arriving in 64 KB reads cost ~64 copies of a growing 8 MB buffer — about 0.5 GB of
|
||||
/// copying per frame, which capped playback at ~4 fps no matter what the pacing said (reported as
|
||||
/// "playing at a hideously slow rate"; measured 6 frames in 3 s on a 30 fps clip). Now the cost is
|
||||
/// one copy per frame plus one copy of the tail that spans reads.</para>
|
||||
/// </summary>
|
||||
public sealed class RawVideoFrameReader
|
||||
{
|
||||
private readonly int _frameBytes;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private byte[]? _pending;
|
||||
|
||||
// Staging buffer for the frame currently being assembled. It BECOMES the emitted frame's
|
||||
// pixel array, then a fresh one is allocated — so a frame is copied exactly once.
|
||||
private byte[] _frame;
|
||||
private int _count;
|
||||
|
||||
public RawVideoFrameReader(int width, int height)
|
||||
{
|
||||
_width = width;
|
||||
_height = height;
|
||||
_frameBytes = width * height * 4;
|
||||
_frame = new byte[_frameBytes];
|
||||
}
|
||||
|
||||
/// <summary>Feeds <paramref name="bytes"/> read from the pipe and returns any
|
||||
@@ -27,32 +38,23 @@ public sealed class RawVideoFrameReader
|
||||
/// next call — frames never split or merge across reads.</summary>
|
||||
public List<VideoFrame> Feed(byte[] bytes)
|
||||
{
|
||||
var pending = _pending;
|
||||
byte[] combined;
|
||||
if (pending != null && pending.Length > 0)
|
||||
{
|
||||
combined = new byte[pending.Length + bytes.Length];
|
||||
Buffer.BlockCopy(pending, 0, combined, 0, pending.Length);
|
||||
Buffer.BlockCopy(bytes, 0, combined, pending.Length, bytes.Length);
|
||||
_pending = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
combined = bytes;
|
||||
}
|
||||
|
||||
var frames = new List<VideoFrame>();
|
||||
var offset = 0;
|
||||
while (combined.Length - offset >= _frameBytes)
|
||||
{
|
||||
var frame = new byte[_frameBytes];
|
||||
Buffer.BlockCopy(combined, offset, frame, 0, _frameBytes);
|
||||
frames.Add(new VideoFrame(_width, _height, frame));
|
||||
offset += _frameBytes;
|
||||
}
|
||||
if (bytes.Length == 0) return frames;
|
||||
|
||||
if (offset < combined.Length)
|
||||
_pending = combined[offset..];
|
||||
var offset = 0;
|
||||
while (offset < bytes.Length)
|
||||
{
|
||||
var take = Math.Min(_frameBytes - _count, bytes.Length - offset);
|
||||
Buffer.BlockCopy(bytes, offset, _frame, _count, take);
|
||||
_count += take;
|
||||
offset += take;
|
||||
|
||||
if (_count < _frameBytes) continue;
|
||||
|
||||
frames.Add(new VideoFrame(_width, _height, _frame));
|
||||
_frame = new byte[_frameBytes];
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
@@ -64,10 +64,14 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
// accommodate in-flight frames) and publishes only monotonically newer frames
|
||||
// (MonotonicGate — a slow older completion must never overwrite a newer LatestFrame).
|
||||
private static readonly TimeSpan MinConvertInterval = TimeSpan.FromMilliseconds(10);
|
||||
/// <summary>TASK 33: the "shed our own load first" cadence — 30 conversions/s instead of ~100.
|
||||
/// The viewer's tier does not change; only how often we pay for a readback.</summary>
|
||||
private static readonly TimeSpan ShedConvertInterval = TimeSpan.FromMilliseconds(33);
|
||||
private static readonly TimeSpan TelemetryInterval = TimeSpan.FromSeconds(2);
|
||||
private const int MaxConcurrentConversions = 3;
|
||||
private const int PoolBufferCount = 5;
|
||||
private DateTime _lastConvertAt = DateTime.MinValue;
|
||||
private TimeSpan _convertInterval = MinConvertInterval;
|
||||
private DateTime _telemetryFrom = DateTime.UtcNow;
|
||||
private DateTime _lastTelemetry = DateTime.UtcNow;
|
||||
private long _skippedCadence;
|
||||
@@ -79,6 +83,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
public string Key { get; }
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
/// <summary>TASK 33: halve our own capture cost before asking the creator to accept a lower tier.
|
||||
/// Idempotent; the pump keeps its declared fps either way (it pads), so no output contract changes.</summary>
|
||||
public void ShedLoad(bool shed) => _convertInterval = shed ? ShedConvertInterval : MinConvertInterval;
|
||||
|
||||
internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item)
|
||||
{
|
||||
Key = key;
|
||||
@@ -171,7 +179,7 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
return;
|
||||
}
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastConvertAt < MinConvertInterval)
|
||||
if (now - _lastConvertAt < _convertInterval)
|
||||
{
|
||||
_skippedCadence++;
|
||||
frame.Dispose();
|
||||
|
||||
@@ -55,6 +55,15 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
_uiDispatcher = uiDispatcher;
|
||||
}
|
||||
|
||||
/// <summary>TASK 33: applies the capture-load shed to every live capture. Before this, the only
|
||||
/// lever for machine starvation was the viewer's tier — the app charged them for its own cost.</summary>
|
||||
public void ShedLoad(bool shed)
|
||||
{
|
||||
lock (_gate)
|
||||
foreach (var session in _sessions.Values)
|
||||
if (session.Source is ScreenCaptureFrameSource source) source.ShedLoad(shed);
|
||||
}
|
||||
|
||||
/// <summary>Increments the refcount for a target key, starting capture the first time.</summary>
|
||||
public async Task<bool> AcquireAsync(string key)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 33 slice 2: the session's adaptor between the pump's evidence and the pure policy. One planner
|
||||
/// per broadcast, because the policy is stateful (budget, cooldown, current tier) and a step must
|
||||
/// survive across windows.
|
||||
///
|
||||
/// <para>It deliberately does not act. It answers "should this session change something, and why"; the
|
||||
/// view model owns the restart, the toast and the session-report line. Keeping the effects out is what
|
||||
/// makes the whole ladder testable without an encoder running.</para>
|
||||
/// </summary>
|
||||
public sealed class StepDownPlanner
|
||||
{
|
||||
private readonly StepDownPolicy _policy;
|
||||
private readonly DateTime _startedUtc;
|
||||
|
||||
public StepDownPlanner(StepDownPolicy policy, DateTime startedUtc)
|
||||
{
|
||||
_policy = policy;
|
||||
_startedUtc = startedUtc;
|
||||
}
|
||||
|
||||
public StepDownPolicy Policy => _policy;
|
||||
|
||||
/// <summary>Current tier after any applied steps, in the shape the encoder restart needs.</summary>
|
||||
public (int Width, int Height, int Fps, double BitrateMbps) Current =>
|
||||
(_policy.Width, _policy.Height, _policy.Fps, _policy.BitrateMbps);
|
||||
|
||||
public StepDownDecision Observe(in FramePumpStats stats, DateTime nowUtc)
|
||||
{
|
||||
var sample = new StepDownSample(
|
||||
WindowSeconds: stats.WindowSeconds,
|
||||
PumpStalls: stats.Stalls,
|
||||
PumpDrops: 0, // the pump pads with the last frame; FramesRendered is ambiguous
|
||||
EncoderDrops: stats.Dropped,
|
||||
AvgRenderMs: stats.AvgRenderMs,
|
||||
AvgSubmitWaitMs: stats.AvgSubmitMs);
|
||||
|
||||
return _policy.Observe(sample, nowUtc - _startedUtc);
|
||||
}
|
||||
|
||||
/// <summary>The plain sentence the creator sees, chosen by cause. Never a mechanism string: "ffmpeg
|
||||
/// exited", "dropped 8", an HTTP code are log findings, not an explanation of what happened to them.
|
||||
/// </summary>
|
||||
public static string Describe(in StepDownDecision decision, (int Width, int Height, int Fps, double BitrateMbps) landed)
|
||||
=> decision.Action switch
|
||||
{
|
||||
StepAction.LowerFrameRate =>
|
||||
"LlamaCasty couldn't keep up with your scene, so it lowered the frame rate to " +
|
||||
$"{landed.Fps}. Your connection wasn't the problem.",
|
||||
StepAction.LowerResolution =>
|
||||
"LlamaCasty couldn't keep up with your scene, so it lowered the picture to " +
|
||||
$"{landed.Width}×{landed.Height}. Your connection wasn't the problem.",
|
||||
StepAction.LowerBitrate =>
|
||||
$"Your upload couldn't hold the bitrate, so LlamaCasty lowered it to {landed.BitrateMbps:0.0} Mbps " +
|
||||
"to stop the freezing. It will stay there for this stream.",
|
||||
StepAction.RaiseFrameRate or StepAction.RaiseResolution or StepAction.RaiseBitrate =>
|
||||
"Conditions were clean for a while, so LlamaCasty restored one step of quality.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>One line for the session report, so a later question is answered from data.</summary>
|
||||
public static string ReportLine(DateTime whenLocal, in StepDownDecision decision) =>
|
||||
decision.Action switch
|
||||
{
|
||||
StepAction.None => string.Empty,
|
||||
StepAction.LowerFrameRate or StepAction.LowerResolution =>
|
||||
$"{whenLocal:HH:mm} quality stepped down — encoder ({decision.Why})",
|
||||
StepAction.LowerBitrate =>
|
||||
$"{whenLocal:HH:mm} quality stepped down — upload ({decision.Why})",
|
||||
_ => $"{whenLocal:HH:mm} quality restored ({decision.Why})",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>What YouTube says about the broadcast once a push is back.</summary>
|
||||
public enum BroadcastLiveness
|
||||
{
|
||||
/// <summary>Could not tell (no token, network, API hiccup) — treat as recovered.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>The broadcast is still live/ready: the push landed and the show continues.</summary>
|
||||
Live,
|
||||
|
||||
/// <summary>The broadcast is complete. It cannot be resumed; the push is talking to a dead ingest.</summary>
|
||||
Complete,
|
||||
}
|
||||
|
||||
/// <summary>Everything the recovery loop needs from the app, as delegates so it can be tested
|
||||
/// without ffmpeg, without a network, and without a window.</summary>
|
||||
public sealed class StreamRecoverySteps
|
||||
{
|
||||
/// <summary>Recreates the audio pipe server. The old one is unusable after its client died
|
||||
/// (single-instance server), and the encoder cannot start without it.</summary>
|
||||
public required Func<CancellationToken, Task> RestartAudio { get; init; }
|
||||
|
||||
/// <summary>Starts the push. True when the encoder came up.</summary>
|
||||
public required Func<CancellationToken, Task<bool>> StartPush { get; init; }
|
||||
|
||||
/// <summary>Asks YouTube whether the broadcast survived. Only called after a push starts.</summary>
|
||||
public required Func<CancellationToken, Task<BroadcastLiveness>> CheckBroadcast { get; init; }
|
||||
|
||||
/// <summary>State for the alert stack: attempt numbers and the outcome.</summary>
|
||||
public required Action<string> Report { get; init; }
|
||||
|
||||
/// <summary>The record: one line per attempt, into the app log.</summary>
|
||||
public required Action<string> Log { get; init; }
|
||||
|
||||
public Func<TimeSpan, CancellationToken, Task> Delay { get; init; }
|
||||
= (delay, ct) => delay <= TimeSpan.Zero ? Task.CompletedTask : Task.Delay(delay, ct);
|
||||
}
|
||||
|
||||
public enum RecoveryOutcome
|
||||
{
|
||||
Recovered,
|
||||
GaveUp,
|
||||
Cancelled,
|
||||
BroadcastComplete,
|
||||
}
|
||||
|
||||
public sealed record RecoveryResult(RecoveryOutcome Outcome, int Attempts, TimeSpan Elapsed);
|
||||
|
||||
/// <summary>
|
||||
/// TASK 32·1 — the blip retry. When the encoder dies while the session is live, relaunch the same push
|
||||
/// against the same ingest, restarting the audio pipe each time (its server is single-instance and the
|
||||
/// dead client leaves it unusable).
|
||||
/// <para><b>The check that stops us lying:</b> a reconnect can succeed at the socket level after
|
||||
/// YouTube has already ended the broadcast. So each successful start asks the API whether the broadcast
|
||||
/// is still there; "complete" ends recovery instead of showing a live sign over a dead stream.</para>
|
||||
/// </summary>
|
||||
public sealed class StreamRecovery
|
||||
{
|
||||
private readonly StreamRetryPolicy _policy;
|
||||
private readonly StreamRecoverySteps _steps;
|
||||
|
||||
public StreamRecovery(StreamRetryPolicy policy, StreamRecoverySteps steps)
|
||||
{
|
||||
_policy = policy;
|
||||
_steps = steps;
|
||||
}
|
||||
|
||||
public async Task<RecoveryResult> RunAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||||
var attempts = 0;
|
||||
|
||||
for (var attempt = 0; attempt < _policy.Attempts; attempt++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return Result(RecoveryOutcome.Cancelled);
|
||||
attempts = attempt + 1;
|
||||
|
||||
var wait = _policy.WaitBefore(attempt);
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
try { await _steps.Delay(wait, cancellationToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return Result(RecoveryOutcome.Cancelled); }
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested) return Result(RecoveryOutcome.Cancelled);
|
||||
|
||||
_steps.Report($"Stream dropped — reconnecting (attempt {attempts} of {_policy.Attempts})");
|
||||
_steps.Log($"RECONNECT: attempt {attempts} of {_policy.Attempts} — restarting the audio pipe and the push");
|
||||
|
||||
try
|
||||
{
|
||||
await _steps.RestartAudio(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_steps.Log($"RECONNECT: audio pipe restart failed on attempt {attempts}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
bool started;
|
||||
try
|
||||
{
|
||||
started = await _steps.StartPush(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_steps.Log($"RECONNECT: push restart failed on attempt {attempts}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!started)
|
||||
{
|
||||
_steps.Log($"RECONNECT: attempt {attempts} of {_policy.Attempts} failed — the encoder did not come up");
|
||||
continue;
|
||||
}
|
||||
|
||||
var liveness = BroadcastLiveness.Unknown;
|
||||
try
|
||||
{
|
||||
liveness = await _steps.CheckBroadcast(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_steps.Log($"RECONNECT: could not check the broadcast on attempt {attempts}: {ex.Message}");
|
||||
}
|
||||
|
||||
if (liveness == BroadcastLiveness.Complete)
|
||||
{
|
||||
_steps.Report("Stream ended — YouTube reports this broadcast complete. It cannot be resumed.");
|
||||
_steps.Log("RECONNECT: push came back, but the broadcast is complete — cannot resume");
|
||||
return Result(RecoveryOutcome.BroadcastComplete);
|
||||
}
|
||||
|
||||
_steps.Report($"Stream recovered after {clock.Elapsed.TotalSeconds:0.0} s");
|
||||
_steps.Log($"RECONNECT: push re-established after {clock.Elapsed.TotalSeconds:0.0} s — still live");
|
||||
return Result(RecoveryOutcome.Recovered);
|
||||
}
|
||||
|
||||
_steps.Report($"Stream dropped — {_policy.Attempts} attempts failed. The stream is off air.");
|
||||
_steps.Log($"RECONNECT: gave up after {_policy.Attempts} attempts — stream is off air");
|
||||
return Result(RecoveryOutcome.GaveUp);
|
||||
|
||||
RecoveryResult Result(RecoveryOutcome outcome) => new(outcome, attempts, clock.Elapsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 32·1: how many times to relaunch the push, and how long to wait between tries.
|
||||
/// <para><b>Minimum waits, deliberately</b> (creator ruling 2026-11-05): YouTube is not generous about
|
||||
/// holding a broadcast whose ingest has gone quiet, so every second spent not pushing is grace spent.
|
||||
/// The first attempt is immediate — there is nothing to wait for when the socket has just died.</para>
|
||||
/// <para>Pure so the schedule is asserted without waiting on it.</para>
|
||||
/// </summary>
|
||||
public sealed class StreamRetryPolicy
|
||||
{
|
||||
/// <summary>Immediate, then +1 s, +2 s, +3 s — four attempts inside about six seconds.</summary>
|
||||
public static readonly StreamRetryPolicy Default = new(new[]
|
||||
{
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(3),
|
||||
});
|
||||
|
||||
public IReadOnlyList<TimeSpan> Waits { get; }
|
||||
|
||||
public StreamRetryPolicy(IReadOnlyList<TimeSpan> waits)
|
||||
{
|
||||
if (waits == null || waits.Count == 0)
|
||||
throw new ArgumentException("A retry policy needs at least one attempt.", nameof(waits));
|
||||
Waits = waits;
|
||||
}
|
||||
|
||||
public int Attempts => Waits.Count;
|
||||
|
||||
/// <summary>The wait BEFORE <paramref name="attemptIndex"/> (0-based).</summary>
|
||||
public TimeSpan WaitBefore(int attemptIndex)
|
||||
=> attemptIndex < 0 || attemptIndex >= Waits.Count ? TimeSpan.Zero : Waits[attemptIndex];
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 3·20: reads the channel's recent Super Chat / Super Sticker events so a session report can be
|
||||
/// shown without storing anything.
|
||||
/// <para><b>YouTube is the authority.</b> This service only asks; it keeps no ledger, and the events it
|
||||
/// returns are the ones YouTube still considers valid (removed or refunded events do not come back).</para>
|
||||
/// <para>Scope: the endpoint is authorised by the app's existing <c>auth/youtube</c> scope, so no
|
||||
/// re-consent is needed — unlike the Analytics API, which does.</para>
|
||||
/// </summary>
|
||||
public interface ISuperChatEventSource
|
||||
{
|
||||
/// <summary>The most recent events for a channel (YouTube caps the window at roughly 30 days).</summary>
|
||||
Task<IReadOnlyList<SuperChatEvent>> GetRecentAsync(
|
||||
string channelId, int maxResults = 200, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class SuperChatEventService : ISuperChatEventSource
|
||||
{
|
||||
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
|
||||
private readonly YouTubeAuthService _auth;
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public SuperChatEventService(YouTubeAuthService auth, HttpClient? http = null)
|
||||
{
|
||||
_auth = auth;
|
||||
_http = http ?? new HttpClient();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SuperChatEvent>> GetRecentAsync(
|
||||
string channelId, int maxResults = 200, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(channelId)) return Array.Empty<SuperChatEvent>();
|
||||
if (_auth.CurrentChannel?.AccessToken is not { } token) return Array.Empty<SuperChatEvent>();
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||||
var url = $"{ApiBase}/superChatEvents?part=snippet&maxResults={Math.Clamp(maxResults, 1, 200)}" +
|
||||
$"&channelId={Uri.EscapeDataString(channelId)}";
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"superChatEvents.list failed: {(int)response.StatusCode}");
|
||||
return Array.Empty<SuperChatEvent>();
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Parse(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"superChatEvents.list failed: {ex.Message}");
|
||||
return Array.Empty<SuperChatEvent>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure parse of a <c>superChatEvents.list</c> response. Both event types carry their money
|
||||
/// in a different object, so a sticker must not be silently dropped.</summary>
|
||||
internal static IReadOnlyList<SuperChatEvent> Parse(string json)
|
||||
{
|
||||
var events = new List<SuperChatEvent>();
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("items", out var items)) return events;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("snippet", out var snippet)) continue;
|
||||
|
||||
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? string.Empty : string.Empty;
|
||||
var publishedAt = snippet.TryGetProperty("publishedAt", out var published)
|
||||
&& DateTime.TryParse(published.GetString(), out var parsed)
|
||||
? parsed.ToUniversalTime()
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var kind = ChatEventKind.SuperChat;
|
||||
JsonElement details;
|
||||
if (snippet.TryGetProperty("superChatDetails", out var chat))
|
||||
details = chat;
|
||||
else if (snippet.TryGetProperty("superStickerDetails", out var sticker))
|
||||
{
|
||||
details = sticker;
|
||||
kind = ChatEventKind.SuperSticker;
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var micros = details.TryGetProperty("amountMicros", out var amount)
|
||||
&& amount.ValueKind == JsonValueKind.Number
|
||||
&& amount.TryGetInt64(out var value)
|
||||
? value
|
||||
: 0;
|
||||
var currency = details.TryGetProperty("currency", out var cur) && cur.ValueKind == JsonValueKind.String
|
||||
? cur.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
var display = details.TryGetProperty("amountDisplayString", out var shown)
|
||||
&& shown.ValueKind == JsonValueKind.String
|
||||
? shown.GetString()
|
||||
: null;
|
||||
|
||||
events.Add(new SuperChatEvent(id, kind, micros, currency, display, publishedAt));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One video FILE played through ffmpeg — the shared pipe behind every clip, so a media
|
||||
/// layer (TASK 21) and an alert clip (TASK 47) decode identically. Owns two subprocesses
|
||||
/// on the same file: video (<c>rawvideo bgra</c>, scaled to the target rect) and audio
|
||||
/// (<c>f32le -ac 2 -ar 48000</c>, matching the mixer's native format so no resampler is
|
||||
/// needed). Both are paced to real time, so the picture and the sound stay together even
|
||||
/// though they arrive on separate pipes.
|
||||
/// <para><b>Looping is the media case</b>: on natural EOF the whole pass restarts — a fresh
|
||||
/// pair of processes, because a <see cref="Process"/> cannot be re-started. An alert clip
|
||||
/// never loops, so its wrapper simply never sets <see cref="Looping"/>.</para>
|
||||
/// <para>The seams (locator, process factory, frame-rate probe, delay) exist so tests drive
|
||||
/// a fake decoder with no ffmpeg, no codec, and no audio device.</para>
|
||||
/// </summary>
|
||||
public sealed class VideoClipPipe : IDisposable
|
||||
{
|
||||
/// <summary>Pipe read size. A 1080p BGRA frame is ~8 MB, so 64 KB reads meant 127 awaits and
|
||||
/// 127 reader calls per frame; 256 KB keeps the syscall count sane without a large allocation.</summary>
|
||||
private const int ReadBufferBytes = 256 * 1024;
|
||||
|
||||
public const int AudioSampleRate = 48000;
|
||||
public const int AudioChannels = 2;
|
||||
private const int AudioChunkFloats = 4800; // 50 ms of stereo
|
||||
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action<AudioSample>? AudioAvailable;
|
||||
public event Action? Completed;
|
||||
|
||||
/// <summary>Restart the pass on natural EOF (media/BRB loops).</summary>
|
||||
public bool Looping { get; set; }
|
||||
|
||||
private readonly string _path;
|
||||
private readonly int _width;
|
||||
private readonly int _height;
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _processFactory;
|
||||
private readonly IFrameRateProbe? _probe;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
private readonly bool _audio;
|
||||
private readonly ChromaKeyOptions? _key;
|
||||
private readonly FramePacer _pacer = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private bool _running;
|
||||
private IDecodeProcess? _activeVideo;
|
||||
private IDecodeProcess? _activeAudio;
|
||||
|
||||
public VideoClipPipe(
|
||||
string path,
|
||||
int width,
|
||||
int height,
|
||||
IFfmpegLocator locator,
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null,
|
||||
bool audio = true,
|
||||
ChromaKeyOptions? chromaKey = null)
|
||||
{
|
||||
_path = path;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
_probe = frameRateProbe;
|
||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||
_audio = audio;
|
||||
_key = chromaKey;
|
||||
}
|
||||
|
||||
/// <summary>Runs one pass (or a loop of passes). A pass that ended naturally leaves the pipe
|
||||
/// restartable — the creator can ask a finished clip to play again, which is what the Loop
|
||||
/// switch and the "Play again" button need.</summary>
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_running) return Task.CompletedTask;
|
||||
_running = true;
|
||||
_ = Task.Run(RunAsync, CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
Kill(_activeVideo);
|
||||
Kill(_activeAudio);
|
||||
_running = false;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_running = false;
|
||||
_cts.Dispose();
|
||||
_activeVideo?.Dispose();
|
||||
_activeAudio?.Dispose();
|
||||
}
|
||||
|
||||
private void StopInternal()
|
||||
{
|
||||
_running = false;
|
||||
Kill(_activeVideo);
|
||||
Kill(_activeAudio);
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
var ct = _cts.Token;
|
||||
var frames = 0;
|
||||
var chunks = 0;
|
||||
try
|
||||
{
|
||||
double? frameRate = null;
|
||||
if (_probe != null)
|
||||
frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false);
|
||||
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
do
|
||||
{
|
||||
// Both pipes per pass, awaited together: a restart must begin a new
|
||||
// video AND a new audio stream, or the picture drifts from the sound
|
||||
// after the first loop.
|
||||
var video = RunVideoAsync(ffmpegPath, frameDuration, ct);
|
||||
var audio = _audio ? RunAudioAsync(ffmpegPath, ct) : Task.FromResult(0);
|
||||
frames += await video.ConfigureAwait(false);
|
||||
chunks += await audio.ConfigureAwait(false);
|
||||
}
|
||||
while (Looping && !ct.IsCancellationRequested);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_running = false;
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed decode is not fatal: the alert layer falls back to its animations,
|
||||
// and a media layer just holds its last frame.
|
||||
AppLog.Write($"Video clip decode failed ({Path.GetFileName(_path)}): {ex.Message}");
|
||||
_running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_running = false;
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
AppLog.Write($"Video clip end: {Path.GetFileName(_path)} frames={frames} audioChunks={chunks}");
|
||||
Completed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
|
||||
{
|
||||
var passStart = Stopwatch.GetTimestamp();
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf {VideoFilterChain.BuildVideo(_width, _height, _key)} -an -";
|
||||
using var process = _processFactory();
|
||||
_activeVideo = process;
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var reader = new RawVideoFrameReader(_width, _height);
|
||||
var buffer = new byte[ReadBufferBytes];
|
||||
_pacer.Reset();
|
||||
var stream = process.StandardOutput;
|
||||
var frames = 0;
|
||||
while (!ct.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
var chunk = new byte[read];
|
||||
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
|
||||
foreach (var frame in reader.Feed(chunk))
|
||||
{
|
||||
frames++;
|
||||
FrameAvailable?.Invoke(frame);
|
||||
if (frameDuration > TimeSpan.Zero)
|
||||
{
|
||||
// Deadline pacing: sleep what is LEFT of this frame's interval, so the frame's
|
||||
// own work is not added on top (see FramePacer).
|
||||
var wait = _pacer.DelayFor(frameDuration, Stopwatch.GetElapsedTime(passStart));
|
||||
if (wait > TimeSpan.Zero)
|
||||
await _delay(wait, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
_activeVideo = null;
|
||||
return frames;
|
||||
}
|
||||
|
||||
private async Task<int> RunAudioAsync(string ffmpegPath, CancellationToken ct)
|
||||
{
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -";
|
||||
using var process = _processFactory();
|
||||
_activeAudio = process;
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var stream = process.StandardOutput;
|
||||
var passStart = Stopwatch.GetTimestamp();
|
||||
var pacer = new FramePacer();
|
||||
var raw = new byte[65536];
|
||||
var carry = Array.Empty<byte>();
|
||||
var floatScratch = new float[AudioChunkFloats];
|
||||
var floatIndex = 0;
|
||||
var chunks = 0;
|
||||
// The pipe is byte-delimited only: a read may split a float, so leftover
|
||||
// bytes are carried into the next read instead of dropped (a dropped
|
||||
// half-float would desync the entire stream).
|
||||
while (!ct.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false);
|
||||
if (read <= 0) break;
|
||||
var total = carry.Length + read;
|
||||
var bytes = total <= raw.Length ? raw : new byte[total];
|
||||
if (carry.Length > 0)
|
||||
{
|
||||
Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length);
|
||||
Buffer.BlockCopy(raw, 0, bytes, carry.Length, read);
|
||||
}
|
||||
var floatsTo = total - (total % 4);
|
||||
for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4)
|
||||
floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i);
|
||||
carry = total > floatsTo ? bytes[floatsTo..total] : Array.Empty<byte>();
|
||||
|
||||
while (floatIndex == floatScratch.Length)
|
||||
{
|
||||
chunks++;
|
||||
AudioAvailable?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
|
||||
// Deadline pacing, exactly like the video pipe: sleep only what is LEFT of this
|
||||
// chunk's 50 ms. A fixed sleep added the read cost every chunk, so audio fell
|
||||
// progressively behind the picture on a long clip (2026-11-05).
|
||||
var wait = pacer.DelayFor(
|
||||
TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)),
|
||||
Stopwatch.GetElapsedTime(passStart));
|
||||
if (wait > TimeSpan.Zero)
|
||||
await _delay(wait, ct).ConfigureAwait(false);
|
||||
floatScratch = new float[AudioChunkFloats];
|
||||
floatIndex = 0;
|
||||
}
|
||||
}
|
||||
_activeAudio = null;
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static void Kill(IDecodeProcess? process)
|
||||
{
|
||||
try { process?.Kill(); } catch { /* already exited */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: the key's settings for one layer, in the app's own terms. <see cref="Color"/> is the
|
||||
/// screen as the creator's camera saw it (whatever the detector measured), not a canonical green.
|
||||
/// </summary>
|
||||
public sealed record ChromaKeyOptions(
|
||||
bool Enabled,
|
||||
string Color,
|
||||
double Similarity,
|
||||
double Softness,
|
||||
bool Spill,
|
||||
ChromaScreen Screen = ChromaScreen.Green)
|
||||
{
|
||||
public static readonly ChromaKeyOptions Off = new(false, "#00B140", 0.12, 0.08, true);
|
||||
|
||||
/// <summary>Creator-facing defaults when a detector suggestion is accepted: a little
|
||||
/// tolerance around the measured colour, a soft edge, and spill cleanup on.</summary>
|
||||
public static ChromaKeyOptions For(ChromaKeySuggestion suggestion)
|
||||
=> new(true, suggestion.ColorHex ?? "#00B140", 0.12, 0.08, true, suggestion.Screen);
|
||||
|
||||
/// <summary>Identifies this look, so decode sessions can be shared per look rather than
|
||||
/// duplicated per layer (two layers keyed identically reuse one decoder).</summary>
|
||||
public string Signature => Enabled
|
||||
? FormattableString.Invariant(
|
||||
$"{Color.ToUpperInvariant()}|{Similarity:0.###}|{Softness:0.###}|{Spill}|{Screen}")
|
||||
: "off";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: builds the ffmpeg filter chain for a clip. Keying happens IN THE DECODE PIPE, not in
|
||||
/// this process: the bundled ffmpeg keys with slices of its own CPU cost, emits straight-alpha
|
||||
/// BGRA (verified against the shipped binary: keyed pixels come out with alpha 0), and — the
|
||||
/// point — the pipe is upstream of BOTH consumers (the shared preview bitmap and the compositor's
|
||||
/// frame), so the creator's preview and the stream cannot disagree.
|
||||
/// <para>Order matters: scale first (cheaper, and the edge is computed at output size), then key,
|
||||
/// then despill, then force BGRA so the alpha survives the pipe.</para>
|
||||
/// </summary>
|
||||
public static class VideoFilterChain
|
||||
{
|
||||
/// <summary>The video half of a clip's `-vf`. Unkeyed clips keep the old single-scale chain.</summary>
|
||||
public static string BuildVideo(int width, int height, ChromaKeyOptions? key = null)
|
||||
{
|
||||
var scale = $"scale={width}:{height}";
|
||||
if (key is not { Enabled: true }) return scale;
|
||||
|
||||
var parts = new List<string>(4)
|
||||
{
|
||||
scale,
|
||||
$"colorkey={NormalizeColor(key.Color)}:{Number(key.Similarity)}:{Number(key.Softness)}",
|
||||
};
|
||||
if (key.Spill)
|
||||
parts.Add($"despill=type={(key.Screen == ChromaScreen.Blue ? "blue" : "green")}");
|
||||
parts.Add("format=bgra");
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
/// <summary>ffmpeg takes `0xRRGGBB`. Anything unparseable falls back to the app default
|
||||
/// rather than producing a filter string ffmpeg would reject at spawn time.</summary>
|
||||
private static string NormalizeColor(string? hex)
|
||||
{
|
||||
var value = (hex ?? string.Empty).Trim();
|
||||
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) value = value[2..];
|
||||
if (value.StartsWith('#')) value = value[1..];
|
||||
if (value.Length != 6) return "0x00B140";
|
||||
foreach (var c in value)
|
||||
if (!Uri.IsHexDigit(c)) return "0x00B140";
|
||||
return "0x" + value.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string Number(double value)
|
||||
=> Math.Clamp(value, 0, 1).ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 39·2: the two numbers YouTube only publishes through the **Analytics** API — long-form watch
|
||||
/// hours (trailing 12 months) and Shorts-feed views (trailing 90 days).
|
||||
/// <para>This is the one place the app needs a scope beyond <c>auth/youtube</c>: the Analytics API
|
||||
/// requires <c>yt-analytics.readonly</c>, which means a one-time Google re-consent. Until that scope is
|
||||
/// granted, the drawer says so instead of inventing numbers.</para>
|
||||
/// <para>Values are estimates: Analytics lags, and YouTube's own validity filtering decides what counts
|
||||
/// towards eligibility.</para>
|
||||
/// </summary>
|
||||
public interface IWatchMetricsSource
|
||||
{
|
||||
/// <summary>Estimated public long-form watch hours over the trailing 12 months, or null when the
|
||||
/// Analytics scope is not granted / the read failed.</summary>
|
||||
Task<long?> GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Shorts-feed views over the trailing 90 days, or null.</summary>
|
||||
Task<long?> GetShortsViewsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class YouTubeAnalyticsService : IWatchMetricsSource
|
||||
{
|
||||
private const string ApiBase = "https://youtubeanalytics.googleapis.com/v2/reports";
|
||||
|
||||
private readonly YouTubeAuthService _auth;
|
||||
private readonly HttpClient _http;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
public YouTubeAnalyticsService(YouTubeAuthService auth, HttpClient? http = null, Func<DateTime>? now = null)
|
||||
{
|
||||
_auth = auth;
|
||||
_http = http ?? new HttpClient();
|
||||
_now = now ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<long?> GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Minutes watched for long-form videos over the trailing year → hours.
|
||||
var minutes = await QueryTotalAsync(
|
||||
"estimatedMinutesWatched", "creatorContentType==video", 365, cancellationToken).ConfigureAwait(false);
|
||||
return minutes is null ? null : (long)Math.Round(minutes.Value / 60.0);
|
||||
}
|
||||
|
||||
public async Task<long?> GetShortsViewsAsync(CancellationToken cancellationToken = default)
|
||||
=> await QueryTotalAsync("views", "creatorContentType==shorts", 90, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
private async Task<long?> QueryTotalAsync(
|
||||
string metric, string filter, int days, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_auth.HasAnalyticsScope) return null;
|
||||
if (_auth.CurrentChannel?.AccessToken is not { } token) return null;
|
||||
|
||||
var end = _now().Date;
|
||||
var start = end.AddDays(-days);
|
||||
var url = $"{ApiBase}?ids=channel==MINE" +
|
||||
$"&startDate={start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" +
|
||||
$"&endDate={end.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" +
|
||||
$"&metrics={Uri.EscapeDataString(metric)}" +
|
||||
$"&filters={Uri.EscapeDataString(filter)}";
|
||||
|
||||
try
|
||||
{
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||||
var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"Analytics query failed ({metric}): {(int)response.StatusCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return ParseTotal(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Analytics query failed ({metric}): {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure: the Analytics API answers with columnHeaders + rows; a channel with no data in the
|
||||
/// window returns no rows at all, which must read as "no data" rather than zero.</summary>
|
||||
internal static long? ParseTotal(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("rows", out var rows)) return null;
|
||||
if (rows.ValueKind != JsonValueKind.Array || rows.GetArrayLength() == 0) return null;
|
||||
var row = rows[0];
|
||||
if (row.ValueKind != JsonValueKind.Array || row.GetArrayLength() == 0) return null;
|
||||
var value = row[0];
|
||||
return value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var total) ? total : null;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,14 @@ public class YouTubeChatService : IDisposable
|
||||
|
||||
public event Action<ChatMessage>? MessageReceived;
|
||||
|
||||
internal void NotifyChatEnded() => ChatEnded?.Invoke();
|
||||
|
||||
/// <summary>Raised when YouTube puts a <c>chatEndedEvent</c> in the feed — the chat is over, which
|
||||
/// means the broadcast is. TASK 32·2 uses it as a cheap prompt to check the broadcast status
|
||||
/// immediately instead of waiting for the next health poll. It never decides anything by itself:
|
||||
/// <c>liveBroadcasts.list</c> stays authoritative.</summary>
|
||||
public event Action? ChatEnded;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
@@ -80,6 +88,7 @@ public class YouTubeChatService : IDisposable
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var page = ParsePage(json);
|
||||
if (json.Contains("chatEndedEvent", StringComparison.Ordinal)) NotifyChatEnded();
|
||||
|
||||
if (page.NextPageToken != null)
|
||||
_nextPageToken = page.NextPageToken;
|
||||
@@ -106,6 +115,27 @@ public class YouTubeChatService : IDisposable
|
||||
/// <summary>One liveChat/messages page → typed chat messages + pagination +
|
||||
/// the server's poll cadence. Internal + separated from the HTTP so the six
|
||||
/// event parses are deterministically testable without any network.</summary>
|
||||
/// <summary>True for the types that carry something a viewer should read. `snippet.hasDisplayContent`
|
||||
/// is authoritative when present; tombstones and chat-mode events are never shown (they exist to
|
||||
/// mark a deletion or a mode change, and carry no message).</summary>
|
||||
internal static bool HasDisplayableContent(JsonElement snippet, string type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "tombstone":
|
||||
case "chatEndedEvent":
|
||||
case "sponsorOnlyModeStartedEvent":
|
||||
case "sponsorOnlyModeEndedEvent":
|
||||
case "messageDeletedEvent":
|
||||
case "messageRetractedEvent":
|
||||
case "userBannedEvent":
|
||||
return false;
|
||||
}
|
||||
|
||||
return !snippet.TryGetProperty("hasDisplayContent", out var has)
|
||||
|| has.ValueKind != JsonValueKind.False;
|
||||
}
|
||||
|
||||
internal static ChatPage ParsePage(string json)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
@@ -126,9 +156,16 @@ public class YouTubeChatService : IDisposable
|
||||
{
|
||||
var snippet = item.GetProperty("snippet");
|
||||
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
? t.GetString() ?? "textMessageEvent"
|
||||
: "textMessageEvent";
|
||||
|
||||
// YouTube owns moderation: blocked words, held-for-review messages and deleted
|
||||
// messages are filtered before the feed ever reaches us (2026-11-05 ruling —
|
||||
// TASK 49's local wordlist retired). What DOES arrive are event markers rather
|
||||
// than chat: a `tombstone` (a deleted message's placeholder, carrying no text at
|
||||
// all) and the silent chat-mode events. They get no row on the overlay.
|
||||
if (!HasDisplayableContent(snippet, type)) continue;
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(),
|
||||
@@ -157,9 +194,15 @@ public class YouTubeChatService : IDisposable
|
||||
message.Kind = ChatEventKind.SuperChat;
|
||||
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
|
||||
{
|
||||
if (scDetails.TryGetProperty("displayString", out var display)
|
||||
// YouTube names the pre-rendered amount `amountDisplayString`; the old
|
||||
// `displayString` lookup never matched, so every Super Chat fell back to a
|
||||
// recomposed amount and mangled non-dollar locales (fixed 2026-11-05).
|
||||
if (scDetails.TryGetProperty("amountDisplayString", out var display)
|
||||
&& display.ValueKind == JsonValueKind.String)
|
||||
message.SuperChatDisplayString = display.GetString();
|
||||
else if (scDetails.TryGetProperty("displayString", out var legacyDisplay)
|
||||
&& legacyDisplay.ValueKind == JsonValueKind.String)
|
||||
message.SuperChatDisplayString = legacyDisplay.GetString();
|
||||
if (scDetails.TryGetProperty("amountMicros", out var amt)
|
||||
&& amt.ValueKind == JsonValueKind.Number)
|
||||
message.SuperChatAmount = amt.GetDouble() / 1_000_000.0;
|
||||
|
||||
@@ -182,6 +182,25 @@ public class YouTubeStreamService
|
||||
/// "public"), read from YouTube rather than trusted from local state — the creator can
|
||||
/// change visibility mid-stream from the broadcast form. Null when signed out or the
|
||||
/// read fails (the caller then does nothing destructive).</summary>
|
||||
/// <summary>TASK 32·1: the broadcast's lifecycle status (<c>created</c>/<c>ready</c>/<c>testing</c>/
|
||||
/// <c>live</c>/<c>complete</c>), or null when it cannot be read. Used after a reconnect: a push can
|
||||
/// succeed at the socket level while YouTube has already ended the broadcast.</summary>
|
||||
public async Task<string?> GetBroadcastStatusAsync(string broadcastId)
|
||||
{
|
||||
if (!await EnsureToken()) return null;
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null;
|
||||
if (!items[0].TryGetProperty("status", out var status)) return null;
|
||||
if (!status.TryGetProperty("lifeCycleStatus", out var lifeCycle)) return null;
|
||||
return lifeCycle.ValueKind == JsonValueKind.String ? lifeCycle.GetString() : null;
|
||||
}
|
||||
|
||||
public async Task<string?> GetBroadcastPrivacyAsync(string broadcastId)
|
||||
{
|
||||
if (!await EnsureToken()) return null;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
@@ -25,6 +28,16 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
private readonly Func<bool> _isConnected;
|
||||
private readonly Func<string?> _displayName;
|
||||
|
||||
// TASK 3·20 — the session report is DERIVED, never stored: ask YouTube for the channel's recent
|
||||
// reward events and roll them up for the window that starts when this session did.
|
||||
private readonly Func<ISuperChatEventSource> _rewardEvents;
|
||||
private readonly Func<string?> _channelId;
|
||||
private readonly Func<DateTime?> _sessionStartedAtUtc;
|
||||
|
||||
// TASK 39·2 — the two numbers only the Analytics API publishes. Null source (or a missing scope)
|
||||
// means the drawer says so rather than inventing values.
|
||||
private readonly Func<IWatchMetricsSource?> _watchMetrics;
|
||||
|
||||
private bool _isDrawerOpen;
|
||||
private bool _isLoading;
|
||||
private bool _isRefreshing;
|
||||
@@ -43,12 +56,20 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
Func<LayoutStore> store,
|
||||
Func<ChannelStatsService> stats,
|
||||
Func<bool> isConnected,
|
||||
Func<string?> displayName)
|
||||
Func<string?> displayName,
|
||||
Func<ISuperChatEventSource>? rewardEvents = null,
|
||||
Func<string?>? channelId = null,
|
||||
Func<DateTime?>? sessionStartedAtUtc = null,
|
||||
Func<IWatchMetricsSource?>? watchMetrics = null)
|
||||
{
|
||||
_store = store;
|
||||
_stats = stats;
|
||||
_isConnected = isConnected;
|
||||
_displayName = displayName;
|
||||
_rewardEvents = rewardEvents ?? (() => null!);
|
||||
_channelId = channelId ?? (() => null);
|
||||
_sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null);
|
||||
_watchMetrics = watchMetrics ?? (() => null);
|
||||
|
||||
RefreshCommand = new RelayCommand(_ => _ = RefreshAsync());
|
||||
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
|
||||
@@ -58,6 +79,7 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
OpenAdsenseCommand = new RelayCommand(_ => OpenUrl(AdsenseGuideUrl));
|
||||
|
||||
(_twoFactorEnabled, _adsenseLinked) = _store().LoadYppChecklist();
|
||||
_showMonetaryMetrics = _store().LoadShowMonetaryMetrics();
|
||||
_lastRefreshedAtUtc = _store().LoadYppLastRefreshedAtUtc();
|
||||
}
|
||||
|
||||
@@ -92,6 +114,63 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
|
||||
public bool IsSignedOut => !_isConnected();
|
||||
|
||||
private SessionRewardSummary _sessionRewards = SessionRewardSummary.Empty;
|
||||
|
||||
/// <summary>This session's reward events, derived on demand. Never persisted.</summary>
|
||||
public SessionRewardSummary SessionRewards
|
||||
{
|
||||
get => _sessionRewards;
|
||||
private set
|
||||
{
|
||||
_sessionRewards = value;
|
||||
OnPropertyChanged(nameof(SessionRewards));
|
||||
OnPropertyChanged(nameof(HasSessionRewards));
|
||||
OnPropertyChanged(nameof(SessionCountsText));
|
||||
OnPropertyChanged(nameof(SessionMoneyLines));
|
||||
OnPropertyChanged(nameof(HasSessionMoney));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasSessionRewards => _sessionRewards.HasEvents;
|
||||
|
||||
/// <summary>Counts are always neutral — "3 Super Chats" says nothing about money, and most
|
||||
/// creators have none.</summary>
|
||||
public string SessionCountsText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_sessionRewards.HasEvents) return string.Empty;
|
||||
var parts = new List<string>();
|
||||
if (_sessionRewards.SuperChatCount > 0) parts.Add($"{_sessionRewards.SuperChatCount} Super Chat{(_sessionRewards.SuperChatCount == 1 ? "" : "s")}");
|
||||
if (_sessionRewards.SuperStickerCount > 0) parts.Add($"{_sessionRewards.SuperStickerCount} Super Sticker{(_sessionRewards.SuperStickerCount == 1 ? "" : "s")}");
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Money is shown only when the creator asked for it AND there is something to show.
|
||||
/// A creator who never monetized never sees a zero (creator ruling 2026-11-05).</summary>
|
||||
public IReadOnlyList<string> SessionMoneyLines
|
||||
=> ShowMonetaryMetrics ? _sessionRewards.MoneyLines() : Array.Empty<string>();
|
||||
|
||||
public bool HasSessionMoney => SessionMoneyLines.Count > 0;
|
||||
|
||||
private bool _showMonetaryMetrics;
|
||||
|
||||
/// <summary>"Show monetary metrics" — lives in this drawer, persisted, OFF by default. Turning it on
|
||||
/// is also what would justify requesting the monetary Analytics scope; while it is off the app never
|
||||
/// asks Google for money data.</summary>
|
||||
public bool ShowMonetaryMetrics
|
||||
{
|
||||
get => _showMonetaryMetrics;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showMonetaryMetrics, value)) return;
|
||||
_store().SaveShowMonetaryMetrics(value);
|
||||
OnPropertyChanged(nameof(SessionMoneyLines));
|
||||
OnPropertyChanged(nameof(HasSessionMoney));
|
||||
}
|
||||
}
|
||||
|
||||
public string? LoadError
|
||||
{
|
||||
get => _loadError;
|
||||
@@ -139,6 +218,38 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
public double Tier2SubsProgress => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers);
|
||||
public double UploadsProgress => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days);
|
||||
|
||||
private long? _watchHours;
|
||||
private long? _shortsViews;
|
||||
|
||||
/// <summary>The three eligibility metrics, with the threshold that applies today (the long-form bar
|
||||
/// doubles on 1 Feb 2027 — the date is data, not a code change).</summary>
|
||||
public YppProgress Eligibility => new(
|
||||
SubscriberCount,
|
||||
YppThresholds.Tier2Now.Subscribers,
|
||||
_watchHours ?? 0,
|
||||
YppThresholds.Tier2Now.WatchHours,
|
||||
_shortsViews ?? 0,
|
||||
YppThresholds.Tier2Now.ShortsViews);
|
||||
|
||||
/// <summary>True once the Analytics scope is granted and both numbers came back. Without it the two
|
||||
/// view bars cannot be drawn honestly, so the drawer says what is missing instead.</summary>
|
||||
public bool HasWatchMetrics => _watchHours.HasValue && _shortsViews.HasValue;
|
||||
|
||||
public string WatchMetricsMissingText =>
|
||||
"Watch hours and Shorts views need YouTube Analytics access — one-time consent, granted from the "
|
||||
+ "button below. Until then those two bars stay empty.";
|
||||
|
||||
public double SubscribersBarWidth => Eligibility.SubscribersFraction * BarMaxWidth;
|
||||
public double WatchHoursBarWidth => Eligibility.WatchHoursFraction * BarMaxWidth;
|
||||
public double ShortsViewsBarWidth => Eligibility.ShortsViewsFraction * BarMaxWidth;
|
||||
|
||||
public string SubscribersText => Eligibility.SubscribersText;
|
||||
public string WatchHoursText => Eligibility.WatchHoursText;
|
||||
public string ShortsViewsText => Eligibility.ShortsViewsText;
|
||||
public string CriteriaMetText => Eligibility.CriteriaMetText;
|
||||
public string ThresholdsAsOfText =>
|
||||
$"Thresholds as published {YppThresholds.AsOfUtc:d MMMM yyyy} · estimates, can lag.";
|
||||
|
||||
public double Tier1SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers) * BarMaxWidth;
|
||||
public double Tier2SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers) * BarMaxWidth;
|
||||
public double UploadsBarWidth => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days) * BarMaxWidth;
|
||||
@@ -203,11 +314,80 @@ public sealed class YppTrackerViewModel : ViewModelBase
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
/// <summary>Asks Analytics for the two view metrics. Missing scope → nulls, and the drawer explains
|
||||
/// what to do; nothing is guessed.</summary>
|
||||
private async Task RefreshWatchMetricsAsync()
|
||||
{
|
||||
var source = _watchMetrics();
|
||||
if (source == null)
|
||||
{
|
||||
_watchHours = null;
|
||||
_shortsViews = null;
|
||||
RaiseProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_watchHours = await source.GetLongFormWatchHoursAsync();
|
||||
_shortsViews = await source.GetShortsViewsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Watch metrics unavailable: {ex.Message}");
|
||||
_watchHours = null;
|
||||
_shortsViews = null;
|
||||
}
|
||||
RaiseProgress();
|
||||
}
|
||||
|
||||
private void RaiseProgress()
|
||||
{
|
||||
OnPropertyChanged(nameof(Eligibility));
|
||||
OnPropertyChanged(nameof(HasWatchMetrics));
|
||||
OnPropertyChanged(nameof(SubscribersBarWidth));
|
||||
OnPropertyChanged(nameof(WatchHoursBarWidth));
|
||||
OnPropertyChanged(nameof(ShortsViewsBarWidth));
|
||||
OnPropertyChanged(nameof(SubscribersText));
|
||||
OnPropertyChanged(nameof(WatchHoursText));
|
||||
OnPropertyChanged(nameof(ShortsViewsText));
|
||||
OnPropertyChanged(nameof(CriteriaMetText));
|
||||
OnPropertyChanged(nameof(ThresholdsAsOfText));
|
||||
}
|
||||
|
||||
/// <summary>Asks YouTube for the channel's recent reward events and keeps only this session's.
|
||||
/// Nothing is written down: the report is a view over YouTube's data, not a local ledger.</summary>
|
||||
private async Task RefreshSessionRewardsAsync()
|
||||
{
|
||||
var started = _sessionStartedAtUtc();
|
||||
var channel = _channelId();
|
||||
var source = _rewardEvents();
|
||||
if (started == null || string.IsNullOrWhiteSpace(channel) || source == null)
|
||||
{
|
||||
SessionRewards = SessionRewardSummary.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var events = await source.GetRecentAsync(channel);
|
||||
SessionRewards = SessionRewardSummary.From(events, started.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Session rewards unavailable: {ex.Message}");
|
||||
SessionRewards = SessionRewardSummary.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
if (_isRefreshing) return;
|
||||
_isRefreshing = true;
|
||||
IsLoading = true;
|
||||
|
||||
await RefreshSessionRewardsAsync();
|
||||
await RefreshWatchMetricsAsync();
|
||||
LoadError = null;
|
||||
try
|
||||
{
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `Encoder/FramePump.cs` | **TASK 4 ship step 5**: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (`Func<SceneElement, VideoFrame?>` resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. `StartAsync` never throws (failures log + `Failed`); no RTMP URL = encoder skipped; `StopAsync` stops the encoder before awaiting the loop (backpressure deadlock); `ProcessFailed` self-stops. Since the bar bug-fix branch it takes an optional `socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam — a pre-rendered bar strip composited last (above the flash) at the top edge or `SourceRectHeight − bar height`. **TASK 47 + scene ticker:** the `alertTicker: Func<Scene, VideoFrame?>?` seam selects the active scene's AlertBox ticker, threaded through `RenderScene`/`RenderFull` + **mixed into `BuildFullRenderSignature`**; standalone tickers are resolved as per-element dynamic sources. See [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Live frame pipeline". **2026-10-16:** owns a `CompositeTimings` collector, passes it through `RenderScene`/`RenderFull`/`GetBakedBase`/`CompositeLayers`, logs `FramePump parts (N renders/5s): …` (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — `FramePump scene dump:` header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
|
||||
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
|
||||
| `INotificationService.cs` | **Toast seam (TASK 24)**: `Show(title, message, AppNotificationSeverity)` + `AppNotificationSeverity { Info, Success, Warning, Error }` + `Info/Success/Warning/Error` extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
|
||||
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (MainWindow bottom-right above the footer); lifetimes via the pure `BuildRequest` mapping + `Cards` tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (`NeverExpires()` = `TimeSpan.MaxValue`, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
|
||||
| `NotificationService.cs` | The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library `NotificationManager`; routes to the in-window `NotificationArea` named `ToastArea` (position configurable in App Settings; bottom-right default); Info/Success ~4s, Warning ~8s by default, Error sticky; the persisted App Settings switch makes all severities sticky and body-click dismissible. Dark card tints match the health-banner language; all Show calls marshal to the captured Dispatcher. See [`../ai.md`](../ai.md) "Toast notifications" |
|
||||
| `Audio/IAudioSource.cs` | **TASK 4 ship step 4 seam**: live capture source — `Start`/`Stop`/`Started`/`SampleReady(AudioSample)`/`Failed(Exception)`, `IDisposable`. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes |
|
||||
| `Audio/AudioSample.cs` | One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
|
||||
| `Audio/WasapiLoopbackAudioSource.cs` | Desktop/game capture: NAudio `WasapiLoopbackCapture` on the default render device — automatic at unity; the game bar's meter consumes it |
|
||||
|
||||
@@ -1,107 +1,57 @@
|
||||
# TASKS — the active queue
|
||||
|
||||
> One line per live item; the detail lives in `TASKS/task-*.md`. Done work is in
|
||||
> [`TASKS/archive.md`](TASKS/archive.md) (2026-10-29 snapshot of the old catalogue). Retired
|
||||
> decisions are in [`docs/ai/decisions.md`](docs/ai/decisions.md).
|
||||
> One line per live item; the detail lives in `TASKS/task-*.md`. Done work and the retired surge plan are
|
||||
> in [`TASKS/archive.md`](TASKS/archive.md). Retired decisions are in
|
||||
> [`docs/ai/decisions.md`](docs/ai/decisions.md).
|
||||
> Markers: ☐ queued · 🔶 in progress · ⏳ blocked · ⏸ deferred · ❌ retired.
|
||||
|
||||
## The surge — six days, all remaining coding (2026-11-02 → 11-07)
|
||||
## Next up
|
||||
|
||||
**Every open coding row is assigned below.** No later phase, no "after launch" list. Target is
|
||||
**6–8 units a day** (above the measured 3–5 baseline — that is the point of a surge). If a day
|
||||
overruns, the smallest item moves to the next day; a launch blocker never moves.
|
||||
**Creator ruling 2026-11-09 — finish the queue before the new features, gold pass always last.**
|
||||
|
||||
**Rules, every unit, no exceptions:** one Good Dog test · clean build 0 warnings · per-class tests
|
||||
green · `./scripts/verify.sh` on the declared scope · commit with the doc fact it falsifies
|
||||
corrected. No new scope mid-week; anything discovered goes on a list for day 6.
|
||||
`40·A/C/D → Move → 9·5 → 14 (remaining slices) → 66 → 67 → 68 → 69 → 70 → 36`
|
||||
|
||||
| Day | Units | Done when |
|
||||
|---|---|---|
|
||||
| **1 · Mon** — licensing | Paid-app ownership check (`BIsSubscribedApp`) + Debug fake + app-wide `IsAppOwned` · greyed tier entries · About conversion copy + llamacasty.com link · current-launch Logs tab (replaces the separate system-message strip) · Debug-only `InternalsVisibleTo` | A locked tier opens About without changing the selected free tier or leaving the app; the site opens only from its link |
|
||||
| **2 · Tue** — release blockers | Visibility flow (creator-selected at release; development/test stays Private) · `OutputProfile` entitlement: 30 fps free / 60 fps paid regardless of resolution · ~~`StreamConfig` defaults~~ retired (no production call sites) · TASK 53 (vertical resolution check ✅; remaining output contract open) · 9·5 webcam identity key · Move transitions (per-layer carry-across-scenes) | Visibility is selectable at creation and live-editable; the 30/60-fps gate is tested; remaining Day 2 items stay queued |
|
||||
| **3 · Wed** — resilience | TASK 32: blip retry → measured grace → amber RECONNECTING → one-click Back on air → fragmented MP4 → ingest pre-flight · 33 bandwidth step-down | Killing the network mid-broadcast recovers without ending it |
|
||||
| **4 · Thu** — content & media | 3·17 Text layer · 21 media acquisition wiring + file picker + loop/volume UI · 49 chat profanity filter · 34 scheduled streams | Text layer renders/persists; media file plays, loops, has volume; scheduled stream created |
|
||||
| **5 · Fri** — data & settings | 3·20 RewardEvent persistence + `superChatEvents.list` backfill · 39·2 YPP slice 2 · 37 defaults-vs-current split · 35 scene-linked audio · 40·A/C/D settings round · 55·2–3 disk hygiene | Session report reads persisted events; defaults can be reverted; temp clips cleaned |
|
||||
| **6 · Sat** — finish & prove | 50 publish recording · preview undo (Ctrl+Z) · 14 creator feedback batch · 36 gold pass · full sweep: clean build, whole per-class suite, `publish.sh` artifact, EULA/privacy/notices in the artifact · commit + push | Gold pass list empty; artifact stages; suite green; pushed |
|
||||
|
||||
**Day 6 is the only polish day.** No polish passes before it — a surge dies on cosmetic passes taken
|
||||
in the middle.
|
||||
|
||||
## Next up (after the surge slice)
|
||||
|
||||
**TASK 3·17 — the Text layer** is scheduled in the surge (day 4). Placeholder detail retained for
|
||||
the unit itself: `AddSource` creates it 0×0, all four compositor paths `continue` past it,
|
||||
`FramePump`'s hash skips it, it is not draggable, and it has no text/font/colour properties or
|
||||
persistence. Text content, font family/size/weight/style, colour, alignment, drag/resize,
|
||||
persisted; renderer mirrors `Services/ChatBoxRenderer.cs`.
|
||||
The rule behind the two retirements below: **do not rebuild the platform's own surface.** Scheduling,
|
||||
publishing, moderation and channel management are YouTube's; the app owns capture → scene → sound →
|
||||
output.
|
||||
|
||||
## Queue
|
||||
|
||||
> Every open coding row here is assigned to a surge day above. Rows still marked ☐ on
|
||||
> 2026-11-07 are a failure of the plan, not a scheduling note.
|
||||
| Task | Status | Scope | Detail |
|
||||
|---|---|---|---|
|
||||
| 53 | ✅ | Output-format contract — six size/FPS modes, vertical YouTube playback, and recordings visually accepted | [`task-53`](TASKS/task-53-output-formats.md) |
|
||||
| — | ✅ | Output entitlement code: free at 30 fps / paid at 60 fps regardless of resolution; Steam App ID is still pending | [`commercial`](docs/ai/commercial.md) |
|
||||
| 3·17 | ☐ **NEXT** | **Text layer** — content, font family/size/weight/style, colour, alignment, drag/resize, persisted | [`task-03`](TASKS/task-03-capture-pipeline.md) |
|
||||
| 21 | 🔶 | Media source: acquisition wiring + file picker + loop/volume UI (decoder, manager, pacing, loop all shipped — **no `AcquireAsync` caller exists**) | [`task-21`](TASKS/task-21-media-source.md) |
|
||||
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
|
||||
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
|
||||
| 3·20 | ☐ | RewardEvent persistence: canonical `RewardEvents` table + `superChatEvents.list` 30-day backfill (the session report and journey read it) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
|
||||
| 32 | ☐ | Stream resilience: blip retry → measured grace + amber "RECONNECTING" → one-click Back on air → fragmented MP4 → ingest pre-flight | [`task-32`](TASKS/task-32-stream-resilience.md) |
|
||||
| 33 | ☐ | Bandwidth auto step-down: pure policy over dropped-frame rate, one-way within a broadcast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) |
|
||||
| 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) |
|
||||
| 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
|
||||
| 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) |
|
||||
| — | ✅ | **Preview undo (Ctrl+Z / Ctrl+Shift+Z)** — done 2026-11-01: `Models/PreviewHistory` + `MainViewModel.Preview`, one step per gesture, gated on `EditingActive`, cleared on layout load. Property edits not yet in the stack | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| 39·2 | ☐ | YPP slice 2: Analytics scopes, watch-hours/Shorts velocity + ETA | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) |
|
||||
| 49 | ☐ | Chat profanity filter: local, opt-in, **non-persistent**, user-supplied word list | [`task-49`](TASKS/task-49-chat-profanity-filter.md) |
|
||||
| 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) |
|
||||
| 54 | 🔶 | Freemium: EULA + privacy shipped; free Steam Demo and paid app use one codebase; paid app ID and Steam Demo setup still pending | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
|
||||
| 35 | ✅ | **Scene-linked audio** — the scene remembers the room: BRB ships muted, three-state checkboxes per scene, applied once when the scene takes the program output (done 2026-11-10) | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
|
||||
| 37 | ✅ | **Defaults vs current layout split** — immutable SQLite default snapshot, build-stamped current layout, staged-scene and all-scene reset controls (done 2026-11-10) | [`task-37`](TASKS/task-37-defaults-current-split.md) |
|
||||
| 40·A/C/D | ☐ | **Settings round** — active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
|
||||
| 55 | ✅ | **Disk hygiene** — log rotation (5 MB, one roll), temp alert clips deleted on close + swept at startup, low-disk guard (block under 1 GB, warn under 5 GB at start, one warning under 2 GB mid-show) — done 2026-11-10 | [`task-55`](TASKS/task-55-disk-hygiene.md) |
|
||||
| — | ☐ | **Move transition** — per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| 9·5 | ☐ | **Webcam identity key reconciliation** — one stable identity per camera across re-plugs | [`task-09`](TASKS/task-09-live-stream-management.md) |
|
||||
| 14 | 🔶 | **Creator feedback batch** — Branch 2 shipped; Branch 3 remaining: webcam blank/standby, opacity+geometry consistency, Show-in-folder on stop (mic-gain slice closed after creator test 2026-11-10) | [`task-14`](TASKS/task-14-creator-feedback-batch.md) |
|
||||
| 65 | ✅ | **Replay clips** — 10/15 s (default 10), RAM ring of fragmented MP4, F10 saves (inert during a Live replay); layer plays once then hides in Live, loops the kept 3/4/5 elsewhere; silent by default (done 2026-11-10) | [`task-65`](TASKS/task-65-replay-clips.md) |
|
||||
| 66 | ☐ | **Timed ticker messages** — "every N minutes show this", from a creator-written list | [`task-66`](TASKS/task-66-timed-ticker.md) |
|
||||
| 67 | ☐ | **Per-event alert videos** — a clip per monetization event, falling back to the default | [`task-67`](TASKS/task-67-per-event-alert-videos.md) |
|
||||
| 68 | ☐ | **Editing while live** + first-run On-Air state matching the 1-2-3 screen | [`task-68`](TASKS/task-68-edit-while-live.md) |
|
||||
| 69 | ☐ | **Chat sample-preview toggle** — per-layer control to show/hide mock chat in the preview; real messages unaffected, never output | [`task-69`](TASKS/task-69-chat-preview-toggle.md) |
|
||||
| 70 | ☐ | **Window App layer** — capture one selected running app window as a visual layer over Live's game/display capture | [`task-70`](TASKS/task-70-window-app-layer.md) |
|
||||
| 36 | ☐ | **Gold pass** — the v1 gate: screens/layers audit (every control does something), native-Windows verification, branding-flash decision. Always the last unit | [`task-36`](TASKS/task-36-gold-pass.md) |
|
||||
| 34 | ❌ | **Scheduled streams — retired 2026-11-09.** YouTube owns scheduling *and* the reminder that makes it useful; the app neither creates nor displays scheduled broadcasts, and there is no value in a copy that can fall out of sync | [`decisions`](docs/ai/decisions.md) |
|
||||
| 50 | ❌ | **Publish recording — retired 2026-11-09.** YouTube Studio owns upload (resumable, quota-aware, and it explains rejections). In-app publishing would also widen the OAuth consent to whole-channel video management. The recording lands in the creator's folder; the app adds "Show in folder" instead | [`decisions`](docs/ai/decisions.md) |
|
||||
| 54 | 🔶 | Freemium: EULA + privacy shipped; one codebase for free Demo + paid app. **Blocked on Valve** — identity verification pending, tax review up to 30 business days | [`task-54`](TASKS/task-54-marcom-free-merch.md) |
|
||||
| — | 🔶 | **Steam ownership** — `BIsSubscribedApp(paidAppId)` + Debug fake + app-wide `IsAppOwned`; paid app ID awaits Steamworks | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ❌ | **Polar subscription path** — retired 2026-11-01 with the one-price model. Design kept in `decisions.md`, not built | [`decisions`](docs/ai/decisions.md) |
|
||||
| — | ✅ | **OutputProfile gate** — resolver + `EncoderOptions` wiring (`Models/OutputProfile.cs`, one ffmpeg/two outputs, dev override); higher tiers follow `IsAppOwned` | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ✅ | **Demo conversion UI** — owned users get higher tiers; non-owners see greyed tiers, and selecting one opens About with conversion copy and an explicit llamacasty.com link. No browser opens until the link is clicked | [`commercial`](docs/ai/commercial.md) |
|
||||
| — | ✅ | **System-message strip replaced by Logs tab** — current launch's newest 500 raw AppLog entries, newest-first; no prior-launch import or logging-policy changes | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| — | ☐ | **Steam packaging** — `publish` output + `steam_api64.dll`, paid app and associated Demo setup, depot, `steamcmd` upload, store pages + assets | [`Distribution.md`](Distribution.md) |
|
||||
| — | ☐ | **Training-video series** — capture the first 8–10 recurring questions as videos on `@llamacasty`; each is documentation, channel content and monetization at once | MARCOM → support→content loop |
|
||||
| 9·5 | ☐ | Webcam identity key reconciliation | [`task-09`](TASKS/task-09-live-stream-management.md) |
|
||||
| 14 | 🔶 | Creator feedback batch — branch 3+ | [`task-14`](TASKS/task-14-creator-feedback-batch.md) |
|
||||
| — | ❌ | Retired: `StreamConfig` defaults are unused; encoder output comes from the selected quality tier | [`Models/index.md`](Models/index.md) |
|
||||
| 36 | ☐ | **Gold pass** — the v1 gate, always the last unit (see 1.0 gates) | [`task-36`](TASKS/task-36-gold-pass.md) |
|
||||
| 48 | ⏸ | Distribution & packaging — route settled 2026-10-31 (winget/portable first; Steam + Store parked). Build steps: [`Distribution.md`](Distribution.md); starts after the entitlement seam | [`task-48`](TASKS/task-48-distribution-msix.md) |
|
||||
| 10 | ❌ | Retired 2026-10-08; superseded by the TASK 54 rows above — the Polar design it described is rebuilt as a lease (`commercial.md`), not revived as written | [`task-10`](TASKS/task-10-monetization.md) |
|
||||
|
||||
## Pending the creator's hands (not programming)
|
||||
|
||||
- ✅ App Settings grouping + right-click gear — reported working 2026-10-29.
|
||||
- ✅ Transition picker/durations, Wipe, preview + program transitions — reported working 2026-10-29.
|
||||
- ✅ Brand logo layer — reported working 2026-10-29.
|
||||
- ⏳ Vertical-tier camera check (Live tile inside the band, Chat rect-only) — outstanding.
|
||||
- ⏳ **Webcam cadence**: a Release take in the failing geometry (vertical tier first) blocks Units 1–2.
|
||||
No fix until a reading names the stage.
|
||||
|
||||
## Creator-requested follow-ups (park for Day 6)
|
||||
|
||||
- Group controls under **This PC** and **YouTube**.
|
||||
- ON-AIR opens a preflight using the saved visibility default and an explicit **Start Broadcast — [scope]** action.
|
||||
- Persistent Help icon immediately left of Edit opens the creator-triggered, app-only 1-2-3 first-broadcast guide. Keep copy top-aligned and high-contrast; never composite the guide into broadcast output.
|
||||
- Add YouTube category selection; send tags through the video API; support thumbnail upload.
|
||||
- Add tooltips to settings tabs and make Logs wrapping effective.
|
||||
| — | ☐ | **Steam packaging** — `publish` output + `steam_api64.dll`, paid app and Demo, depots, `steamcmd` upload, store pages + capsule assets, measured system requirements | [`Distribution.md`](Distribution.md) |
|
||||
| 48 | ⏸ | Distribution & packaging (winget/portable) — parked behind the entitlement seam | [`task-48`](TASKS/task-48-distribution-msix.md) |
|
||||
| — | ☐ | **Training-video series** — 32 subjects listed in `youTubeTrainingVideos.md`; each is documentation, channel content and support deflection at once | `youTubeTrainingVideos.md` |
|
||||
|
||||
## 1.0 gates (before the first shipped build)
|
||||
|
||||
- **Release visibility flow** — default to Private; let the creator choose Private/Unlisted/Public
|
||||
in the ON-AIR preflight and change it live through `UpdateBroadcast`; keep development/test runs
|
||||
forced Private. Hide the separate TEST button as already decided for v1.
|
||||
- **Kill the dev multi-instance affordance** — `Helpers/InstanceProfile.cs` is already `#if DEBUG`;
|
||||
re-prove it on a Release build.
|
||||
- **Release engineering** — `scripts/publish.sh` **done** (self-contained win-x64, `ffmpeg\`
|
||||
guarded, no test artifacts), `LlamaCasty.exe` assembly rename **done**, full license texts beside
|
||||
`THIRD-PARTY-NOTICES.txt` (TASK 4 req 9). Outstanding: Debug-only `InternalsVisibleTo`.
|
||||
- **Gold pass** (TASK 36): the screens/layers audit, native-Windows verification, and the
|
||||
branding-flash decision.
|
||||
- **Release visibility flow** — default to Private; let the creator choose Private/Unlisted/Public at
|
||||
creation and change it live through `UpdateBroadcast`; keep development/test runs forced Private.
|
||||
- **Kill the dev multi-instance affordance** — `Helpers/InstanceProfile.cs` is `#if DEBUG`; re-prove on a
|
||||
Release build.
|
||||
- **Single-instance guard** — Release has none: two launches share one layout DB (read-whole/write-whole)
|
||||
and one audio pipe. A named mutex that activates the first window.
|
||||
- **Gold pass** (TASK 36).
|
||||
- Release engineering: `publish.sh` **done**, exe rename **done**, full licence texts beside
|
||||
`THIRD-PARTY-NOTICES.txt` needed; Debug-only `InternalsVisibleTo` outstanding.
|
||||
|
||||
## Research
|
||||
|
||||
|
||||
@@ -474,3 +474,55 @@ These were explicitly ruled out:
|
||||
default. Tests: `BrandLogoTests`. On-screen check pending.
|
||||
- ☐ **Cube** — GPU path gated on capability; CPU path only if it holds 60fps.
|
||||
- Gear: right-click opens App Settings (built, on-screen check pending). Left-click menu stays for now.
|
||||
|
||||
---
|
||||
|
||||
# 2026-11-09 trim — completed rows and the retired surge plan
|
||||
|
||||
Moved out of `TASKS.md` when the queue was refactored (creator ruling: finish the queue, then the new
|
||||
features). Nothing here is active; the reasons for the two `❌` rows live in `docs/ai/decisions.md`.
|
||||
|
||||
## Completed
|
||||
|
||||
| Task | Scope |
|
||||
|---|---|
|
||||
| 53 | Output-format contract — six size/FPS modes, vertical YouTube playback, recordings visually accepted |
|
||||
| 3·17 | Text layer — content, font, colour, alignment, drag/resize, persisted (2026-11-04) |
|
||||
| 21 | Media source — video file decodes, composites, plays audio, loops, stops with last reference (2026-11-05) |
|
||||
| 64 | Green/Blue Screen — detect + key from the decode pipe, opt-in auto-apply, alert clips included (2026-11-05) |
|
||||
| 3·20 | Session report — derived from `superChatEvents.list` on demand, never persisted (2026-11-05) |
|
||||
| 39·2 | YPP slice 2 — three eligibility bars, dated thresholds, "x of 3 criteria met" (2026-11-05) |
|
||||
| 32 | Stream resilience — fragmented MP4, blip retry, poll-driven RECONNECTING, ingest pre-flight, one-click Back on air |
|
||||
| 33 | Auto step-down — cause-classified (machine → shed, fps, resolution; upload → bitrate), one-way, floors |
|
||||
| — | Preview undo (Ctrl+Z / Ctrl+Shift+Z) |
|
||||
| — | OutputProfile gate + Demo conversion UI |
|
||||
| — | System-message strip replaced by the Logs tab |
|
||||
|
||||
## Retired rows
|
||||
|
||||
| Task | Why |
|
||||
|---|---|
|
||||
| 49 | Chat profanity filter — YouTube owns moderation (2026-11-05) |
|
||||
| 10 | Polar subscription path — superseded by the one-price model (2026-11-01) |
|
||||
| — | `StreamConfig` defaults — no production call sites |
|
||||
| 34 | Scheduled streams — retired 2026-11-09, platform's surface |
|
||||
| 50 | Publish recording — retired 2026-11-09, platform's surface |
|
||||
|
||||
## The surge plan (2026-11-02 → 11-07) — superseded
|
||||
|
||||
Six days of assigned work, written before the queue was re-ordered. Kept for the record only; the live
|
||||
order is in `TASKS.md`:
|
||||
|
||||
| Day | Units |
|
||||
|---|---|
|
||||
| 1 | Licensing: ownership check, greyed tiers, About conversion, Logs tab |
|
||||
| 2 | Release blockers: visibility flow, 30/60-fps gate, vertical resolution, webcam identity, Move |
|
||||
| 3 | Resilience: TASK 32 + 33 |
|
||||
| 4 | Content & media: text layer, media wiring, profanity filter (retired), scheduled streams (retired) |
|
||||
| 5 | Data & settings: reward persistence, YPP slice 2, defaults split, scene audio, settings round, disk hygiene |
|
||||
| 6 | Finish: publish recording (retired), undo, feedback batch, gold pass, sweep |
|
||||
|
||||
## Creator hands — cleared 2026-11-09
|
||||
|
||||
- Vertical-tier camera check: 1–6 pass (Live tile inside the band, Chat rect-only, 16:9 restored exactly).
|
||||
- Webcam cadence, vertical geometry: 29.8 distinct images/s — the 25.6/s failure does not reproduce.
|
||||
|
||||
@@ -35,10 +35,14 @@
|
||||
**Creator rulings:** a free-subscriber mention is a plain chat row — NO sub alert (YouTube
|
||||
emits no public event for free subs), NO viewer count (vetoed), and every card draws the
|
||||
"made with LlamaCasty!" brand line (each broadcast = free product placement). **Precursor
|
||||
(still open): the reward-event persistence half — the canonical `RewardEvents` SQLite table
|
||||
(all six types persisted per broadcast) + `superChatEvents.list` backfill + the session-report
|
||||
rollup (TASK 10 → "Related work — monetization awareness"). The shipped alert path parses
|
||||
events into `ChatMessage` directly in memory; the report reads that table once it exists.**
|
||||
✅ **RESOLVED 2026-11-05 — derived, not persisted.** The creator's ruling: YouTube is the authority
|
||||
for monetization, so the app asks it on demand instead of keeping a ledger. `Services/SuperChatEventService.cs`
|
||||
reads `superChatEvents.list` (existing `auth/youtube` scope — no re-consent), `Models/SessionRewardSummary.cs`
|
||||
rolls it up for the session window with **per-currency totals that are never summed together**, and the YPP
|
||||
drawer shows "THIS SESSION" counts. No table, no migration, no backfill, and the published promise that no
|
||||
viewer/member/donor information is retained stays literally true.
|
||||
**Money is off by default** ("Show monetary metrics", in the drawer, persisted): counts are neutral, and a
|
||||
creator who never monetized is never shown a zero. Tests: `SessionRewardReportTests`.**
|
||||
21. ✅ **Show Desktop toggle** — `Source.ShowDesktop` bool persisted in DB (schema migration + LayoutStore read/write). When checked, primary monitor captures regardless of fullscreen game state. Toggling off falls back to running game or releases to static placeholder. `ClearBackdropCaptureAsync` clears `CaptureKey` + `VideoImageSource` so static fallback renders. Toggle on Live backdrop context menu (IsCheckable MenuItem).
|
||||
22. ✅ **Top bar — status light + avatar + Log In button** — red/green/pulsing ellipse (disconnected/connected/live). Avatar border visible only when connected, loads via `BitmapImage` code-behind. "Log In" button visible when disconnected, calls `StartStreamCommand` → GoLiveWindow. `ShowStartStream` now requires `IsConnected`.
|
||||
23. ✅ **Chat preview — one-at-a-time mock messages** — `RunMockChatPreviewAsync`: simple async loop, displays `MockChatMessages[n]` via `Take(n+1)`, 1000ms between messages, wraps at end. `ChatPreviewEnabled` property on Source (default true). Stops on real messages, restarts on fade timer clear.
|
||||
|
||||
@@ -26,6 +26,9 @@ until a frame source exists, so the compositor is ship step 1. The pipeline is
|
||||
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
|
||||
2. **RTMP push** — **FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push. **No self-heal (claim-check 2026-09-01): ffmpeg's reconnect flags are input-side; an RTMP *output* push does not reconnect itself — retry is app-side, owned by TASK 32.** **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL-shared win64** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract `ffmpeg.exe` **plus the `libav*.dll` family**) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
|
||||
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
|
||||
> **Superseded 2026-11-12:** the shipped ladder is 1080p60@12, 1080p30@10, 720p@6, vertical mirroring
|
||||
> its landscape pair, and the rate is CBR (`docs/ai/ui-surface.md`, `docs/ai/output.md`). The numbers
|
||||
> below are the original decision, recorded as history — the flat 8 was never derived from anything.
|
||||
1. 720p30 @ 6 Mbps
|
||||
2. 720p60 @ 6 Mbps
|
||||
3. 1080p30 @ 8 Mbps
|
||||
|
||||
@@ -22,3 +22,15 @@
|
||||
13. ✅ Web source URI hidden for webcam — URI section was showing for webcam due to WPF binding path `SelectedElement.Type` not resolving on `WebcamSceneConfig`; replaced with `IsWebSource` virtual property on `SceneElement` (mirrors existing `IsWebcam` pattern), overridden in `Source` to return `Type == SourceType.WebSource`, bound via `BoolToVis`
|
||||
14. ✅ Check/revert consistent order + accept — all three element sections (webcam border, countdown timer, web URI) now use consistent [✕][✓] button order; green check buttons enabled with click handlers that save current values as snapshot baseline (so revert undoes to last accepted state, not initial selection)
|
||||
|
||||
|
||||
### Branch 3 — queued 2026-11-09 (creator testing notes)
|
||||
|
||||
| # | Slice | Why |
|
||||
|---|---|---|
|
||||
| 15 | ✅ **Mic input gain closed 2026-11-10** — creator tested the mic at −2.6 dB and reports it works great; closed by creator direction | No additional input-gain stage requested |
|
||||
| 16 | ☐ **Webcam blank / standby** — a visibility hotkey for the webcam layer (tenth registration), optional standby media while hidden | "My webcam has to go away *now*." The privacy blur covers the screen case; this covers the camera in one keystroke |
|
||||
| 17 | ☐ **Opacity + geometry consistency** — every layer type exposes the same opacity control and the same position/size affordances | Parity across layer types. Geometry fields stay, but collapse behind a "Geometry" disclosure: keyboard-only and screen-reader users cannot drag a layer, and precise alignment between two monitors is otherwise guesswork |
|
||||
| 18 | ☐ **Show in folder** — on the stop notification, reveal the finished recording | Publishing is retired to YouTube (TASK 50); the app's remaining job is "where did my file go?" |
|
||||
|
||||
One Good Dog test per slice. If any of them turns out to be a redesign rather than a fix, it gets its own
|
||||
task number instead of hiding in this batch.
|
||||
|
||||
@@ -5,54 +5,48 @@
|
||||
|
||||
**Goal:** play video files (MP4, MOV, AVI) into scenes — starting soon videos, BRB loops, intro/outro clips.
|
||||
|
||||
### Status: 🔶 In progress — Increment A (model + persistence) + Increment B (decoder) + slice 1 (manager + resolver/preview wire-in) + slice 2a/2b (ffprobe probe + native-FPS pacing) + slice 3 (loop mechanism) shipped. **Remaining: the UI picker slice (AddMedia command + file dialog + Acquire/Release wiring + loop-flag wiring) — GUI, handed off to build/verify natively on Windows (see HANDOFF → "UI picker slice — handoff spec").**
|
||||
### Status: ✅ Complete 2026-11-05 — a video file added in the (+) menu decodes, composites into the
|
||||
preview and the stream, loops on the creator's toggle, and stops when the layer or its last scene
|
||||
reference goes away, and **the file's own soundtrack plays into the stream** (item 10).
|
||||
|
||||
1. ✅ `MediaSourceType` enum: `Video`, `Audio` (audio-only files via media source)
|
||||
2. ✅ `SourceType.MediaSource` addition to the enum
|
||||
3. ✅ `MediaSourceModel`: `MediaPath`, `MediaIsLooping`, `MediaVolume` (0-1), `MediaPlaybackState` — persisted in LayoutStore (schema migration + SELECT/INSERT + round-trip test)
|
||||
4. ✅ `MediaVideoSource` implements `IMediaFrameSource`: FFmpeg raw video decoder → `VideoFrame` pipeline — spawns ffmpeg `-f rawvideo -pix_fmt bgra`, drains the pipe via pure `RawVideoFrameReader` (`Services/RawVideoFrameReader.cs`), raises `FrameAvailable`/`Completed`; lifecycle is `StartAsync`/`StopAsync`; decode process behind `IDecodeProcess`/`FfmpegDecodeProcess` seam (binary-stdout mirror of `IEncoderProcess`). Shipped 2026-08-31; refactored onto `IMediaFrameSource` with this slice.
|
||||
4b. ✅ Slice 2a — native-FPS probe seam: `FfmpegFrameRateParser` (pure, prefers `avg_frame_rate=` then `r_frame_rate=`, rational N/N/M, unknown→null) + `IFrameRateProbe`/`FfmpegFrameRateProbe` (derives sibling `ffprobe.exe` from the located ffmpeg dir, reuses the `IDecodeProcess` seam; null if ffprobe absent) + `FfmpegLocator.ProbeFileName` now also extracts `ffprobe.exe` from the pinned archive (conditional). Tests: `FfmpegFrameRateParserTests` (6 pure units + 1 probe integration via fake locator/process) + `FfmpegLocatorTests` still green — 15/15.
|
||||
5. ✅ `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func<string, IMediaFrameSource?>` factory seam; `AcquireAsync`/`ReleaseAsync`/`ReleaseAllAsync`/`GetLatestFrame`; coalesces frames onto the UI dispatcher onto a single shared `WriteableBitmap` per file (mirror of `ScreenCaptureManager`); `MediaFailed` + `PreviewBitmapChanged`. Unit tests + one integration test (bitmap share/coalesce) — `MediaVideoSourceManagerTests`, 5/5 pass.
|
||||
6. ✅ Native-FPS pacing (slice 2b): `MediaVideoSource` takes optional `IFrameRateProbe?` + `Func<TimeSpan,CancellationToken,Task>? delay` seams, probes FPS once in `RunAsync`, and delays by 1/fps after each emitted frame; unknown/absent probe → no pacing. Test: `MediaVideoSource_PacesFramesByProbedFps` (fake probe + recording delay, one delay per frame ≈ 1ms).
|
||||
7. ☐ Wire into `FramePump` resolver — `Source { Type: MediaSource }` → latest video frame — **resolver + preview routing + manager wired; session acquisition (start/stop on add/remove) still open (comes with the UI picker)**
|
||||
8. ☐ Wire into `SceneCompositor` — render media source as an image element at its position/size
|
||||
9. ✅ Loop control (mechanism) — `IMediaFrameSource.Looping`; `MediaVideoSource` takes a `Func<IDecodeProcess>` process factory and restarts the decode on natural EOF when `Looping` (fresh process per pass, since a `Process` can't be re-`Start()`ed). Test: `MediaVideoSource_LoopsUntilLoopDisabled` (single frame re-emits across passes, `Completed` only after loop cleared). Wiring `Source.MediaIsLooping` into the flag lands with the UI-picker (acquisition) slice.
|
||||
10. ☐ Volume control — per-source volume slider for audio playback
|
||||
11. ☐ UI: file picker (filtered to video formats), loop toggle, volume slider
|
||||
12. ☐ Schema migration for media source settings (file path, loop, volume)
|
||||
13. ☐ Tests: video frame extraction, loop behavior, volume scaling, file validation
|
||||
5. ✅ `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func<string, IMediaFrameSource?>` factory seam; `AcquireAsync`/`ReleaseAsync`/`ReleaseAllAsync`/`GetLatestFrame`/`HasSession`/`GetActiveKeys`/`SetLooping`; coalesces frames onto the UI dispatcher onto a single shared `WriteableBitmap` per file (mirror of `ScreenCaptureManager`); `MediaFailed` + `PreviewBitmapChanged`. Unit tests + one integration test (bitmap share/coalesce) — `MediaVideoSourceManagerTests`.
|
||||
6. ✅ Native-FPS pacing (slice 2b): `MediaVideoSource` takes optional `IFrameRateProbe?` + `Func<TimeSpan,CancellationToken,Task>? delay` seams, probes FPS once in `RunAsync`, and delays by 1/fps after each emitted frame; unknown/absent probe → no pacing. Test: `MediaVideoSource_PacesFramesByProbedFps`.
|
||||
7. ✅ Wire into `FramePump` resolver — `Source { Type: MediaSource }` → latest video frame, and the same
|
||||
resolver serves preview and thumbnails (`FramePump` defaults `resolver` to the frame resolver).
|
||||
8. ✅ Wire into `SceneCompositor` — media renders through the generic `frameFor(element)` path at the
|
||||
element's rect, so no compositor change was needed.
|
||||
9. ✅ Loop control — `IMediaFrameSource.Looping`; `MediaVideoSource` restarts the decode on natural EOF
|
||||
when `Looping` (fresh process per pass). `MediaIsLooping` reaches the session through the manager's
|
||||
per-path `loopProvider` at start and `SetLooping` on a live toggle. Rule (recorded): the path's
|
||||
session loops if **any** live media reference has it on, because one decoder is shared per path.
|
||||
10. ✅ File audio + volume — the shared clip pipe decodes the soundtrack (`f32le -ac 2 -ar 48000 -vn`,
|
||||
50 ms chunks paced to real time, byte-carry across reads) and the layer carries **Mute** + **Level**.
|
||||
The manager applies them per session (one decoder per path, so the last layer touched wins — the
|
||||
alternative would play the same file twice) and the audio joins the DESKTOP/GAME group in
|
||||
`AudioMixer.EnqueueDesktopMediaAudio`, so the desktop fader and the voice ducker behave exactly as
|
||||
they do for game audio and TRAX. Test: `MediaSourceAudioTests` (chunks, level scaling, mute, and the
|
||||
layer's sound reaching the desktop meter).
|
||||
11. ✅ UI: `AddMediaCommand` (+ menu → "Video File", video-filtered `OpenFileDialog`), `ChangeMediaFileCommand`
|
||||
("Change…" in the properties panel), loop toggle, video name display.
|
||||
12. ✅ Schema migration for media source settings (file path, loop, volume) — shipped with item 3.
|
||||
13. ✅ Tests: decode/loop/pacing at the unit level, and `MediaSourceAcquisitionTests` (the Good Dog for the
|
||||
wiring): add a file → one decoder starts, frames reach the layer, loop flip reaches the decoder, remove → stops.
|
||||
|
||||
### Design decisions
|
||||
|
||||
- **FFmpeg handles all formats** — no codec-specific code. FFmpeg already in the project.
|
||||
- **Audio plays through the desktop channel** — media source audio is captured by the WASAPI loopback (like TRAX/game audio). No separate audio routing needed.
|
||||
- **Audio joins the desktop/game group** (creator ruling 2026-11-05) — the clip's sound is decoded by
|
||||
the same pipe as its picture, mixed directly into the stream (not played locally and captured), and
|
||||
grouped with game audio so one fader and the ducker cover it. A per-layer Mute/Level sits on top.
|
||||
Chosen over TRAX-style local playback because it works regardless of the creator's desktop-capture
|
||||
state, and because an alert clip needs the same direct route anyway.
|
||||
- **This pairs with TRAX** — TRAX is background music, media source is background video. Together they make non-Live scenes (Starting/BRB/Ending) feel polished.
|
||||
- **Not a full NLE** — no trimming, no multi-track, no effects. Just "play this video in the scene."
|
||||
|
||||
### UI picker slice — spec (imported from HANDOFF 2026-09-01 so it can't be lost by a rewrite)
|
||||
|
||||
Nothing `AcquireAsync`s a media path yet, so **no frames flow in a running app** — decoder, manager,
|
||||
pacing, and loop mechanism are shipped and unit-tested, but a media Source has no way to start a
|
||||
session. Deliverables, in order:
|
||||
|
||||
1. **File picker + add/remove commands** (mirror `MainViewModel.Trax.cs:68` `OpenFileDialog` usage).
|
||||
A "Media" source action opens an `OpenFileDialog` filtered to video (mp4/mov/avi); on OK set
|
||||
`Source.MediaPath`, add to scene, and **`AcquireAsync(MediaPath)`**; on remove
|
||||
**`ReleaseAsync(MediaPath)`** (refcounted — not `ReleaseAllAsync`, unless the path leaves the
|
||||
layout entirely). On `StagedScene` layout loads/teardown, release every media path no longer
|
||||
present and acquire new ones so sessions track the live layout.
|
||||
2. **Wire `Source.MediaIsLooping` → `IMediaFrameSource.Looping`** — needs a manager-level per-**path**
|
||||
loop provider. Ambiguity to rule + record: sessions are refcounted per `MediaPath` and shared
|
||||
across scenes, but `MediaIsLooping` is per-`Source`. Chosen rule: the path's session `Looping` =
|
||||
**any** live reference has it on (shared behaviour). Record it in the slice commit.
|
||||
3. **Loop toggle + volume slider** in the source context menu / inspector (items 10/11).
|
||||
4. `MediaVolume`/`MediaPlaybackState` wiring — audio path for media is future work (desktop loopback
|
||||
already carries it, TRAX-style; `MediaVolume` slider can drive the local file's audio separately
|
||||
later — out of this slice).
|
||||
|
||||
**Design notes to preserve:** `ResolveOutputFrame` reads `_mediaManager.GetLatestFrame(MediaPath)`;
|
||||
`OnMediaPreviewBitmapChanged` adopts the shared `WriteableBitmap` per path; `MediaFailed` →
|
||||
`OnMediaFailed` (currently `Debug.WriteLine` — surface in UI via toast, TASK 24 stack). The compositor
|
||||
scales any frame size — media renders through the generic `frameFor(element)` path, no compositor
|
||||
change. First GUI smoke test: add a short real mp4 on native Windows, confirm it plays and previews.
|
||||
|
||||
- **Layers are defined in Live first** (creator 2026-11-05): this unit adds the layer and the picker in
|
||||
the ordinary (+) menu, with no per-scene availability rules. Which scenes may host a video layer is a
|
||||
later pass, and scene-only layers (an ending credit roll, for instance) are defined after that pass.
|
||||
|
||||
@@ -11,29 +11,58 @@ the API — creator ruling 2026-09-01): no lying in the copy.
|
||||
|
||||
**Slices (one integration test each, in order):**
|
||||
|
||||
1. ☐ **Blip retry** — app-side encoder restart: push dies while live (ProcessFailed / stdin
|
||||
1. ✅ **Blip retry** (shipped 2026-11-05) — app-side encoder restart: push dies while live (ProcessFailed / stdin
|
||||
backpressure death) → bounded-backoff relaunch (2s/5s/10s, N attempts) against the cached
|
||||
constant reusable-stream ingest URL. Success = push running again while the broadcast is still
|
||||
live; viewers never know. Fixes the map's stale "ffmpeg does reconnect" claim (corrected
|
||||
2026-09-01: reconnect flags are input-side only). Test: fake process fails once then starts →
|
||||
pump recovers without surfacing to the VM.
|
||||
2. ☐ **Measured grace + the sign** — while the push is dead, escalate the existing health poll
|
||||
(30s → ~5s). ON-AIR dot goes **amber "RECONNECTING"** with a mm:ss countdown to the observed
|
||||
YouTube cutoff; the grace length is a **measured, pinned constant** — probe it on a real private
|
||||
test stream, cite the observation (spin-guard rule; do NOT invent a number). Poll reports the
|
||||
broadcast `complete` → dot **red "OFF AIR"**, primary button relabels **"● Back on air"** and
|
||||
pulses. Test: fake status provider drives the dot through live → reconnecting → cut-off.
|
||||
3. ☐ **One-click Back on air** — the relabeled button fires the existing go-live path with saved
|
||||
**Shipped:** `Services/StreamRetryPolicy.cs` (immediate, +1 s, +2 s, +3 s — minimum waits, because
|
||||
YouTube's grace is short), `Services/StreamRecovery.cs` (the loop, with the audio pipe recreated per
|
||||
attempt because its single-instance server is unusable after the old client died), and
|
||||
`FramePump.EncoderFailed` separating a dead push (recoverable) from a fatal pump fault. Each episode
|
||||
reports into the left panel's alert stack; a reconnect that lands on a **complete** broadcast is
|
||||
reported as such instead of showing a live sign over a dead stream. Tests: `StreamRecoveryTests`.
|
||||
2. 🔶 **Poll-driven sign — NO invented countdown** (spec revised 2026-11-05, creator challenge:
|
||||
*"I really don't think that's going to be a constant — it's going to depend on so many variables, some
|
||||
that are dark to us"*). The original plan was a measured grace constant and a countdown to YouTube's
|
||||
cutoff. **Dropped**: YouTube's cutoff is server-side and depends on things we cannot observe
|
||||
(ingest node health, prior stream quality, region, load), so any pinned number is a guess wearing a
|
||||
timer. What ships instead:
|
||||
- **Amber RECONNECTING** in the top bar, with **measured** facts only: elapsed time and attempt
|
||||
count ("RECONNECTING · 0:12 · attempt 2 of 4"). No countdown.
|
||||
- **The status poll speeds up** while recovering (30 s → ~4 s) and drives the truth. Signals used,
|
||||
cheapest first: `liveBroadcasts.list` `status.lifeCycleStatus` (authoritative `complete`),
|
||||
`liveStreams.list` `status.streamStatus`/`healthStatus` (is the push being received), and the
|
||||
**`chatEndedEvent`** YouTube already puts in the chat feed we poll — free, and previously skipped.
|
||||
- At `complete`: **red OFF AIR**, stop retrying, and (slice 3) offer **Back on air**.
|
||||
Test: a fake status provider drives the sign through live → reconnecting → off air; a `chatEndedEvent`
|
||||
triggers an immediate status check rather than a decision of its own.
|
||||
|
||||
3. ✅ **One-click Back on air** (shipped 2026-11-05) — the relabeled button fires the existing go-live path with saved
|
||||
`Broadcast.*` + cached stream, dialog skipped: new broadcast, new VOD (accepted cost, stated
|
||||
in-product). Test: seeded saved form + fake stream service → click creates + pushes, no dialog.
|
||||
4. ☐ **Crash-safe recording** — fragmented MP4 on the record block (`-movflags
|
||||
**Shipped:** `Services/BroadcastTitle.cs` (pure — resume marker replaces rather than stacks, base
|
||||
truncated to YouTube's 100 characters) and `MainViewModel.BackOnAirCommand`, which reuses the ordinary
|
||||
go-live path with the saved details and the cached stream: new broadcast, new VOD, no dialog. The
|
||||
button appears only after a drop ended the session, in place of the normal start button, and the
|
||||
ordinary start button steps aside so there is exactly one way forward. Tests: `BackOnAirTests`.
|
||||
4. ✅ **Crash-safe recording** (shipped 2026-11-05) — fragmented MP4 on the record block (`-movflags
|
||||
frag_keyframe+empty_moov+default_base_moof`): plain MP4's trailing moov atom means a power loss
|
||||
kills the WHOLE file. Rename-on-stop unchanged. Closes the TASK 18 "MKV as an option" design-note
|
||||
remnant (fragmented MP4 chosen over MKV: single container, YouTube-upload-native — verify player/
|
||||
editor tolerance, cite the research). Test: `FfmpegArgs` emits the movflags only on the record block.
|
||||
5. ☐ **Pre-flight** — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout).
|
||||
editor tolerance, cite the research). Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` — the flags are on the record block and
|
||||
absent from the streaming block. Verified by construction only: the file still needs one real
|
||||
interrupted-take check on Windows (kill ffmpeg mid-recording, play the result).
|
||||
5. ✅ **Pre-flight** (shipped 2026-11-05) — before broadcast insert, one TCP probe to the ingest host:1935 (~2s timeout).
|
||||
Unreachable → Error toast, no insert, no ghost broadcast ("check VPN/network"). The "forgot the
|
||||
VPN" story, solved as prevention instead of recovery. Test: fake probe failure aborts go-live before any API call.
|
||||
**Shipped:** `Services/IngestProbe.cs` (`IIngestProbe` + `TcpIngestProbe`, 2 s timeout, rtmp→1935 /
|
||||
rtmps→443) and `MainViewModel.IngestPreflightAsync`, called BEFORE the first API call whenever a
|
||||
cached ingest URL exists (first run probes after resolving the stream, still before any broadcast is
|
||||
created). Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, and the probe is
|
||||
exercised against real sockets (a listening port answers true, the same port closed answers false
|
||||
quickly).
|
||||
|
||||
### Design decisions
|
||||
|
||||
|
||||
@@ -1,21 +1,76 @@
|
||||
# TASK 33 — Bandwidth auto step-down (queued 2026-09-01; v1 per the complete-v1 ruling)
|
||||
# TASK 33 — Automatic step-down when the show can't hold its tier (queued 2026-09-01; v1)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
> Design settled with the creator 2026-11-09. **Not a magenta question of taste: this is the app
|
||||
> admitting it is part of the problem when it is.**
|
||||
|
||||
**Goal:** the stream degrades *predictably and honestly* instead of drowning viewers in freeze frames
|
||||
— and when LlamaCasty itself is the starving process, it says so out loud instead of blaming the
|
||||
creator's connection. The `variable` reusable stream (shipped) makes every step zero-API: a tier or
|
||||
bitrate change is just an encoder relaunch at the same URL.
|
||||
|
||||
**Goal:** when the upload can't hold the tier, drop it automatically instead of drowning viewers in
|
||||
freeze frames. The `variable` reusable stream (shipped) makes this zero-API; the governor that decides
|
||||
WHEN is what TASKS.md:25 always claimed existed and was never built.
|
||||
## Cause first, lever second
|
||||
|
||||
1. ☐ Pure `StepDownPolicy` — sliding window over the encoder's reported dropped-frame rate
|
||||
(`StreamHealth`, already parsed): sustained overrun → downgrade exactly one 16:9 tier
|
||||
(1080p60 → 1080p30 → 720p60 → 720p30). Hysteresis cooldown; **one-way within a broadcast**
|
||||
(no oscillation — a recovered connection doesn't climb back mid-show; the creator may manually
|
||||
step up). Vertical tier stays a manual choice.
|
||||
2. ☐ Restart machinery = TASK 32 slice 1 (tier change is an encoder relaunch at new W×H/FPS over the
|
||||
same pipe/URL; the master canvas is 1920×1080 regardless, so geometry never rewrites).
|
||||
3. ☐ Surface: the resolution badge + dropdown update to the landed tier; ONE Info toast
|
||||
("Quality lowered to hold your stream") — no nagging after that.
|
||||
4. ☐ Tests: pure-policy units (drop-rate windows, one-way, cooldown) + the ONE integration: fake
|
||||
health stream with sustained drops → exactly one lower-tier encoder restart.
|
||||
| Cause | Signal | Lever |
|
||||
|---|---|---|
|
||||
| **Machine starvation** — the PC (often the game) can't make the frame | pump stalls, render time over the frame budget, frames never produced | reduce work: **60 → 30 fps**, then **1080 → 720** |
|
||||
| **Upload congestion** — the socket can't carry the bits | frames produced on schedule but rejected; encoder queue full; **rising submit block time** (pump already logs `avg submit` / `avg wait`) | reduce bits: **8.0 → 6.4 → 5.1 → 4.1 Mbps** (~20% steps), geometry untouched |
|
||||
|
||||
Resolution is **not** a bandwidth lever: 1080p30 and 720p60 are both 6 Mbps in the shipped table, so
|
||||
that step relieves nothing. Fps is **not** a congestion lever: same bitrate, same socket.
|
||||
|
||||
## Shed our own load before asking the viewer to pay
|
||||
|
||||
1. If our capture/convert cadence is what starves, **halve our own capture rate first**
|
||||
(`ScreenCaptureFrameSource.ShedLoad`, 10 ms → 33 ms between conversions) — same output tier, less of
|
||||
the creator's machine. It costs no step and changes nothing the viewer sees.
|
||||
2. Only then step fps, then resolution.
|
||||
|
||||
**Shipped 2026-11-09** — `StepDownPolicy` + `StepDownPlanner` + `FramePump.ChangeEncoderAsync` +
|
||||
`MainViewModel.StepDown`, 18 tests. The shed runs on machine evidence before the budget is touched.
|
||||
|
||||
## Budgets, floors, terminal state
|
||||
|
||||
| Start | Steps allowed | Worst case it can reach |
|
||||
|---|---|---|
|
||||
| 1080p60 | **3, any mix of the two ladders** | 720p30 @ 6.4 Mbps |
|
||||
| 720p60 | **1** | 720p30 @ 4.8 Mbps |
|
||||
| Vertical 1080p60 | **1** | Vertical 1080p30 @ 6.4 Mbps |
|
||||
|
||||
> **Superseded 2026-11-12:** the bitrate floor is the tier's YouTube minimum — 6 Mbps at 1080p60, 5 at
|
||||
> 1080p30, 3 at 720p (`Models/RateLadder`, passed into `StepDownPolicy`) — and the ladder's defaults are
|
||||
> 12/10/6. The table above records the 8-Mbps-era examples, not current output.
|
||||
- Floors: 16:9 never below **720p30**; vertical never below **Vertical 1080p30**.
|
||||
- Budget exhausted → **hold**, keep streaming, one persistent warning naming the cause, manual
|
||||
step-up stays available. Never disconnect, never nag repeatedly.
|
||||
- Trigger: ≥5% dropped over 15 s, twice, 30 s cooldown between steps. Mixed/ambiguous evidence does
|
||||
nothing. One-way within a broadcast (no climb-back; revisit with session data).
|
||||
|
||||
## Own the mistake in plain words
|
||||
|
||||
A toast that reports a mechanism is a bug in the toast. Every message names **what happened, who
|
||||
caused it, and what the app already did** — no exception text, no error codes:
|
||||
|
||||
- ours: *"LlamaCasty couldn't keep up with your scene, so it lowered the frame rate to 30. Your
|
||||
connection wasn't the problem."*
|
||||
- theirs: *"Your upload couldn't hold 8 Mbps, so LlamaCasty dropped it to 6.4 to stop the freezing.
|
||||
It will stay there for this stream."*
|
||||
|
||||
One toast per step, plus one **Logs-tab** line (`quality stepped down 12:04 — encoder` / `— upload`,
|
||||
cause-tagged) so support answers from data, not memory. It is logged rather than added to the session
|
||||
report on purpose: that report is the money summary, and quality chatter in it would bury the numbers. Wording rules live in
|
||||
[`docs/ai/ui-surface.md`](../docs/ai/ui-surface.md) → Toast notifications.
|
||||
|
||||
## Tests
|
||||
|
||||
Pure `StepDownPolicy` units (classifier, both ladders, budgets, floors, cooldown, one-way, ambiguity →
|
||||
no action) + the ONE integration: a fake health stream with sustained drops of each cause produces the
|
||||
matching step and exactly one encoder restart.
|
||||
|
||||
## Reference
|
||||
|
||||
OBS Dynamic Bitrate (27.2) is the established answer for the congestion half: bitrate-only, small
|
||||
steps, hysteresis. **Adopted:** bitrate as the sole congestion lever, stepped with cooldown.
|
||||
**Discarded for v1:** its automatic climb-back (the creator's one-way ruling). **Ours:** the
|
||||
machine-side ladder and "shed our own cost first" — OBS has no equivalent, and that is the half that
|
||||
matters when the game and the app share one machine.
|
||||
|
||||
@@ -7,13 +7,19 @@
|
||||
audio at transitions — the scene carries the room state. OBS needs scripts/plugins; native = the
|
||||
creator-proof pitch.
|
||||
|
||||
1. ☐ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` — `null` = don't touch (the default;
|
||||
**Shipped 2026-11-10.** Columns `Scene.SceneMutesMic` / `SceneMutesDesktop` (nullable = passive),
|
||||
three-state checkboxes under the layers list ("When this scene goes live"), applied once at the program
|
||||
transition in `TransitionToScene`. `SceneAudioTests` pins the whole rule: offline staging edits nothing,
|
||||
`null` never overrides a manual mute, a scene with an opinion wins on its next entry, and all three
|
||||
states survive a save/reload.
|
||||
|
||||
1. ✅ `Scene` gains nullable `SceneMutesMic` / `SceneMutesDesktop` — `null` = don't touch (the default;
|
||||
scenes are passive until told). Schema column bump + roundtrip.
|
||||
2. ☐ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry — never on
|
||||
2. ✅ Applied on **live transitions only** (thumbnail click / hotkey), ONCE at entry — never on
|
||||
offline staging (no surprise stream-state edits while auditioning).
|
||||
3. ☐ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry.
|
||||
4. ☐ BRB ships `SceneMutesMic = true`; other scenes default `null` — creator's choice.
|
||||
5. ☐ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome.
|
||||
6. ☐ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override
|
||||
3. ✅ The creator is boss: a manual mute/unmute mid-scene is never overridden until the next entry.
|
||||
4. ✅ BRB ships `SceneMutesMic = true`; other scenes default `null` — creator's choice.
|
||||
5. ✅ Edit UI: two checkboxes in the scene's Properties section (left panel, edit mode) — no new chrome.
|
||||
6. ✅ Test (the ONE): transition applies scene state; `null` leaves state untouched; manual override
|
||||
survives until the next entry.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TASK 37 — Defaults vs current layout split
|
||||
|
||||
**Status:** ☐ Queued (2026-09-20)
|
||||
**Status:** ✅ Complete (2026-11-10)
|
||||
|
||||
## Idea (creator directive 2026-09-20)
|
||||
|
||||
@@ -43,6 +43,20 @@ scene[0-4].layerList.[default | current].elementList
|
||||
- A `current` stamped by a different (older/newer) build-id falls back to `default`.
|
||||
- Revert-to-default restores `default` for one scene or all.
|
||||
|
||||
## Shipped 2026-11-10
|
||||
|
||||
- Current scene/source tables remain the live workspace. One-time `Default*` snapshot tables and
|
||||
background-path settings live in the same SQLite file; schema additions are mirrored into the
|
||||
snapshots. Existing profiles promote their saved layout once; a fresh profile snapshots its seeded
|
||||
layout before creator edits. Default asset references are retained during current-layout saves.
|
||||
- Startup restores the snapshot transactionally when the current build stamp differs. Matching builds
|
||||
keep the saved current layout. Camera identity is never replaced by a layout restore; default scene
|
||||
placements are rebound to the creator's currently selected camera.
|
||||
- App Settings offers **Restore staged scene** and **Restore all scenes**, disabled while live or
|
||||
recording. A one-scene reset saves pending changes in other scenes and keeps that scene staged.
|
||||
- Integration coverage: `LayoutStoreSnapshotTests` and `LayoutResetIntegrationTests`.
|
||||
**615 tests green; clean build, 0 warnings.**
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `ai.md` — persistence architecture; `MyMistakes.md` — prior reorder persistence failures.
|
||||
- `ai.md` — persistence architecture; `MyMistakes.md` — prior reorder persistence failures.
|
||||
|
||||
@@ -91,7 +91,22 @@ MyMistakes.
|
||||
- 2FA: `https://myaccount.google.com/signinoptions/two-step-verification`
|
||||
- AdSense: `https://support.google.com/youtube/answer/72851`
|
||||
|
||||
## Slice 2 (outline, queued)
|
||||
## Slice 2 — ✅ SHIPPED 2026-11-05 (the three bars)
|
||||
|
||||
Shipped as designed: `yt-analytics.readonly` behind `IWatchMetricsSource`, three bars (subscribers,
|
||||
long-form watch hours, Shorts views) against `YppThresholds.Tier2Now`, and one line —
|
||||
"x of 3 criteria met — YouTube reviews eligibility." Notes recorded with it:
|
||||
|
||||
- **The view routes are either/or**, so the line is qualified in the drawer ("subscribers plus watch
|
||||
hours, or subscribers plus Shorts views"); `YppProgress.MeetsPublishedRule` encodes that shape.
|
||||
- **Thresholds carry a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a test
|
||||
fails once that reading is older than 180 days — YouTube moves the bar and a stale bar must not look
|
||||
current. The 2027-02-01 doubling is data, not a code change.
|
||||
- **No analytics scope → no invented numbers**: the drawer says what is missing instead of drawing
|
||||
empty bars as zero.
|
||||
- Money stays behind the drawer's "Show monetary metrics" toggle (TASK 3·20).
|
||||
|
||||
## Slice 2 (original outline, for reference)
|
||||
|
||||
`yt-analytics.readonly` (+ re-consent) → `YouTubeAnalyticsService` behind `IChannelStatsProvider`
|
||||
(`ai.md`): long-form `estimatedMinutesWatched` (12-mo), Shorts-feed views (90-d), then velocity /
|
||||
|
||||
@@ -50,6 +50,11 @@ separate scope-checked work unit with its own Good Dog integration test.
|
||||
|
||||
## Unit A — App Settings "Active Web Camera" (your ask 1)
|
||||
|
||||
> **Plan drift corrected 2026-11-12:** App Settings is no longer the `OverlayHost.xaml` StackPanel —
|
||||
> it is the right-edge drawer `Controls/SettingsDrawer.xaml`, opened by `IsSettingsDrawerOpen`
|
||||
> (creator ruling 2026-11-01). The camera group belongs under "Default Location for Recordings"
|
||||
> there. Everything else below still holds.
|
||||
|
||||
New group **under** Recordings in the App Settings overlay (`OverlayHost.xaml:39` settings
|
||||
StackPanel): current camera display + **Change…** + **Tune…**.
|
||||
|
||||
|
||||
@@ -4,7 +4,30 @@
|
||||
> **Research: [`TASKS/research-store-certification.md`](research-store-certification.md) §12.**
|
||||
> Carved out 2026-09-27; shares a scope lock with nothing else.
|
||||
|
||||
**Status:** ☐ Queued — not blocked (does not depend on the route decision)
|
||||
**Status:** ❌ **Retired 2026-11-05** — YouTube owns chat moderation (creator ruling). What replaced it:
|
||||
`Services/YouTubeChatService.cs` no longer turns YouTube's own moderation markers into chat rows
|
||||
(`tombstone`, `hasDisplayContent:false`, chat-mode events), with tests in
|
||||
`ytLive.Tests/ChatModerationParseTests.cs`. The reasoning and citations are below and in
|
||||
`docs/ai/decisions.md`; the body of this file is kept so the option is not re-derived from scratch.
|
||||
|
||||
### Why retired
|
||||
|
||||
- YouTube's **blocked words** filter ("block live chat messages that contain or closely match certain
|
||||
words") and **hold for review** both run upstream of the API: blocked/held messages are not returned
|
||||
by `liveChatMessages.list` (community evidence: held messages never appear —
|
||||
<https://stackoverflow.com/questions/74511524/>). Ours would be a second list, a second set of false
|
||||
positives and a second support surface for a switch the creator already owns.
|
||||
- Deleted messages arrive as a `tombstone` — "signifies that a message used to exist … it has since
|
||||
been deleted … only liveChatId, type and publishedAt are present"
|
||||
(<https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages>). The parser had
|
||||
no filter, so a deletion rendered as a **blank chat row**. That is the actual defect, and it is fixed.
|
||||
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented in
|
||||
the resource but have not been delivered to clients for years
|
||||
(<https://stackoverflow.com/questions/44346893/>), so no client-side moderation machinery can be built
|
||||
on them. The parser skips them if they ever do arrive.
|
||||
|
||||
The constraints below are therefore **moot for us and binding on YouTube** — except constraint 4, which is
|
||||
why the Super Chat parse path still asserts that reward fields are never reinterpreted.
|
||||
|
||||
**Goal:** let the creator decide what reaches the projected chat box, without the app ever
|
||||
becoming a moderator that stores what people said.
|
||||
|
||||
@@ -30,9 +30,9 @@ ticket and a trust breach.
|
||||
Guard: `ytLive.Tests/AppLogRotationTests.cs` (the ONE test: the log rolls at the cap, the
|
||||
live file starts fresh, and the directory still holds exactly `startup.log` + `startup.1.log`
|
||||
after a SECOND roll — the growth is bounded).
|
||||
2. ☐ **Temp alert clip cleanup** — delete the `%TEMP%` alert clip on app close (and sweep
|
||||
2. ✅ **Temp alert clip cleanup** — `Services/AlertClipCleanup` deletes the session's clip on close and sweeps `ytLive-alert-*.mp4` at startup (never anything else); shipped 2026-11-10 — delete the `%TEMP%` alert clip on app close (and sweep
|
||||
stale ones at startup). Test: create, close, assert gone.
|
||||
3. ☐ **Low-disk guard for recordings** — check free space on the record folder's drive before
|
||||
Record starts, and warn (toast) when it runs low mid-recording. Thresholds TBD with the creator.
|
||||
3. ✅ **Low-disk guard for recordings** — `Services/DiskSpaceGuard`: refuse to start under **1 GB** (toast, recording not started, stream continues), warn under **5 GB** at start, one mid-show warning under **2 GB**; shipped 2026-11-10 — check free space on the record folder's drive before
|
||||
Record starts, and warn (toast) when it runs low mid-recording. Thresholds chosen for 8 Mbps ≈ 3.6 GB/h; revisit if tiers change.
|
||||
|
||||
**Design decisions:** keep `AppLog`'s call surface unchanged — rotation lives inside it.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user