Compare commits
166
Commits
1c2eedcaba
..
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 | ||
|
|
d195c29f79 | ||
|
|
5f7bcc2425 | ||
|
|
5c4acf141b | ||
|
|
58eb640c4e | ||
|
|
60c4a0032f | ||
|
|
106896c408 | ||
|
|
7216c95449 | ||
|
|
1993e7cc8b | ||
|
|
ede6ecac8e | ||
|
|
bde2a4cf4b | ||
|
|
4b7e798757 | ||
|
|
f921c0174b | ||
|
|
6310f50c27 | ||
|
|
76e8c22815 | ||
|
|
a6aea5bf26 | ||
|
|
01a3fcaaf7 | ||
|
|
264a90dae8 | ||
|
|
c856d11fb5 | ||
|
|
c9bd7e6093 | ||
|
|
e6a7147dc3 | ||
|
|
0e1f948189 | ||
|
|
caf91e6564 | ||
|
|
82609a5046 | ||
|
|
91e308e8e2 | ||
|
|
221b81628a | ||
|
|
3985a413a7 | ||
|
|
9fa06c67c2 | ||
|
|
20f059422b | ||
|
|
6208cb9409 | ||
|
|
ff5e8d94ae | ||
|
|
54902cb838 | ||
|
|
790cff45c2 | ||
|
|
b3d7cf30b2 | ||
|
|
31b655cecb | ||
|
|
0fdd9d5faf | ||
|
|
41b3933b08 | ||
|
|
d6088f8e35 | ||
|
|
c297b3fb0f | ||
|
|
8221bd6478 | ||
|
|
82960d0ca3 | ||
|
|
5d836aecbb |
@@ -141,8 +141,9 @@ line points at the file that explains it.
|
||||
→ `docs/ai/ui-surface.md`
|
||||
- **Licensing: no non-CC0 icon art, no dropped `THIRD-PARTY-NOTICES.txt`, no
|
||||
licence-incompatible dependency, no unvetted ML model.** → `docs/ai/product.md`
|
||||
- **The private-only broadcast lock is deliberate** — do not unlock it, do not raise it as a
|
||||
question; it ends at v1 by the creator's ruling. → `docs/ai/product.md`
|
||||
- **Force Private only for development/test runs.** The released creator flow offers Private,
|
||||
Unlisted, or Public, defaults to Private, and allows live visibility changes through
|
||||
`UpdateBroadcast`. → `docs/ai/youtube-api.md`
|
||||
- **Read render numbers in Release only** — Debug pixel paths are several× slower.
|
||||
→ `docs/ai/environment.md`
|
||||
|
||||
|
||||
+7
-2
@@ -19,12 +19,17 @@ public partial class App : Application
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
AppLog.Write(args.ExceptionObject is Exception ex
|
||||
? "AppDomain unhandled exception: " + ex
|
||||
: "AppDomain unhandled exception object: " + args.ExceptionObject);
|
||||
: "AppDomain unhandled exception object: " + args.ExceptionObject,
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class1);
|
||||
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||
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();
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
to a window or reference it from code that compiles in Release; the only host is
|
||||
OverlayHost.xaml.cs inside #if DEBUG. -->
|
||||
<StackPanel>
|
||||
<TextBlock Text="Developer" Foreground="{StaticResource Brush.Attention}" FontSize="11"
|
||||
FontWeight="SemiBold" Margin="0,10,0,2"/>
|
||||
<TextBlock Text="Developer" Style="{StaticResource PropertyHeader}"
|
||||
Foreground="{StaticResource Brush.Attention}"/>
|
||||
|
||||
<CheckBox Margin="10,2,0,0" IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
|
||||
Foreground="{StaticResource Brush.OnSurface}"
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,2,0,0"
|
||||
IsChecked="{Binding DeveloperMode, Mode=TwoWay}"
|
||||
Content="Developer mode — all resolutions, output budget ignored"/>
|
||||
|
||||
<TextBlock Text="Debug builds only. Overrides the free output profile (921,600 px / 30 fps). Never present in a release build."
|
||||
Foreground="{StaticResource Brush.Attention}" FontSize="10" TextWrapping="Wrap"
|
||||
Style="{StaticResource PropertyHint}" Foreground="{StaticResource Brush.Attention}"
|
||||
Margin="10,4,0,8"/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
+576
-229
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
+85
-57
@@ -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>
|
||||
@@ -36,7 +36,7 @@
|
||||
</Grid>
|
||||
|
||||
<!-- Report Bug -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsBugOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="What went wrong?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
@@ -76,44 +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 Text="Free forever — live streams carry a brief "made with LlamaCasty!" flash. A subscription removes it and adds alerts."
|
||||
Foreground="#888" FontSize="12" TextWrapping="Wrap" MaxWidth="380"
|
||||
TextAlignment="Center" Margin="0,12,0,0"/>
|
||||
<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}" Margin="0,0,8,0"/>
|
||||
<Button Content="Unlock Premium" Command="{Binding OpenPremiumCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"
|
||||
IsEnabled="{Binding IsPremiumAvailable}"
|
||||
ToolTip="Open the LlamaCasty subscription page"/>
|
||||
<Button Content="Enter License Key" Command="{Binding OpenLicenseEntryCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"
|
||||
ToolTip="Activate a license key to remove the watermark"/>
|
||||
</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}}">
|
||||
@@ -131,25 +178,6 @@
|
||||
Click="BackToAbout_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- License key entry panel -->
|
||||
<StackPanel Visibility="{Binding IsLicenseEntryOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Activate LlamaCasty Premium" FontSize="15" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.OnSurface}" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Enter your license key to remove the branding watermark."
|
||||
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,12"/>
|
||||
<TextBlock Text="License Key" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding LicenseKey, UpdateSourceTrigger=PropertyChanged}"
|
||||
MaxLength="100"/>
|
||||
<Button Content="Activate" Command="{Binding ActivateLicenseCommand}"
|
||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right"
|
||||
Margin="0,0,0,8"/>
|
||||
<TextBlock Text="{Binding LicenseMessage}" Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"/>
|
||||
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Left" Margin="0,8,0,0"
|
||||
Click="BackToAbout_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
+90
-15
@@ -90,6 +90,15 @@
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
<!-- ⛔ The brand logo renders ONCE, in BrandLogoOverlay above
|
||||
the blur host, so it can never be blurred. Drawing it here
|
||||
as well stacked two copies on top of each other
|
||||
(creator report, 2026-11-01). -->
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsBrandLogo}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
@@ -143,15 +152,32 @@
|
||||
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsTicker}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<!-- The scoreboard strip is a frame-source too: without this trigger its
|
||||
Image stays collapsed and every property change looks inert. -->
|
||||
<DataTrigger Binding="{Binding IsScoreboard}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsTextOverlay}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsWebSource}" Value="True">
|
||||
<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"/>
|
||||
@@ -183,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>
|
||||
@@ -476,6 +511,7 @@
|
||||
<Image Canvas.Left="{Binding StagedBrandLogo.X}" Canvas.Top="{Binding StagedBrandLogo.Y}"
|
||||
Width="{Binding StagedBrandLogo.Width}" Height="{Binding StagedBrandLogo.Height}"
|
||||
Source="{Binding StagedBrandLogo.DisplaySource}" Stretch="Fill"
|
||||
Opacity="{Binding StagedBrandLogo.Opacity}"
|
||||
IsHitTestVisible="False"
|
||||
Visibility="{Binding StagedBrandLogo, Converter={StaticResource NotNullToVis}}"/>
|
||||
</Canvas>
|
||||
@@ -732,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"/>
|
||||
|
||||
@@ -677,6 +677,13 @@ public partial class PreviewPane : UserControl
|
||||
if (vm.SelectedElement is { } selected && IsHitTestable(selected) && Contains(selected, p))
|
||||
return selected;
|
||||
|
||||
// The brand logo is drawn ABOVE everything (the compositor blits it after the blur group,
|
||||
// and the preview renders it in BrandLogoOverlay), so a click inside it must select it —
|
||||
// otherwise you see the logo on top and grab the webcam underneath it.
|
||||
var brand = vm.StagedBrandLogo;
|
||||
if (brand is { IsVisible: true } && Contains(brand, p))
|
||||
return brand;
|
||||
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-3
@@ -10,13 +10,20 @@ See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
| `BottomBar.xaml(.cs)` | Footer (Row 3): intentionally empty, height-preserving spacer; stream readouts and resolution picker moved to the top-left cell in the 2026-10-26 surface redesign |
|
||||
| `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here |
|
||||
| `TopBar.xaml(.cs)` | Row 0 session surface: brand + gear menu (left), explicit `EDIT` toggle beside the REC|ON-AIR mode switch and world actions (Record: Start Recording · Stream: Go Live + Test), running-state reality line + End (center), account zone (right). Left-click gear opens the menu; right-click opens App Settings directly. `RefreshAvatar()`, `GearButton_Click` |
|
||||
| `PreviewPane.xaml(.cs)` | Row 2 Col 1 center: 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, 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. |
|
||||
| `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. **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; App Settings now groups recording, brand-logo, preview, and transition controls. `BackToAbout_Click` lives here |
|
||||
| `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, 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 |
|
||||
|
||||
The Add Layer menu exposes one standalone recent-alert Ticker per scene. The
|
||||
properties panel edits its independent `Ticker` settings; Stream Alerts retains
|
||||
its separate current-event ticker controls. Outside edit mode, the left panel
|
||||
offers Live Chat first and Logs second; Logs wraps and colors typed entries,
|
||||
pins explicit Class 1/2 errors above the timeline until acknowledged, then
|
||||
returns them to the timeline; it excludes earlier file contents.
|
||||
|
||||
## Window ↔ control contract
|
||||
|
||||
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
||||
|
||||
+43
-33
@@ -53,8 +53,8 @@ on the partner dashboard — the dashboard's Downloads page only re-offers the S
|
||||
| SDK zip (sign in to partner first) | `partner.steamgames.com/downloads/steamworks_sdk.zip` |
|
||||
| Docs home | `partner.steamgames.com/doc/home` |
|
||||
| SteamPipe upload (steamcmd + VDF fields) | `partner.steamgames.com/doc/sdk/uploading` |
|
||||
| DLC configuration | `partner.steamgames.com/doc/store/application/dlc` |
|
||||
| `BIsDlcInstalled` | `partner.steamgames.com/doc/api/ISteamApps` |
|
||||
| Demo configuration | `partner.steamgames.com/doc/store/application/demos` |
|
||||
| Paid app ownership (`BIsSubscribedApp`) | `partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp` |
|
||||
| Store artwork sizes | `partner.steamgames.com/doc/store/assets/standard` |
|
||||
| Coming Soon / 2-week public window | `partner.steamgames.com/doc/store/coming_soon` |
|
||||
| Wallet rule (in-app purchases) | `partner.steamgames.com/doc/features/microtransactions` |
|
||||
@@ -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 **base app** (free) and one **DLC app** (the lifetime Pro). Note both numbers:
|
||||
the DLC app ID is what the app passes to `BIsDlcInstalled`.
|
||||
3. **Depot.** One depot holding the publish output; the base app and the DLC share the install
|
||||
directory, and the DLC package grants no files of its own — it is entitlement only.
|
||||
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.BIsDlcInstalled`).
|
||||
### 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.
|
||||
|
||||
@@ -89,31 +102,28 @@ publish → steam-stage → steamcmd + app_build.vdf → build upload → set li
|
||||
2. **Set the build live** in Steamworks → *Builds* → the new build → **Set Build Live** on the
|
||||
default branch. That *is* the release; there is no artifact to hash and no manifest to submit.
|
||||
3. **Announce** with Steam Events (release notes appear in the library and on the store page).
|
||||
4. **Verify**: install from a clean Steam account, confirm `BIsDlcInstalled` returns false, buy the
|
||||
DLC (or use a test licence), confirm the high tiers unlock, then refund the test purchase.
|
||||
4. **Verify**: install the Demo from a clean Steam account, confirm paid-app ownership is false,
|
||||
then test with an account that owns the paid app and confirm the higher tiers unlock.
|
||||
|
||||
**Updates** are the same upload with a higher build ID. The app keeps **no self-updater and no
|
||||
version banner** — Steam is the update path, and that policy has not changed.
|
||||
|
||||
## 4. Entitlement wiring
|
||||
|
||||
- `IEntitlement` (`Services/Entitlement/`) has one method, `IsPro`. **Steam DLC** answers it today
|
||||
(`SteamEntitlement`, `Steamworks.NET` + `steam_api64.dll`); the **Polar** monthly rail plugs in
|
||||
beside it. Resolution is `Pro = steamDlc || polarLease`, then `MainViewModel.HasProOutput` adds
|
||||
the Debug-only `DeveloperMode`; anything unresolved is the free profile.
|
||||
- **`SteamEntitlement.NotConfigured()` is the shipping default** until the DLC app id exists — it
|
||||
answers false without touching the native library. Set the id when Steamworks creates the DLC.
|
||||
- `IEntitlement` reports `IsAppOwned`. Debug uses a fake owned result; Release checks the paid app
|
||||
ID through `BIsSubscribedApp`. `MainViewModel.IsAppOwned` is the app-wide binary ownership flag.
|
||||
- The paid app ID is not yet configured. Until it is assigned, the Release path fails closed without
|
||||
touching the native library.
|
||||
- **Fail closed on every path:** no id, no client, missing DLL, thrown load, not owned.
|
||||
- **Never sell the monthly inside the Steam build.** Steamworks requires in-app purchases to use
|
||||
the Steam Wallet; the app may *state* that monthly plans exist on the site, with no checkout,
|
||||
no price and no payment field. Confirm the wording with Steamworks support before shipping it.
|
||||
- The EULA should expressly allow commercial/monetized streaming use; confirm with counsel or Valve
|
||||
that this satisfies the Steam Subscriber Agreement's software terms.
|
||||
- **Never gate a feature.** The only delta is the output profile.
|
||||
|
||||
## 5. Release checklist
|
||||
|
||||
- Build clean, 0 warnings (`./scripts/verify.sh`), tests green.
|
||||
- `steam_api64.dll` present; app starts **without** Steam and falls back to the free profile.
|
||||
- DLC ownership false → free profile; true → every tier offered.
|
||||
- Paid-app ownership false → free profile; true → higher output tiers offered.
|
||||
- `LlamaCasty.exe` rename landed (never rename a live exe).
|
||||
- Clean Steam install on a second machine, then an update pushed and observed.
|
||||
- Store page public for the full two weeks; support contact and announcements configured.
|
||||
@@ -123,15 +133,15 @@ version banner** — Steam is the update path, and that policy has not changed.
|
||||
|
||||
- **`LICENSE`** (EULA) forbids redistribution, resale, reverse-engineering and circumvention, and
|
||||
disclaims warranty.
|
||||
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube today, **Polar** the
|
||||
day licence validation ships. Steam itself is named by Valve's own policy, not ours.
|
||||
- **`PRIVACY.md`** must name any third party the app contacts — Google/YouTube. Steam itself is
|
||||
named by Valve's own policy, not ours.
|
||||
- **`THIRD-PARTY-NOTICES.txt`** stays shipped and shown in About; the ffmpeg pin stays
|
||||
`lgpl-shared`; no GPL component may be linked (see `product.md` → "Licensing").
|
||||
- **DMCA**: a takedown needs only the notice — host, link, good-faith statement, signature.
|
||||
Statutory damages need a registration ($65). Worth doing once there is something to pirate.
|
||||
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a free tier
|
||||
good enough that a crack buys nothing, Steam's own delivery, short Polar leases, and a licence ID
|
||||
in About. **Not obfuscation, not a native DLL, not hardware locking.**
|
||||
- **What actually protects the product** is in `commercial.md` → "Anti-piracy posture": a useful
|
||||
free tier, Steam's own delivery, and Steam ownership of the paid app. **Not obfuscation, not a
|
||||
native DLL, not hardware locking.**
|
||||
|
||||
## 7. Effort, once
|
||||
|
||||
@@ -141,4 +151,4 @@ version banner** — Steam is the update path, and that policy has not changed.
|
||||
| Publish profile + `steam_api64.dll` + `IEntitlement` Steam path | 1 day |
|
||||
| Depot + first `steamcmd` upload | 0.5 day, then ~15 min per release |
|
||||
| Store page + assets (screenshots, capsule art) | 1–2 days |
|
||||
| Clean-account install + DLC purchase/refund test | 0.5 day |
|
||||
| Clean-account Demo install + paid-app ownership test | 0.5 day |
|
||||
|
||||
+31
-31
@@ -1,40 +1,40 @@
|
||||
# HANDOFF — 2026-11-01 (exe renamed, depot builds, output rule enforced)
|
||||
# HANDOFF — 2026-11-12 (bitrate + audio-clock fixes; sync number pending)
|
||||
|
||||
## State
|
||||
|
||||
- Branch `main`, level with `origin` through `8f79b20`; this unit is local.
|
||||
- **The shipped exe is `LlamaCasty.exe`** (`AssemblyName` renamed; four pack URIs fixed — the docs
|
||||
said three, `KeepBroadcastDialog.xaml:5` was the fourth). Guarded by `AssemblyIdentityTests`.
|
||||
- **`scripts/publish.sh`** stages `artifacts/steam-stage` and refuses to finish if `ffmpeg\`, the
|
||||
licence, privacy or notices file is missing, or test assemblies leaked in. Output 257 MB.
|
||||
- **The output-profile rule is built and enforced**: `Models/OutputProfile.cs` (921,600 px / 30 fps,
|
||||
fail closed) resolved once in `BuildEncoderOptions` — one ffmpeg process, two outputs.
|
||||
`MainViewModel.DeveloperMode` bypasses it in Debug only.
|
||||
- **Distribution is Steam-exclusive** (2026-10-31): free app + paid edition as a Steam **DLC**; the
|
||||
**$10/month** plan sold off-Steam by Polar, *stated, never sold*, in-app. Entitlement is
|
||||
`steamDlcOwned || polarKeyValid`; the app must start without Steam, falling back to free.
|
||||
- **Site live with Steam** (`a9d07ce`); `www.llamacasty.com` 301s to the apex.
|
||||
- `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
|
||||
|
||||
**Surge, 2026-11-02 → 11-07 — six days, all remaining code.** Day 1 is partly done ahead of time:
|
||||
`IEntitlement` + `SteamEntitlement` (Steamworks.NET, fail-closed on every path) + `FakeEntitlement`,
|
||||
with `HasProOutput` reading the rail. Undo/redo, the rail gear and the EDIT alignment also landed
|
||||
early. Remaining on day 1: greyed tiers, licence UI's four states, system messages, licence ID in
|
||||
About. Days 2–6 in `TASKS.md` → "The surge".
|
||||
- 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
|
||||
|
||||
- **⛔ `ExcludeFromSingleFile="true"` on the ffmpeg content** — without it the publisher bundles
|
||||
libav\*.dll into the exe and the app ships unable to find ffmpeg. Recipe in `MyMistakes.md`.
|
||||
- **Dev affordances are compiled out, not hidden** — `DeveloperPanel` is removed from the Release
|
||||
build; `Helpers/DevBuild.cs` is the single source for the flag (never `const`).
|
||||
- **Nothing may gate a feature.** The only paid delta is the output profile; `ShowBrandFlash` stays
|
||||
`false` and entitlement must never enable it.
|
||||
- **No checkout inside a Steam build** — the Wallet rule. The monthly is named, never sold, in-app.
|
||||
- **The org access token never ships.** `organization_id` is public; the token is not.
|
||||
- **The site publishes the model, never the numbers**, and never takes payment or links a checkout.
|
||||
- **No self-updater and no version banner**: Steam updates the app.
|
||||
- Pre-existing: the **full test suite hangs** (WASAPI teardown) — per-class runs; `ytLive.Tests`
|
||||
carries 3 xUnit warnings the app-only gate does not see.
|
||||
- Not every hex is a theme colour — `BorderColorPalette`/chat defaults are USER DATA, not tokens.
|
||||
- `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.
|
||||
|
||||
+70
-2
@@ -2,6 +2,24 @@ using System.IO;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>Presentation category; independent from the entry's attention class.</summary>
|
||||
public enum AppLogMessageType
|
||||
{
|
||||
Information,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// <summary>Explicit attention priority, assigned by the writer rather than inferred from text or source.</summary>
|
||||
public enum AppLogErrorClass
|
||||
{
|
||||
/// <summary>Fatal or unhandled failure; highest priority.</summary>
|
||||
Class1 = 1,
|
||||
/// <summary>Recoverable failure that still requires attention.</summary>
|
||||
Class2 = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
||||
/// where the WPF app has no visible console. Writes to
|
||||
@@ -12,6 +30,7 @@ public static class AppLog
|
||||
{
|
||||
/// <summary>The cap for the live log file. Past this, it rolls to <c>startup.1.log</c>.</summary>
|
||||
private const long DefaultMaxBytes = 5 * 1024 * 1024;
|
||||
private const int CurrentLaunchEntryLimit = 500;
|
||||
|
||||
/// <summary>How many rolled copies to keep (startup.1.log, startup.2.log, …).</summary>
|
||||
private const int RetainedFiles = 1;
|
||||
@@ -20,6 +39,8 @@ public static class AppLog
|
||||
InstanceProfile.DataRoot, "startup.log");
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly List<AppLogEntry> CurrentLaunchEntries = new();
|
||||
private static long _nextSequence;
|
||||
|
||||
/// <summary>Test seam: point the log somewhere else. Null = the real profile's log.</summary>
|
||||
internal static string? PathOverride { get; set; }
|
||||
@@ -29,16 +50,40 @@ public static class AppLog
|
||||
|
||||
private static string LogPath => PathOverride ?? DefaultLogPath;
|
||||
|
||||
internal static IReadOnlyList<AppLogEntry> SnapshotCurrentLaunch()
|
||||
{
|
||||
lock (Gate)
|
||||
return CurrentLaunchEntries.ToArray();
|
||||
}
|
||||
|
||||
public static void Write(string message)
|
||||
=> Write(message, AppLogMessageType.Information);
|
||||
|
||||
public static void WriteWarning(string message)
|
||||
=> Write(message, AppLogMessageType.Warning);
|
||||
|
||||
public static void WriteSuccess(string message)
|
||||
=> Write(message, AppLogMessageType.Success);
|
||||
|
||||
public static void Write(string message, AppLogMessageType type, AppLogErrorClass? errorClass = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
var entry = new AppLogEntry(
|
||||
++_nextSequence,
|
||||
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}",
|
||||
type,
|
||||
errorClass);
|
||||
CurrentLaunchEntries.Add(entry);
|
||||
if (CurrentLaunchEntries.Count > CurrentLaunchEntryLimit)
|
||||
CurrentLaunchEntries.RemoveAt(0);
|
||||
|
||||
var path = LogPath;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
|
||||
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
||||
File.AppendAllText(path, $"{entry.Text}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -49,6 +94,29 @@ public static class AppLog
|
||||
|
||||
public static void Write(Exception exception, string message)
|
||||
{
|
||||
Write($"{message}: {exception}");
|
||||
Write($"{message}: {exception}", AppLogMessageType.Error);
|
||||
}
|
||||
|
||||
public static void Write(Exception exception, string message, AppLogErrorClass errorClass)
|
||||
=> Write($"{message}: {exception}", AppLogMessageType.Error, errorClass);
|
||||
}
|
||||
|
||||
public sealed record AppLogEntry(long Sequence, string Text, AppLogMessageType Type,
|
||||
AppLogErrorClass? ErrorClass)
|
||||
{
|
||||
public string? ErrorClassLabel => ErrorClass switch
|
||||
{
|
||||
AppLogErrorClass.Class1 => "CLASS 1",
|
||||
AppLogErrorClass.Class2 => "CLASS 2",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public string TypeLabel => ErrorClassLabel ?? Type switch
|
||||
{
|
||||
AppLogMessageType.Information => "INFO",
|
||||
AppLogMessageType.Success => "SUCCESS",
|
||||
AppLogMessageType.Warning => "WARNING",
|
||||
AppLogMessageType.Error => "ERROR",
|
||||
_ => Type.ToString().ToUpperInvariant(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
||||
|------|---------|
|
||||
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
|
||||
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture. Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55). Also keeps the newest 500 formatted entries in a process-local buffer for the Logs tab, with independent message type and explicit Class 1/2 severity metadata; it never loads prior-launch file contents |
|
||||
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
|
||||
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
|
||||
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
|
||||
|
||||
@@ -11,6 +11,8 @@ public partial class KeepBroadcastDialog : Window
|
||||
public KeepBroadcastDialog(string title)
|
||||
{
|
||||
InitializeComponent();
|
||||
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, this))
|
||||
Owner = owner;
|
||||
MessageText.Text = string.IsNullOrWhiteSpace(title)
|
||||
? "Your private broadcast is on YouTube as a private video. Keep it, or delete it?"
|
||||
: $"\u201c{title}\u201d is on YouTube as a private video. Keep it, or delete it?";
|
||||
|
||||
+52
-10
@@ -137,12 +137,54 @@
|
||||
<TextBlock Grid.Row="4" Grid.ColumnSpan="2" Text="{Binding CurrentHealth.HealthMessage}"
|
||||
Foreground="{StaticResource Brush.Attention}" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
|
||||
<ComboBox Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom" Style="{StaticResource YtComboBox}"
|
||||
ItemsSource="{Binding QualityOptions}"
|
||||
SelectedItem="{Binding SelectedQuality}"
|
||||
DisplayMemberPath="Label"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding IsOffline}"/>
|
||||
<StackPanel Grid.Row="5" Grid.ColumnSpan="2" VerticalAlignment="Bottom">
|
||||
<ComboBox x:Name="QualityPicker" Style="{StaticResource YtComboBox}"
|
||||
ItemsSource="{Binding QualityOptions}"
|
||||
SelectedItem="{Binding SelectedQuality}"
|
||||
ItemTemplate="{StaticResource YtQualityComboBoxItemTemplate}"
|
||||
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding CanChangeQuality}"/>
|
||||
<!-- 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>
|
||||
</Border>
|
||||
|
||||
@@ -165,12 +207,12 @@
|
||||
|
||||
|
||||
<!-- ═══ OVERLAY: Settings / Bug / Feature / About ═══ -->
|
||||
<controls:OverlayHost Grid.RowSpan="3"/>
|
||||
<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,
|
||||
}
|
||||
+19
-12
@@ -3,11 +3,8 @@ namespace ytLive.Models;
|
||||
/// <summary>
|
||||
/// The one entitlement rule, as pure policy: what a build is allowed to emit.
|
||||
///
|
||||
/// <para><b>Why pixels and a frame rate rather than "720p".</b> The paid delta is output
|
||||
/// quality, and a resolution name is a marketing word, not a measurable bound — 720p60 is
|
||||
/// 921,600 pixels at twice the frame rate, and vertical 720×1280 is the same pixel count
|
||||
/// with a different shape. A pixel budget plus a frame-rate ceiling expresses the same
|
||||
/// promise without a lookup table that a new tier can quietly escape.</para>
|
||||
/// <para><b>Frame rate is the paid boundary.</b> Every supported resolution at 30 fps is free;
|
||||
/// 60 fps requires paid ownership, regardless of frame dimensions.</para>
|
||||
///
|
||||
/// <para><b>Fail closed.</b> Callers pass <c>pro: false</c> when entitlement is unknown,
|
||||
/// unreadable or unresolved; the free profile is the default state, never the fallback
|
||||
@@ -19,19 +16,29 @@ namespace ytLive.Models;
|
||||
/// </summary>
|
||||
public static class OutputProfile
|
||||
{
|
||||
/// <summary>Free ceiling: 921,600 px = 1280×720, i.e. one 720p frame.</summary>
|
||||
public const int FreeMaxPixels = 921_600;
|
||||
|
||||
/// <summary>Free ceiling: 30 frames per second.</summary>
|
||||
public const int FreeMaxFps = 30;
|
||||
|
||||
/// <summary>The named tier a capped build falls back to when nothing else qualifies.</summary>
|
||||
public const string FreeFallbackName = "720p30";
|
||||
/// <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";
|
||||
|
||||
/// <summary>Is this tier within the free budget? Pro may use anything shipped.</summary>
|
||||
public static bool IsAllowed(QualityOption tier, bool pro)
|
||||
=> pro
|
||||
|| (tier.Width * tier.Height <= FreeMaxPixels && tier.Fps <= FreeMaxFps);
|
||||
|| tier.Fps <= FreeMaxFps;
|
||||
|
||||
/// <summary>
|
||||
/// The tier a session will actually encode. A permitted selection passes through
|
||||
@@ -57,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, 6.0, 1280, 720);
|
||||
return candidate ?? new QualityOption(FreeFallbackName, FreeMaxFps, 10.0, 1920, 1080);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ public sealed record QualityOption(string Display, int Fps, double Bitrate, int
|
||||
{
|
||||
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
|
||||
public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
|
||||
|
||||
public bool RequiresPro => !OutputProfile.IsAllowed(this, pro: false);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+293
-13
@@ -1,5 +1,6 @@
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
@@ -21,7 +22,9 @@ public enum SourceType
|
||||
AlertBox,
|
||||
/// <summary>A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
|
||||
/// only, always below the brand logo, several allowed (strongest wins on overlap).</summary>
|
||||
Blur
|
||||
Blur,
|
||||
/// <summary>A scene-local feed of recent alert events.</summary>
|
||||
Ticker
|
||||
}
|
||||
|
||||
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||||
@@ -39,6 +42,20 @@ public enum MediaPlaybackState
|
||||
Paused
|
||||
}
|
||||
|
||||
public enum TextLayerAlignment
|
||||
{
|
||||
Left,
|
||||
Center,
|
||||
Right
|
||||
}
|
||||
|
||||
public enum TextLayerVerticalAlignment
|
||||
{
|
||||
Top,
|
||||
Center,
|
||||
Bottom
|
||||
}
|
||||
|
||||
public enum ClipShape
|
||||
{
|
||||
Traditional,
|
||||
@@ -66,6 +83,19 @@ public enum AlertDisplayMethod
|
||||
/// </summary>
|
||||
public class Source : SceneElement
|
||||
{
|
||||
private readonly Ticker _ticker = new();
|
||||
|
||||
public Source()
|
||||
{
|
||||
_ticker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker));
|
||||
if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod));
|
||||
};
|
||||
}
|
||||
|
||||
public Ticker Ticker => _ticker;
|
||||
|
||||
private SourceType _type;
|
||||
public SourceType Type
|
||||
{
|
||||
@@ -80,6 +110,8 @@ public class Source : SceneElement
|
||||
Raise(nameof(IsWebSource));
|
||||
Raise(nameof(IsTextOverlay));
|
||||
Raise(nameof(IsScoreboard));
|
||||
Raise(nameof(IsTicker));
|
||||
Raise(nameof(ShowsChromaKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,18 +120,20 @@ 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.AlertBox or SourceType.Blur;
|
||||
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
|
||||
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
|
||||
or SourceType.Blur
|
||||
or SourceType.Blur or SourceType.Ticker
|
||||
? ElementKind.Dynamic : ElementKind.Static;
|
||||
|
||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||||
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
|
||||
|
||||
public override bool IsScoreboard => Type == SourceType.Scoreboard;
|
||||
public bool IsTicker => Type == SourceType.Ticker;
|
||||
|
||||
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
|
||||
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
|
||||
@@ -155,8 +189,8 @@ public class Source : SceneElement
|
||||
/// scoreboard joined this list 2026-10-30 — without it the strip was rendered, published to
|
||||
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
|
||||
private bool IsFrameSource =>
|
||||
Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
|
||||
or SourceType.AlertBox or SourceType.Scoreboard;
|
||||
Type is SourceType.TextOverlay or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
|
||||
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
|
||||
|
||||
public override ImageSource? DisplaySource => IsLiveCapture
|
||||
? (VideoImageSource ?? _imageSource)
|
||||
@@ -200,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
|
||||
{
|
||||
@@ -207,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
|
||||
{
|
||||
@@ -225,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
|
||||
{
|
||||
@@ -234,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
|
||||
@@ -249,21 +452,26 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
private bool _alertShowTicker = true;
|
||||
public bool AlertShowTicker
|
||||
{
|
||||
get => _alertShowTicker;
|
||||
set => Set(ref _alertShowTicker, value);
|
||||
get => _ticker.IsEnabled;
|
||||
set => _ticker.IsEnabled = value;
|
||||
}
|
||||
|
||||
private double _alertVideoVolume = 1.0;
|
||||
@@ -295,11 +503,10 @@ public class Source : SceneElement
|
||||
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
|
||||
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
|
||||
|
||||
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
|
||||
public AlertDisplayMethod AlertDisplayMethod
|
||||
{
|
||||
get => _alertDisplayMethod;
|
||||
set => Set(ref _alertDisplayMethod, value);
|
||||
get => _ticker.DisplayMethod;
|
||||
set => _ticker.DisplayMethod = value;
|
||||
}
|
||||
|
||||
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
||||
@@ -376,6 +583,79 @@ public class Source : SceneElement
|
||||
|
||||
public override bool IsChatBox => Type == SourceType.ChatBox;
|
||||
|
||||
private string _textContent = "Text";
|
||||
public string TextContent
|
||||
{
|
||||
get => _textContent;
|
||||
set => Set(ref _textContent, value ?? string.Empty);
|
||||
}
|
||||
|
||||
private string _textFontFamily = "Segoe UI";
|
||||
public string TextFontFamily
|
||||
{
|
||||
get => _textFontFamily;
|
||||
set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim());
|
||||
}
|
||||
|
||||
private double _textFontSize = 48;
|
||||
public double TextFontSize
|
||||
{
|
||||
get => _textFontSize;
|
||||
set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48);
|
||||
}
|
||||
|
||||
private string _textColor = "#FFFFFF";
|
||||
public string TextColor
|
||||
{
|
||||
get => _textColor;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return;
|
||||
Raise(nameof(TextColorEntry));
|
||||
Raise(nameof(TextColorOptions));
|
||||
}
|
||||
}
|
||||
|
||||
public NamedColor TextColorEntry
|
||||
{
|
||||
get => BorderColorPalette.EntryFor(TextColor);
|
||||
set => TextColor = value.Hex;
|
||||
}
|
||||
|
||||
public IReadOnlyList<NamedColor> TextColorOptions => BorderColorPalette.OptionsFor(TextColor);
|
||||
|
||||
private bool _textBold;
|
||||
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); }
|
||||
|
||||
private bool _textItalic;
|
||||
public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); }
|
||||
|
||||
private bool _textUnderline;
|
||||
public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); }
|
||||
|
||||
private TextLayerAlignment _textAlignment;
|
||||
public TextLayerAlignment TextAlignment
|
||||
{
|
||||
get => _textAlignment;
|
||||
set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left);
|
||||
}
|
||||
|
||||
private TextLayerVerticalAlignment _textVerticalAlignment;
|
||||
public TextLayerVerticalAlignment TextVerticalAlignment
|
||||
{
|
||||
get => _textVerticalAlignment;
|
||||
set => Set(ref _textVerticalAlignment,
|
||||
Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top);
|
||||
}
|
||||
|
||||
private static bool IsValidTextColor(string? value)
|
||||
{
|
||||
if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
|
||||
for (var i = 1; i < value.Length; i++)
|
||||
if (!Uri.IsHexDigit(value[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool IsCountdown => Type == SourceType.Countdown;
|
||||
|
||||
private bool _chatPreviewEnabled = true;
|
||||
|
||||
@@ -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,31 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
public sealed class Ticker : INotifyPropertyChanged
|
||||
{
|
||||
private bool _isEnabled = true;
|
||||
private AlertDisplayMethod _displayMethod = AlertDisplayMethod.TickerScroll;
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => Set(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
public AlertDisplayMethod DisplayMethod
|
||||
{
|
||||
get => _displayMethod;
|
||||
set => Set(ref _displayMethod, value);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
+4
-3
@@ -7,7 +7,8 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
|------|---------|
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (one per layout)**/**Ticker (one per scene)**/Blur), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **TextOverlay content + font/size/color/emphasis/horizontal-alignment/vertical-alignment settings** (independent per source; static text), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture); each source owns `Ticker` settings, with AlertBox compatibility properties persisted on that source row |
|
||||
| `Ticker.cs` | Shared per-source ticker settings: `IsEnabled` and `DisplayMethod`; one independent instance for each scene ticker and the AlertBox ticker |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
|
||||
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
|
||||
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||
@@ -15,8 +16,8 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
|
||||
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)`, `RequiresPro` derives from `OutputProfile` (current code uses pixel/FPS; the FPS-only policy correction is queued in `commercial.md`) — drives the quality dropdown and output-rect math |
|
||||
| `StreamConfig.cs` | Legacy `StreamConfig` (no production call sites; defaults do not drive output), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum; encoder output comes from the selected quality tier |
|
||||
| `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) |
|
||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) — since TASK 43 every message carries `Kind` (`ChatEventKind` None/SuperChat/SuperSticker/NewMember/MemberGift/GiftReceived/MemberMilestone) + event payload fields (`SuperChatDisplayString`, `SuperStickerDescription`, `GiftNewMembersCount`, `GifterName`, `MilestoneMonths`) |
|
||||
| `Socials.cs` | **Social bar** (global layer, never a Source): `SocialService` enum (YouTube/Twitch/X/Instagram/TikTok/Facebook/Discord/Kick/Threads/Bluesky/GitHub/LinkedIn/Pinterest/Snapchat/Reddit/WhatsApp/Telegram/Link/Website/**Fediverse**), `SocialEntry` (Service/Handle/ProfileUrl/`FediverseSoftware`), `SocialsConfig` (Entries + `BarPosition` Top/Bottom + `BarEnabled` on/off — `BarJustify` dropped, column back-compat), `SocialServiceIcons` (canonical URL builder + `DetectService` (URL domain / fediverse `@user@domain` → **Fediverse** / bare→Website) + bundled SVG logo path data per service (`LogoDataFor`) + `LogoDataForFediverse(software)` mapping nodeinfo software names (mastodon/peertube/pixelfed/misskey/lemmy/pleroma/firefish) to logos with a generic fediverse glyph fallback (`FediverseIconData`) + `LockedIconData`/`DoNotIconData` — initials/colors gone) |
|
||||
|
||||
+92
-12
@@ -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
|
||||
@@ -209,11 +264,11 @@ After: 127 MB exe, `ffmpeg\` present, depot ≈ 257 MB total.
|
||||
`scripts/publish.sh` fails loudly when `ffmpeg\`, `LICENSE`, `PRIVACY.md` or
|
||||
`THIRD-PARTY-NOTICES.txt` are missing, and when test assemblies leak in.
|
||||
|
||||
### STEAM IS THE DISTRIBUTOR — AND WHAT IT DOES NOT ALLOW (2026-10-31)
|
||||
### STEAM DISTRIBUTION, COMMERCIAL USE, AND THE DEMO OWNERSHIP CHECK (2026-11-02)
|
||||
|
||||
**Why recorded:** the distribution answer changed three times in one day (winget → zip+GitHub →
|
||||
Steam), and each pivot invalidated a doc written hours earlier. These are the facts that won't
|
||||
change.
|
||||
**Why recorded:** the Steam Subscriber Agreement's private-use wording was misread as a blocker
|
||||
for monetized streaming. Check the exception and the established Steam software precedent before
|
||||
raising it again.
|
||||
|
||||
- **Steam installs skip SmartScreen entirely** — Valve's client delivers the files, so there is no
|
||||
browser download, no Mark-of-the-Web and no publisher warning. An unsigned exe is *fine* in this
|
||||
@@ -221,16 +276,25 @@ change.
|
||||
- **Steam updates the app** — no self-updater, no version banner, no update endpoint.
|
||||
- **⛔ In-app purchases must use the Steam Wallet.** Steamworks, *Microtransactions (In-Game
|
||||
Purchases)*: *"For any in-game purchases, you'll need to use the microtransaction API so Steam
|
||||
customers can only make purchases from the Steam Wallet."* So a subscription sold through Polar
|
||||
inside a Steam build is a policy violation. **Lifetime = plain DLC bought on the store page**
|
||||
(the app only calls `BIsDlcInstalled`); the monthly is *stated*, never *sold*, in-app.
|
||||
- **Steam can technically do recurring billing** (`ISteamMicroTxn` + `ProcessAgreement`) but its own
|
||||
doc opens with *"Recurring Subscriptions is not a fully supported feature"* and requires a server
|
||||
of yours to poll renewals — more infrastructure than Polar, for a 30% cut.
|
||||
customers can only make purchases from the Steam Wallet."* LlamaCasty has no in-app checkout.
|
||||
- **The private-use wording is not, by itself, a commercial-use ban for LlamaCasty.** The Steam
|
||||
Subscriber Agreement §2.A permits commercial use when the applicable product terms expressly
|
||||
allow it; §5 has general private/personal-use wording for third-party software. OBS Studio is
|
||||
distributed on Steam as video-production software under GPL, and OBS confirms the Steam build is
|
||||
the same as its website build. GPL permits commercial use. LlamaCasty's EULA should expressly
|
||||
allow commercial and monetized streaming; confirm the wording with Valve or counsel. Do not call
|
||||
this a Steam blocker based only on §5.
|
||||
Sources: https://store.steampowered.com/subscriber_agreement/ ·
|
||||
https://obsproject.com/kb/steam-faq · https://www.gnu.org/licenses/gpl-faq.html
|
||||
- **Demo ownership check:** the free Demo uses its own App ID; `ISteamApps::BIsSubscribedApp`
|
||||
checks ownership of the paid app. The paid App ID must be configured; zero/missing ID fails closed.
|
||||
Valve reference: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
|
||||
- **Steam sells one paid app and distributes its associated free Demo.** Same code; no GitHub
|
||||
binary. The demo links to the paid store page and can send a one-time wishlist notification.
|
||||
- **Renaming a live exe is painful** (Steamworks → *Tip – Renaming a live exe*). Do the
|
||||
`LlamaCasty.exe` rename **before** the first depot upload, not after.
|
||||
- **The Steam store page must be public two weeks before release**, and the $100 Steam Direct fee
|
||||
must be paid before the page can be configured at all.
|
||||
- **The paid app's Steam store page must be public two weeks before release.** The Steamworks
|
||||
application and $100 fee are already submitted; Valve's decision is pending.
|
||||
|
||||
### CONVERTING A MEDIAPIPE .tflite (WITH CUSTOM OPS) TO ONNX — AND PROVING IT (RECIPE, TASK 51)
|
||||
|
||||
@@ -556,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
|
||||
@@ -1698,6 +1773,11 @@ afterward** — and I got it wrong in the very commit written to apply that idea
|
||||
|
||||
## Asked twice about a deliberate, already-documented decision (2026-10-04)
|
||||
|
||||
**Superseded 2026-11-04:** the creator clarified that forcing Private is a development/test safeguard,
|
||||
not a permanent product lock. Release go-live must let the creator choose Private/Unlisted/Public,
|
||||
default to Private, and allow live changes. Do not repeat the former “always Private until v1” rule
|
||||
as current policy; keep development and test runs Private.
|
||||
|
||||
**What happened:** the creator asked whether the test streams still upload private, and added "I
|
||||
don't think you're able to delete them". I answered, researched `videos.delete`, and then — in the
|
||||
very next exchange — *offered to build a cleanup tool*, which re-litigated a decision that has been
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-178
@@ -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)})");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
|
||||
private ChatMessage? _current;
|
||||
private Source? _currentBox;
|
||||
private sealed record TickerFeed(string Text, double Duration);
|
||||
private volatile TickerFeed? _tickerFeed;
|
||||
private IAlertClipDecoder? _clip;
|
||||
private VideoFrame? _latestClipFrame;
|
||||
private bool _clipEof;
|
||||
@@ -66,8 +68,6 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
private int _clipHeight;
|
||||
private bool _clipSawFrame;
|
||||
private bool _clipAudioHeard;
|
||||
private string? _tickerText;
|
||||
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
|
||||
private double _elapsed;
|
||||
private IEnumerable<Scene>? _scenes;
|
||||
|
||||
@@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
BeginClip();
|
||||
}
|
||||
_contentVersion++;
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
|
||||
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
|
||||
@@ -172,6 +173,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
_contentVersion++;
|
||||
return;
|
||||
@@ -184,6 +186,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
_contentVersion++;
|
||||
RefreshTickerFeed();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
RefreshTickerFeed();
|
||||
}
|
||||
_contentVersion++;
|
||||
}
|
||||
@@ -249,27 +253,55 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
/// <see cref="VideoFrame.OriginX"/>/<see cref="VideoFrame.OriginY"/> instead of
|
||||
/// a hardcoded 0. <b>No alert box in the scene means no ticker at all</b> — there
|
||||
/// is no longer a global position for it to live in.</para></summary>
|
||||
public VideoFrame? AlertTickerFrame
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
|
||||
var box = _currentBox;
|
||||
if (box == null) return null;
|
||||
public VideoFrame? AlertTickerFrame => AlertBoxTickerFrame(_currentBox);
|
||||
|
||||
var w = (int)Math.Max(1, Math.Round(box.Width));
|
||||
var h = (int)Math.Max(1, Math.Min(
|
||||
Math.Round(box.Height), AlertTickerRenderer.Height));
|
||||
var frame = AlertTickerRenderer.Render(
|
||||
_tickerText!, _elapsed, _tickerMethod, AlertSeconds(),
|
||||
TickerReadsPerAlert, w, h);
|
||||
return frame == null
|
||||
? null
|
||||
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
|
||||
{
|
||||
Placement = ((int)Math.Round(box.X), (int)Math.Round(box.Y)),
|
||||
};
|
||||
public VideoFrame? AlertBoxTickerFrame(Source? alertBox)
|
||||
{
|
||||
if (_current == null || alertBox is not { Type: SourceType.AlertBox }) return null;
|
||||
|
||||
var w = (int)Math.Max(1, Math.Round(alertBox.Width));
|
||||
var h = (int)Math.Max(1, Math.Min(
|
||||
Math.Round(alertBox.Height), AlertTickerRenderer.Height));
|
||||
var frame = AlertTickerRenderer.Render(
|
||||
alertBox.Ticker, ComposeTicker(_current), _elapsed, AlertSeconds(),
|
||||
TickerReadsPerAlert, w, h);
|
||||
return frame == null
|
||||
? null
|
||||
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
|
||||
{
|
||||
Placement = ((int)Math.Round(alertBox.X), (int)Math.Round(alertBox.Y)),
|
||||
};
|
||||
}
|
||||
|
||||
public VideoFrame? RenderSceneTicker(Source tickerSource)
|
||||
{
|
||||
if (tickerSource.Type != SourceType.Ticker || !tickerSource.Ticker.IsEnabled)
|
||||
return null;
|
||||
|
||||
var feed = _tickerFeed;
|
||||
if (feed == null) return null;
|
||||
return AlertTickerRenderer.Render(
|
||||
tickerSource.Ticker,
|
||||
feed.Text,
|
||||
_elapsed,
|
||||
feed.Duration,
|
||||
TickerReadsPerAlert,
|
||||
(int)Math.Max(1, Math.Round(tickerSource.Width)),
|
||||
(int)Math.Max(1, Math.Round(tickerSource.Height)));
|
||||
}
|
||||
|
||||
private void RefreshTickerFeed()
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_tickerFeed = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var messages = new[] { _current }.Concat(_queue).ToArray();
|
||||
_tickerFeed = new TickerFeed(
|
||||
string.Join(" • ", messages.Select(ComposeTicker)),
|
||||
messages.Sum(DurationOf));
|
||||
}
|
||||
|
||||
/// <summary>How many times the marquee must cross the frame per alert, so the
|
||||
@@ -338,7 +370,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert sound byte failed");
|
||||
AppLog.Write(ex, "Alert sound byte failed", AppLogErrorClass.Class2);
|
||||
StopSoundByte();
|
||||
}
|
||||
}
|
||||
@@ -354,17 +386,11 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
private void BeginClip()
|
||||
{
|
||||
StopClip();
|
||||
_tickerText = null;
|
||||
if (_current == null) return;
|
||||
|
||||
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
|
||||
_currentBox = box;
|
||||
if (box != null && box.AlertShowTicker)
|
||||
{
|
||||
_tickerText = ComposeTicker(_current);
|
||||
_tickerMethod = box.AlertDisplayMethod;
|
||||
}
|
||||
|
||||
if (box == null) return;
|
||||
StartSoundByte(box);
|
||||
@@ -387,7 +413,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert clip setup failed");
|
||||
AppLog.Write(ex, "Alert clip setup failed", AppLogErrorClass.Class2);
|
||||
StopClip(); // decode setup failed (bad path) — fall back to animations
|
||||
}
|
||||
}
|
||||
@@ -552,6 +578,9 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
};
|
||||
UpdatePreview(alertBox);
|
||||
}
|
||||
foreach (var ticker in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.Ticker))
|
||||
UpdateTickerPreview(ticker);
|
||||
}
|
||||
|
||||
private void RefreshAlertPreviews()
|
||||
@@ -566,6 +595,27 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.AlertBox))
|
||||
UpdatePreview(alertBox);
|
||||
foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.Ticker))
|
||||
UpdateTickerPreview(ticker);
|
||||
}
|
||||
|
||||
private void UpdateTickerPreview(Source ticker)
|
||||
{
|
||||
var frame = RenderSceneTicker(ticker);
|
||||
if (frame == null)
|
||||
{
|
||||
ticker.VideoImageSource = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var bitmap = ticker.VideoImageSource;
|
||||
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
|
||||
{
|
||||
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||
ticker.VideoImageSource = bitmap;
|
||||
}
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -574,6 +624,7 @@ public sealed class AlertOverlayLayer : IDisposable
|
||||
_ticker?.Stop();
|
||||
_queue.Clear();
|
||||
_current = null;
|
||||
_tickerFeed = null;
|
||||
StopClip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class AlertAudioOutput : IDisposable
|
||||
{
|
||||
// No output device (a headless stream box, a locked-down session): the alert still
|
||||
// renders, it is simply silent. Never let audio take the alert down.
|
||||
AppLog.Write(ex, "Alert audio output failed");
|
||||
AppLog.Write(ex, "Alert audio output failed", AppLogErrorClass.Class2);
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+118
-35
@@ -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;
|
||||
@@ -422,7 +490,7 @@ public sealed class AudioMixer : IDisposable
|
||||
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
|
||||
{
|
||||
_lastLoopErrorLogged = now;
|
||||
AppLog.Write(ex, "Audio live loop error");
|
||||
AppLog.Write(ex, "Audio live loop error", AppLogErrorClass.Class2);
|
||||
_log?.Invoke($"Audio live loop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,8 @@ public sealed class CameraManager : IDisposable
|
||||
if (suspects.Count > 0)
|
||||
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
|
||||
|
||||
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
|
||||
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
CameraFailed?.Invoke(session.DeviceId, enriched);
|
||||
_ = SafeStopAsync(session.Source);
|
||||
}
|
||||
@@ -335,7 +336,8 @@ public sealed class CameraManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
|
||||
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +462,8 @@ public sealed class CameraManager : IDisposable
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate) session.EffectWorkerRunning = false;
|
||||
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}");
|
||||
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using ytLive.Models;
|
||||
using AppLogErrorClass = ytLive.Helpers.AppLogErrorClass;
|
||||
using AppLogMessageType = ytLive.Helpers.AppLogMessageType;
|
||||
using AppLog = ytLive.Helpers.AppLog;
|
||||
|
||||
namespace ytLive.Services;
|
||||
@@ -54,7 +56,8 @@ public class ChannelStatsService
|
||||
// (insufficientPermissions / quotaExceeded / …) — the bare code was
|
||||
// undiagnosable for the 2026-09-23 auditDetails 403.
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}");
|
||||
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode}): {errorBody}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -107,7 +110,8 @@ public class ChannelStatsService
|
||||
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
|
||||
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -129,4 +133,4 @@ public class ChannelStatsService
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,15 @@ public static class AlertTickerRenderer
|
||||
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
|
||||
public const double FlashHalfSeconds = 0.5;
|
||||
|
||||
public static VideoFrame? Render(Ticker ticker, string? text, double elapsedSeconds,
|
||||
double alertSeconds, int readsPerAlert, int width, int height)
|
||||
{
|
||||
if (ticker == null) throw new ArgumentNullException(nameof(ticker));
|
||||
return ticker.IsEnabled
|
||||
? Render(text, elapsedSeconds, ticker.DisplayMethod, alertSeconds, readsPerAlert, width, height)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly Dictionary<string, VideoFrame> StripCache = new();
|
||||
|
||||
@@ -179,4 +188,4 @@ public static class AlertTickerRenderer
|
||||
}
|
||||
return new VideoFrame(pillW, Height, pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Where the compositor draws from (the active quality tier's output rect over the
|
||||
/// 1920×1080 master) and at what size. 16:9 tiers = the full master 1:1; the vertical
|
||||
/// 9:16 tier = the centered 607×1080 crop scaled up to 1080×1920. The source rect is
|
||||
/// integer-aligned — MainViewModel's <c>OutputRectX</c> can be 656.5, so the caller
|
||||
/// rounds before building these options.
|
||||
/// Where the compositor draws from (the active quality tier's crop over the
|
||||
/// 1920×1080 master) and the size of the BGRA frame it sends to the encoder. The
|
||||
/// compositor frame is the crop itself; FFmpeg scales it to the selected output
|
||||
/// dimensions. The source rect is integer-aligned — MainViewModel's
|
||||
/// <c>OutputRectX</c> can be 656.5, so the caller rounds before building these options.
|
||||
/// </summary>
|
||||
public sealed class CompositorOptions
|
||||
{
|
||||
|
||||
@@ -6,10 +6,11 @@ using ytLive.Models;
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
|
||||
/// The output compositor: renders a scene into the encoder's raw input frame (tightly-packed
|
||||
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
|
||||
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
|
||||
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). FFmpeg scales that frame
|
||||
/// to the selected output dimensions. The preview stays the editing view; this is the output
|
||||
/// view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
///
|
||||
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
|
||||
/// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping
|
||||
@@ -163,7 +164,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
@@ -186,7 +186,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
|
||||
// the blur is universal and must never hide the brand.
|
||||
case Source { IsBrandLogo: true }:
|
||||
@@ -310,7 +309,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement:
|
||||
continue;
|
||||
}
|
||||
@@ -369,7 +367,6 @@ public sealed class SceneCompositor
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
// ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01):
|
||||
// the blur is universal and must never hide the brand.
|
||||
case Source { IsBrandLogo: true }:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,16 @@ public sealed class EncoderOptions
|
||||
public const string DefaultAudioPipeName = "ytllive_audio";
|
||||
|
||||
public string RtmpUrl { get; init; } = string.Empty;
|
||||
/// <summary>Final encoded output width and height.</summary>
|
||||
public int Width { get; init; } = 1920;
|
||||
public int Height { get; init; } = 1080;
|
||||
|
||||
/// <summary>Raw BGRA frame size from the compositor. Zero uses the final output size.</summary>
|
||||
public int InputWidth { get; init; }
|
||||
public int InputHeight { get; init; }
|
||||
|
||||
public int RawWidth => InputWidth > 0 ? InputWidth : Width;
|
||||
public int RawHeight => InputHeight > 0 ? InputHeight : Height;
|
||||
public int Fps { get; init; } = 60;
|
||||
public int BitrateKbps { get; init; } = 8000;
|
||||
|
||||
@@ -42,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;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class FfmpegArgs
|
||||
"-hide_banner", "-loglevel", "info",
|
||||
"-stats", "-stats_period", "0.5",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-video_size", $"{options.RawWidth}x{options.RawHeight}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
"-i", "pipe:0",
|
||||
"-f", "f32le", "-ar", options.AudioSampleRate.ToString(),
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -53,10 +72,34 @@ public static class FfmpegArgs
|
||||
{
|
||||
var gop = options.GopSize;
|
||||
args.AddRange(["-map", "0:v", "-map", "1:a"]);
|
||||
if (options.RawWidth != options.Width || options.RawHeight != options.Height)
|
||||
{
|
||||
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
|
||||
@@ -103,7 +107,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess");
|
||||
AppLog.Write(ex, "FFmpeg encoder: failed to start subprocess", AppLogErrorClass.Class2);
|
||||
lock (_gate) _options = null;
|
||||
throw;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -175,7 +184,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop", AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -186,12 +195,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing");
|
||||
AppLog.Write("FFmpeg encoder: did not exit after stdin EOF — killing",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
process!.Kill();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed");
|
||||
AppLog.Write(ex, "FFmpeg encoder: waiting for exit failed", AppLogErrorClass.Class2);
|
||||
process!.Kill();
|
||||
}
|
||||
|
||||
@@ -201,7 +211,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop");
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted during stop", AppLogErrorClass.Class2);
|
||||
}
|
||||
|
||||
process.Dispose();
|
||||
@@ -238,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 () =>
|
||||
@@ -263,7 +291,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted", AppLogErrorClass.Class2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -274,7 +302,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted", AppLogErrorClass.Class2);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -303,7 +331,8 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: encoder probe failed — falling back to software");
|
||||
AppLog.Write($"FFmpeg encoder: encoder probe failed — falling back to software: {ex}",
|
||||
AppLogMessageType.Warning);
|
||||
return FfmpegEncoderPicker.Preference[^1];
|
||||
}
|
||||
}
|
||||
@@ -347,14 +376,15 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
// The old `code != 0` gate let ffmpeg's clean early exit vanish
|
||||
// without a word while the pump kept feeding a corpse
|
||||
// (2026-09-01 first real recording: died at ~0.9s, zero logs).
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})");
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly (code {exitCode})",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
_health.LastError = $"FFmpeg exited with code {exitCode}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted");
|
||||
AppLog.Write(ex, "FFmpeg encoder: stderr loop faulted", AppLogErrorClass.Class2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace ytLive.Services.Encoder;
|
||||
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||||
/// managers + compositor and the encoder. While live it snapshots the active
|
||||
/// scene each tick, resolves every element to its latest frame, composites it
|
||||
/// into the tier's output frame, and paces frames into the encoder at the tier's
|
||||
/// into the tier's cropped input frame, and paces frames into the encoder at the tier's
|
||||
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||||
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||||
/// the capture managers and is fully hermetic in tests.
|
||||
@@ -29,7 +29,7 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly Func<VideoFrame?>? _alertTicker;
|
||||
private readonly Func<Scene, VideoFrame?>? _alertTicker;
|
||||
private readonly Func<VideoFrame?>? _brandFlash;
|
||||
private readonly TransitionService? _transition;
|
||||
private VideoFrame? _lastOutput;
|
||||
@@ -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,
|
||||
@@ -138,7 +150,7 @@ public sealed class FramePump : IDisposable
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
Func<VideoFrame?>? alertTicker = null,
|
||||
Func<Scene, VideoFrame?>? alertTicker = null,
|
||||
Func<VideoFrame?>? brandFlash = null,
|
||||
TransitionService? transition = null,
|
||||
SceneGraph? sceneGraph = null)
|
||||
@@ -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;
|
||||
@@ -486,7 +535,7 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
VideoFrame? tickerFrame = _alertTicker?.Invoke();
|
||||
VideoFrame? tickerFrame = _alertTicker?.Invoke(scene);
|
||||
tickerTicks += sw.ElapsedTicks;
|
||||
sw.Restart();
|
||||
VideoFrame? flashFrame = _brandFlash?.Invoke();
|
||||
@@ -722,11 +771,9 @@ public sealed class FramePump : IDisposable
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver,
|
||||
CompositeTimings? timings = null)
|
||||
{
|
||||
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
|
||||
// at 1:1 they are the same buffer size. Off-size tiers (a vertical 1080×1920
|
||||
// tier, an H264-1080p tier) fall back to a per-tick full render; the deployed
|
||||
// config is master-sized and caching a per-tick fresh scale buffer is the known
|
||||
// vertical-tier follow-up (see ai.md), not silently baked here.
|
||||
// The cache stores the compositor frame at crop size; FFmpeg scales that frame
|
||||
// to the final output dimensions. With product options, scratch and rendered
|
||||
// frames are therefore the same size, including for vertical output.
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
@@ -858,7 +905,8 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
else if (element is Source { Type: SourceType.TextOverlay })
|
||||
{
|
||||
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
|
||||
h = Mix(h, 2UL); // static text is resolved from its raster-on-change frame cache
|
||||
if (element.IsVisible) frame = resolver(element);
|
||||
}
|
||||
else if (element is Source { Type: SourceType.Background })
|
||||
{
|
||||
@@ -970,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);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for the entitlement rail. Production never uses it — a fake that could be reached
|
||||
/// in a shipped build is a licence bypass, so it lives here only because tests need to drive both
|
||||
/// answers without a Steam client or a network.
|
||||
/// Development/test ownership result. Release builds exclude this type.
|
||||
/// </summary>
|
||||
public sealed class FakeEntitlement : IEntitlement
|
||||
{
|
||||
public FakeEntitlement(bool isPro = false) => IsPro = isPro;
|
||||
public FakeEntitlement(bool isAppOwned = false, string? errorMessage = null)
|
||||
{
|
||||
IsAppOwned = isAppOwned;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public bool IsPro { get; set; }
|
||||
public bool IsAppOwned { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// Where "is this copy licensed for Pro output?" comes from.
|
||||
/// Whether the current Steam account owns the paid app.
|
||||
///
|
||||
/// <para><b>One question, two sources.</b> The lifetime SKU is a Steam DLC; the monthly is a Polar
|
||||
/// lease. Both answer this interface, and everything downstream — the output profile, the greyed
|
||||
/// tier list, the notices — reads only this. Nothing outside <c>Services/Entitlement</c> may know
|
||||
/// which rail proved it.</para>
|
||||
///
|
||||
/// <para><b>Fail closed.</b> An implementation that cannot reach its source, cannot load its
|
||||
/// native library, or has no product id configured returns <c>false</c>. Pro is never a default,
|
||||
/// never a cached "probably", and never the result of an exception being swallowed as true.</para>
|
||||
/// <para>Steam is the source of truth. Failure to reach Steam, load its native library, or
|
||||
/// resolve the paid app ID returns <c>false</c>.</para>
|
||||
/// </summary>
|
||||
public interface IEntitlement
|
||||
{
|
||||
/// <summary>True when this session may emit above the free output budget.</summary>
|
||||
bool IsPro { get; }
|
||||
bool IsAppOwned { get; }
|
||||
|
||||
string? ErrorMessage { get; }
|
||||
}
|
||||
|
||||
@@ -3,41 +3,58 @@ using Steamworks;
|
||||
namespace ytLive.Services.Entitlement;
|
||||
|
||||
/// <summary>
|
||||
/// The lifetime SKU: a Steam **DLC** on the free app, read locally from the Steam client.
|
||||
///
|
||||
/// <para><b>This is the whole purchase story.</b> There is no key, no server and no lease — the
|
||||
/// customer buys the DLC on the store page and Steam already knows. The app asks once and caches
|
||||
/// the answer; a refunded DLC stops reporting ownership the next time the app starts.</para>
|
||||
///
|
||||
/// <para><b>⛔ Never sell the monthly plan through this.</b> Steamworks requires in-app purchases
|
||||
/// to use the Steam Wallet, so anything sold from inside the app would be a policy violation. The
|
||||
/// monthly lives on the website and is billed by Polar (<see cref="PolarEntitlement"/> when it
|
||||
/// lands); this class only ever reads what Valve already sold.</para>
|
||||
///
|
||||
/// <para><b>Steam absent is normal, not exceptional.</b> A copied folder, a shortcut run without
|
||||
/// the client, or a CI machine has no Steam — <see cref="SteamAPI.Init"/> returns false and the
|
||||
/// app runs as the free tier. It must never refuse to start over an entitlement lookup.</para>
|
||||
/// Reads ownership of the paid app from Steam. In the free Demo, this checks the associated paid
|
||||
/// app ID; no key, server, or LlamaCasty account is involved. A missing ID, Steam client, or native
|
||||
/// library fails closed so the app can continue with the free output profile.
|
||||
/// </summary>
|
||||
public sealed class SteamEntitlement : IEntitlement
|
||||
{
|
||||
/// <summary>The DLC's own app id, not the base app's. Zero = not configured yet, which reads
|
||||
/// as "no entitlement" rather than "try anyway".</summary>
|
||||
private readonly uint _dlcAppId;
|
||||
private readonly uint _paidAppId;
|
||||
private readonly Func<bool> _initializeSteam;
|
||||
private readonly Func<uint, bool> _isAppSubscribed;
|
||||
private readonly Action _shutdownSteam;
|
||||
|
||||
private bool _initAttempted;
|
||||
private bool _available;
|
||||
private bool? _cached;
|
||||
private string? _errorMessage;
|
||||
|
||||
public SteamEntitlement(uint dlcAppId) => _dlcAppId = dlcAppId;
|
||||
public SteamEntitlement(uint paidAppId)
|
||||
: this(
|
||||
paidAppId,
|
||||
SteamAPI.Init,
|
||||
appId => SteamApps.BIsSubscribedApp(new AppId_t(appId)),
|
||||
SteamAPI.Shutdown)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Configured with no product id — the state before the Steamworks app exists.</summary>
|
||||
internal SteamEntitlement(
|
||||
uint paidAppId,
|
||||
Func<bool> initializeSteam,
|
||||
Func<uint, bool> isAppSubscribed,
|
||||
Action shutdownSteam)
|
||||
{
|
||||
_paidAppId = paidAppId;
|
||||
_initializeSteam = initializeSteam;
|
||||
_isAppSubscribed = isAppSubscribed;
|
||||
_shutdownSteam = shutdownSteam;
|
||||
}
|
||||
|
||||
/// <summary>Configured with no paid app ID; ownership fails closed without touching Steam.</summary>
|
||||
public static SteamEntitlement NotConfigured() => new(0);
|
||||
|
||||
public bool IsPro
|
||||
public string? ErrorMessage => _errorMessage;
|
||||
|
||||
public bool IsAppOwned
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_dlcAppId == 0) return false;
|
||||
if (_paidAppId == 0)
|
||||
{
|
||||
_cached = false;
|
||||
_errorMessage = "Steam licensing is not configured in this build.";
|
||||
return false;
|
||||
}
|
||||
if (_cached is { } known) return known;
|
||||
|
||||
if (!_initAttempted)
|
||||
@@ -45,38 +62,44 @@ public sealed class SteamEntitlement : IEntitlement
|
||||
_initAttempted = true;
|
||||
try
|
||||
{
|
||||
// No steam_appid.txt and no client → false. That is the documented free path.
|
||||
_available = SteamAPI.Init();
|
||||
_available = _initializeSteam();
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A missing steam_api64.dll throws on first touch. Free tier, never a crash.
|
||||
_available = false;
|
||||
_errorMessage = $"Steam could not start ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!_available) { _cached = false; return false; }
|
||||
if (!_available)
|
||||
{
|
||||
_cached = false;
|
||||
_errorMessage ??= "Steam could not verify ownership. Open Steam, sign in, then restart LlamaCasty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var owned = SteamApps.BIsDlcInstalled(new AppId_t(_dlcAppId));
|
||||
var owned = _isAppSubscribed(_paidAppId);
|
||||
_cached = owned;
|
||||
_errorMessage = null;
|
||||
return owned;
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_cached = false;
|
||||
_errorMessage = $"Steam could not check ownership ({ex.Message}). Open Steam, sign in, then restart LlamaCasty.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Matches Steam's own lifecycle callbacks; called from the frame pump's pump thread
|
||||
/// would be wrong, so nothing here schedules it automatically.</summary>
|
||||
public void Shutdown()
|
||||
{
|
||||
if (_available) SteamAPI.Shutdown();
|
||||
if (_available) _shutdownSteam();
|
||||
_available = false;
|
||||
_cached = null;
|
||||
_initAttempted = false;
|
||||
_errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,11 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
|
||||
ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness,
|
||||
ChromaKeySpill, ChromaScreen, IsReplayLayer
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -167,6 +173,33 @@ public partial class LayoutStore : IDisposable
|
||||
BlurAmount = reader.IsDBNull(reader.GetOrdinal("BlurAmount")) ? 0.5 : reader.GetDouble(reader.GetOrdinal("BlurAmount")),
|
||||
AlertAudioPath = reader.IsDBNull(reader.GetOrdinal("AlertAudioPath")) ? null : reader.GetString(reader.GetOrdinal("AlertAudioPath")),
|
||||
AlertAudioLoop = reader.GetInt32(reader.GetOrdinal("AlertAudioLoop")) != 0,
|
||||
TextContent = reader.IsDBNull(41) ? "Text" : reader.GetString(41),
|
||||
TextFontFamily = reader.IsDBNull(42) ? "Segoe UI" : reader.GetString(42),
|
||||
TextFontSize = reader.IsDBNull(43) ? 48 : reader.GetDouble(43),
|
||||
TextColor = reader.IsDBNull(44) ? "#FFFFFF" : reader.GetString(44),
|
||||
TextBold = !reader.IsDBNull(45) && reader.GetInt32(45) != 0,
|
||||
TextItalic = !reader.IsDBNull(46) && reader.GetInt32(46) != 0,
|
||||
TextUnderline = !reader.IsDBNull(47) && reader.GetInt32(47) != 0,
|
||||
TextAlignment = reader.IsDBNull(48) || !Enum.IsDefined((TextLayerAlignment)reader.GetInt32(48))
|
||||
? TextLayerAlignment.Left
|
||||
: (TextLayerAlignment)reader.GetInt32(48),
|
||||
TextVerticalAlignment = reader.IsDBNull(49)
|
||||
|| !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();
|
||||
@@ -290,6 +337,62 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 3.17: independent static text-layer content and formatting.
|
||||
if (!columns.Contains("TextContent"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextContent TEXT NOT NULL DEFAULT 'Text';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextFontFamily"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontFamily TEXT NOT NULL DEFAULT 'Segoe UI';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextFontSize"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextFontSize REAL NOT NULL DEFAULT 48;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextColor TEXT NOT NULL DEFAULT '#FFFFFF';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextBold"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextBold INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextItalic"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextItalic INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextUnderline"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextUnderline INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextAlignment"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextAlignment INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("TextVerticalAlignment"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN TextVerticalAlignment INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("WebUri"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
@@ -322,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();
|
||||
}
|
||||
@@ -92,7 +102,11 @@ public partial class LayoutStore : IDisposable
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop)
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
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,
|
||||
@@ -100,7 +114,11 @@ public partial class LayoutStore : IDisposable
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop)
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
|
||||
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
|
||||
$textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted,
|
||||
$chromaEnabled, $chromaColor, $chromaSimilarity, $chromaSoftness,
|
||||
$chromaSpill, $chromaScreen, $isReplayLayer)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -133,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);
|
||||
@@ -144,6 +170,15 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.Parameters.Add("$blurAmount", SqliteType.Real);
|
||||
var alertAudioPathP = cmd.Parameters.Add("$alertAudioPath", SqliteType.Text);
|
||||
var alertAudioLoopP = cmd.Parameters.Add("$alertAudioLoop", SqliteType.Integer);
|
||||
var textContentP = cmd.Parameters.Add("$textContent", SqliteType.Text);
|
||||
var textFontFamilyP = cmd.Parameters.Add("$textFontFamily", SqliteType.Text);
|
||||
var textFontSizeP = cmd.Parameters.Add("$textFontSize", SqliteType.Real);
|
||||
var textColorP = cmd.Parameters.Add("$textColor", SqliteType.Text);
|
||||
var textBoldP = cmd.Parameters.Add("$textBold", SqliteType.Integer);
|
||||
var textItalicP = cmd.Parameters.Add("$textItalic", SqliteType.Integer);
|
||||
var textUnderlineP = cmd.Parameters.Add("$textUnderline", SqliteType.Integer);
|
||||
var textAlignmentP = cmd.Parameters.Add("$textAlignment", SqliteType.Integer);
|
||||
var textVerticalAlignmentP = cmd.Parameters.Add("$textVerticalAlignment", SqliteType.Integer);
|
||||
|
||||
// Source and WebcamSceneConfig share ONE z-order space per scene:
|
||||
// each element gets its index within scene.Elements regardless of
|
||||
@@ -185,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;
|
||||
@@ -196,6 +239,15 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.Parameters["$blurAmount"].Value = source.BlurAmount;
|
||||
alertAudioPathP.Value = (object?)source.AlertAudioPath ?? DBNull.Value;
|
||||
alertAudioLoopP.Value = source.AlertAudioLoop ? 1 : 0;
|
||||
textContentP.Value = source.TextContent;
|
||||
textFontFamilyP.Value = source.TextFontFamily;
|
||||
textFontSizeP.Value = source.TextFontSize;
|
||||
textColorP.Value = source.TextColor;
|
||||
textBoldP.Value = source.TextBold ? 1 : 0;
|
||||
textItalicP.Value = source.TextItalic ? 1 : 0;
|
||||
textUnderlineP.Value = source.TextUnderline ? 1 : 0;
|
||||
textAlignmentP.Value = (int)source.TextAlignment;
|
||||
textVerticalAlignmentP.Value = (int)source.TextVerticalAlignment;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -382,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)
|
||||
{
|
||||
@@ -79,7 +88,8 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
|
||||
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
CaptureFailed?.Invoke(key, ex.Message);
|
||||
return false;
|
||||
}
|
||||
@@ -109,7 +119,8 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
lock (_gate)
|
||||
_sessions.Remove(key);
|
||||
session.Source.FrameAvailable -= session.FrameHandler;
|
||||
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
|
||||
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
CaptureFailed?.Invoke(key, ex.Message);
|
||||
await SafeStopAsync(session.Source);
|
||||
return false;
|
||||
@@ -189,7 +200,8 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
|
||||
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}",
|
||||
AppLogMessageType.Error, AppLogErrorClass.Class2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,158 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public sealed class TextOverlayRenderer
|
||||
{
|
||||
private const int MaxRasterDimension = 4096;
|
||||
private readonly ConcurrentDictionary<Source, CacheEntry> _cache = new();
|
||||
|
||||
public VideoFrame? RenderFrame(Source source)
|
||||
=> _cache.TryGetValue(source, out var entry) ? entry.Frame : null;
|
||||
|
||||
public void Remove(Source source) => _cache.TryRemove(source, out _);
|
||||
|
||||
public void Retain(IEnumerable<Source> sources)
|
||||
{
|
||||
var active = new HashSet<Source>(sources);
|
||||
foreach (var source in _cache.Keys)
|
||||
if (!active.Contains(source))
|
||||
_cache.TryRemove(source, out _);
|
||||
}
|
||||
|
||||
public void UpdatePreview(Source source)
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher != null && !dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.Invoke(() => UpdatePreview(source));
|
||||
return;
|
||||
}
|
||||
|
||||
var config = Capture(source);
|
||||
if (_cache.TryGetValue(source, out var cached) && cached.Config == config)
|
||||
{
|
||||
if (!ReferenceEquals(source.VideoImageSource, cached.Preview))
|
||||
source.VideoImageSource = cached.Preview;
|
||||
return;
|
||||
}
|
||||
|
||||
var frame = Rasterize(config);
|
||||
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||
_cache[source] = new CacheEntry(config, frame, bitmap);
|
||||
source.VideoImageSource = bitmap;
|
||||
}
|
||||
|
||||
private static RenderConfig Capture(Source source) => new(
|
||||
source.TextContent,
|
||||
source.TextFontFamily,
|
||||
source.TextFontSize,
|
||||
source.TextColor,
|
||||
source.TextBold,
|
||||
source.TextItalic,
|
||||
source.TextUnderline,
|
||||
source.TextAlignment,
|
||||
source.TextVerticalAlignment,
|
||||
RasterDimension(source.Width),
|
||||
RasterDimension(source.Height));
|
||||
|
||||
private static int RasterDimension(double value) =>
|
||||
double.IsFinite(value) ? (int)Math.Clamp(Math.Ceiling(value), 1, MaxRasterDimension) : 1;
|
||||
|
||||
private static VideoFrame Rasterize(RenderConfig config)
|
||||
{
|
||||
var visual = new DrawingVisual();
|
||||
if (!string.IsNullOrEmpty(config.Content))
|
||||
{
|
||||
using var drawing = visual.RenderOpen();
|
||||
var family = new FontFamily(config.FontFamily);
|
||||
var typeface = new Typeface(
|
||||
family,
|
||||
config.Italic ? FontStyles.Italic : FontStyles.Normal,
|
||||
config.Bold ? FontWeights.Bold : FontWeights.Normal,
|
||||
FontStretches.Normal);
|
||||
var brush = new SolidColorBrush(ParseColor(config.Color));
|
||||
brush.Freeze();
|
||||
var formatted = new FormattedText(
|
||||
config.Content,
|
||||
CultureInfo.CurrentUICulture,
|
||||
FlowDirection.LeftToRight,
|
||||
typeface,
|
||||
config.FontSize,
|
||||
brush,
|
||||
1.0)
|
||||
{
|
||||
MaxTextWidth = config.Width,
|
||||
MaxTextHeight = config.Height,
|
||||
TextAlignment = config.Alignment switch
|
||||
{
|
||||
TextLayerAlignment.Center => System.Windows.TextAlignment.Center,
|
||||
TextLayerAlignment.Right => System.Windows.TextAlignment.Right,
|
||||
_ => System.Windows.TextAlignment.Left,
|
||||
},
|
||||
Trimming = TextTrimming.CharacterEllipsis,
|
||||
};
|
||||
if (config.Underline)
|
||||
formatted.SetTextDecorations(TextDecorations.Underline, 0, config.Content.Length);
|
||||
var top = config.VerticalAlignment switch
|
||||
{
|
||||
TextLayerVerticalAlignment.Center => Math.Max(0, (config.Height - formatted.Height) / 2),
|
||||
TextLayerVerticalAlignment.Bottom => Math.Max(0, config.Height - formatted.Height),
|
||||
_ => 0,
|
||||
};
|
||||
drawing.DrawText(formatted, new Point(0, top));
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(config.Width, config.Height, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
var pixels = new byte[config.Width * config.Height * 4];
|
||||
bitmap.CopyPixels(pixels, config.Width * 4, 0);
|
||||
UnpremultiplyAlpha(pixels);
|
||||
return new VideoFrame(config.Width, config.Height, pixels);
|
||||
}
|
||||
|
||||
private static Color ParseColor(string hex) =>
|
||||
(Color)ColorConverter.ConvertFromString(hex)!;
|
||||
|
||||
private static void UnpremultiplyAlpha(byte[] pixels)
|
||||
{
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var alpha = pixels[i + 3];
|
||||
if (alpha == 0)
|
||||
{
|
||||
pixels[i] = pixels[i + 1] = pixels[i + 2] = 0;
|
||||
continue;
|
||||
}
|
||||
if (alpha == 255) continue;
|
||||
pixels[i] = Unpremultiply(pixels[i], alpha);
|
||||
pixels[i + 1] = Unpremultiply(pixels[i + 1], alpha);
|
||||
pixels[i + 2] = Unpremultiply(pixels[i + 2], alpha);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte Unpremultiply(byte value, byte alpha) =>
|
||||
(byte)Math.Min(255, (value * 255 + alpha / 2) / alpha);
|
||||
|
||||
private sealed record CacheEntry(RenderConfig Config, VideoFrame Frame, WriteableBitmap Preview);
|
||||
|
||||
private readonly record struct RenderConfig(
|
||||
string Content,
|
||||
string FontFamily,
|
||||
double FontSize,
|
||||
string Color,
|
||||
bool Bold,
|
||||
bool Italic,
|
||||
bool Underline,
|
||||
TextLayerAlignment Alignment,
|
||||
TextLayerVerticalAlignment VerticalAlignment,
|
||||
int Width,
|
||||
int Height);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user