Compare commits
228
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931ab46783 | ||
|
|
8e1d6f44db | ||
|
|
06f88f4142 | ||
|
|
63bf8444bd | ||
|
|
c2d64dcd6d | ||
|
|
305706956e | ||
|
|
5685c5dc3a | ||
|
|
87f05ba2ce | ||
|
|
b140024ebf | ||
|
|
8303b1cf7f | ||
|
|
352f117cb2 | ||
|
|
de22983b36 | ||
|
|
55210b9a61 | ||
|
|
227ee8fca6 | ||
|
|
fbeb63ba79 | ||
|
|
4e42871fae | ||
|
|
db2fdbf68d | ||
|
|
c6569a7ccb | ||
|
|
f529d47f42 | ||
|
|
9f3c154d84 | ||
|
|
f8dd1079f3 | ||
|
|
ed47480116 | ||
|
|
058bef4312 | ||
|
|
29ef5d5f85 | ||
|
|
d8febbd0d8 | ||
|
|
9fb628b473 | ||
|
|
fe80e8c994 | ||
|
|
948dd08214 | ||
|
|
6bff21b234 | ||
|
|
4b3d82ff0f | ||
|
|
966e718f1e | ||
|
|
6b6284d05c | ||
|
|
3901cb8298 | ||
|
|
623d77e531 | ||
|
|
dc1343896d | ||
|
|
77d5120c87 | ||
|
|
08290ece16 | ||
|
|
bdea18e12c | ||
|
|
0464593a02 | ||
|
|
cea641d429 | ||
|
|
25d5416a93 | ||
|
|
81da58ab9c | ||
|
|
a8e3954f01 | ||
|
|
9d04aea55b | ||
|
|
55ca1029a3 | ||
|
|
b2f219739d | ||
|
|
509ef36aa0 | ||
|
|
07a088a23f | ||
|
|
8fbc3701d2 | ||
|
|
0fb2f9c6c3 | ||
|
|
74da99c988 | ||
|
|
5ad0106502 | ||
|
|
048cab79f8 | ||
|
|
cc2d1ef622 | ||
|
|
ad10176e8b | ||
|
|
f3fed0a2a8 | ||
|
|
d95a23c6fb | ||
|
|
2569c09617 | ||
|
|
3637950df5 | ||
|
|
967df83925 | ||
|
|
b3edc2e42e | ||
|
|
4fcf76289c | ||
|
|
eb0dd91126 | ||
|
|
4873ef85ea | ||
|
|
44344c9327 | ||
|
|
536676f11a | ||
|
|
e7cded6879 | ||
|
|
b2036a38e8 | ||
|
|
85fad1ab79 | ||
|
|
e78c58fc28 | ||
|
|
938c5b3de4 | ||
|
|
9761b1d4be | ||
|
|
e3e69d941d | ||
|
|
de81fa3c5d | ||
|
|
f5a9d46881 | ||
|
|
5aedca7305 | ||
|
|
fae8ec5f32 | ||
|
|
7f14ffb11e | ||
|
|
e2ecc248e0 | ||
|
|
aea0670723 | ||
|
|
80038ff152 | ||
|
|
7b940b6a5a | ||
|
|
a11b15e444 | ||
|
|
2c9af9426b | ||
|
|
ecb329e578 | ||
|
|
0981a72eea | ||
|
|
94f015044a | ||
|
|
d4aa588da0 | ||
|
|
3a4e17c735 | ||
|
|
cb750660c3 | ||
|
|
d72949e2f9 | ||
|
|
ac6e67aff0 | ||
|
|
18ab553b0e | ||
|
|
e69db4d26c | ||
|
|
bb5dcb4ba2 | ||
|
|
57841d39ac | ||
|
|
4d9188ab2e | ||
|
|
197ee815a0 | ||
|
|
a9d5f29084 | ||
|
|
4e0915a36e | ||
|
|
523c17b7da | ||
|
|
0c556450cb | ||
|
|
94e114bfdf | ||
|
|
5a1993b6db | ||
|
|
f91bf8715d | ||
|
|
2ac00db0da | ||
|
|
0d0e55de31 | ||
|
|
c11788574e | ||
|
|
fd5198df9f | ||
|
|
75723acc1b | ||
|
|
9d00955004 | ||
|
|
1e4017df03 | ||
|
|
94a934ffa9 | ||
|
|
64a5a6d06f | ||
|
|
b37b8a30f9 | ||
|
|
71932b9756 | ||
|
|
76f51e6f4e | ||
|
|
394d86b94c | ||
|
|
c01206fb8a | ||
|
|
40c453805d | ||
|
|
d0e4a0fb25 | ||
|
|
b22d08eca6 | ||
|
|
11a7af2dc0 | ||
|
|
5ba4d709ae | ||
|
|
724af1499b | ||
|
|
a62a283fd4 | ||
|
|
992bb21bdb | ||
|
|
58050d9ab1 | ||
|
|
efe88b8a0b | ||
|
|
0f72c53aa5 | ||
|
|
b4bba4bf68 | ||
|
|
ea347f211c | ||
|
|
f3d578c81b | ||
|
|
5e78065c2d | ||
|
|
ec7c734bdd | ||
|
|
c45cbc93b7 | ||
|
|
bd396e488c | ||
|
|
d212d5ae2c | ||
|
|
1a39b091a0 | ||
|
|
e564e9884c | ||
|
|
081e4c1bf8 | ||
|
|
5175c1c8c9 | ||
|
|
f6802c7124 | ||
|
|
84aa70f532 | ||
|
|
ed9d7c1ffc | ||
|
|
5ca9778e23 | ||
|
|
e002847777 | ||
|
|
70db34455f | ||
|
|
0f059ece33 | ||
|
|
6d11e8b3fc | ||
|
|
87509bcf99 | ||
|
|
98d30c2056 | ||
|
|
1295e0ed80 | ||
|
|
0f441d825a | ||
|
|
d35823a4a9 | ||
|
|
99aeef7994 | ||
|
|
c46f3a1168 | ||
|
|
fbc8562cf4 | ||
|
|
eb4c379b91 | ||
|
|
09a866e6a1 | ||
|
|
6af2026906 | ||
|
|
27bf74389d | ||
|
|
1c48849853 | ||
|
|
432adfdaef | ||
|
|
716a77f61a | ||
|
|
7fcb2ad3da | ||
|
|
4509befb98 | ||
|
|
af0d372b65 | ||
|
|
89fee6ca4a | ||
|
|
97ffc426fe | ||
|
|
8dcaee0b5b | ||
|
|
7f2bda8ed3 | ||
|
|
5ead064d54 | ||
|
|
5a1a3c566a | ||
|
|
688682d5b5 | ||
|
|
22b780e075 | ||
|
|
7294a1aa2d | ||
|
|
b4f3b52b85 | ||
|
|
6799c40175 | ||
|
|
711ba54af7 | ||
|
|
e1d8b10388 | ||
|
|
7c6ec3e40a | ||
|
|
8fa54d2de7 | ||
|
|
4a321b19ff | ||
|
|
097dd0d9bd | ||
|
|
881addb5b4 | ||
|
|
a2219be198 | ||
|
|
8fa78423e6 | ||
|
|
071ec8be0c | ||
|
|
480ead0fc7 | ||
|
|
a1d9751a6f | ||
|
|
8f490102ee | ||
|
|
f89b9f9ffa | ||
|
|
e8ff4df05f | ||
|
|
670fe3a10a | ||
|
|
3bf053a3b1 | ||
|
|
84a038c498 | ||
|
|
85893ea709 | ||
|
|
3107f928ab | ||
|
|
5b9ff5b742 | ||
|
|
dcb3637cca | ||
|
|
d3271a0405 | ||
|
|
fab2e09903 | ||
|
|
31362d1a43 | ||
|
|
6944db8ab4 | ||
|
|
cb54637e9f | ||
|
|
1f4624c636 | ||
|
|
544208e002 | ||
|
|
c9fd1bdaaa | ||
|
|
73397cfea7 | ||
|
|
c5e3466df0 | ||
|
|
90f5ad50dd | ||
|
|
691425288f | ||
|
|
bc23b64315 | ||
|
|
14d3ed4924 | ||
|
|
1efde5a1e6 | ||
|
|
3b77a132be | ||
|
|
e1006a04ab | ||
|
|
c41ff7fd0a | ||
|
|
535a50192c | ||
|
|
c1126a52b9 | ||
|
|
fc33022006 | ||
|
|
46978b8bc3 | ||
|
|
58e7b3f3d1 | ||
|
|
e2867198b9 | ||
|
|
db068abbc7 | ||
|
|
dcdfcb9cee | ||
|
|
9baae33916 |
@@ -1,6 +1,10 @@
|
||||
bin/
|
||||
obj/
|
||||
Helpers/OAuthCredentials.cs
|
||||
|
||||
# Bundled FFmpeg (135 MB of DLLs). Fetched once by scripts/fetch-ffmpeg.sh, which pins
|
||||
# the URL + SHA-256; the build then copies it to the output. Never committed — see the
|
||||
# script header for why the hash, not the binaries, is the audit trail.
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
@@ -10,3 +14,5 @@ Helpers/OAuthCredentials.cs
|
||||
MONETIZATION.md
|
||||
MARCOM.md
|
||||
CREDENTIALS.md
|
||||
.aider*
|
||||
ffmpeg/
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# LlamaCasty — copyright, license, and additional terms
|
||||
|
||||
Copyright (C) 2026 Gramps — https://llamacasty.com
|
||||
|
||||
LlamaCasty is free software: you can redistribute it and/or modify it under the terms of the
|
||||
**GNU General Public License as published by the Free Software Foundation, either version 3 of
|
||||
the License, or (at your option) any later version** (GPL-3.0-or-later), together with the
|
||||
additional terms below, which section 7 of that license permits.
|
||||
The full license text is in [`LICENSE`](LICENSE).
|
||||
|
||||
LlamaCasty is distributed in the hope that it will be useful, but **WITHOUT ANY WARRANTY**;
|
||||
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
Third-party components bundled with LlamaCasty keep their own licenses — see
|
||||
[`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt).
|
||||
|
||||
---
|
||||
|
||||
## Additional terms (GPL-3.0 section 7)
|
||||
|
||||
1. **Attribution — section 7(b).** Every conveyed copy of this program, modified or not, must keep
|
||||
the copyright notice above and must show, in its About screen (or the equivalent place in its
|
||||
user interface), the notice:
|
||||
|
||||
> Based on LlamaCasty by Gramps — https://llamacasty.com
|
||||
|
||||
2. **Modified versions — section 7(c).** A modified version must be clearly marked as modified,
|
||||
must use a name other than "LlamaCasty", and must not be represented as the original LlamaCasty
|
||||
or as made or endorsed by Gramps.
|
||||
|
||||
3. **Trademarks — section 7(e).** This license grants no right to use the LlamaCasty name, the
|
||||
llama logo, or any other LlamaCasty brand mark, except for the factual attribution required by
|
||||
term 1.
|
||||
|
||||
## Brand artwork — not licensed under the GPL
|
||||
|
||||
The following files are Copyright (C) 2026 Gramps, **all rights reserved**, and are **not**
|
||||
covered by GPL-3.0:
|
||||
|
||||
- `Assets/llamacasty-icon.png`
|
||||
- `Assets/llamacasty-icon.ico`
|
||||
- `Assets/llamacasty-logo.jpg`
|
||||
|
||||
Unmodified copies of LlamaCasty may be redistributed with these files included. Modified versions
|
||||
must replace them with their own artwork. No other use — including on merchandise — is permitted
|
||||
without written permission.
|
||||
|
||||
## Placeholder artwork — licensed with the code
|
||||
|
||||
The scene background images (`Assets/*-background.jpg`) and the default alert video
|
||||
(`Assets/alert-default.mp4`) are AI-generated placeholders, there so the app works the moment it
|
||||
opens. Creators are expected to replace them with their own. They are distributed with the code
|
||||
under GPL-3.0-or-later and carry none of the restrictions above.
|
||||
@@ -20,6 +20,13 @@ work lands directly on `main`, committed and pushed per work unit. No
|
||||
`taskNN-*` branches, no PRs. The scope discipline above is about test focus,
|
||||
not git mechanics.
|
||||
|
||||
**Commit / push policy (2026-08-30, user directive):** commit every work unit —
|
||||
committing is expected and never needs extra permission. Pushing is different:
|
||||
push ONLY at sub-milestones and milestones. During a multi-commit refactor,
|
||||
that means commits land locally every partial; `git push` happens at the
|
||||
session's agreed checkpoint(s), not per-commit. When a milestone warrants a
|
||||
push the user will say so (or say "push it").
|
||||
|
||||
MUST read the architecture guide before planning or modifying the codebase:
|
||||
[`ai.md`](ai.md) (+ [`schema.md`](schema.md) for the memory-map conventions).
|
||||
|
||||
@@ -31,7 +38,7 @@ conventions live here and in `ai.md`.
|
||||
|
||||
1. [`schema.md`](schema.md) — the memory-map conventions (what lives where, how to keep it true).
|
||||
2. [`ai.md`](ai.md) — the AI guide: architecture, patterns, decisions, current state.
|
||||
3. [`TASKS.md`](TASKS.md) — the task queue + authoritative YouTube API research.
|
||||
3. [`TASKS.md`](TASKS.md) — the task catalog (index + queue state); open items and API research facts are in [`TASKS/`](TASKS/).
|
||||
4. [`HANDOFF.md`](HANDOFF.md) — current operational state: what's in flight, landmines, next step.
|
||||
5. `<dir>/index.md` — the index for any directory you're about to touch.
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ public partial class App : Application
|
||||
AppLog.Write(args.Exception, "Dispatcher unhandled exception");
|
||||
|
||||
AppLog.Write("App OnStartup begin");
|
||||
// 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.
|
||||
AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})");
|
||||
|
||||
// Velopack auto-update bootstrap — checks for updates on startup and
|
||||
// applies them (the app restarts automatically if an update is found).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 660 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 717 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 560 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 553 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
@@ -0,0 +1,48 @@
|
||||
<UserControl x:Class="ytLive.Controls.BottomBar"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Border Background="#0f3460" Padding="16,4">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Stream stats: centered under the preview pane -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Text="Bitrate:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0">
|
||||
<Run Text="{Binding CurrentHealth.CurrentBitrate, Mode=OneWay}"/>
|
||||
<Run Text=" Mbps"/>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Text="FPS:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,24,0">
|
||||
<Run Text="{Binding CurrentHealth.FPS, Mode=OneWay, StringFormat={}{0:0}}"/>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Text="Dropped:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.DroppedFrames}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White" Margin="0,0,20,0"/>
|
||||
|
||||
<TextBlock Text="Duration:" Style="{StaticResource YtLabel}" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="{Binding CurrentHealth.StreamDuration}" Style="{StaticResource YtLabel}"
|
||||
Foreground="White"/>
|
||||
|
||||
<TextBlock Text="{Binding CurrentHealth.HealthMessage}" Style="{StaticResource YtLabel}"
|
||||
Foreground="#e94560" Margin="20,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Resolution dropdown: far right -->
|
||||
<ComboBox Grid.Column="2" Style="{StaticResource YtComboBox}" MinWidth="150"
|
||||
ItemsSource="{Binding QualityOptions}"
|
||||
SelectedItem="{Binding SelectedQuality}"
|
||||
DisplayMemberPath="Label"
|
||||
ToolTip="{Binding ResolutionHelp}"
|
||||
IsEnabled="{Binding IsOffline}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class BottomBar : UserControl
|
||||
{
|
||||
public BottomBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<UserControl x:Class="ytLive.Controls.HealthBanner"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- YouTube health banner (TASK 5 item 3): report-by-exception — visible
|
||||
ONLY while a warning/error configuration issue exists. Amber for
|
||||
warning, darker red for error (HealthIssueBackground). -->
|
||||
<Border Background="{Binding HealthIssueBackground}"
|
||||
Visibility="{Binding HealthIssueBanner, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock Text="{Binding HealthIssueBanner}" Foreground="White" FontSize="13"
|
||||
FontWeight="SemiBold" Margin="16,8" TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class HealthBanner : UserControl
|
||||
{
|
||||
public HealthBanner()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
<UserControl x:Class="ytLive.Controls.LeftPanel"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||
xmlns:models="clr-namespace:ytLive.Models">
|
||||
<Border Background="#16213e" CornerRadius="6" Padding="12">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Edit mode (offline): layers + properties -->
|
||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- LAYERS header + Add button -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<TextBlock Text="LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
<Button ToolTip="Add Layer" Style="{StaticResource IconButton}" Margin="4,-4,0,0"
|
||||
Click="AddSourceButton_Click">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
|
||||
IsEnabled="{Binding CanAddWebcam}"
|
||||
ToolTip="{Binding WebcamAddToolTip}"/>
|
||||
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
|
||||
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
||||
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
||||
Visibility="{Binding IsCountdownAvailable, Converter={StaticResource BoolToVis}}"
|
||||
ToolTip="Timer overlay — Starting and BRB screens only"/>
|
||||
<MenuItem Header="Web Resource" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.WebSource}"
|
||||
ToolTip="Embedded web page — set URI in Properties panel"/>
|
||||
<MenuItem Header="YouTube Chat" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.ChatBox}"
|
||||
IsEnabled="{Binding CanAddYouTubeChat}"
|
||||
ToolTip="One chat layer at a time — it's already in your stream"/>
|
||||
<MenuItem Header="Stream Alerts" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.AlertBox}"
|
||||
IsEnabled="{Binding CanAddAlerts}"
|
||||
ToolTip="Celebration zone for Super Chat & memberships — one alert layer at a time"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Helpers:FocusPreservingListBox x:Name="SourceList" Grid.Row="1" Background="Transparent" BorderThickness="0"
|
||||
ItemsSource="{Binding StagedScene.Elements}"
|
||||
SelectedItem="{Binding SelectedElement, Mode=TwoWay}"
|
||||
PreviewMouseLeftButtonDown="List_PreviewMouseLeftButtonDown"
|
||||
PreviewMouseMove="List_PreviewMouseMove"
|
||||
PreviewMouseLeftButtonUp="List_PreviewMouseLeftButtonUp"
|
||||
Foreground="#e0e0e0" FontSize="13">
|
||||
<Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem" BasedOn="{StaticResource YtListBoxItem}">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
<!-- The transient zoom layer has no editing UI — never
|
||||
show it as a layer row (TASK 51). -->
|
||||
<DataTrigger Binding="{Binding IsZoomLayer}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,2"
|
||||
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Capture Window…"
|
||||
ItemsSource="{Binding DataContext.OpenWindows, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
<MenuItem.ItemContainerStyle>
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Header" Value="{Binding Label}"/>
|
||||
<Setter Property="Command"
|
||||
Value="{Binding DataContext.CaptureWindowCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||
</Style>
|
||||
</MenuItem.ItemContainerStyle>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Refresh Window List" Click="BackgroundMenu_RefreshWindowList">
|
||||
<MenuItem.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</MenuItem.Visibility>
|
||||
</MenuItem>
|
||||
<Separator>
|
||||
<Separator.Visibility>
|
||||
<MultiBinding Converter="{StaticResource AllTrueToVis}">
|
||||
<Binding Path="IsBackground"/>
|
||||
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
|
||||
</MultiBinding>
|
||||
</Separator.Visibility>
|
||||
</Separator>
|
||||
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
|
||||
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
|
||||
</ContextMenu>
|
||||
</Grid.ContextMenu>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
|
||||
Padding="4,2" TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
|
||||
<TextBox Grid.Column="0" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="0"
|
||||
Padding="4,2" VerticalContentAlignment="Center"
|
||||
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
|
||||
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsEditing}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBox.Style>
|
||||
</TextBox>
|
||||
|
||||
<Button Grid.Column="1" ToolTip="Edit Layer Name"
|
||||
Command="{Binding DataContext.EditElementCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}">
|
||||
<Path Data="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
|
||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="2" Style="{StaticResource EyeButton}"
|
||||
Command="{Binding DataContext.ToggleElementVisibilityCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}">
|
||||
<Path Style="{StaticResource EyeIconStyle}" Fill="#d0d0d0"
|
||||
Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" ToolTip="Remove Layer"
|
||||
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
|
||||
CommandParameter="{Binding}" Style="{StaticResource IconButton}"
|
||||
Visibility="{Binding IsBackground, Converter={StaticResource HiddenBoolToVis}}">
|
||||
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
|
||||
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</Helpers:FocusPreservingListBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="No layers yet" Foreground="#555" FontSize="12"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="{Binding ShowSourcesEmptyHint, Converter={StaticResource BoolToVis}}"/>
|
||||
|
||||
<!-- Resource alert: pinned to the bottom of the Layers panel.
|
||||
Red when a required resource (the one-and-only webcam)
|
||||
could not be locked; persists and re-polls until resolved. -->
|
||||
<Border Grid.Row="3" Margin="0,8,0,0" Padding="8,6" CornerRadius="4"
|
||||
Background="#3a1216" BorderBrush="#e94560" BorderThickness="1"
|
||||
Visibility="{Binding WebcamLockAlert, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding WebcamLockAlert}" Foreground="#ffb4b4" FontSize="11"
|
||||
TextWrapping="Wrap"/>
|
||||
<Button Content="Retry" Command="{Binding RetryWebcamLockCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,2"
|
||||
HorizontalAlignment="Right" Margin="0,6,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ELEMENTS panel: per-element config beneath Sources.
|
||||
Collapses entirely when no element is selected. -->
|
||||
<Rectangle Grid.Row="4" Height="1" Fill="#2c3a5c" Margin="0,10,0,2"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}"/>
|
||||
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel x:Name="ElementsPanel" Margin="0,4,0,0">
|
||||
<!-- Opacity + mirror/shape (draggable elements: image, webcam) -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsDraggable, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource InverseBoolToVis}}">
|
||||
<TextBlock Text="Opacity" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#a0a0b0" FontSize="11"
|
||||
DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0" Width="32"/>
|
||||
<Slider x:Name="OpacitySlider" Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.Opacity, Mode=TwoWay}"
|
||||
ValueChanged="OpacitySlider_ValueChanged" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,4"
|
||||
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<Button Content="{Binding SelectedElement.ShapeButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Margin="8,0,0,0" Click="ShapeButton_Click"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="..." Width="24" Height="24"
|
||||
Click="WebcamBorderColorPicker_Click" Margin="4,0,0,0"
|
||||
ToolTip="Pick color"/>
|
||||
<TextBox Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
||||
MaxLength="7" ToolTip="#RRGGBB"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
Click="WebcamBorderAccept_Click">
|
||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" ToolTip="Revert"
|
||||
Click="WebcamBorderRevert_Click" Margin="4,0,0,0">
|
||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="10"
|
||||
Value="{Binding SelectedElement.BorderWidth, Mode=TwoWay}"
|
||||
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderWidth}" Foreground="#a0a0b0" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- TASK 51: background effects — two pills acting as radio buttons
|
||||
(one on turns the other off; both off = no effect). -->
|
||||
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,6,0,6"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Remove" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton x:Name="RemoveBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.RemoveBackground, Mode=TwoWay}"
|
||||
AutomationProperties.Name="Remove background"
|
||||
ToolTip="Remove the background behind you — the layers below show through"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Blur" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton x:Name="BlurBackgroundPill" Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.BlurBackground, Mode=TwoWay}"
|
||||
AutomationProperties.Name="Blur background"
|
||||
ToolTip="Blur the background behind you"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,6"
|
||||
Visibility="{Binding SelectedElement.HasBackgroundEffect, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Strength" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.BackgroundStrength, StringFormat={}{0:P0}}"
|
||||
Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Margin="8,0,0,0" Width="34"/>
|
||||
<Slider x:Name="BackgroundStrengthSlider" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.BackgroundStrength, Mode=TwoWay}"
|
||||
TickFrequency="0.05" IsSnapToTickEnabled="True" VerticalAlignment="Center"
|
||||
AutomationProperties.Name="Background effect strength"
|
||||
ToolTip="Blur: how strong the blur is · Remove: how soft the edge is"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Countdown config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsCountdown, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Timer (minutes)" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0"
|
||||
Click="CountdownAccept_Click">
|
||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" ToolTip="Revert"
|
||||
Click="CountdownRevert_Click" Margin="4,0,0,0">
|
||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="1" Maximum="60"
|
||||
Value="{Binding SelectedElement.TimerMinutes, Mode=TwoWay}"
|
||||
VerticalAlignment="Center" TickFrequency="1" IsSnapToTickEnabled="True"/>
|
||||
<TextBlock Text="{Binding SelectedElement.TimerMinutes}" Foreground="#a0a0b0" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="20"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Web source config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebSource, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" Y" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Y, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
<TextBlock DockPanel.Dock="Left" Text=" H" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="20" Margin="8,0,0,0"/>
|
||||
<TextBox Text="{Binding SelectedElement.Height, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
|
||||
Click="WebAccept_Click">
|
||||
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
|
||||
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
|
||||
Click="WebRevert_Click" Margin="4,0,0,0">
|
||||
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
|
||||
Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
<TextBox Text="{Binding SelectedElement.WebUri, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0d0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="12"
|
||||
ToolTip="https://example.com"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Chat box config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsChatBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Chat Overlay" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||
|
||||
<!-- Font Size -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Size" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<Slider x:Name="ChatFontSizeSlider" Width="120" Minimum="8" Maximum="72" TickFrequency="2" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatFontSize, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFontSize, StringFormat={}{0:0}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Font Family -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.ChatFontFamily, Mode=TwoWay}"
|
||||
SelectedValuePath="Content"
|
||||
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
<ComboBoxItem Content="Segoe UI"/>
|
||||
<ComboBoxItem Content="Arial"/>
|
||||
<ComboBoxItem Content="Consolas"/>
|
||||
<ComboBoxItem Content="Courier New"/>
|
||||
<ComboBoxItem Content="Comic Sans MS"/>
|
||||
<ComboBoxItem Content="Mouse Memoirs"/>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Font Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Font Color" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatFontColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Member Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Member" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatMemberColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Super Chat Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Super Chat" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatSuperChatColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Max Messages -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Max Msgs" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<Slider x:Name="ChatMaxMsgsSlider" Width="120" Minimum="1" Maximum="20" TickFrequency="1" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatMaxMessages, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatMaxMessages}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="24"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Background Color -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Background" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<TextBox Text="{Binding SelectedElement.ChatBackgroundColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#0f3460" Foreground="#e0e0e0" BorderThickness="1"
|
||||
BorderBrush="#2c3a5c" Padding="4,2" FontSize="11" Width="80"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Background Opacity -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Bg Opacity" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<Slider x:Name="ChatBgOpacitySlider" Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.ChatBackgroundOpacity, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatBackgroundOpacity, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Fade After (seconds) -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Fade After" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="70"/>
|
||||
<Slider x:Name="ChatFadeSlider" Width="120" Minimum="0" Maximum="60" TickFrequency="5" IsSnapToTickEnabled="True"
|
||||
Value="{Binding SelectedElement.ChatFadeSeconds, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.ChatFadeSeconds, StringFormat='{}{0}s'}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="28"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Toggles -->
|
||||
<CheckBox Content="Show timestamps" Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,2"
|
||||
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Show badges" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"
|
||||
IsChecked="{Binding SelectedElement.ChatShowBadges, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Stream Alerts config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsAlertBox, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Stream Alerts" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||
<TextBlock Text="The built-in clip plays on every alert (Super Chat, membership…) unless a custom video is picked."
|
||||
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,0,0,6"/>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Built-in video" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.AlertUseDefaultVideo, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
|
||||
<Grid Margin="0,0,0,4"
|
||||
Visibility="{Binding SelectedElement.AlertUseDefaultVideo, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding SelectedElement.AlertVideoPath, Mode=OneWay}"
|
||||
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseAlertVideoCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
Margin="4,0,0,0" Padding="8,2"/>
|
||||
</Grid>
|
||||
|
||||
<Button Content="Reset to built-in" Command="{Binding ResetAlertVideoCommand}"
|
||||
CommandParameter="{Binding SelectedElement}" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,4,0"/>
|
||||
<TextBlock Text="Custom clips are referenced from disk — never stored in the layout."
|
||||
Foreground="#888" FontSize="10" TextWrapping="Wrap" Margin="0,4,0,6"/>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Message ticker" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.AlertShowTicker, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Announcement display: scroll (marquee), flash (pulse) or
|
||||
solid (still). Inline items, not a bound ItemsSource —
|
||||
same shape as the chat Font selector above. -->
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Display" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<ComboBox SelectedValue="{Binding SelectedElement.AlertDisplayMethod, Mode=TwoWay}"
|
||||
SelectedValuePath="Tag"
|
||||
Background="#0f3460" Foreground="#e0e0e0" FontSize="11"
|
||||
Width="140" VerticalAlignment="Center">
|
||||
<ComboBoxItem Content="Scroll" Tag="TickerScroll"/>
|
||||
<ComboBoxItem Content="Flash" Tag="Flash"/>
|
||||
<ComboBoxItem Content="Solid" Tag="Solid"/>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Volume" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="84"/>
|
||||
<Slider Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.AlertVideoVolume, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding SelectedElement.AlertVideoVolume, StringFormat={}{0:0%}}" Foreground="#e0e0e0" FontSize="11" Margin="6,0,0,0" VerticalAlignment="Center" Width="32"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Background properties -->
|
||||
<StackPanel Margin="0,8,0,0"
|
||||
Visibility="{Binding SelectedElement.IsBackground, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Background" Style="{StaticResource SubHeader}"/>
|
||||
<StackPanel Margin="4,0,0,0">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<TextBlock Text="Use default background:" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center"/>
|
||||
<ToggleButton Style="{StaticResource PillToggle}"
|
||||
IsChecked="{Binding SelectedElement.UseDefaultBackground, Mode=TwoWay}"
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
<Grid Margin="0,0,0,4"
|
||||
Visibility="{Binding SelectedElement.UseDefaultBackground, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0" Text="{Binding SelectedElement.CustomBackgroundPath, Mode=OneWay}"
|
||||
IsReadOnly="True" Background="#0f3460" Foreground="#a0a0b0"
|
||||
FontSize="11" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Content="Browse…" Command="{Binding BrowseBackgroundCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="4,0,0,0" Padding="8,2"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding BackgroundSizeMismatchText}"
|
||||
Foreground="#ef4444" FontSize="11" TextWrapping="Wrap"
|
||||
Margin="0,4,0,0"
|
||||
Visibility="{Binding BackgroundSizeMismatch, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Live mode: YouTube Live Chat (entire left panel replaced) -->
|
||||
<Grid Grid.Row="0" Visibility="{Binding IsEditMode, Converter={StaticResource InverseBoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="LIVE CHAT" Style="{StaticResource SectionHeader}"/>
|
||||
|
||||
<!-- Inactive overlay (shown when NOT live) -->
|
||||
<StackPanel Grid.Row="1" VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Chat is inactive" Foreground="#a0a0b0" FontSize="13"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,4"/>
|
||||
<TextBlock Text="Chat becomes active once LlamaCasty is connected and streaming on YouTube."
|
||||
Foreground="#666" FontSize="11" TextWrapping="Wrap"
|
||||
HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="240"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Pinned message -->
|
||||
<Border Grid.Row="1" VerticalAlignment="Top" Margin="4,4,4,0" Padding="6,4" CornerRadius="4"
|
||||
Background="#1a2a3a" BorderBrush="#38bdf8" BorderThickness="1"
|
||||
Visibility="{Binding PinnedChatMessage, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding PinnedChatMessage.AuthorName}" Foreground="#38bdf8" FontWeight="SemiBold" FontSize="11"/>
|
||||
<TextBlock Text="{Binding PinnedChatMessage.Message}" Foreground="#e0e0e0" FontSize="11" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Message feed (shown when live) -->
|
||||
<ListBox Grid.Row="1" Background="Transparent" BorderThickness="0" Margin="0,40,0,0"
|
||||
ItemsSource="{Binding ChatMessages}"
|
||||
ItemContainerStyle="{StaticResource YtListBoxItem}"
|
||||
Visibility="{Binding ShowChatInactiveMessage, Converter={StaticResource InverseBoolToVis}}"
|
||||
x:Name="ChatListBox">
|
||||
<ListBox.ItemTemplate><DataTemplate>
|
||||
<Border x:Name="MessageBorder" Padding="6,4" CornerRadius="4">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsSuperChat}" Value="True">
|
||||
<Setter Property="Background" Value="#2a1a0a"/>
|
||||
<Setter Property="BorderBrush" Value="#eab308"/>
|
||||
<Setter Property="BorderThickness" Value="1,0,1,0"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,2">
|
||||
<TextBlock Text="⭐" FontSize="10" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{Binding IsMember, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding AuthorName}" Foreground="#e94560"
|
||||
FontWeight="Bold" FontSize="12" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding SuperChatAmount, StringFormat=${0:F2}}"
|
||||
Foreground="#eab308" FontWeight="Bold" FontSize="12"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
<TextBlock Text="{Binding SuperChatCurrency}" Foreground="#eab308"
|
||||
FontSize="11" Margin="2,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsSuperChat, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Message}" Foreground="#e0e0e0"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate></ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Collections;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class LeftPanel : UserControl
|
||||
{
|
||||
private Models.SceneElement? _snapshotElement;
|
||||
private string _snapshotBorderColor = string.Empty;
|
||||
private int _snapshotBorderWidth;
|
||||
private int _snapshotTimerMinutes = 15;
|
||||
private string? _snapshotWebUri;
|
||||
|
||||
private Point _dragStartPoint;
|
||||
private int _dragIndex = -1;
|
||||
private bool _isDragging;
|
||||
private object? _lastHoveredItem;
|
||||
private bool _dragReordered;
|
||||
|
||||
public LeftPanel()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
vm.ChatMessages.CollectionChanged += OnChatMessagesChanged;
|
||||
};
|
||||
Unloaded += (_, _) =>
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
vm.ChatMessages.CollectionChanged -= OnChatMessagesChanged;
|
||||
};
|
||||
}
|
||||
|
||||
// Called by the window's UpdateSelectionOverlay (edit mode only).
|
||||
public void OnSelectionChanged(MainViewModel vm)
|
||||
{
|
||||
if (vm.SelectedElement is { } s && PreviewPane.IsDraggableElement(s))
|
||||
OpacityValueText.Text = $"{Math.Round(s.Opacity * 100)}%";
|
||||
TakeSnapshot(vm);
|
||||
}
|
||||
|
||||
// Deselection guard: true when the click is inside the source list or the
|
||||
// element property scroller (window's Window_PreviewMouseLeftButtonDown).
|
||||
public bool IsClickInside(DependencyObject original)
|
||||
=> IsDescendantOf(original, SourceList) || IsDescendantOf(original, ElementsScrollViewer);
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
{
|
||||
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: LogicalTreeHelper.GetParent(child);
|
||||
}
|
||||
return child != null;
|
||||
}
|
||||
|
||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { IsVisible: true } box)
|
||||
{
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.IsMirrored = !webcam.IsMirrored;
|
||||
}
|
||||
|
||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.ToggleClipShape();
|
||||
}
|
||||
|
||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element } && DataContext is MainViewModel vm)
|
||||
vm.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = (DataContext as MainViewModel)?.ChangeBackgroundCaptureAsync();
|
||||
}
|
||||
|
||||
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
(DataContext as MainViewModel)?.RefreshBackgroundAutoCapture();
|
||||
}
|
||||
|
||||
private void BackgroundMenu_RefreshWindowList(object sender, RoutedEventArgs e)
|
||||
{
|
||||
(DataContext as MainViewModel)?.RefreshWindowsCommand.Execute(null);
|
||||
}
|
||||
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Enter or Key.Escape)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
}
|
||||
|
||||
private static void CommitSourceEdit(object sender)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||
element.IsEditing = false;
|
||||
}
|
||||
|
||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var listBox = (ListBox)sender;
|
||||
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
|
||||
if (item == null)
|
||||
{
|
||||
if (ReferenceEquals(listBox, SourceList))
|
||||
(DataContext as MainViewModel)!.SelectedElement = null;
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (listBox.ItemsSource is not IList items || items.Count == 0)
|
||||
{
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
_dragIndex = listBox.Items.IndexOf(item.DataContext);
|
||||
_dragStartPoint = e.GetPosition(listBox);
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
_dragReordered = false;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (_dragIndex < 0) return;
|
||||
var listBox = (ListBox)sender;
|
||||
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
|
||||
{
|
||||
EndListDrag(listBox);
|
||||
return;
|
||||
}
|
||||
|
||||
var position = e.GetPosition(listBox);
|
||||
if (!_isDragging)
|
||||
{
|
||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
return;
|
||||
_isDragging = true;
|
||||
listBox.CaptureMouse();
|
||||
}
|
||||
|
||||
var item = FindItemContainerAt(listBox, position);
|
||||
if (item == null) return;
|
||||
|
||||
var targetItem = item.DataContext;
|
||||
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
|
||||
return;
|
||||
|
||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
||||
if (targetIndex < 0) return;
|
||||
|
||||
var dragged = items[_dragIndex];
|
||||
items.RemoveAt(_dragIndex);
|
||||
items.Insert(targetIndex, dragged);
|
||||
_dragIndex = targetIndex;
|
||||
_lastHoveredItem = targetItem;
|
||||
_dragReordered = true;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
EndListDrag((ListBox)sender);
|
||||
}
|
||||
|
||||
private void EndListDrag(ListBox listBox)
|
||||
{
|
||||
if (_isDragging)
|
||||
listBox.ReleaseMouseCapture();
|
||||
var reordered = _dragReordered;
|
||||
_dragIndex = -1;
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
_dragReordered = false;
|
||||
if (reordered && DataContext is MainViewModel vm)
|
||||
vm.OnSceneElementsReordered();
|
||||
}
|
||||
|
||||
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
|
||||
{
|
||||
var hit = listBox.InputHitTest(position) as DependencyObject;
|
||||
while (hit != null && hit != listBox)
|
||||
{
|
||||
if (hit is ListBoxItem item) return item;
|
||||
hit = VisualTreeHelper.GetParent(hit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void TakeSnapshot(MainViewModel vm)
|
||||
{
|
||||
if (vm.SelectedElement is Models.Source src)
|
||||
{
|
||||
_snapshotElement = src;
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
_snapshotWebUri = src.WebUri;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((DataContext as MainViewModel)?.SelectedElement is not SceneElement src) return;
|
||||
if (sender is FrameworkElement fe && fe.ContextMenu == null)
|
||||
{
|
||||
var menu = new System.Windows.Controls.ContextMenu();
|
||||
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
|
||||
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
|
||||
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
|
||||
foreach (var c in colors)
|
||||
{
|
||||
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
|
||||
var brush = new System.Windows.Media.SolidColorBrush(
|
||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
|
||||
item.Icon = new System.Windows.Controls.Border
|
||||
{
|
||||
Width = 16, Height = 16,
|
||||
Background = brush,
|
||||
CornerRadius = new System.Windows.CornerRadius(2)
|
||||
};
|
||||
var color = c;
|
||||
item.Click += (_, _) => src.BorderColor = color;
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
fe.ContextMenu = menu;
|
||||
}
|
||||
if (sender is FrameworkElement fe2)
|
||||
fe2.ContextMenu.IsOpen = true;
|
||||
}
|
||||
|
||||
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
src.BorderColor = _snapshotBorderColor;
|
||||
src.BorderWidth = _snapshotBorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.TimerMinutes = _snapshotTimerMinutes;
|
||||
}
|
||||
|
||||
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
}
|
||||
|
||||
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.WebUri = string.Empty;
|
||||
_snapshotWebUri = string.Empty;
|
||||
}
|
||||
|
||||
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotWebUri = src.WebUri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<UserControl x:Class="ytLive.Controls.OverlayHost"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"
|
||||
xmlns:Models="clr-namespace:ytLive.Models">
|
||||
<Grid Grid.RowSpan="3"
|
||||
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
|
||||
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
|
||||
Command="{Binding CloseOverlayCommand}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}"/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
|
||||
<Border Background="#0A0C1A" CornerRadius="8" Width="650" MaxHeight="700"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Padding="28" BorderBrush="#333" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<Grid Grid.Row="0">
|
||||
<TextBlock Text="{Binding OverlayTitle}" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||
<Button HorizontalAlignment="Right" ToolTip="Close" Style="{StaticResource IconButton}"
|
||||
Command="{Binding CloseOverlayCommand}">
|
||||
<Path Data="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
|
||||
Fill="#d0d0d0" Width="14" Height="14" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- App Settings: broadcast fields live in the preview's
|
||||
Text pull-out now — this panel keeps app-level prefs only.
|
||||
(Currently empty; the overlay shell stays for future use.) -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Default Location for Recordings" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}"
|
||||
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
|
||||
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
|
||||
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Zoom View Shape" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding ZoomShape}" SelectedValuePath="Tag">
|
||||
<ComboBoxItem Content="Circle" Tag="{x:Static Models:ZoomShape.Circle}"/>
|
||||
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
|
||||
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Report Bug -->
|
||||
<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"
|
||||
Margin="0,0,0,12"
|
||||
Text="{Binding BugReportText, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding BugReportEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
||||
<Button Content="Send Report" Command="{Binding SubmitBugCommand}"
|
||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Feature Request -->
|
||||
<StackPanel Grid.Row="1" Margin="0,16,0,0"
|
||||
Visibility="{Binding IsFeatureOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="What would you like to see?" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Height="110" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
Margin="0,0,0,12"
|
||||
Text="{Binding FeatureRequestText, UpdateSourceTrigger=PropertyChanged}"
|
||||
VerticalScrollBarVisibility="Auto"/>
|
||||
|
||||
<TextBlock Text="Email (optional)" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
|
||||
<TextBox Style="{StaticResource YtTextBox}" Margin="0,0,0,12"
|
||||
Text="{Binding FeatureRequestEmail, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
|
||||
<TextBlock Text="Opens a pre-filled email to the LlamaCasty team." Style="{StaticResource YtLabel}"/>
|
||||
<Button Content="Send Request" Command="{Binding SubmitFeatureCommand}"
|
||||
Style="{StaticResource YtButton}" HorizontalAlignment="Right" Margin="0,16,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- About -->
|
||||
<Grid Grid.Row="1" Margin="0,16,0,0"
|
||||
Background="#171928"
|
||||
Visibility="{Binding IsAboutOpen, Converter={StaticResource BoolToVis}}">
|
||||
|
||||
<!-- Hub view -->
|
||||
<StackPanel Visibility="{Binding IsHubVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Image Source="/ytLive;component/Assets/llamacasty-logo.jpg" Height="125" Stretch="Uniform"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||
<TextBlock Text="{Binding AppVersionLabel}" Foreground="#a0a0b0" 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="#a0a0b0" 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="#a0a0b0" FontSize="12"
|
||||
HorizontalAlignment="Center" Margin="0,16,0,8"/>
|
||||
|
||||
<!-- 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 Content="Licenses & legal" Command="{Binding OpenLicensesCommand}"
|
||||
Style="{StaticResource YtButtonSecondary}" HorizontalAlignment="Center"
|
||||
Margin="0,16,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Licensing/legal panel -->
|
||||
<StackPanel Visibility="{Binding IsLicensesOpen, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Third-party notices & license texts" FontSize="15" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,8"/>
|
||||
<Border Background="#10101e" CornerRadius="4" Padding="10" MaxHeight="360"
|
||||
BorderBrush="#333" BorderThickness="1">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock Text="{Binding LicensesText}" Foreground="#b8b8c8" FontSize="11"
|
||||
FontFamily="Consolas" TextWrapping="Wrap"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<Button Content="← Back to About" Style="{StaticResource YtButtonSecondary}"
|
||||
HorizontalAlignment="Left" Margin="0,12,0,0"
|
||||
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="#e0e0e0" Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Enter your license key to remove the branding watermark."
|
||||
Foreground="#a0a0b0" 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="#a0a0b0" 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>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class OverlayHost : UserControl
|
||||
{
|
||||
public OverlayHost()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.IsLicensesOpen = false;
|
||||
vm.IsLicenseEntryOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,586 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Shapes;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class PreviewPane : UserControl
|
||||
{
|
||||
private bool _isDraggingOverlay;
|
||||
private bool _isResizing;
|
||||
private bool _isZoomDragging;
|
||||
private Point _grabOffset;
|
||||
private double _resizeAspect;
|
||||
|
||||
private readonly System.Collections.Generic.Dictionary<string, Storyboard> _activeBorderAnimations =
|
||||
new();
|
||||
|
||||
private DispatcherTimer? _socialGlowTimer;
|
||||
private double _socialGlowAngle;
|
||||
|
||||
public PreviewPane()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += PreviewPane_DataContextChanged;
|
||||
}
|
||||
|
||||
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.OldValue is MainViewModel oldVm)
|
||||
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
|
||||
if (e.NewValue is MainViewModel newVm)
|
||||
{
|
||||
newVm.PropertyChanged += ViewModel_PropertyChanged;
|
||||
UpdateZoomBox();
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
|
||||
{
|
||||
UpdateZoomBox();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Static images and the webcam share the move/resize/selection behavior.
|
||||
public static bool IsDraggableElement(SceneElement element)
|
||||
=> element.IsDraggable;
|
||||
|
||||
public void UpdateSelectionOverlay(bool selected)
|
||||
{
|
||||
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
// ─── Clicking inside the preview / pull-out drawer (used by the window's
|
||||
// Window_PreviewMouseLeftButtonDown deselection logic) ───
|
||||
public bool IsClickInsidePreview(DependencyObject original)
|
||||
=> IsDescendantOf(original, PreviewGrid);
|
||||
|
||||
public bool IsClickInsideDrawer(DependencyObject original)
|
||||
=> IsDescendantOf(original, TextPullOutHost);
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
{
|
||||
child = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: LogicalTreeHelper.GetParent(child);
|
||||
}
|
||||
return child != null;
|
||||
}
|
||||
|
||||
// ─── Audio meters + TRAX + socials (ROW 2 of the preview pane) ───
|
||||
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
|
||||
{
|
||||
if (FindParent<System.Windows.Controls.Primitives.Thumb>(e.OriginalSource as Visual) != null)
|
||||
return;
|
||||
var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
|
||||
if (track == null) return;
|
||||
var pos = e.GetPosition(track);
|
||||
var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
|
||||
slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static T? FindParent<T>(Visual? visual) where T : Visual
|
||||
{
|
||||
while (visual != null)
|
||||
{
|
||||
if (visual is T match) return match;
|
||||
visual = VisualTreeHelper.GetParent(visual) as Visual;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleMicMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleGameMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
|
||||
// loaded); right-click always opens the picker. The picker is the OS file
|
||||
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
|
||||
private void TraxButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxLeftClick();
|
||||
}
|
||||
|
||||
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxRightClick();
|
||||
e.Handled = true; // the button's right-click context menu must not fire
|
||||
}
|
||||
|
||||
// ─── Webcam / background context menus (right-click on overlay elements) ───
|
||||
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
||||
=> (DataContext as MainViewModel)?.ChangeWebcamCommand.Execute(null);
|
||||
|
||||
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
|
||||
|| !Enum.TryParse<BorderAnimation>(tag, out var animation))
|
||||
return;
|
||||
|
||||
element.BorderAnimation = animation;
|
||||
|
||||
StopBorderAnimation(element.Id);
|
||||
|
||||
if (animation == BorderAnimation.None) return;
|
||||
|
||||
var shape = FindBorderShape(sender as DependencyObject);
|
||||
if (shape == null) return;
|
||||
|
||||
var sb = CreateBorderAnimation(animation, shape);
|
||||
if (sb == null) return;
|
||||
|
||||
sb.Freeze();
|
||||
_activeBorderAnimations[element.Id] = sb;
|
||||
shape.BeginAnimation(Shape.OpacityProperty, null);
|
||||
Storyboard.SetTarget(sb, shape);
|
||||
sb.Begin();
|
||||
}
|
||||
|
||||
private void StopBorderAnimation(string elementId)
|
||||
{
|
||||
if (_activeBorderAnimations.TryGetValue(elementId, out var old))
|
||||
{
|
||||
old.Stop();
|
||||
_activeBorderAnimations.Remove(elementId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Shape? FindBorderShape(DependencyObject? start)
|
||||
{
|
||||
var current = start;
|
||||
while (current != null)
|
||||
{
|
||||
if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
|
||||
{
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(target, i);
|
||||
if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
|
||||
return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
current = VisualTreeHelper.GetParent(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
|
||||
{
|
||||
var sb = new Storyboard();
|
||||
switch (animation)
|
||||
{
|
||||
case BorderAnimation.Pulse:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
|
||||
break;
|
||||
case BorderAnimation.Glow:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
|
||||
break;
|
||||
case BorderAnimation.Shimmer:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
|
||||
break;
|
||||
case BorderAnimation.Electricity:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
|
||||
break;
|
||||
case BorderAnimation.Sparkles:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
|
||||
break;
|
||||
case BorderAnimation.MarchingAnts:
|
||||
shape.StrokeDashArray = new DoubleCollection { 4, 2 };
|
||||
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
|
||||
break;
|
||||
case BorderAnimation.Chase:
|
||||
shape.StrokeDashArray = new DoubleCollection { 8, 4 };
|
||||
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
|
||||
break;
|
||||
case BorderAnimation.Rainbow:
|
||||
if (shape.Stroke is SolidColorBrush brush)
|
||||
{
|
||||
var frozen = brush.IsFrozen;
|
||||
if (frozen) brush = brush.Clone();
|
||||
shape.Stroke = brush;
|
||||
var colorAnim = new ColorAnimationUsingKeyFrames
|
||||
{
|
||||
Duration = new Duration(TimeSpan.FromSeconds(3)),
|
||||
RepeatBehavior = RepeatBehavior.Forever
|
||||
};
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
|
||||
Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
|
||||
sb.Children.Add(colorAnim);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return sb.Children.Count > 0 ? sb : null;
|
||||
}
|
||||
|
||||
private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
|
||||
double from, double to, Duration duration, bool autoReverse)
|
||||
{
|
||||
var anim = new DoubleAnimation(from, to, duration)
|
||||
{
|
||||
AutoReverse = autoReverse,
|
||||
RepeatBehavior = RepeatBehavior.Forever
|
||||
};
|
||||
Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
|
||||
sb.Children.Add(anim);
|
||||
}
|
||||
|
||||
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
|
||||
element.BorderColor = color;
|
||||
}
|
||||
|
||||
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
element.IsVisible = false;
|
||||
}
|
||||
|
||||
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
(DataContext as MainViewModel)?.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
||||
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
||||
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
||||
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
(DataContext as MainViewModel)?.ToggleSocialBarPosition();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SocialBarElement == null) return;
|
||||
|
||||
// Kick the social bar to full brightness so it's immediately visible
|
||||
var effect = SocialBarElement.Effect as DropShadowEffect;
|
||||
if (effect != null)
|
||||
{
|
||||
effect.BlurRadius = 40;
|
||||
effect.Opacity = 1.0;
|
||||
}
|
||||
|
||||
// Pulse the glow so it catches the eye
|
||||
_socialGlowAngle = 0;
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_socialGlowTimer.Tick += SocialGlowTick;
|
||||
_socialGlowTimer.Start();
|
||||
}
|
||||
|
||||
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = null;
|
||||
|
||||
if (SocialBarElement?.Effect is DropShadowEffect effect)
|
||||
{
|
||||
effect.BlurRadius = 18;
|
||||
effect.Opacity = 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
private void SocialGlowTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
|
||||
|
||||
_socialGlowAngle += 0.18;
|
||||
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
|
||||
|
||||
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
|
||||
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
|
||||
effect.BlurRadius = 24 + t * 24;
|
||||
effect.Opacity = 0.7 + t * 0.3;
|
||||
}
|
||||
|
||||
// ─── Preview: element selection / move / resize / hit-testing ───
|
||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
if (vm is null || !vm.IsEditMode) return;
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = vm.SelectedElement;
|
||||
|
||||
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||
{
|
||||
_isResizing = true;
|
||||
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var hit = HitElement(vm, p);
|
||||
if (hit != null)
|
||||
{
|
||||
vm.SelectedElement = hit;
|
||||
_isDraggingOverlay = true;
|
||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
vm.SelectedElement = null;
|
||||
}
|
||||
|
||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
if (vm is null) return;
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
|
||||
if (e.LeftButton == MouseButtonState.Pressed && vm.IsZoomActive)
|
||||
{
|
||||
if (!_isZoomDragging)
|
||||
{
|
||||
vm.StartZoomMove(p.X, p.Y);
|
||||
_isZoomDragging = true;
|
||||
grid.CaptureMouse();
|
||||
}
|
||||
else
|
||||
{
|
||||
vm.UpdateZoomMove(p.X, p.Y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vm.IsEditMode) return;
|
||||
if (!_isDraggingOverlay && !_isResizing) return;
|
||||
|
||||
var selected = vm.SelectedElement;
|
||||
if (selected == null)
|
||||
{
|
||||
EndPreviewDrag(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDraggingOverlay)
|
||||
{
|
||||
var minX = -(selected.Width - 20);
|
||||
var maxX = 1920 - 20;
|
||||
var minY = -(selected.Height - 20);
|
||||
var maxY = 1080 - 20;
|
||||
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
||||
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
||||
}
|
||||
else if (_isResizing)
|
||||
{
|
||||
var isWebcam = selected is WebcamSceneConfig;
|
||||
var isRound = selected.ClipShape == ClipShape.Round;
|
||||
var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(vm.StagedScene?.Name) : 1920;
|
||||
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(vm.StagedScene?.Name) : 1080;
|
||||
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
|
||||
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
|
||||
if (proportional && newH > maxH)
|
||||
{
|
||||
newH = maxH;
|
||||
newW = newH * _resizeAspect;
|
||||
}
|
||||
selected.Width = newW;
|
||||
selected.Height = newH;
|
||||
}
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (_isZoomDragging && DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.EndZoomMove();
|
||||
_isZoomDragging = false;
|
||||
((Grid)sender).ReleaseMouseCapture();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
EndPreviewDrag((Grid)sender);
|
||||
}
|
||||
|
||||
private void Preview_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
if (vm == null) return;
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
if ((vm.IsLive || vm.IsRecording) && !vm.IsEditMode)
|
||||
{
|
||||
if (vm.IsZoomActive)
|
||||
{
|
||||
vm.DismissZoom();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
vm.ActivateZoom(p.X, p.Y);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
vm.ToggleZoom();
|
||||
e.Handled = true;
|
||||
} private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (_isZoomDragging)
|
||||
{
|
||||
var grid = (Grid)sender;
|
||||
grid.ReleaseMouseCapture();
|
||||
_isZoomDragging = false;
|
||||
if (DataContext is MainViewModel vm) vm.EndZoomMove();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void EndPreviewDrag(Grid grid)
|
||||
{
|
||||
if (_isDraggingOverlay || _isResizing || _isZoomDragging)
|
||||
grid.ReleaseMouseCapture();
|
||||
_isDraggingOverlay = false;
|
||||
_isResizing = false;
|
||||
_isZoomDragging = false;
|
||||
if (DataContext is MainViewModel vm) vm.EndZoomMove();
|
||||
}
|
||||
|
||||
private bool HitHandle(Point mouseScreen, SceneElement element)
|
||||
{
|
||||
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
|
||||
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
||||
}
|
||||
|
||||
private SceneElement? HitElement(MainViewModel vm, Point p)
|
||||
{
|
||||
var scene = vm.StagedScene;
|
||||
if (scene == null) return null;
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!IsDraggableElement(element)) continue;
|
||||
if (element is Source { IsEnabled: false }) continue;
|
||||
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||
if (p.X >= element.X && p.X <= element.X + element.Width &&
|
||||
p.Y >= element.Y && p.Y <= element.Y + element.Height)
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void UpdateZoomBox()
|
||||
{
|
||||
if (DataContext is not MainViewModel vm) return;
|
||||
if (vm.IsZoomActive && ZoomVisualBrush != null)
|
||||
{
|
||||
// The preview mirrors the output zoom layer: sample the WHOLE composed
|
||||
// preview (CanvasGrid = backdrop + every element layer — the same all-layer
|
||||
// source the output compositor magnifies) at the FIXED capture window (the
|
||||
// box/2 region centred on the click) → 2x via Stretch=Fill. Moving the box
|
||||
// only moves the ZoomBox element; the Viewbox stays glued to the capture.
|
||||
ZoomVisualBrush.Visual = CanvasGrid;
|
||||
ZoomVisualBrush.Viewbox = new Rect(
|
||||
vm.ZoomState.CaptureX,
|
||||
vm.ZoomState.CaptureY,
|
||||
vm.ZoomState.Width / 2,
|
||||
vm.ZoomState.Height / 2);
|
||||
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
|
||||
if (vm.ZoomShape == Models.ZoomShape.Circle)
|
||||
{
|
||||
ZoomCircleOutline.Visibility = Visibility.Visible;
|
||||
ZoomRectOutline.Visibility = Visibility.Collapsed;
|
||||
ZoomBox.Clip = new EllipseGeometry(new Rect(0, 0, vm.ZoomState.Width, vm.ZoomState.Height));
|
||||
}
|
||||
else
|
||||
{
|
||||
ZoomCircleOutline.Visibility = Visibility.Collapsed;
|
||||
ZoomRectOutline.Visibility = Visibility.Visible;
|
||||
ZoomBox.Clip = null;
|
||||
}
|
||||
}
|
||||
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
|
||||
{
|
||||
ZoomVisualBrush.Visual = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<UserControl x:Class="ytLive.Controls.SceneThumbnailStrip"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Border Background="#0d1117" CornerRadius="4" Margin="0,0,0,4" Padding="4">
|
||||
<ItemsControl ItemsSource="{Binding Scenes}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Rows="1" Columns="5"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border CornerRadius="4" Margin="2" Padding="2"
|
||||
BorderThickness="2"
|
||||
BorderBrush="Transparent"
|
||||
Cursor="Hand"
|
||||
MouseLeftButtonDown="Thumbnail_MouseLeftButtonDown">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="BorderBrush" Value="#333344"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsStaged}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="#e94560"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="#22c55e"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Border Grid.Row="0" CornerRadius="3"
|
||||
Background="#0a0a1a" ClipToBounds="True">
|
||||
<Image Source="{Binding Snapshot}"
|
||||
Stretch="UniformToFill"
|
||||
RenderOptions.BitmapScalingMode="LowQuality"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="20"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,2,0,0"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
<Run Text="{Binding Name, Mode=OneWay}"/><Run Text=" "/><Run Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="#6a6a7a" FontWeight="SemiBold"/>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class SceneThumbnailStrip : UserControl
|
||||
{
|
||||
public SceneThumbnailStrip()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Border { DataContext: Scene scene } && DataContext is MainViewModel vm)
|
||||
vm.TransitionToScene(scene);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<UserControl x:Class="ytLive.Controls.TopBar"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:helpers="clr-namespace:ytLive.Helpers"
|
||||
x:Name="TopBarRoot">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- LOGO / BRAND — clicking opens the in-app About overlay; the gear sits
|
||||
a couple characters past the build stamp and opens App Settings. -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"
|
||||
Margin="0,0,24,0">
|
||||
<Button Background="Transparent" BorderThickness="0" Cursor="Hand"
|
||||
VerticalAlignment="Center" ToolTip="About LlamaCasty"
|
||||
Command="{Binding OpenAboutCommand}">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<TextBlock FontSize="22" FontWeight="Bold" VerticalAlignment="Center">
|
||||
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
|
||||
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
|
||||
BaselineAlignment="Superscript" Foreground="#8888aa"
|
||||
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
|
||||
</TextBlock>
|
||||
</Button>
|
||||
<!-- Settings gear (user directive): a couple characters beyond the brand,
|
||||
nudged right a couple more places 2026-09-22 (build 77 feedback).
|
||||
Moved up from the bottom bar — one menu: App Settings / Bug / Feature / About. -->
|
||||
<Button Style="{StaticResource IconButton}" Margin="18,0,0,2" VerticalAlignment="Center"
|
||||
ToolTip="Menu" Click="GearButton_Click">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="App Settings" Command="{Binding OpenSettingsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Report Bug" Command="{Binding OpenBugCommand}"/>
|
||||
<MenuItem Header="Feature Request" Command="{Binding OpenFeatureCommand}"/>
|
||||
<MenuItem Header="About" Command="{Binding OpenAboutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Path Data="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98c0,-0.33 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.38,-0.31 -0.61,-0.22l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98c0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.38,0.31 0.61,0.22l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64L19.43,12.98zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5c0,-1.93 1.57,-3.5 3.5,-3.5c1.93,0 3.5,1.57 3.5,3.5C15.5,13.93 13.93,15.5 12,15.5z"
|
||||
Fill="#a0a0b0" Width="18" Height="18" Stretch="Uniform"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Session surface (top bar redesign): the bar is ONE surface rendered by
|
||||
the FIRST decision — Record or Stream. Each world is the whole bar; the
|
||||
other world never exists on screen. While running, the bar drops to a
|
||||
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="#0f1622" CornerRadius="4" Padding="2" Height="32" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}"
|
||||
IsChecked="{Binding RecordPillOn}">
|
||||
<TextBlock Text="REC" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}"
|
||||
IsChecked="{Binding OnAirPillOn}">
|
||||
<TextBlock Text="ON-AIR" Style="{StaticResource SegmentLabel}"/>
|
||||
</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 StartStreamCommand}"
|
||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<!-- Test is a child of Stream — a dry-run of the livestream, so it
|
||||
exists only inside the Stream world (stream armed + signed in). -->
|
||||
<Button Content="Test" Style="{StaticResource YtButtonSecondary}" Padding="12,4" FontSize="11"
|
||||
Height="32" Margin="8,0,0,0" VerticalAlignment="Center"
|
||||
Command="{Binding StartTestCommand}"
|
||||
ToolTip="Run a private test broadcast to check your chat overlay."
|
||||
Visibility="{Binding ShowTestButton, Converter={StaticResource BoolToVis}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- RUNNING: one reality line (dot + word + elapsed) and the stop -->
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Visibility="{Binding RunningVisible, Converter={StaticResource BoolToVis}}">
|
||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||
Fill="{Binding RunningDotBrush}"/>
|
||||
<TextBlock Text="{Binding RunningText}" FontSize="16" FontWeight="Bold"
|
||||
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"/>
|
||||
<Button Content="{Binding EndButtonText}" Style="{StaticResource YtButton}"
|
||||
Background="#333" Margin="14,0,0,0" Command="{Binding EndStreamCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Account zone: lives only in the Stream world. Signed out → Sign In;
|
||||
signed in → a big avatar of the streaming account (build 77 feedback:
|
||||
"make the user icon much bigger"). Record world has no YouTube
|
||||
identity at all. -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Content="Sign In" Style="{StaticResource YtButtonSecondary}"
|
||||
Margin="0,0,8,0" Command="{Binding SignInCommand}"
|
||||
ToolTip="Sign in to YouTube to go live."
|
||||
Visibility="{Binding ShowSignInButton, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Style="{StaticResource IconButton}" Margin="0,0,0,0"
|
||||
ToolTip="{Binding AccountDisplayName}"
|
||||
Visibility="{Binding ShowAvatarButton, Converter={StaticResource BoolToVis}}">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Change Account" Command="{Binding ChangeAccountCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Logout" Command="{Binding LogoutCommand}"/>
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Border Width="40" Height="40" CornerRadius="20" Background="#16213e"
|
||||
ClipToBounds="True" BorderBrush="#3a4a66" BorderThickness="1">
|
||||
<Grid>
|
||||
<Image x:Name="AvatarImage" Stretch="UniformToFill"/>
|
||||
<TextBlock x:Name="AvatarInitial" Foreground="#e0e0e0" FontSize="20"
|
||||
FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Controls;
|
||||
|
||||
public partial class TopBar : UserControl
|
||||
{
|
||||
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private string? _loadedAvatarUrl;
|
||||
|
||||
public TopBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public void RefreshAvatar()
|
||||
{
|
||||
_ = LoadAvatarImageAsync();
|
||||
}
|
||||
|
||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// One menu (Settings / Bug / Feature / About), opened on click — moved up
|
||||
// from the bottom bar so app-level actions live next to the brand.
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadAvatarImageAsync()
|
||||
{
|
||||
var vm = DataContext as MainViewModel;
|
||||
var url = vm?.AccountAvatarUrl;
|
||||
if (vm is not { IsConnected: true })
|
||||
{
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
AppLog.Write("Avatar: connected but no profile image URL on the session");
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
|
||||
try
|
||||
{
|
||||
var bytes = await AvatarHttp.GetByteArrayAsync(url);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = ms;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
_loadedAvatarUrl = url;
|
||||
AvatarImage.Source = bitmap;
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Collapsed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
|
||||
ShowAvatarFallback(vm?.AccountDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAvatarFallback(string? name)
|
||||
{
|
||||
_loadedAvatarUrl = null;
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
|
||||
AvatarInitial.Visibility = System.Windows.Visibility.Visible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Controls — index
|
||||
|
||||
The phase-2 carved pieces of what used to be one monolithic `MainWindow.xaml`
|
||||
(1,681 lines → a 123-line shell). Each is a `UserControl` in the `ytLive.Controls`
|
||||
namespace, extracted one roll-back commit at a time (refactor-commit-12..18).
|
||||
See [`schema.md`](../schema.md) for the memory-map conventions.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) |
|
||||
| `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 (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` |
|
||||
| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) |
|
||||
| `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight |
|
||||
| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. 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) |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here |
|
||||
| `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
|
||||
| `LeftPanel.xaml.cs` | Code-behind for the panel above |
|
||||
|
||||
## Window ↔ control contract
|
||||
|
||||
The shell (`MainWindow.xaml(.cs)`) wires up each control and routes the few
|
||||
cross-cutting calls through it:
|
||||
|
||||
- Ctor finds each control by `x:Name` (`TopBar`, `PreviewPane`, `LeftPanel`)
|
||||
AFTER `InitializeComponent`, **before** any method touches it (null-ref guard).
|
||||
- `_previewPane.IsClickInsidePreview/Drawer` + `_leftPanel.IsClickInside` →
|
||||
deselection guard in `Window_PreviewMouseLeftButtonDown`.
|
||||
- `_previewPane.UpdateSelectionOverlay(bool)` + `_leftPanel.OnSelectionChanged(vm)`
|
||||
→ selection/edit-mode routing from the VM's `SelectedElement` change.
|
||||
- `_topBar.RefreshAvatar()` on `IsConnected`/`AccountAvatarUrl` change.
|
||||
- Handlers in moved controls cast `(DataContext as MainViewModel)` — controls
|
||||
inherit the window's DataContext; never pass the VM through a property.
|
||||
|
||||
## Gotchas learned the hard way
|
||||
|
||||
- **A UserControl cannot see `Window.Resources`** (StaticResource resolves by
|
||||
element lookup, so it stops at the control boundary). The 7 converters
|
||||
(`BoolToVis`, `InverseBoolToVis`, `NotNullToVis`, `NullToVis`, `HiddenBoolToVis`,
|
||||
`EnumToBool`, `AllTrueToVis`) were promoted from `MainWindow.Resources` into
|
||||
`Themes/Controls.xaml` (see [`Themes/index.md`](../Themes/index.md)).
|
||||
- **Moved XAML can lose its namespace**: a block cut from MainWindow carries no
|
||||
root xmlns, so any `models:`/`Helpers:` ref in it breaks the build with
|
||||
`MC2000: Value cannot be null (key)`. Declare `xmlns:models` / `xmlns:Helpers`
|
||||
on each control's root (LeftPanel hit this).
|
||||
- **Name-looked-up host stubs must stay in the window**, not a control:
|
||||
`WebViewHostPanel` (ctor `InitWebView2(WebViewHostPanel)`) and `ToastArea`
|
||||
(`NotificationService.InArea("ToastArea")`) are resolved by name from the
|
||||
window root — so `OverlayHost` deliberately leaves them as thin window-root
|
||||
children (Commit 18). Since the 2026-09-14 composition-capture redesign only
|
||||
`ToastArea` remains — `WebViewHostPanel` is deleted (web sources no longer
|
||||
host a multiline XAML control; `InitWebView2()` runs from `MainWindow_Loaded`
|
||||
with no panel argument).
|
||||
- When cutting visual wrappers, don't drop the outer `Border`'s
|
||||
background/glow/margin — Commit 15 initially lost the preview's outer border
|
||||
and had to restore it same-commit.
|
||||
|
||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) (MainViewModel the
|
||||
controls bind to), [`Themes/index.md`](../Themes/index.md) (styles/converters),
|
||||
[`Helpers/index.md`](../Helpers/index.md) (ViewModelBase, converters), the
|
||||
`MainWindow.xaml` shell at repo root that hosts these.
|
||||
+106
-18
@@ -1,12 +1,42 @@
|
||||
# Distribution & IP Protection Plan
|
||||
|
||||
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF), built around Polar.sh.
|
||||
> 360-degree protection strategy for LlamaCasty (.NET 8 / WPF).
|
||||
|
||||
> ## ⛔ SUPERSEDED IN PART — 2026-09-27
|
||||
>
|
||||
> **Distribution is now the Microsoft Store: MSIX package + Store IAP.** The creator's criteria
|
||||
> were *"zero headaches, minimal maintenance (for me) while still providing accountability and
|
||||
> a reasonably easy upgrade flow"*, and that ruling deleted this document's central premise.
|
||||
>
|
||||
> **What still stands below:** §1 (code architecture / IP protection), §5 (obfuscation posture),
|
||||
> §6 (legal), §7 (hardening) — those are about protecting the *binary*, and an MSIX package is
|
||||
> still a binary. **§1.2's build-posture ceiling (HARDENED + MOCK_REWARDS, additive-only)
|
||||
> remains the agreed ceiling.**
|
||||
>
|
||||
> **What is dead:**
|
||||
> - **Polar as the distribution backbone** — the entire section below. Store IAP handles
|
||||
> checkout, entitlements, refunds, tax, and the customer portal. So does **file hosting**:
|
||||
> the Store is the delivery mechanism, so "upload `LlamaCasty.exe` to Polar as a File
|
||||
> Download benefit" (§2) no longer exists as a step.
|
||||
> - **Code signing as our problem** — MSIX is signed by **Microsoft**. There is no certificate
|
||||
> to buy, no HSM, no annual renewal. See §4.3, which was already corrected to say this, and
|
||||
> `TASKS/research-store-certification.md` §2–3.
|
||||
> - **Velopack / the update URL / the DO droplet** — Store auto-update.
|
||||
>
|
||||
> **The authoritative plan is now [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md).**
|
||||
> Keep this file for the protection sections; do not re-derive a delivery strategy from it.
|
||||
|
||||
---
|
||||
|
||||
## Polar.sh as the Distribution Backbone
|
||||
## Polar.sh as the Distribution Backbone — ⛔ OBSOLETE (2026-09-27)
|
||||
|
||||
Polar handles everything between "customer wants to pay" and "customer has a working license key." We don't build any of that. What Polar gives us:
|
||||
> **Historical record only.** Retained because the fee tables and the reasoning about *why*
|
||||
> one-time beat subscription were worth working out. **None of the "How We Use It" rows are
|
||||
> current**, with one exception worth keeping: the Merchant-of-Record point — *someone else
|
||||
> collects and remits VAT/GST* — is still true under Store IAP, because **Microsoft** is now
|
||||
> that someone. That fact alone was the strongest argument for the ruling.
|
||||
|
||||
Polar handled everything between "customer wants to pay" and "customer has a working license key." We didn't build any of that. What Polar *was* going to give us:
|
||||
|
||||
| Capability | How We Use It |
|
||||
|------------|---------------|
|
||||
@@ -14,13 +44,15 @@ Polar handles everything between "customer wants to pay" and "customer has a wor
|
||||
| **File hosting** (up to 10GB) | Upload `LlamaCasty.exe` (self-contained, ~80-120MB) as a "File Download" benefit. Customers get signed, personal download URLs. SHA-256 checksums included. |
|
||||
| **Checkout** | Polar's hosted checkout page handles payment. No Stripe integration, no PCI scope, no custom checkout UI. |
|
||||
| **Customer portal** | Customers self-serve: view/copy license keys, see expiry, deactivate devices, rotate compromised keys. We don't build a portal. |
|
||||
| **Subscriptions** | If we go subscription model (deferred), Polar handles recurring billing, dunning, cancellation. |
|
||||
| **One-time orders** | **Chosen model (2026-09-21):** perpetual license via a one-time Polar order — no subscription. Polar also supports recurring billing if ever needed, but we do not use it. |
|
||||
| **Merchant of Record** | Polar collects and remits VAT/GST/sales tax globally. We never touch tax compliance. |
|
||||
| **Webhooks** | Polar notifies our backend on purchase, cancellation, key rotation. Used for optional telemetry (section 6.3). |
|
||||
|
||||
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee.
|
||||
**Polar pricing (2026):** 5% + $0.50 per transaction (Starter plan). No monthly fee. ⛔ *No
|
||||
longer paid to anyone — the equivalent cost is now inside the Store revenue share, whose rate
|
||||
is unverified (see `TASKS/research-store-certification.md` §11).*
|
||||
|
||||
**What Polar does NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
|
||||
**What Polar did NOT handle:** Obfuscation, code signing, anti-tamper, runtime protection, EULA, DMCA. That's all us — sections 1-3, 5-7 below.
|
||||
|
||||
---
|
||||
|
||||
@@ -286,11 +318,19 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
|
||||
|
||||
**Caveat:** Self-contained single-file means ~80-120MB due to bundled .NET runtime. For smaller size, use framework-dependent publishing (requires .NET 8 runtime on user machines) — drops to ~5-10MB.
|
||||
|
||||
### 4.2 Distribution via Polar
|
||||
### 4.2 Distribution via Polar — ⛔ OBSOLETE (2026-09-27)
|
||||
|
||||
**Product setup on Polar:**
|
||||
> **Superseded by Microsoft Store MSIX + Store IAP** (ruling in
|
||||
> `TASKS/task-48-distribution-msix.md` item 0). The Store re-signs, hosts, updates, and takes
|
||||
> payment — so file hosting, license keys, checkout, the customer portal, device deactivation and
|
||||
> key rotation are **all** handled by Microsoft and none of it needs building. Retained below as a
|
||||
> record of the model that was designed and abandoned. ⚠️ **The `$29`/`$49` one-time prices in this
|
||||
> section are dead** — the settled pricing is **`$10/mo` or `$400` lifetime**, net of a verified
|
||||
> 15% Store share.
|
||||
|
||||
1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred).
|
||||
**Product setup on Polar (as designed, never shipped):**
|
||||
|
||||
1. Create a product: "LlamaCasty" — **one-time purchase** (perpetual license; $29 founder / $49 list).
|
||||
2. Add two benefits:
|
||||
- **License Key** — brandable prefix `LLAMA-****`, activation limit (e.g., 3 devices), optional expiry.
|
||||
- **File Download** — upload the signed `LlamaCasty.exe` (~80-120MB). Polar generates signed, personal download URLs. SHA-256 checksum included.
|
||||
@@ -313,9 +353,43 @@ Single-file means everything is inside the `.exe`. No DLLs, no runtime dependenc
|
||||
- If a key is compromised, customer or merchant can rotate it via Polar.
|
||||
- Previous key stops validating immediately. Status, usage, limits, expiry preserved.
|
||||
|
||||
**What the Store does instead:** the customer installs from the Store listing, pays through Store
|
||||
checkout, and the app reads the entitlement the OS already holds. There is no key to enter, no
|
||||
activation call, no portal to visit, and no offline-grace period to maintain — which also retires
|
||||
the whole "network flaky → app thinks I'm expired" bug class in §4.5.
|
||||
|
||||
### 4.3 Code Signing
|
||||
|
||||
**Certificate:** Buy an EV (Extended Validation) code-signing certificate from DigiCert or Sectigo ($200-400/year). EV certificates get instant SmartScreen reputation; DV/OV certs trigger SmartScreen warnings for months.
|
||||
> ⚠️ **The cert choice below is a live decision, and the old plan's EV recommendation is
|
||||
> dead.** Microsoft **removed the SmartScreen instant-bypass for EV certificates in March
|
||||
> 2024** — an EV-signed file now accrues reputation exactly like an OV-signed one, so paying
|
||||
> $400+/yr buys precisely what paying ~$150 buys. **Do not buy EV.**
|
||||
>
|
||||
> **But the more interesting finding is that you may not need a certificate at all.** Three
|
||||
> costs, current as of 2026-09-27:
|
||||
>
|
||||
> | Route | Cert/yr | SmartScreen | Note |
|
||||
> |---|---|---|---|
|
||||
> | **Microsoft Store (MSIX)** | **$0** — Microsoft re-signs | **No warning** | Requires MSIX packaging; Store review; public listing |
|
||||
> | **Microsoft Store (EXE/MSI)** | $120–300 — *your* cert | Warning ramp | Store does **not** re-sign; silent install mandatory. Strictly dominated — cert required anyway |
|
||||
> | **Direct + OV cert** | $150–300 | Warning ramp | Worldwide availability |
|
||||
> | **Direct + Azure Artifact Signing** | ~$9.99/mo ≈ $120 | Warning ramp | **Individuals: USA/Canada only.** Identity validation required |
|
||||
>
|
||||
> Signing does **not** clear the warning on day one either way: a valid cert stops the
|
||||
> *malware* warning immediately, but "unrecognized publisher" clears as SmartScreen accrues
|
||||
> reputation from real download volume. Budget cert validity at **460 days** (CA/B Forum
|
||||
> CSC-31) — this is a recurring line item, not a one-time purchase — and note that private
|
||||
> keys must sit on an **HSM or hardware token**.
|
||||
>
|
||||
> **The full comparison, the four real combinations, and the policy analysis live in
|
||||
> [`TASKS/research-store-certification.md`](TASKS/research-store-certification.md) §2–3.
|
||||
> The executable checklist is [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md),
|
||||
> and the route decision is the creator's.**
|
||||
|
||||
**Certificate:** Buy an **OV (Organization Validation)** code-signing certificate from DigiCert
|
||||
or Sectigo (~$150–300/year), or an **Azure Artifact Signing** profile (~$9.99/month,
|
||||
USA/Canada individuals only) if the geography allows. ⛔ **Not EV** — no SmartScreen benefit
|
||||
since March 2024. **Or buy nothing at all** if the route is the Microsoft Store.
|
||||
|
||||
**Signing process:**
|
||||
```powershell
|
||||
@@ -332,7 +406,13 @@ Set-AuthenticodeSignature -FilePath "LlamaCasty.exe" `
|
||||
|
||||
**Storage:** Certificate `.pfx` file stored in GitHub Secrets (or Azure DevOps secure files). Never committed to the repo.
|
||||
|
||||
### 4.4 Update Mechanism
|
||||
### 4.4 Update Mechanism — ⛔ OBSOLETE (2026-09-27)
|
||||
|
||||
> **Superseded by Store updates.** Microsoft delivers and updates the app from the listing, so
|
||||
> there is nothing to check and no manifest to host. The Velopack updater currently in
|
||||
> `App.xaml.cs` **downloads *and executes***, which Store policy **10.2.2** does not permit — its
|
||||
> removal is a **compliance action**, not optional cleanup (TASK 48 item 3). The Polar-native design
|
||||
> below is retained as a record of the approach that was rejected; it is not an option.
|
||||
|
||||
**Polar-native approach (no custom infrastructure):**
|
||||
|
||||
@@ -367,10 +447,18 @@ public class UpdateCheckService
|
||||
- Download link points to Polar's customer portal (not direct download).
|
||||
- This adds one external dependency (GitHub) but keeps Polar as the source of truth.
|
||||
|
||||
### 4.5 Licensing Logic (already implemented)
|
||||
### 4.5 Licensing Logic — ⛔ BEING DELETED (2026-09-27)
|
||||
|
||||
> ⚠️ **"Already implemented" was true as of 2026-09-21 and is no longer.** `PolarLicenseService`
|
||||
> and `Helpers/PolarLicense.cs` are being **removed** as part of the TASK 10 teardown (TASK 48 item 0).
|
||||
> Store IAP replaces the whole validation hop: `IsPremium` is derived from the **OS-supplied Store
|
||||
> entitlement**, so there is no network call, no key, and — importantly — **no 14-day offline-grace
|
||||
> machine to protect**, which retires the entire "network flaky → app thinks I'm expired" bug class.
|
||||
> What survives is only `IsPremium` → `BrandFlashEnabled = !value`. Retained below as the record of
|
||||
> what shipped and why it no longer applies.
|
||||
|
||||
```csharp
|
||||
// Services/PolarLicenseService.cs — already ships
|
||||
// Services/PolarLicenseService.cs — shipped under Polar; being deleted
|
||||
// Calls Polar's customer-portal validation endpoint (no auth required)
|
||||
// Survives obfuscation — property names excluded via [Obfuscation(Exclude = true)]
|
||||
[Obfuscation(Exclude = true)]
|
||||
@@ -383,11 +471,11 @@ public class LicenseValidationResult
|
||||
}
|
||||
```
|
||||
|
||||
**What's already done:**
|
||||
- `PolarLicenseService` validates keys against Polar API.
|
||||
- `OrganizationId` is hardcoded (safe — it's public knowledge, tied to our Polar account).
|
||||
- License state persists in `LayoutStore.Settings` (SQLite) with offline grace period.
|
||||
- `PremiumUrl` points to Polar checkout.
|
||||
**What shipped under Polar (now being deleted):**
|
||||
- `PolarLicenseService` validated keys against the Polar API.
|
||||
- `OrganizationId` was hardcoded.
|
||||
- License state persisted in `LayoutStore.Settings` (SQLite) with an offline grace period.
|
||||
- `PremiumUrl` pointed to Polar checkout.
|
||||
|
||||
**What survives obfuscation:** The validation logic in `Services.dll` is obfuscated. An attacker who decompiles the exe sees only the UI calling an interface — the actual Polar API call is in the obfuscated DLL.
|
||||
|
||||
|
||||
+67
-34
@@ -1,45 +1,78 @@
|
||||
# HANDOFF — Session State
|
||||
# HANDOFF — 2026-10-10 (strip tile artwork + on-air tile unit; uncommitted)
|
||||
|
||||
## Branch / Commit State
|
||||
## At a glance
|
||||
- **Branch** main (no feature branch). Pushed through `5685c5d`; 19 commits local, **not pushed**
|
||||
(push at the creator's call).
|
||||
- Scene-order unit committed `06f88f4`; desktop-lock committed `63bf844`. The **strip tile
|
||||
artwork unit below is uncommitted**.
|
||||
|
||||
**`main`**, working tree is **CLEAN** — all changes committed and pushed.
|
||||
## Branch
|
||||
- main (no feature branch). Pushed through `5685c5d` (TRAX toggle); everything after is local,
|
||||
**not pushed** (push at the creator's call).
|
||||
|
||||
No feature branches pre-1.0: all work lands on `main` per work unit.
|
||||
## New work unit (this handoff) — VERIFIED, ready to commit
|
||||
Strip tile artwork + on-air indicator (creator ruling 2026-10-10). Three sub-parts:
|
||||
|
||||
**Latest commit:** `6031a56` — gear cleanup + Default Location for Recordings + single Start + socials hover glow.
|
||||
1. **Per-scene CRT tile art** — each canonical scene's top-bar tile is its OWN image stored in the
|
||||
internal DB (`Scene.ThumbnailAssetId`, additive migration, seeded at heal from the creator's five
|
||||
Downloads JPGs via `SceneThumbnailSeedPaths`). The preview pane keeps rendering the scene's original
|
||||
background — never the tile. `Save()` Asset prune now keeps `Scene.ThumbnailAssetId` rows
|
||||
(this was a real bug: the first save would have deleted the seeded tiles).
|
||||
2. **On-air image** — one shared image (`Gemini_Generated_Image_4zajyl4zajyl4zaj.jpg`) that REPLACES the
|
||||
staged scene's tile ("the on-air screen jumps around the top bar as the creator swaps scenes").
|
||||
Stored as an Asset BLOB tracked by the `OnAirImageAssetId` Setting (`EnsureOnAirImage`, same contract
|
||||
as the alert clip; prune keep-set extended). Seed path override: `MainViewModel.OnAirSeedPath`.
|
||||
`RefreshSnapshotsAsync`: staged scene → on-air art; unstaged → own thumbnail or background art;
|
||||
staged Live without on-air art → green placeholder (unchanged invariant: minis never render captures).
|
||||
3. **Preview + labels polish** — removed the 6px `#22c55e` rectangle in `BackdropLayer`
|
||||
(`Controls/PreviewPane.xaml`) — the ONLY live signal is the outer `PreviewGlow` red border
|
||||
(`MainViewModel.Streaming.cs`, transparent otherwise); the redundant rect was only visible when a
|
||||
scene had no background ("green outline around the preview screen" the creator saw). Scene-tile
|
||||
labels doubled: `Controls/SceneThumbnailStrip.xaml` FontSize 10 → 20.
|
||||
|
||||
## What's In Flight
|
||||
- Files: `Models/Scene.cs`, `Services/LayoutStore.{Migrations,Load,Save,Settings}.cs`,
|
||||
`ViewModels/MainViewModel.Background.cs`, `Controls/PreviewPane.xaml` (rect removed),
|
||||
`Controls/SceneThumbnailStrip.xaml` (label), `ytLive.Tests/SceneThumbnailTests.cs` (4 tests).
|
||||
- Full suite **427 passed / 0 failed / 0 warnings** (one run flaked on the known
|
||||
`RealMouseDrag_…PersistsTheReorder` env test — needs an idle desktop — and passed on re-run;
|
||||
nothing in this unit touches layer reorder). Existing 3 tile tests stub seed paths + `OnAirSeedPath`
|
||||
so the suite is hermetic; the new 4th test covers the on-air swap + persistence.
|
||||
- **To commit later (this unit only):** the files above. Then the working tree is back to just the
|
||||
creator's pinned-chat unit (NOT mine to commit).
|
||||
|
||||
Nothing — clean tree, ready for next task.
|
||||
## Desktop-lock unit (COMMITTED 63bf844)
|
||||
- `ShowActivated = !FullscreenGameRunningNow` + `FullScreenDetectorOverride` seam + `DesktopLockTests`.
|
||||
Creator still to verify in-game (launch while an exclusive-fullscreen game owns the foreground).
|
||||
|
||||
## Landmines
|
||||
## Scene-order unit (COMMITTED 06f88f4)
|
||||
- `ReorderScenesToCanonical()` + `SceneOrderTests`. The creator's DB (Chat SortOrder 2, BRB 3) heals to
|
||||
canonical F1-F5 order at load; next save re-stamps SortOrder.
|
||||
|
||||
- **TextBox.Mode=OneWay** is required for `RecordFolderDisplay` binding (get-only computed property).
|
||||
- **Pre-existing test failures (do not chase):** `AudioPipelineTests.Mix_HonorsProviderGains…` +
|
||||
`RoundClipInteractionTests.Round_Clip_Corner_Is_Grabbable…`.
|
||||
- **App running locks `ytLive.exe`** → build fails MSB3027. Kill PID before building.
|
||||
- **Test suite hangs** when running full vstest (WPF `RealAppHost` dispatcher deadlock); individual
|
||||
tests pass in isolation. Use `dotnet test` on individual test classes, not the full suite DLL.
|
||||
- **Pre-commit gate:** `./scripts/verify.sh` — clean build (0 warnings) + full tests + scope check.
|
||||
## Dirty files — current reality (2026-10-10)
|
||||
- **NOT mine — do NOT commit:** the creator's in-flight "pinned chat" unit (`Models/YouTube.cs`,
|
||||
`ViewModels/MainViewModel.Chat.cs`, untracked `Helpers/RelayCommandGeneric.cs`, and **its hunks in
|
||||
`Controls/LeftPanel.xaml`**), plus the creator's untracked `Assets/llamacasty*` art.
|
||||
- ⚠️ Pre-commit: `scripts/verify.sh` runs `scripts/scope-check.sh` against ALL working-tree changes —
|
||||
the not-mine pinned-chat + art files WILL flag. Pass them as declared-known.
|
||||
|
||||
## Todo state
|
||||
## Next step
|
||||
- Commit this unit (files listed above; gate already green at 427/427, 0 warnings). Then the creator
|
||||
launches the app on the real DB: the five CRT tiles seed from `C:\Users\gramp\Downloads\Gemini_…`,
|
||||
the staged scene's tile shows the on-air image, the preview shows the scene's real background with no
|
||||
green frame, and the tile labels are legible (FontSize 20). Push is still at the creator's call.
|
||||
|
||||
- TASK 3 (Capture Pipeline): 27/30 items done. Open: preview compositor (16), text source (17), alerts (20). Window capture absorbed into Screen picker (done). Background removal excluded from this build.
|
||||
- TASK 4 (RTMP Ingest): ✅ Shipped — all 7 items done (compositor, FFmpeg locator, encoder+RTMP, WASAPI audio, frame-pipeline, health stats, one-click go-live).
|
||||
- TASK 9 (Stream Management): items 1–3 shipped (reusable stream, health monitoring). Items 4–7 open: live chat, error handling, visibility picker, full broadcast form.
|
||||
- TASK 10 (Monetization): steps 1–7 shipped. Velopack auto-update pending.
|
||||
- TASK 18: shipped. Remaining = running-app verification (creator).
|
||||
- TASK 19/23 (Control Surface UX): ✅ Shipped — thumbnails, transitions (Cut/Fade/Move), edit mode, left panel two-state.
|
||||
- TASK 20 (Hotkeys): ✅ Shipped — F1-F5 scenes, F6-F9 actions, config UI, global hotkeys, conflict detection, persistence.
|
||||
- TASK 21 (Media source): Increment A shipped (model + persistence). Slices 4–12 open (decoder, FramePump wiring, compositor, loop/volume, UI, tests). Blocked on ffmpeg for integration test.
|
||||
- TASK 22 (Audio sync offset): not started — required for v1.
|
||||
- TASK 30: gear cleanup + record location + single Start + preview layout restructure — shipped across commits `6031a56` + `61b850f` + `455a409`.
|
||||
- Monetization: deferred (creator rethinking price/subscription model).
|
||||
- **File refactor (in progress — Commit 0 done):** MainViewModel.cs (3,900 lines) will be split into functional partials (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License). `MainViewModel` is now `public partial class` (scaffold commit). Remaining: one commit per partial, each a roll-back point, `verify.sh` + `scope-check.sh` before each, docs in the same commit. MainWindow.xaml (1,681 lines) will be split into UserControls after Phase 1. Zero behavior change, tests stay green.
|
||||
## Tests
|
||||
- Full suite **427 passed / 0 failed / 0 skipped / 0 warnings** (2026-10-10). Known env flakes:
|
||||
`Mix_HonorsProviderGains…` (fails while system audio plays), `RealMouseDrag…` (needs an idle desktop),
|
||||
`Round_Clip_Corner…` (grab/geometry under active mouse). The verify gate whitelists only the latter two —
|
||||
`RealMouseDrag…` failing mid-session needs one re-run when the desktop idles.
|
||||
|
||||
## Session summary (2026-08-29)
|
||||
|
||||
- Corrected task status tracking: TASK 4 → ✅ Done, TASK 3 item15 (Window capture) → ✅ absorbed into Screen picker, TASK 3 status updated to 27/30
|
||||
- ai.md updated: TASK 4 steps 5-7 added as shipped, stale "frame-pipeline wiring follows" removed
|
||||
- HANDOFF.md todo state expanded with full statuses for all active tasks
|
||||
- Discussed file organization: MainViewModel.cs (3,900 lines) is a god-object with 49 commands mixing scenes, camera, audio, streaming, socials, overlays, and auth. Next session: split into functional partials
|
||||
## Also still open
|
||||
- **Vertical-format regression (STALE, not fixed):** 1080p60 vertical selection no longer shrinks the
|
||||
capture border in the preview. Mechanism mapped (see prior summary): the crop Rectangle
|
||||
`Controls/PreviewPane.xaml:319-322` bound to `OutputRectX/Y/W/H` + `IsOutputCropped`, driven by
|
||||
`MainViewModel.Streaming.cs` `ApplyStreamQuality` from `SelectedQuality` (binding + ctor only,
|
||||
`new("Vertical 1080p60", 60, 8.0, 1080, 1920)`), `DimRects` in `MainViewModel.cs:91-94`. Root cause
|
||||
unknown — paused.
|
||||
- ⛔ Sudo password rotation pending (user; then update both password lines in `CREDENTIALS.md`).
|
||||
- TASK 56 secret scan before `gramps/LlamaCasty` goes public. TASK 55 disk hygiene queued.
|
||||
+3
-5
@@ -5,15 +5,13 @@ namespace ytLive.Helpers;
|
||||
/// <summary>
|
||||
/// Minimal file logger used to diagnose startup/shutdown crashes on Windows,
|
||||
/// where the WPF app has no visible console. Writes to
|
||||
/// %APPDATA%\ytLlive\startup.log, appending synchronously so entries survive
|
||||
/// a hard crash.
|
||||
/// %APPDATA%\ytLlive\startup.log (or a per-instance subfolder in DEBUG — see
|
||||
/// InstanceProfile), appending synchronously so entries survive a hard crash.
|
||||
/// </summary>
|
||||
public static class AppLog
|
||||
{
|
||||
private static readonly string LogPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
"startup.log");
|
||||
InstanceProfile.DataRoot, "startup.log");
|
||||
|
||||
private static readonly object Gate = new();
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Arrival cadence for one window of a timestamped frame stream: how many frames
|
||||
/// arrived, how far apart they were, and how many gaps were long enough to read
|
||||
/// as a hitch. Fed any shared monotonic clock (<see cref="Stopwatch.GetTimestamp"/>
|
||||
/// values, or a device's system-relative stamps on the same clock) and read through
|
||||
/// <see cref="Summarize"/>.
|
||||
///
|
||||
/// Why it exists: a 60fps container makes a missing camera frame indistinguishable
|
||||
/// from a padded repeat. rawvideo carries no per-frame timestamps — ffmpeg stamps
|
||||
/// frames by ARRIVAL at the declared rate — so the OBS "duplicate on lag" loop
|
||||
/// (FramePump) emits 300 authored frames per 5s whether the renderer managed 300
|
||||
/// composites or 84. The recorded file therefore cannot name the stage that lost
|
||||
/// the frames; only these counters can. The same counter run twice tells the two
|
||||
/// apart: the camera's OWN timestamps vs our ARRIVAL timestamps. Steady camera
|
||||
/// stamps with ragged arrivals means WE dropped it (compositor/reader); ragged in
|
||||
/// both means the camera, USB link, or driver did.
|
||||
/// </summary>
|
||||
public sealed class FrameCadence
|
||||
{
|
||||
/// <summary>Default hitch threshold in ms. 50ms is ~1.5x a 30fps period — a
|
||||
/// missed 30fps frame arrives as ~66ms — so this counts dropped frames without
|
||||
/// flagging ordinary jitter (a 33ms frame can legitimately measure 35ms).</summary>
|
||||
public const double DefaultHitchThresholdMs = 50;
|
||||
|
||||
private readonly double _hitchThresholdMs;
|
||||
private long _firstTicks;
|
||||
private long _previousTicks;
|
||||
private double _gapTicksTotal;
|
||||
private int _frames;
|
||||
private int _gaps;
|
||||
private int _hitches;
|
||||
private double _longestGapMs;
|
||||
|
||||
public FrameCadence(double hitchThresholdMs = DefaultHitchThresholdMs)
|
||||
{
|
||||
if (hitchThresholdMs <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(hitchThresholdMs), hitchThresholdMs,
|
||||
"Hitch threshold must be positive.");
|
||||
_hitchThresholdMs = hitchThresholdMs;
|
||||
}
|
||||
|
||||
public int Frames => _frames;
|
||||
public int Gaps => _gaps;
|
||||
public int Hitches => _hitches;
|
||||
public double HitchThresholdMs => _hitchThresholdMs;
|
||||
|
||||
/// <summary>Records one frame at <paramref name="timestampTicks"/> on the shared
|
||||
/// monotonic clock. Monotonicity is asserted, not trusted: an out-of-order stamp
|
||||
/// (a device whose clock domain differs from ours, a wrapped counter, a dropped
|
||||
/// timestamp) is counted as a frame but produces no gap AND does not become the
|
||||
/// reference for the next one — otherwise a single bad stamp would poison both
|
||||
/// its own interval and the one after it, inventing a multi-second "hitch".</summary>
|
||||
public void Add(long timestampTicks)
|
||||
{
|
||||
if (_frames == 0)
|
||||
{
|
||||
_firstTicks = timestampTicks;
|
||||
_previousTicks = timestampTicks;
|
||||
_frames++;
|
||||
return;
|
||||
}
|
||||
|
||||
var delta = timestampTicks - _previousTicks;
|
||||
_frames++;
|
||||
if (delta <= 0) return;
|
||||
var gapMs = delta * 1000d / Stopwatch.Frequency;
|
||||
_gaps++;
|
||||
_gapTicksTotal += gapMs;
|
||||
if (gapMs > _longestGapMs) _longestGapMs = gapMs;
|
||||
if (gapMs >= _hitchThresholdMs) _hitches++;
|
||||
_previousTicks = timestampTicks;
|
||||
}
|
||||
|
||||
public FrameCadenceSummary Summarize()
|
||||
{
|
||||
var spanMs = _frames >= 2
|
||||
? (_previousTicks - _firstTicks) * 1000d / Stopwatch.Frequency
|
||||
: 0d;
|
||||
// Rate is gaps-per-span, not frames-per-span: N frames span N-1 periods, and
|
||||
// counting the extra interval would report one extra frame per window.
|
||||
var rate = _gaps > 0 && spanMs > 0 ? _gaps / (spanMs / 1000d) : 0d;
|
||||
return new FrameCadenceSummary(
|
||||
_frames, rate, spanMs, _gaps, _hitches, _longestGapMs,
|
||||
_gaps > 0 ? _gapTicksTotal / _gaps : 0d, _hitchThresholdMs);
|
||||
}
|
||||
|
||||
/// <summary>Starts a fresh window, keeping the configured threshold.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_firstTicks = 0;
|
||||
_previousTicks = 0;
|
||||
_gapTicksTotal = 0;
|
||||
_frames = 0;
|
||||
_gaps = 0;
|
||||
_hitches = 0;
|
||||
_longestGapMs = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One measured cadence window — see <see cref="FrameCadence"/>.</summary>
|
||||
public readonly record struct FrameCadenceSummary(
|
||||
int Frames,
|
||||
double RatePerSecond,
|
||||
double SpanMs,
|
||||
int Gaps,
|
||||
int Hitches,
|
||||
double LongestGapMs,
|
||||
double AverageGapMs,
|
||||
double HitchThresholdMs)
|
||||
{
|
||||
/// <summary>One-line log form. Identical in shape from every producer (camera
|
||||
/// source, frame pump) so two lines can be read against each other: same window
|
||||
/// length, same fields, same order.</summary>
|
||||
public string Describe(string label) => Frames == 0
|
||||
? $"{label} no frames"
|
||||
: $"{label} {Frames}f/{SpanMs / 1000d:F1}s {RatePerSecond:F1}/s, {Gaps} gaps, " +
|
||||
$"{Hitches} >= {HitchThresholdMs:F0}ms, avg {AverageGapMs:F1}ms, longest {LongestGapMs:F1}ms";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Which run of LlamaCasty this process is, and where its private state lives.
|
||||
/// <para><b>Dev-only affordance (creator directive, 2026-09-26).</b> Pre-1.0 the creator
|
||||
/// needs two instances side by side: one streaming, one screen-capturing the other.
|
||||
/// Two instances on one Windows profile collide on state, and two of those collisions
|
||||
/// are hard failures rather than annoyances:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>The SQLite layout DB.</b> The app's model is read-whole-scene / write-whole-scene,
|
||||
/// so two instances saving different layouts clobber each other.</item>
|
||||
/// <item><b>The WebView2 user data folder.</b> Chromium takes an exclusive lock on it; the
|
||||
/// second instance of the same exe simply fails to start.</item>
|
||||
/// <item><b>The auth token store.</b> A test instance would overwrite the real YouTube
|
||||
/// sign-in.</item>
|
||||
/// <item><b>startup.log.</b> Two appenders interleave into one file, so a crash in either
|
||||
/// instance is ambiguous.</item>
|
||||
/// </list>
|
||||
/// So in <c>DEBUG</c> a process can claim an identity and gets its own private root
|
||||
/// under <c>instances\<id></c>: <c>$env:YTLIVE_INSTANCE=2; dotnet run</c>.
|
||||
/// Recording, screen capture and the ffmpeg <c>tools\</c> cache are deliberately LEFT
|
||||
/// shared — the cache should be shared, and the creator chooses the record folder.
|
||||
/// Global hotkeys are also left alone: if both instances register the same one, Windows
|
||||
/// refuses the second, which is the correct answer.</para>
|
||||
/// <para><b>At 1.0 this disappears.</b> The whole implementation is inside
|
||||
/// <c>#if DEBUG</c>; a Release build compiles to the two pass-throughs at the bottom —
|
||||
/// no environment variable, no alternate folder, not even the variable's NAME in the
|
||||
/// binary. Call sites are unconditional, so Release cannot accidentally diverge: it
|
||||
/// always resolves the one real user profile.</para>
|
||||
/// </summary>
|
||||
public static class InstanceProfile
|
||||
{
|
||||
#if DEBUG
|
||||
/// <summary>Environment variable naming this instance. Absent or blank = the primary
|
||||
/// instance on the real user profile.</summary>
|
||||
public const string InstanceVariable = "YTLIVE_INSTANCE";
|
||||
|
||||
/// <summary>This instance's id, or null for the primary instance.</summary>
|
||||
public static string? Id => Sanitize(Environment.GetEnvironmentVariable(InstanceVariable));
|
||||
|
||||
/// <summary>Private state root: the real profile normally, a per-instance subfolder
|
||||
/// when an id is claimed.</summary>
|
||||
public static string DataRoot => Id is { } id
|
||||
? Path.Combine(DefaultRoot, "instances", id)
|
||||
: DefaultRoot;
|
||||
|
||||
/// <summary>Chromium's user data folder, or null to let WebView2 pick its default
|
||||
/// (which is what Release always does). Must be per-instance or the second
|
||||
/// instance dies on Chromium's exclusive lock.</summary>
|
||||
public static string? WebViewDataFolder => Id is null ? null : Path.Combine(DataRoot, "webview2");
|
||||
|
||||
/// <summary>Reject anything that is not a plain path segment, so the variable can
|
||||
/// never be used to walk out of the profile directory or name a UNC path.</summary>
|
||||
private static string? Sanitize(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var trimmed = raw.Trim();
|
||||
foreach (var c in trimmed)
|
||||
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_') return null;
|
||||
return trimmed;
|
||||
}
|
||||
#else
|
||||
// Release: one profile, no alternate paths, no environment variable. Multi-instance
|
||||
// is a development affordance, not a shipping feature.
|
||||
public static string DataRoot => DefaultRoot;
|
||||
public static string? WebViewDataFolder => null;
|
||||
#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(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public class RelayCommand<T> : ICommand
|
||||
{
|
||||
private readonly Action<T?> _execute;
|
||||
private readonly Predicate<T?>? _canExecute;
|
||||
|
||||
public RelayCommand(Action<T?> execute, Predicate<T?>? canExecute = null)
|
||||
{
|
||||
_execute = execute;
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public event EventHandler? CanExecuteChanged
|
||||
{
|
||||
add => CommandManager.RequerySuggested += value;
|
||||
remove => CommandManager.RequerySuggested -= value;
|
||||
}
|
||||
|
||||
public bool CanExecute(object? parameter)
|
||||
{
|
||||
if (parameter is T t) return _canExecute?.Invoke(t) ?? true;
|
||||
return _canExecute?.Invoke(default) ?? true;
|
||||
}
|
||||
|
||||
public void Execute(object? parameter)
|
||||
{
|
||||
if (parameter is T t) _execute(t);
|
||||
else _execute(default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// A fixed pool of four DEDICATED background threads at <see cref="ThreadPriority.BelowNormal"/>
|
||||
/// that split one composite into horizontal row strips. The compositor needs the split to get
|
||||
/// render inside a 16.67ms slot, but it must not compete with the encoder, the WGC capture
|
||||
/// callbacks, the WebView2 chat or the game the user is playing — hence the hard cap of four
|
||||
/// and the demotion.
|
||||
///
|
||||
/// Research (2026-10-05, Unit 5): the demotion must NOT be done by poking
|
||||
/// <c>Thread.CurrentThread.Priority</c> inside <c>Parallel.For</c> — the pool is shared, so the
|
||||
/// demotion leaks into every unrelated work item that thread later runs
|
||||
/// (https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority:
|
||||
/// "you have next to no control of which thread pool thread will actually run your task");
|
||||
/// https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool:
|
||||
/// "You shouldn't mess with the priority of the regular ThreadPool, since you aren't the only
|
||||
/// consumer ... write your own producer/consumer queue, using your own Thread(s) as the
|
||||
/// consumer(s) — setting the thread priority yourself"). A real failed attempt confirmed it: a
|
||||
/// full test run after demoting the pool threads broke an unrelated audio-mix timing test.
|
||||
/// This class IS that producer/consumer queue; the caller thread is the producer and also runs
|
||||
/// one strip itself, so four workers never means five-way oversubscription.
|
||||
///
|
||||
/// Contract: <see cref="For"/> blocks until every strip has run, each strip must write only its
|
||||
/// own rows (the compositor's blits already partition by row), and it must not be called from
|
||||
/// inside a strip (the blits never nest).
|
||||
/// </summary>
|
||||
internal static class StripPool
|
||||
{
|
||||
/// <summary>Four workers: the measured sweet spot on this box. Fewer leaves render over
|
||||
/// budget; more steals cycles from the game and the encoder and made render worse, not better.</summary>
|
||||
public const int Workers = 4;
|
||||
|
||||
/// <summary>Below this many rows the fan-out costs more than it saves — run it inline.</summary>
|
||||
private const int MinRowsForFanOut = 64;
|
||||
|
||||
private static readonly object[] _queueLocks = CreateLocks();
|
||||
private static readonly Queue<Strip>[] _queues = CreateQueues();
|
||||
private static readonly Queue<Strip>[] _free = CreateQueues();
|
||||
private static readonly SemaphoreSlim[] _signals = CreateSignals();
|
||||
private static Thread[]? _threads;
|
||||
private static int _roundRobin = -1;
|
||||
|
||||
private sealed class Strip
|
||||
{
|
||||
public int From;
|
||||
public int To; // exclusive
|
||||
public Action<int>? Body;
|
||||
public Batch? Owner;
|
||||
}
|
||||
|
||||
private sealed class Batch
|
||||
{
|
||||
public int Remaining;
|
||||
public Exception? Error;
|
||||
}
|
||||
|
||||
/// <summary>Run <paramref name="body"/> for every row in [from, to), split across the pool.</summary>
|
||||
public static void For(int from, int to, Action<int> body)
|
||||
{
|
||||
if (from >= to) return;
|
||||
|
||||
var rows = to - from;
|
||||
if (rows < MinRowsForFanOut || !EnsureStarted())
|
||||
{
|
||||
for (var y = from; y < to; y++) body(y);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strips are handed to whole workers (not per-row tasks) so the strip boundaries —
|
||||
// and therefore the destination row each strip owns — are fixed before any of them run.
|
||||
var strips = Math.Min(Workers, rows);
|
||||
var chunk = (rows + strips - 1) / strips;
|
||||
var batch = new Batch { Remaining = strips - 1 }; // the caller runs the last one
|
||||
|
||||
var first = from;
|
||||
for (var k = 0; k < strips - 1; k++)
|
||||
{
|
||||
var end = Math.Min(first + chunk, to);
|
||||
var w = NextWorker();
|
||||
var strip = Rent(w);
|
||||
strip.From = first;
|
||||
strip.To = end;
|
||||
strip.Body = body;
|
||||
strip.Owner = batch;
|
||||
lock (_queueLocks[w]) _queues[w].Enqueue(strip);
|
||||
_signals[w].Release();
|
||||
first = end;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
for (var y = first; y < to; y++) body(y);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
batch.Error ??= ex;
|
||||
}
|
||||
|
||||
if (batch.Remaining > 0)
|
||||
{
|
||||
lock (batch)
|
||||
{
|
||||
while (batch.Remaining > 0) Monitor.Wait(batch);
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.Error != null) throw new InvalidOperationException("A compositor strip failed.", batch.Error);
|
||||
}
|
||||
|
||||
private static void Worker(int index)
|
||||
{
|
||||
var signal = _signals[index];
|
||||
while (true)
|
||||
{
|
||||
signal.Wait();
|
||||
Strip? strip = null;
|
||||
lock (_queueLocks[index])
|
||||
{
|
||||
if (_queues[index].Count > 0) strip = _queues[index].Dequeue();
|
||||
}
|
||||
if (strip == null) continue; // released for an already-drained queue
|
||||
var batch = strip.Owner!;
|
||||
try
|
||||
{
|
||||
for (var y = strip.From; y < strip.To; y++) strip.Body!(y);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
batch.Error ??= ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
strip.Body = null;
|
||||
strip.Owner = null;
|
||||
lock (_queueLocks[index]) _free[index].Enqueue(strip);
|
||||
lock (batch)
|
||||
{
|
||||
if (--batch.Remaining == 0) Monitor.PulseAll(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Strip Rent(int worker)
|
||||
{
|
||||
lock (_queueLocks[worker])
|
||||
return _free[worker].Count > 0 ? _free[worker].Dequeue() : new Strip();
|
||||
}
|
||||
|
||||
private static int NextWorker()
|
||||
{
|
||||
var w = Interlocked.Increment(ref _roundRobin) % Workers;
|
||||
return w < 0 ? w + Workers : w;
|
||||
}
|
||||
|
||||
/// <summary>Start the workers on first use, so a WPF cold start pays nothing for them.</summary>
|
||||
private static bool EnsureStarted()
|
||||
{
|
||||
if (_threads != null) return true;
|
||||
lock (_signals)
|
||||
{
|
||||
if (_threads != null) return true;
|
||||
var threads = new Thread[Workers];
|
||||
for (var i = 0; i < Workers; i++)
|
||||
{
|
||||
var index = i; // capture per-iteration: the loop variable would be Workers by the time the thread runs
|
||||
var t = new Thread(() => Worker(index), 256 * 1024)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"compositor-strip-{i}",
|
||||
Priority = ThreadPriority.BelowNormal,
|
||||
};
|
||||
threads[i] = t;
|
||||
t.Start();
|
||||
}
|
||||
_threads = threads;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static object[] CreateLocks()
|
||||
{
|
||||
var l = new object[Workers];
|
||||
for (var i = 0; i < Workers; i++) l[i] = new object();
|
||||
return l;
|
||||
}
|
||||
|
||||
private static Queue<Strip>[] CreateQueues()
|
||||
{
|
||||
var q = new Queue<Strip>[Workers];
|
||||
for (var i = 0; i < Workers; i++) q[i] = new Queue<Strip>();
|
||||
return q;
|
||||
}
|
||||
|
||||
private static SemaphoreSlim[] CreateSignals()
|
||||
{
|
||||
var s = new SemaphoreSlim[Workers];
|
||||
for (var i = 0; i < Workers; i++) s[i] = new SemaphoreSlim(0, int.MaxValue);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,7 @@ namespace ytLive.Helpers;
|
||||
public static class TokenStore
|
||||
{
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
"ytLlive.auth");
|
||||
InstanceProfile.DataRoot, "ytLlive.auth");
|
||||
|
||||
public static void Save(YouTubeChannel channel, string? path = null)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,8 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
||||
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
|
||||
| `AllTrueToVisibilityConverter.cs` | Multi-value AND-gate → Visible; gates the layer-row capture menu items on row-IsBackground + staged-scene-is-Live (TASK 25) |
|
||||
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
|
||||
| `StripPool.cs` | Four DEDICATED background threads at `BelowNormal` priority that split one composite into horizontal row strips (the caller runs a strip too); started lazily, runs inline below 64 rows. UNIT 5 — do NOT demote the ThreadPool from `Parallel.For`, it leaks into unrelated work (see ai.md "Render budget") |
|
||||
| `FrameCadence.cs` | Pure arrival-cadence counter (`FrameCadence` + `FrameCadenceSummary.Describe`): frames, gaps, hitches, longest/avg gap for a window of a timestamped stream. Fed by `MediaCaptureFrameSource` (camera stamps vs our arrivals) and `FramePump` (distinct webcam Epochs that reached the output) — UNIT 0, see ai.md "Frame cadence" |
|
||||
|
||||
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
|
||||
class backs; [`Models/index.md`](../Models/index.md) and
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<Window x:Class="ytLive.KeepBroadcastDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Keep this broadcast?" Height="240" Width="460"
|
||||
Icon="/ytLive;component/Assets/llamacasty-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Keep this broadcast?" FontSize="18" FontWeight="Bold"
|
||||
Foreground="#e0e0e0" Margin="0,0,0,12"/>
|
||||
|
||||
<TextBlock Grid.Row="1" x:Name="MessageText" TextWrapping="Wrap" Foreground="#a0a0b0" FontSize="13"
|
||||
Margin="0,0,0,16"/>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button x:Name="DeleteBroadcastButton" Content="Delete" IsCancel="True" Click="Delete_Click"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"
|
||||
ToolTip="Esc — delete the recording from YouTube"/>
|
||||
<Button x:Name="KeepBroadcastButton" Content="Keep" IsDefault="True" Click="Keep_Click"
|
||||
Style="{StaticResource YtButton}" MinWidth="90"
|
||||
ToolTip="Enter — keep it on YouTube as a private video"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ytLive;
|
||||
|
||||
/// <summary>Asked when a PRIVATE broadcast ends — the same contract as the recording's save
|
||||
/// dialog (creator ruling 2026-10-09): <b>Enter keeps</b> it on YouTube as a private video,
|
||||
/// <b>Escape deletes</b> it. <c>DialogResult == true</c> = keep; false (Delete, Escape, the X)
|
||||
/// = delete.</summary>
|
||||
public partial class KeepBroadcastDialog : Window
|
||||
{
|
||||
public KeepBroadcastDialog(string title)
|
||||
{
|
||||
InitializeComponent();
|
||||
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?";
|
||||
Loaded += (_, _) => KeepBroadcastButton.Focus();
|
||||
}
|
||||
|
||||
private void Keep_Click(object sender, RoutedEventArgs e) => DialogResult = true;
|
||||
|
||||
private void Delete_Click(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
+12
-1588
File diff suppressed because it is too large
Load Diff
+70
-794
@@ -1,19 +1,10 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Threading;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Shapes;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive;
|
||||
@@ -21,7 +12,9 @@ namespace ytLive;
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _viewModel;
|
||||
private readonly Dictionary<string, Storyboard> _activeBorderAnimations = new();
|
||||
private Controls.TopBar _topBar = null!;
|
||||
private Controls.PreviewPane _previewPane = null!;
|
||||
private Controls.LeftPanel _leftPanel = null!;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -29,13 +22,17 @@ public partial class MainWindow : Window
|
||||
InitializeComponent();
|
||||
AppLog.Write("MainWindow ctor: after InitializeComponent");
|
||||
_viewModel = (MainViewModel)DataContext;
|
||||
_viewModel.InitWebView2(WebViewHostPanel);
|
||||
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
_viewModel.ChatMessages.CollectionChanged += OnChatMessagesChanged;
|
||||
// Don't steal focus from an exclusive-fullscreen game the creator is already
|
||||
// playing (e.g. while starting a gameplay stream): activating our window would
|
||||
// drop that game to the taskbar and make the desktop look "locked".
|
||||
ShowActivated = !_viewModel.FullscreenGameRunningNow;
|
||||
_topBar = (Controls.TopBar)FindName("TopBar")!;
|
||||
_previewPane = (Controls.PreviewPane)FindName("PreviewPane")!;
|
||||
_leftPanel = (Controls.LeftPanel)FindName("LeftPanel")!;
|
||||
UpdateTaskbarOverlay();
|
||||
UpdateSelectionOverlay();
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Visibility = Visibility.Collapsed;
|
||||
_topBar.RefreshAvatar();
|
||||
RestoreWindowGeometry();
|
||||
AppLog.Write("MainWindow ctor: end");
|
||||
}
|
||||
@@ -43,33 +40,62 @@ public partial class MainWindow : Window
|
||||
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
AppLog.Write("MainWindow loaded");
|
||||
// Web sources need the window HWND as the composition controller's parent,
|
||||
// which does not exist in the ctor — InitWebView2 runs here (Loaded), after
|
||||
// SourceInitialized. Nothing renders from web frames before this.
|
||||
_viewModel.InitWebView2();
|
||||
}
|
||||
|
||||
// ─── Window geometry persistence (launch size/position) ───
|
||||
// Saved on close (RestoreBounds so a maximized window remembers its
|
||||
// un-maximize rect); restored in the ctor before the window is shown.
|
||||
// Values clamp to the minimums and to the primary work area — a position
|
||||
// saved on a since-disconnected secondary monitor falls back on-screen.
|
||||
// ─── Window geometry (launch position/size) ───
|
||||
// Launch POSITION is derived, never restored: upper-left of the work area plus
|
||||
// padding (creator ruling 2026-10-04). The old restore clamped the saved position
|
||||
// to [work.Left - width + 120, work.Right - 120] horizontally and
|
||||
// [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave the window
|
||||
// almost entirely off-screen, which is what the creator was seeing ("below the
|
||||
// fold"). Size and maximized state are still persisted. Placement math is pure and
|
||||
// tested (WindowPlacementTests); changing it back to restore position is reverting
|
||||
// this block only, since the saved values are still written.
|
||||
|
||||
/// <summary>Launch inset from the work-area corner, in device-independent units —
|
||||
/// so it scales with DPI rather than being a fixed physical pixel count.</summary>
|
||||
internal const double LaunchPadding = 75;
|
||||
|
||||
/// <summary>Upper-left of <paramref name="workArea"/> plus padding, clamped so the
|
||||
/// window is never pushed off-screen. The clamp is the point: a window that fits
|
||||
/// stays wholly visible, and one too big to fit falls back to the work-area origin
|
||||
/// rather than hanging off an edge.</summary>
|
||||
internal static Point TopLeftPlacement(Rect workArea, double width, double height, double padding)
|
||||
{
|
||||
var left = workArea.Left + padding;
|
||||
var top = workArea.Top + padding;
|
||||
|
||||
if (width <= workArea.Width) left = Math.Min(left, workArea.Right - width);
|
||||
else left = workArea.Left;
|
||||
|
||||
if (height <= workArea.Height) top = Math.Min(top, workArea.Bottom - height);
|
||||
else top = workArea.Top;
|
||||
|
||||
return new Point(left, top);
|
||||
}
|
||||
|
||||
private void RestoreWindowGeometry()
|
||||
{
|
||||
try
|
||||
{
|
||||
var saved = _viewModel.RestoreWindowState();
|
||||
if (saved == null) return;
|
||||
var (left, top, width, height, state) = saved.Value;
|
||||
|
||||
var work = SystemParameters.WorkArea;
|
||||
width = Math.Clamp(width, MinWidth, work.Width);
|
||||
height = Math.Clamp(height, MinHeight, work.Height);
|
||||
left = Math.Clamp(left, work.Left - width + 120, work.Right - 120);
|
||||
top = Math.Clamp(top, work.Top, work.Bottom - 80);
|
||||
var saved = _viewModel.RestoreWindowState();
|
||||
|
||||
Left = left;
|
||||
Top = top;
|
||||
// No saved state: keep the XAML-declared size rather than collapsing to Min*.
|
||||
var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width);
|
||||
var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height);
|
||||
Width = width;
|
||||
Height = height;
|
||||
if (state == "Maximized") WindowState = WindowState.Maximized;
|
||||
|
||||
var place = TopLeftPlacement(work, width, height, LaunchPadding);
|
||||
Left = place.X;
|
||||
Top = place.Y;
|
||||
|
||||
if (saved?.State == "Maximized") WindowState = WindowState.Maximized;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -116,7 +142,7 @@ public partial class MainWindow : Window
|
||||
UpdateSelectionOverlay();
|
||||
else if (e.PropertyName == nameof(MainViewModel.AccountAvatarUrl)
|
||||
|| e.PropertyName == nameof(MainViewModel.IsConnected))
|
||||
LoadAvatarImage();
|
||||
_topBar.RefreshAvatar();
|
||||
}
|
||||
|
||||
private void MainWindow_Closing(object? sender, CancelEventArgs e)
|
||||
@@ -141,782 +167,32 @@ public partial class MainWindow : Window
|
||||
: null;
|
||||
}
|
||||
|
||||
private static readonly HttpClient AvatarHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private string? _loadedAvatarUrl;
|
||||
|
||||
private async void LoadAvatarImage()
|
||||
{
|
||||
var url = _viewModel.AccountAvatarUrl;
|
||||
if (!_viewModel.IsConnected)
|
||||
{
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
AppLog.Write("Avatar: connected but no profile image URL on the session");
|
||||
ShowAvatarFallback(null);
|
||||
return;
|
||||
}
|
||||
if (url == _loadedAvatarUrl && AvatarImage.Source != null) return;
|
||||
try
|
||||
{
|
||||
var bytes = await AvatarHttp.GetByteArrayAsync(url);
|
||||
using var ms = new MemoryStream(bytes);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = ms;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
_loadedAvatarUrl = url;
|
||||
AvatarImage.Source = bitmap;
|
||||
AvatarInitial.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Avatar load failed ({url}): {ex.Message}");
|
||||
ShowAvatarFallback(_viewModel.AccountDisplayName);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAvatarFallback(string? name)
|
||||
{
|
||||
_loadedAvatarUrl = null;
|
||||
AvatarImage.Source = null;
|
||||
AvatarInitial.Text = string.IsNullOrWhiteSpace(name) ? "?" : name.Substring(0, 1).ToUpperInvariant();
|
||||
AvatarInitial.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void SourceNameBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { IsVisible: true } box)
|
||||
{
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void VolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void VolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
SetSliderValueFromClick(slider, e);
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(true);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private void GameVolumeSlider_LostMouseCapture(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
vm.SetGameVolumeAdjusting(false);
|
||||
}
|
||||
|
||||
private static void SetSliderValueFromClick(Slider slider, MouseButtonEventArgs e)
|
||||
{
|
||||
if (FindParent<System.Windows.Controls.Primitives.Thumb>(e.OriginalSource as Visual) != null)
|
||||
return;
|
||||
var track = slider.Template.FindName("PART_Track", slider) as System.Windows.Controls.Primitives.Track;
|
||||
if (track == null) return;
|
||||
var pos = e.GetPosition(track);
|
||||
var fraction = Math.Clamp(pos.X / track.RenderSize.Width, 0, 1);
|
||||
slider.Value = slider.Minimum + fraction * (slider.Maximum - slider.Minimum);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static T? FindParent<T>(Visual? visual) where T : Visual
|
||||
{
|
||||
while (visual != null)
|
||||
{
|
||||
if (visual is T match) return match;
|
||||
visual = VisualTreeHelper.GetParent(visual) as Visual;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void MicSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleMicMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void GameSpeaker_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: MainViewModel vm })
|
||||
{
|
||||
vm.ToggleGameMuteCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
// TRAX (TASK 9): left-click toggles play/pause (picks a track when none is
|
||||
// loaded); right-click always opens the picker. The picker is the OS file
|
||||
// dialog — the "never the OS viewer" rule covers the preview, not pickers.
|
||||
private void TraxButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxLeftClick();
|
||||
}
|
||||
|
||||
private void TraxButton_PreviewMouseRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
vm.TraxRightClick();
|
||||
e.Handled = true; // the button's right-click context menu must not fire
|
||||
}
|
||||
|
||||
private void GearButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void BackToAbout_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ViewModels.MainViewModel vm)
|
||||
{
|
||||
vm.IsLicensesOpen = false;
|
||||
vm.IsLicenseEntryOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSourceButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
{
|
||||
menu.PlacementTarget = button;
|
||||
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Preview image selection / move / resize ───
|
||||
private void Thumbnail_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is Border { DataContext: Scene scene })
|
||||
_viewModel.TransitionToScene(scene);
|
||||
}
|
||||
|
||||
private bool _isDraggingOverlay;
|
||||
private bool _isResizing;
|
||||
private Point _grabOffset;
|
||||
private double _resizeAspect;
|
||||
|
||||
private void Window_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is not DependencyObject original) return;
|
||||
var form = _viewModel.BroadcastForm;
|
||||
if (form.IsDrawerOpen && !IsDescendantOf(original, TextPullOutHost))
|
||||
form.CloseDrawerCommand.Execute(null);
|
||||
if (IsDescendantOf(original, PreviewGrid)) return;
|
||||
if (IsDescendantOf(original, SourceList)) return;
|
||||
if (IsDescendantOf(original, ElementsScrollViewer)) return;
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
var ypp = _viewModel.Ypp;
|
||||
var test = _viewModel.TestSession;
|
||||
if (!_previewPane.IsClickInsideDrawer(original))
|
||||
{
|
||||
child = child is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: LogicalTreeHelper.GetParent(child);
|
||||
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
||||
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
||||
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
|
||||
}
|
||||
return child != null;
|
||||
if (_previewPane.IsClickInsidePreview(original)) return;
|
||||
if (_leftPanel.IsClickInside(original)) return;
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private void UpdateSelectionOverlay()
|
||||
{
|
||||
if (!_viewModel.IsEditMode)
|
||||
{
|
||||
SelectionOverlay.Visibility = Visibility.Collapsed;
|
||||
_previewPane.UpdateSelectionOverlay(false);
|
||||
return;
|
||||
}
|
||||
var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
|
||||
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (selected)
|
||||
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
|
||||
TakeSnapshot();
|
||||
}
|
||||
|
||||
private void OnChatMessagesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
|
||||
{
|
||||
ChatListBox.ScrollIntoView(ChatListBox.Items[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Static images and the webcam share the move/resize/selection behavior.
|
||||
private static bool IsDraggableElement(SceneElement element)
|
||||
=> element.IsDraggable;
|
||||
|
||||
private void MirrorButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.IsMirrored = !webcam.IsMirrored;
|
||||
}
|
||||
|
||||
private void ShapeButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
|
||||
webcam.ToggleClipShape();
|
||||
}
|
||||
|
||||
private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
|
||||
=> _viewModel.ChangeWebcamCommand.Execute(null);
|
||||
|
||||
private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not MenuItem { DataContext: SceneElement element, Tag: string tag }
|
||||
|| !Enum.TryParse<BorderAnimation>(tag, out var animation))
|
||||
return;
|
||||
|
||||
element.BorderAnimation = animation;
|
||||
|
||||
StopBorderAnimation(element.Id);
|
||||
|
||||
if (animation == BorderAnimation.None) return;
|
||||
|
||||
var shape = FindBorderShape(sender as DependencyObject);
|
||||
if (shape == null) return;
|
||||
|
||||
var sb = CreateBorderAnimation(animation, shape);
|
||||
if (sb == null) return;
|
||||
|
||||
sb.Freeze();
|
||||
_activeBorderAnimations[element.Id] = sb;
|
||||
shape.BeginAnimation(Shape.OpacityProperty, null);
|
||||
Storyboard.SetTarget(sb, shape);
|
||||
sb.Begin();
|
||||
}
|
||||
|
||||
private void StopBorderAnimation(string elementId)
|
||||
{
|
||||
if (_activeBorderAnimations.TryGetValue(elementId, out var old))
|
||||
{
|
||||
old.Stop();
|
||||
_activeBorderAnimations.Remove(elementId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Shape? FindBorderShape(DependencyObject? start)
|
||||
{
|
||||
var current = start;
|
||||
while (current != null)
|
||||
{
|
||||
if (current is ContextMenu cm && cm.PlacementTarget is FrameworkElement target)
|
||||
{
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(target); i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(target, i);
|
||||
if (child is Shape s && (s is Rectangle or Ellipse) && !s.IsHitTestVisible)
|
||||
return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
current = VisualTreeHelper.GetParent(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Storyboard? CreateBorderAnimation(BorderAnimation animation, Shape shape)
|
||||
{
|
||||
var sb = new Storyboard();
|
||||
switch (animation)
|
||||
{
|
||||
case BorderAnimation.Pulse:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.3, TimeSpan.FromSeconds(0.8), true);
|
||||
break;
|
||||
case BorderAnimation.Glow:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.5, TimeSpan.FromSeconds(1.2), true);
|
||||
break;
|
||||
case BorderAnimation.Shimmer:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.15, TimeSpan.FromSeconds(0.5), true);
|
||||
break;
|
||||
case BorderAnimation.Electricity:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.2, TimeSpan.FromSeconds(0.1), true);
|
||||
break;
|
||||
case BorderAnimation.Sparkles:
|
||||
AddDoubleAnimation(sb, Shape.OpacityProperty, 1.0, 0.4, TimeSpan.FromSeconds(0.3), true);
|
||||
break;
|
||||
case BorderAnimation.MarchingAnts:
|
||||
shape.StrokeDashArray = new DoubleCollection { 4, 2 };
|
||||
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -20, TimeSpan.FromSeconds(0.8), false);
|
||||
break;
|
||||
case BorderAnimation.Chase:
|
||||
shape.StrokeDashArray = new DoubleCollection { 8, 4 };
|
||||
AddDoubleAnimation(sb, Shape.StrokeDashOffsetProperty, 0, -30, TimeSpan.FromSeconds(1.5), false);
|
||||
break;
|
||||
case BorderAnimation.Rainbow:
|
||||
if (shape.Stroke is SolidColorBrush brush)
|
||||
{
|
||||
var frozen = brush.IsFrozen;
|
||||
if (frozen) brush = brush.Clone();
|
||||
shape.Stroke = brush;
|
||||
var colorAnim = new ColorAnimationUsingKeyFrames
|
||||
{
|
||||
Duration = new Duration(TimeSpan.FromSeconds(3)),
|
||||
RepeatBehavior = RepeatBehavior.Forever
|
||||
};
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.Zero)));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Yellow, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(0.5))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Green, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.DodgerBlue, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(1.5))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.MediumPurple, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2))));
|
||||
colorAnim.KeyFrames.Add(new LinearColorKeyFrame(Colors.Red, KeyTime.FromTimeSpan(TimeSpan.FromSeconds(2.5))));
|
||||
Storyboard.SetTargetProperty(colorAnim, new PropertyPath("(Shape.Stroke).(SolidColorBrush.Color)"));
|
||||
sb.Children.Add(colorAnim);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return sb.Children.Count > 0 ? sb : null;
|
||||
}
|
||||
|
||||
private static void AddDoubleAnimation(Storyboard sb, DependencyProperty prop,
|
||||
double from, double to, Duration duration, bool autoReverse)
|
||||
{
|
||||
var anim = new DoubleAnimation(from, to, duration)
|
||||
{
|
||||
AutoReverse = autoReverse,
|
||||
RepeatBehavior = RepeatBehavior.Forever
|
||||
};
|
||||
Storyboard.SetTargetProperty(anim, new PropertyPath(prop));
|
||||
sb.Children.Add(anim);
|
||||
}
|
||||
|
||||
private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element, Tag: string color })
|
||||
element.BorderColor = color;
|
||||
}
|
||||
|
||||
private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
element.IsVisible = false;
|
||||
}
|
||||
|
||||
private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
_viewModel.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
private void BackgroundMenu_ChangeCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = _viewModel.ChangeBackgroundCaptureAsync();
|
||||
}
|
||||
|
||||
private void BackgroundMenu_RefreshCapture(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.RefreshBackgroundAutoCapture();
|
||||
}
|
||||
|
||||
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is MenuItem { DataContext: SceneElement element })
|
||||
_viewModel.RemoveSourceCommand.Execute(element);
|
||||
}
|
||||
|
||||
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
|
||||
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
|
||||
|
||||
// ─── Social bar: click toggles top ⇄ bottom. KISS — the drag variant was
|
||||
// unusable for shaky hands (jitter around the direction deadzone), so the bar
|
||||
// rides on {Binding SocialBarTop} alone and a click flips it. ───
|
||||
private void SocialBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
_viewModel.ToggleSocialBarPosition();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private DispatcherTimer? _socialGlowTimer;
|
||||
private double _socialGlowAngle;
|
||||
|
||||
private void SocialsButton_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (SocialBarElement == null) return;
|
||||
|
||||
// Kick the social bar to full brightness so it's immediately visible
|
||||
var effect = SocialBarElement.Effect as DropShadowEffect;
|
||||
if (effect != null)
|
||||
{
|
||||
effect.BlurRadius = 40;
|
||||
effect.Opacity = 1.0;
|
||||
}
|
||||
|
||||
// Pulse the glow so it catches the eye
|
||||
_socialGlowAngle = 0;
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_socialGlowTimer.Tick += SocialGlowTick;
|
||||
_socialGlowTimer.Start();
|
||||
}
|
||||
|
||||
private void SocialsButton_MouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
_socialGlowTimer?.Stop();
|
||||
_socialGlowTimer = null;
|
||||
|
||||
if (SocialBarElement?.Effect is DropShadowEffect effect)
|
||||
{
|
||||
effect.BlurRadius = 18;
|
||||
effect.Opacity = 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
private void SocialGlowTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (SocialBarElement?.Effect is not DropShadowEffect effect) return;
|
||||
|
||||
_socialGlowAngle += 0.18;
|
||||
if (_socialGlowAngle > Math.PI * 2) _socialGlowAngle -= Math.PI * 2;
|
||||
|
||||
// Smooth sine pulse: BlurRadius 24→48, Opacity 0.7→1.0
|
||||
double t = (Math.Sin(_socialGlowAngle) + 1.0) / 2.0; // 0..1
|
||||
effect.BlurRadius = 24 + t * 24;
|
||||
effect.Opacity = 0.7 + t * 0.3;
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (!_viewModel.IsEditMode) return;
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = _viewModel.SelectedElement;
|
||||
|
||||
if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
|
||||
{
|
||||
_isResizing = true;
|
||||
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var hit = HitElement(p);
|
||||
if (hit != null)
|
||||
{
|
||||
_viewModel.SelectedElement = hit;
|
||||
_isDraggingOverlay = true;
|
||||
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
|
||||
grid.CaptureMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private void Preview_MouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (!_viewModel.IsEditMode) return;
|
||||
if (!_isDraggingOverlay && !_isResizing) return;
|
||||
|
||||
var grid = (Grid)sender;
|
||||
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
|
||||
var p = toCanvas.Transform(e.GetPosition(grid));
|
||||
var selected = _viewModel.SelectedElement;
|
||||
if (selected == null)
|
||||
{
|
||||
EndPreviewDrag(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDraggingOverlay)
|
||||
{
|
||||
var minX = -(selected.Width - 20);
|
||||
var maxX = 1920 - 20;
|
||||
var minY = -(selected.Height - 20);
|
||||
var maxY = 1080 - 20;
|
||||
selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX));
|
||||
selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY));
|
||||
}
|
||||
else if (_isResizing)
|
||||
{
|
||||
var isWebcam = selected is WebcamSceneConfig;
|
||||
var isRound = selected.ClipShape == ClipShape.Round;
|
||||
var proportional = isRound || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.StagedScene?.Name) : 1920;
|
||||
var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.StagedScene?.Name) : 1080;
|
||||
var newW = Math.Clamp(p.X - selected.X, 32, maxW);
|
||||
var newH = proportional ? newW / _resizeAspect : Math.Clamp(p.Y - selected.Y, 32, maxH);
|
||||
if (proportional && newH > maxH)
|
||||
{
|
||||
newH = maxH;
|
||||
newW = newH * _resizeAspect;
|
||||
}
|
||||
selected.Width = newW;
|
||||
selected.Height = newH;
|
||||
}
|
||||
}
|
||||
|
||||
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
=> EndPreviewDrag((Grid)sender);
|
||||
|
||||
private void EndPreviewDrag(Grid grid)
|
||||
{
|
||||
if (_isDraggingOverlay || _isResizing)
|
||||
grid.ReleaseMouseCapture();
|
||||
_isDraggingOverlay = false;
|
||||
_isResizing = false;
|
||||
}
|
||||
|
||||
private bool HitHandle(Point mouseScreen, SceneElement element)
|
||||
{
|
||||
var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
|
||||
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
|
||||
}
|
||||
|
||||
private SceneElement? HitElement(Point p)
|
||||
{
|
||||
var scene = _viewModel.StagedScene;
|
||||
if (scene == null) return null;
|
||||
for (var i = scene.Elements.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!IsDraggableElement(element)) continue;
|
||||
if (element is Source { IsEnabled: false }) continue;
|
||||
if (element is WebcamSceneConfig { IsVisible: false }) continue;
|
||||
if (p.X >= element.X && p.X <= element.X + element.Width &&
|
||||
p.Y >= element.Y && p.Y <= element.Y + element.Height)
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SourceNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.Enter or Key.Escape)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SourceNameBox_LostFocus(object sender, RoutedEventArgs e)
|
||||
{
|
||||
CommitSourceEdit(sender);
|
||||
}
|
||||
|
||||
private static void CommitSourceEdit(object sender)
|
||||
{
|
||||
if (sender is FrameworkElement { DataContext: SceneElement element })
|
||||
element.IsEditing = false;
|
||||
}
|
||||
|
||||
// ─── List drag-to-reorder (scenes list + sources list) ───
|
||||
private Point _dragStartPoint;
|
||||
private int _dragIndex = -1;
|
||||
private bool _isDragging;
|
||||
private object? _lastHoveredItem;
|
||||
|
||||
private void List_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var listBox = (ListBox)sender;
|
||||
var item = FindItemContainerAt(listBox, e.GetPosition(listBox));
|
||||
if (item == null)
|
||||
{
|
||||
if (ReferenceEquals(listBox, SourceList))
|
||||
_viewModel.SelectedElement = null;
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (listBox.ItemsSource is not IList items || items.Count == 0)
|
||||
{
|
||||
_dragIndex = -1;
|
||||
return;
|
||||
}
|
||||
_dragIndex = listBox.Items.IndexOf(item.DataContext);
|
||||
_dragStartPoint = e.GetPosition(listBox);
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (_dragIndex < 0) return;
|
||||
var listBox = (ListBox)sender;
|
||||
if (e.LeftButton != MouseButtonState.Pressed || listBox.ItemsSource is not IList items)
|
||||
{
|
||||
EndListDrag(listBox);
|
||||
return;
|
||||
}
|
||||
|
||||
var position = e.GetPosition(listBox);
|
||||
if (!_isDragging)
|
||||
{
|
||||
if (Math.Abs(position.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance &&
|
||||
Math.Abs(position.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
return;
|
||||
_isDragging = true;
|
||||
listBox.CaptureMouse();
|
||||
}
|
||||
|
||||
var item = FindItemContainerAt(listBox, position);
|
||||
if (item == null) return;
|
||||
|
||||
var targetItem = item.DataContext;
|
||||
if (ReferenceEquals(targetItem, items[_dragIndex]) || ReferenceEquals(targetItem, _lastHoveredItem))
|
||||
return;
|
||||
|
||||
var targetIndex = listBox.Items.IndexOf(targetItem);
|
||||
if (targetIndex < 0) return;
|
||||
|
||||
var dragged = items[_dragIndex];
|
||||
items.RemoveAt(_dragIndex);
|
||||
items.Insert(targetIndex, dragged);
|
||||
_dragIndex = targetIndex;
|
||||
_lastHoveredItem = targetItem;
|
||||
}
|
||||
|
||||
private void List_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
EndListDrag((ListBox)sender);
|
||||
}
|
||||
|
||||
private void EndListDrag(ListBox listBox)
|
||||
{
|
||||
if (_isDragging)
|
||||
listBox.ReleaseMouseCapture();
|
||||
_dragIndex = -1;
|
||||
_isDragging = false;
|
||||
_lastHoveredItem = null;
|
||||
}
|
||||
|
||||
private static ListBoxItem? FindItemContainerAt(ListBox listBox, Point position)
|
||||
{
|
||||
var hit = listBox.InputHitTest(position) as DependencyObject;
|
||||
while (hit != null && hit != listBox)
|
||||
{
|
||||
if (hit is ListBoxItem item) return item;
|
||||
hit = VisualTreeHelper.GetParent(hit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Models.SceneElement? _snapshotElement;
|
||||
private string _snapshotBorderColor = string.Empty;
|
||||
private int _snapshotBorderWidth;
|
||||
private int _snapshotTimerMinutes = 15;
|
||||
private string? _snapshotWebUri;
|
||||
|
||||
private void TakeSnapshot()
|
||||
{
|
||||
if (_viewModel.SelectedElement is Models.Source src)
|
||||
{
|
||||
_snapshotElement = src;
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
_snapshotWebUri = src.WebUri;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderColorPicker_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SelectedElement is not SceneElement src) return;
|
||||
if (sender is FrameworkElement fe && fe.ContextMenu == null)
|
||||
{
|
||||
var menu = new System.Windows.Controls.ContextMenu();
|
||||
string[] colors = ["#FFFFFF", "#000000", "#FF0000", "#00FF00", "#0000FF",
|
||||
"#FFFF00", "#FF00FF", "#00FFFF", "#FF8800", "#8800FF",
|
||||
"#e94560", "#22c55e", "#3b82f6", "#f59e0b"];
|
||||
foreach (var c in colors)
|
||||
{
|
||||
var item = new System.Windows.Controls.MenuItem { Header = c, ToolTip = c };
|
||||
var brush = new System.Windows.Media.SolidColorBrush(
|
||||
(System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(c));
|
||||
item.Icon = new System.Windows.Controls.Border
|
||||
{
|
||||
Width = 16, Height = 16,
|
||||
Background = brush,
|
||||
CornerRadius = new System.Windows.CornerRadius(2)
|
||||
};
|
||||
var color = c;
|
||||
item.Click += (_, _) => src.BorderColor = color;
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
fe.ContextMenu = menu;
|
||||
}
|
||||
if (sender is FrameworkElement fe2)
|
||||
fe2.ContextMenu.IsOpen = true;
|
||||
}
|
||||
|
||||
private void WebcamBorderRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
src.BorderColor = _snapshotBorderColor;
|
||||
src.BorderWidth = _snapshotBorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void WebcamBorderAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
{
|
||||
_snapshotBorderColor = src.BorderColor;
|
||||
_snapshotBorderWidth = src.BorderWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void CountdownRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.TimerMinutes = _snapshotTimerMinutes;
|
||||
}
|
||||
|
||||
private void CountdownAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotTimerMinutes = src.TimerMinutes;
|
||||
}
|
||||
|
||||
private void WebRevert_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
src.WebUri = string.Empty;
|
||||
_snapshotWebUri = string.Empty;
|
||||
}
|
||||
|
||||
private void WebAccept_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_snapshotElement is Models.Source src)
|
||||
_snapshotWebUri = src.WebUri;
|
||||
var selected = _viewModel.SelectedElement is { } s && Controls.PreviewPane.IsDraggableElement(s);
|
||||
_previewPane.UpdateSelectionOverlay(selected);
|
||||
_leftPanel.OnSelectionChanged(_viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>Per-webcam background effect (TASK 51). <see cref="Remove"/> makes everything
|
||||
/// outside the person transparent so the layers below show through; <see cref="Blur"/>
|
||||
/// blurs the background behind the person.</summary>
|
||||
public enum BackgroundEffect
|
||||
{
|
||||
None,
|
||||
Remove,
|
||||
Blur,
|
||||
}
|
||||
@@ -72,6 +72,14 @@ public class Scene : INotifyPropertyChanged
|
||||
|
||||
public bool IsChatScene { get; init; }
|
||||
|
||||
/// <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
|
||||
/// rendering the scene's original background, the strip shows this instead.
|
||||
/// Null = the strip falls back to the background art (creator ruling 2026-10-10).
|
||||
/// </summary>
|
||||
public string? ThumbnailAssetId { get; set; }
|
||||
|
||||
private BitmapSource? _snapshot;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -19,6 +19,17 @@ public enum BorderAnimation
|
||||
Sparkles
|
||||
}
|
||||
|
||||
/// <summary>Whether an element's pixel content is fixed (bake once into the
|
||||
/// compositor's cached base) or changes every frame (composite each tick).
|
||||
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
|
||||
public enum ElementKind
|
||||
{
|
||||
/// <summary>Pixels never change at runtime (images, background art).</summary>
|
||||
Static,
|
||||
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
|
||||
Dynamic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
|
||||
/// or a singleton resource's per-scene config (webcam). Carries the shared
|
||||
@@ -44,6 +55,15 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
private string _name = string.Empty;
|
||||
public string Name { get => _name; set => Set(ref _name, value); }
|
||||
|
||||
/// <summary>Whether this element's pixels are fixed (Static) or change every
|
||||
/// frame (Dynamic). The compositor bakes all Static layers below the first
|
||||
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
|
||||
public virtual ElementKind Kind => ElementKind.Static;
|
||||
|
||||
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
|
||||
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
|
||||
public virtual bool IsZoomLayer => false;
|
||||
|
||||
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
||||
public virtual bool IsWebcam => false;
|
||||
|
||||
@@ -61,6 +81,9 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
|
||||
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
|
||||
public virtual bool IsCountdown => false;
|
||||
|
||||
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
|
||||
public virtual bool IsAlertBox => false;
|
||||
public virtual bool IsBackground { get; set; }
|
||||
|
||||
private bool _useDefaultBackground = true;
|
||||
|
||||
+72
-3
@@ -13,7 +13,10 @@ public enum SourceType
|
||||
Countdown,
|
||||
WebSource,
|
||||
ChatBox,
|
||||
MediaSource
|
||||
MediaSource,
|
||||
/// <summary>The designated alert zone: the six monetization events play their
|
||||
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
|
||||
AlertBox
|
||||
}
|
||||
|
||||
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||||
@@ -37,6 +40,19 @@ public enum ClipShape
|
||||
Round
|
||||
}
|
||||
|
||||
/// <summary>How an alert box presents its announcement strip (TASK 47). The strip is
|
||||
/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").</summary>
|
||||
public enum AlertDisplayMethod
|
||||
{
|
||||
/// <summary>Marquee: the pill scrolls across the top edge, paced so the text is
|
||||
/// read <see cref="AlertOverlayLayer.TickerReadsPerAlert"/> times per alert.</summary>
|
||||
TickerScroll,
|
||||
/// <summary>Pulse: the static pill blinks on and off for the whole alert.</summary>
|
||||
Flash,
|
||||
/// <summary>Static: the pill sits centred and still for the whole alert.</summary>
|
||||
Solid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A multi-instance scene object: image/background/text (screen/window later).
|
||||
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||||
@@ -61,7 +77,12 @@ public class Source : SceneElement
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox;
|
||||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox or SourceType.AlertBox;
|
||||
|
||||
/// <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
|
||||
? ElementKind.Dynamic : ElementKind.Static;
|
||||
|
||||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||||
|
||||
@@ -113,7 +134,7 @@ public class Source : SceneElement
|
||||
|
||||
public override ImageSource? DisplaySource => IsLiveCapture
|
||||
? (VideoImageSource ?? _imageSource)
|
||||
: (Type is SourceType.ChatBox or SourceType.WebSource ? VideoImageSource : _imageSource);
|
||||
: (Type is SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox ? VideoImageSource : _imageSource);
|
||||
|
||||
public override bool IsImageSource => Type == SourceType.Image;
|
||||
|
||||
@@ -171,6 +192,54 @@ public class Source : SceneElement
|
||||
|
||||
public bool IsMediaSource => Type == SourceType.MediaSource;
|
||||
|
||||
// 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
|
||||
// order: replacement path exists → DB default → six celebration animations.
|
||||
private string? _alertVideoAssetId;
|
||||
public string? AlertVideoAssetId
|
||||
{
|
||||
get => _alertVideoAssetId;
|
||||
set => Set(ref _alertVideoAssetId, value);
|
||||
}
|
||||
|
||||
private string? _alertVideoPath;
|
||||
public string? AlertVideoPath
|
||||
{
|
||||
get => _alertVideoPath;
|
||||
set => Set(ref _alertVideoPath, value);
|
||||
}
|
||||
|
||||
private bool _alertUseDefaultVideo = true;
|
||||
public bool AlertUseDefaultVideo
|
||||
{
|
||||
get => _alertUseDefaultVideo;
|
||||
set => Set(ref _alertUseDefaultVideo, value);
|
||||
}
|
||||
|
||||
private bool _alertShowTicker = true;
|
||||
public bool AlertShowTicker
|
||||
{
|
||||
get => _alertShowTicker;
|
||||
set => Set(ref _alertShowTicker, value);
|
||||
}
|
||||
|
||||
private double _alertVideoVolume = 1.0;
|
||||
public double AlertVideoVolume
|
||||
{
|
||||
get => _alertVideoVolume;
|
||||
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
|
||||
}
|
||||
|
||||
private AlertDisplayMethod _alertDisplayMethod = AlertDisplayMethod.TickerScroll;
|
||||
public AlertDisplayMethod AlertDisplayMethod
|
||||
{
|
||||
get => _alertDisplayMethod;
|
||||
set => Set(ref _alertDisplayMethod, value);
|
||||
}
|
||||
|
||||
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
||||
|
||||
// Chat box config (TASK 3.18)
|
||||
private double _chatFontSize = 16;
|
||||
public double ChatFontSize
|
||||
|
||||
@@ -13,5 +13,48 @@ public class WebcamSceneConfig : SceneElement
|
||||
public string WebcamId { get; init; } = string.Empty;
|
||||
public override bool IsWebcam => true;
|
||||
public override bool IsDraggable => true;
|
||||
public override ElementKind Kind => ElementKind.Dynamic;
|
||||
public override ImageSource? DisplaySource => VideoImageSource;
|
||||
|
||||
private BackgroundEffect _backgroundEffect = BackgroundEffect.None;
|
||||
/// <summary>TASK 51: this scene's background effect for the webcam (persisted).</summary>
|
||||
public BackgroundEffect BackgroundEffect
|
||||
{
|
||||
get => _backgroundEffect;
|
||||
set
|
||||
{
|
||||
if (!Set(ref _backgroundEffect, value)) return;
|
||||
Raise(nameof(RemoveBackground));
|
||||
Raise(nameof(BlurBackground));
|
||||
Raise(nameof(HasBackgroundEffect));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while Remove or Blur is on — shows the strength slider.</summary>
|
||||
public bool HasBackgroundEffect => BackgroundEffect != BackgroundEffect.None;
|
||||
|
||||
private double _backgroundStrength = 0.5;
|
||||
/// <summary>TASK 51: 0..1 — Blur amount, or Remove's edge softness (persisted).</summary>
|
||||
public double BackgroundStrength
|
||||
{
|
||||
get => _backgroundStrength;
|
||||
set => Set(ref _backgroundStrength, Math.Clamp(value, 0, 1));
|
||||
}
|
||||
|
||||
/// <summary>Pill-switch surface: Remove and Blur behave like radio buttons — turning one
|
||||
/// on turns the other off; turning the active one off means <see cref="BackgroundEffect.None"/>.</summary>
|
||||
public bool RemoveBackground
|
||||
{
|
||||
get => BackgroundEffect == BackgroundEffect.Remove;
|
||||
set => BackgroundEffect = value ? BackgroundEffect.Remove
|
||||
: BackgroundEffect == BackgroundEffect.Remove ? BackgroundEffect.None : BackgroundEffect;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RemoveBackground"/>
|
||||
public bool BlurBackground
|
||||
{
|
||||
get => BackgroundEffect == BackgroundEffect.Blur;
|
||||
set => BackgroundEffect = value ? BackgroundEffect.Blur
|
||||
: BackgroundEffect == BackgroundEffect.Blur ? BackgroundEffect.None : BackgroundEffect;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One user-visible open window offered by the "Capture Window…"
|
||||
/// submenu. Carries its owning process name for disambiguation and the raw
|
||||
/// HWND that becomes the "window:<hwnd>" capture key.</summary>
|
||||
public sealed class WindowInfo
|
||||
{
|
||||
public WindowInfo(string title, string processName, long hwnd, int processId)
|
||||
{
|
||||
Title = title;
|
||||
ProcessName = processName;
|
||||
Hwnd = hwnd;
|
||||
ProcessId = processId;
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
public string ProcessName { get; }
|
||||
public long Hwnd { get; }
|
||||
public int ProcessId { get; }
|
||||
|
||||
/// <summary>Menu label: "Title — ProcessName" (e.g. "bash — WindowsTerminal").</summary>
|
||||
public string Label => $"{Title} — {ProcessName}";
|
||||
|
||||
/// <summary>The capture key fed to the screen-capture factory
|
||||
/// ("window:<hwnd>").</summary>
|
||||
public string CaptureKey => $"window:0x{Hwnd:X}";
|
||||
}
|
||||
@@ -18,6 +18,21 @@ public record ReusableStream(string Id, string IngestionAddress, string StreamNa
|
||||
public string RtmpUrl => $"{IngestionAddress}/{StreamName}";
|
||||
}
|
||||
|
||||
/// <summary>The six monetization events YouTube's live chat feed emits (the ONLY
|
||||
/// native event stream there is — free subscribers produce no event). Each kind
|
||||
/// drives its own celebration animation in the AlertBox. <c>None</c> = a normal
|
||||
/// chat row: it never enqueues an alert (subscriber mentions stay chat rows).</summary>
|
||||
public enum ChatEventKind
|
||||
{
|
||||
None,
|
||||
SuperChat,
|
||||
SuperSticker,
|
||||
NewMember,
|
||||
MemberGift,
|
||||
GiftReceived,
|
||||
MemberMilestone,
|
||||
}
|
||||
|
||||
public class ChatMessage
|
||||
{
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
@@ -29,6 +44,31 @@ public class ChatMessage
|
||||
public bool IsSuperChat { get; set; }
|
||||
public double SuperChatAmount { get; set; }
|
||||
public string? SuperChatCurrency { get; set; }
|
||||
|
||||
/// <summary>YouTube's own pre-rendered amount ("$12.34") — preferred over
|
||||
/// recomposing amount+currency ourselves, which mangles non-dollar locales.</summary>
|
||||
public string? SuperChatDisplayString { get; set; }
|
||||
|
||||
public bool IsMember { get; set; }
|
||||
public string? MembershipLevel { get; set; }
|
||||
|
||||
public ChatEventKind Kind { get; set; }
|
||||
|
||||
public string? SuperStickerDescription { get; set; }
|
||||
|
||||
/// <summary>membershipGiftingEvent: how many memberships were bought.</summary>
|
||||
public int GiftNewMembersCount { get; set; }
|
||||
|
||||
/// <summary>giftMembershipReceivedEvent: who gifted this one.</summary>
|
||||
public string? GifterName { get; set; }
|
||||
|
||||
/// <summary>memberMilestoneChatEvent: streak months (0 = YouTube didn't
|
||||
/// expose a count — the animation falls back to the message text).</summary>
|
||||
public int MilestoneMonths { get; set; }
|
||||
|
||||
/// <summary>TASK 41: the Test Stream dock injects synthetic member / SuperChat /
|
||||
/// subscriber events locally (YouTube's insert API only supports text), so the
|
||||
/// buffer can label them honestly — simulated events never reach YouTube.</summary>
|
||||
public bool IsSimulated { get; set; }
|
||||
public bool IsPinned { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>One captured snapshot of a connected channel's YPP-relevant numbers
|
||||
/// (TASK 39). Appended on every refresh and archived in SQLite so a later slice
|
||||
/// can compute velocity/(de)acceleration/ETA from the history.</summary>
|
||||
public sealed class YppStatSnapshot
|
||||
{
|
||||
public DateTime CapturedAtUtc { get; set; }
|
||||
public long SubscriberCount { get; set; }
|
||||
public long VideoCount { get; set; }
|
||||
public long TotalViews { get; set; }
|
||||
public int UploadsLast90Days { get; set; }
|
||||
public bool CommunityGuidelinesGoodStanding { get; set; }
|
||||
public bool CopyrightStrikesGoodStanding { get; set; }
|
||||
public bool ContentIdClaimsGoodStanding { get; set; }
|
||||
public bool OverallGoodStanding { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>YPP eligibility thresholds as versioned, date-aware DATA — never
|
||||
/// constants (ai.md "Journey tracker"). Tier 1 (fan funding) is time-invariant;
|
||||
/// Tier 2 (ad revenue) doubles its long-form-hours and Shorts-view bars for NEW
|
||||
/// applicants after 2027-02-01. Hardcoding the pre-2027 numbers would show the
|
||||
/// wrong ETA once the doubling lands, so callers resolve the active Tier 2
|
||||
/// threshold through <see cref="Tier2For"/>.</summary>
|
||||
public static class YppThresholds
|
||||
{
|
||||
public sealed record TierThreshold(string Name, long Subscribers, long WatchHours, long ShortsViews);
|
||||
|
||||
/// <summary>The date the Tier-2 ad-revenue bars double for new applicants.</summary>
|
||||
public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>The Tier-2 bar for a given point in time — 4,000h / 10M Shorts
|
||||
/// before the doubling date, 8,000h / 20M Shorts on or after it.</summary>
|
||||
public static TierThreshold Tier2For(DateTime when) => when >= Tier2DoublingDate
|
||||
? new TierThreshold("Ad revenue", 1_000, 8_000, 20_000_000)
|
||||
: new TierThreshold("Ad revenue", 1_000, 4_000, 10_000_000);
|
||||
|
||||
/// <summary>The currently-active Tier-2 threshold (resolved against today).</summary>
|
||||
public static TierThreshold Tier2Now => Tier2For(DateTime.UtcNow);
|
||||
|
||||
/// <summary>Valid public long-form uploads required in the trailing 90 days
|
||||
/// to keep each tier route eligible.</summary>
|
||||
public const int UploadsRequiredPer90Days = 3;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The transient zoom layer. It is a real scene element, but a special one: while
|
||||
/// active the SceneCompositor draws it LAST (layer n+1, above everything) as a live
|
||||
/// 2x view of a FIXED capture window (the box/2 region centred on where the creator
|
||||
/// clicked), sampled from the just-composited frame — so it captures ALL layers and
|
||||
/// keeps showing the same content no matter where the box is dragged (creator ruling
|
||||
/// 2026-10-08). Its X/Y/W/H are the movable BOX placement; CaptureX/CaptureY is the
|
||||
/// anchored capture window. While inactive it is an invisible placeholder that is
|
||||
/// never persisted (LayoutStore hand-rolls only Source/WebcamSceneConfig rows) — the
|
||||
/// VM re-inserts it on load and on scene creation, mirroring how backgrounds heal.
|
||||
/// </summary>
|
||||
public sealed class ZoomSceneElement : SceneElement
|
||||
{
|
||||
/// <summary>True only while a zoom box is live; flips Kind so the layer leaves
|
||||
/// the baked static base and composites per-frame.</summary>
|
||||
public bool IsZoomActive { get; set; }
|
||||
|
||||
/// <summary>Master-space top-left of the fixed capture window. Set once by
|
||||
/// <c>ActivateZoom</c>; moving the box must never touch it.</summary>
|
||||
public double CaptureX { get; set; }
|
||||
public double CaptureY { get; set; }
|
||||
|
||||
public override ElementKind Kind => IsZoomActive ? ElementKind.Dynamic : ElementKind.Static;
|
||||
|
||||
public override bool IsZoomLayer => true;
|
||||
|
||||
public override bool IsDraggable => false;
|
||||
|
||||
/// <summary>Never rendered by the element template or the resolver — the preview
|
||||
/// magnifier (ZoomBox) and the output compositor's zoom pass own its pixels.</summary>
|
||||
public override ImageSource? DisplaySource => null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
public enum ZoomShape
|
||||
{
|
||||
Circle,
|
||||
VerticalRectangle,
|
||||
HorizontalRectangle
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
public class ZoomState
|
||||
{
|
||||
public bool IsActive { get; set; }
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
/// <summary>Master-space top-left of the FIXED capture window (the box/2 region
|
||||
/// centred on the click). The box can be dragged anywhere; this never changes —
|
||||
/// the box always shows this same region magnified 2x (creator ruling 2026-10-08).</summary>
|
||||
public double CaptureX { get; set; }
|
||||
public double CaptureY { get; set; }
|
||||
|
||||
public bool IsMoving { get; set; }
|
||||
public double GrabOffsetX { get; set; }
|
||||
public double GrabOffsetY { get; set; }
|
||||
|
||||
public double Left => X;
|
||||
public double Top => Y;
|
||||
public double Right => X + Width;
|
||||
public double Bottom => Y + Height;
|
||||
}
|
||||
+6
-4
@@ -7,14 +7,16 @@ 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 — **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`/`ShapeButtonText`, 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) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
||||
| `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`/`ShapeButtonText`, 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) |
|
||||
| `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) |
|
||||
| `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`. |
|
||||
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||
| `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `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) |
|
||||
|
||||
Related: [`ViewModels/index.md`](../ViewModels/index.md) consume these;
|
||||
|
||||
+1541
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,134 @@
|
||||
# ytLlive
|
||||
# LlamaCasty
|
||||
|
||||
<p align="center">
|
||||
<img src="Assets/llamacasty-logo.jpg" alt="LlamaCasty — livestream smarter" height="120"/>
|
||||
</p>
|
||||
|
||||
A creator-proof live-streaming and recording app for YouTube — native Windows (C# / WPF / .NET 8).
|
||||
A free, open-source Windows app for streaming live to YouTube and recording to your PC.
|
||||
Native Windows (C# / WPF / .NET 8). Website: https://llamacasty.com
|
||||
|
||||
## Screenshot
|
||||

|
||||
|
||||

|
||||
## Why I built it
|
||||
|
||||
## Design Principle
|
||||
I'm Gramps. I've been gaming since 1981, and these days I stream *The Division 2* on YouTube.
|
||||
|
||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||
When I started streaming, I did what everybody does: I installed the big, general-purpose
|
||||
streaming tools. They're excellent software — powerful, flexible, and built to handle every
|
||||
platform, every camera rig, and every plugin anyone has ever dreamed up. That breadth is exactly
|
||||
what makes them great, and it's also what makes them a lot to learn. Before my first stream I was
|
||||
reading setup guides, picking encoders, balancing audio by hand, and wiring up scenes from
|
||||
scratch. The tools were ready for anything. I just wanted to go live.
|
||||
|
||||
Every feature is measured against this. One-click go-live, sane YouTube defaults, visual scene
|
||||
building, and no dead ends — every action has a visible outcome.
|
||||
Most streamers I talk to are in the same spot. They don't need a tool for every platform and every
|
||||
setup — they need one that's ready for *theirs*. So I built LlamaCasty around three choices:
|
||||
|
||||
## Why ytLlive
|
||||
- **One platform, done properly.** LlamaCasty streams to YouTube and only YouTube. That focus lets
|
||||
it be built around how YouTube Live actually works — your channel, your chat, Super Chats and
|
||||
memberships, Shorts-style vertical video, and the YouTube Partner Program — instead of treating
|
||||
YouTube as one destination among many.
|
||||
- **Ready the moment you open it.** Five scenes (Starting, Live, BRB, Chat, Ending) are set up,
|
||||
your game and voice are balanced with auto-ducking and voice polish built in, and YouTube's
|
||||
settings are filled in. You arrange things by dragging them, then press Go Live.
|
||||
- **Calm while you're live.** A private Test Stream to practice, a layout that locks while you're
|
||||
on air, and warnings only when YouTube reports a real problem.
|
||||
|
||||
OBS treats YouTube as an afterthought; SLOBS is Twitch-first with YouTube bolted on. ytLlive is
|
||||
designed and dedicated to YouTube livestreaming and recording, and to YouTube's specific quirks.
|
||||
The general-purpose tools are the right choice when you need everything. LlamaCasty is for when you
|
||||
just want to get your show on air.
|
||||
|
||||
## Account Assumption
|
||||
## Free, and staying that way
|
||||
|
||||
ytLlive assumes you already have a YouTube creator account — connecting uses "Sign in with Google"
|
||||
to link that existing account. **ytLlive does not create accounts.** If you don't have a YouTube
|
||||
channel yet, set one up on YouTube first, then connect it here.
|
||||
LlamaCasty is free forever: no watermark, no branding on your stream, no subscription, no
|
||||
LlamaCasty account. It's also open source (see [License](#license)) — enjoy it, explore it,
|
||||
experiment with it. If it earns a spot in your setup and you'd like to say thanks, there's a
|
||||
coffee jar and a merch store linked from https://llamacasty.com. Completely optional, never
|
||||
expected, and the app will never ask.
|
||||
|
||||
## Run
|
||||
## How it's built
|
||||
|
||||
```bash
|
||||
dotnet build # Windows only — WPF
|
||||
dotnet run
|
||||
```
|
||||
LlamaCasty is built through AI-assisted pair programming, under the direction of a developer with
|
||||
decades of experience. Architecture, product direction, and quality gates are human-owned; AI
|
||||
speeds up the implementation. The repo's memory map (below) is how that collaboration stays honest
|
||||
from one session to the next.
|
||||
|
||||
## Version Roadmap
|
||||
## Requirements
|
||||
|
||||
| Version | Scope |
|
||||
|---------|-------|
|
||||
| v0.1 | Scene/source management, YouTube RTMP ingest, YouTube OAuth2, live chat, stream health |
|
||||
| v0.2 | Recording to local file |
|
||||
| v0.3 | Stream scheduling |
|
||||
| v0.4 | Multi-destination restreaming |
|
||||
| 1.0 | General availability |
|
||||
- **64-bit Windows 10 or 11.** (The bundled FFmpeg and ONNX Runtime are 64-bit only.)
|
||||
- **A YouTube channel, to stream.** LlamaCasty links an existing channel with "Sign in with
|
||||
Google" — it doesn't create accounts. Recording to an .mp4 works without signing in.
|
||||
- **Working sound in Windows.** Desktop and game audio come from your default output
|
||||
automatically; you choose your mic in the app. LlamaCasty doesn't diagnose or repair Windows
|
||||
audio — if it doesn't sound right in Windows, fix it there first.
|
||||
|
||||
## Build from source
|
||||
|
||||
The internal project, assembly, and namespace names are still `ytLive` (the app was renamed to
|
||||
LlamaCasty; the internals weren't). Building needs Windows and the .NET 8 SDK.
|
||||
|
||||
1. **Fetch the bundled FFmpeg** (not in git — ~135 MB, pinned and SHA-256-verified):
|
||||
`scripts/fetch-ffmpeg.sh`
|
||||
2. **Add your own Google OAuth client.** `Helpers/OAuthCredentials.cs` is gitignored. Create a
|
||||
"Desktop app" OAuth client with the YouTube Data API v3 enabled in Google Cloud Console, then:
|
||||
|
||||
```csharp
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public static class OAuthCredentials
|
||||
{
|
||||
public const string ClientId = "your-client-id.apps.googleusercontent.com";
|
||||
public const string ClientSecret = "your-client-secret";
|
||||
}
|
||||
```
|
||||
3. **Build and run:**
|
||||
|
||||
```bash
|
||||
dotnet build ytLive.csproj
|
||||
dotnet run --project ytLive.csproj
|
||||
```
|
||||
|
||||
## Version roadmap
|
||||
|
||||
**v1 is the finished product** (no v1.x releases are planned). Everything still in the queue lands
|
||||
in v1; anything not on the feature list is on the **"Out of product — permanently"** list in
|
||||
`TASKS.md` — read that before asking for a feature.
|
||||
|
||||
What v1 ships: the five-scene control surface, webcam + screen capture, media + web + text
|
||||
sources, chat on stream, the social bar, mic/desktop audio with auto-ducking and TRAX background
|
||||
music, local recording, quality tiers including vertical 9:16, one-click go-live + scheduling,
|
||||
stream resilience, monetization awareness (YPP tracker), animated alerts, and webcam background
|
||||
removal and blur — all free and ungated.
|
||||
|
||||
## Structure
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Models/` | Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTube channel/chat, Socials (social bar) |
|
||||
| `ViewModels/` | MainViewModel — scenes, stream controls, chat; GoLiveViewModel, ReuseImageViewModel, SocialsDialogViewModel |
|
||||
| `Services/` | YouTubeAuthService (OAuth2), YouTubeStreamService (broadcast/health), YouTubeChatService (chat polling), LayoutStore (SQLite), SocialValidator (social link/fediverse validation + nodeinfo icon resolution) |
|
||||
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, FocusPreservingListBox, OAuthCredentials |
|
||||
| `Models/` | Scene, Source, WebcamSceneConfig, SceneElement, SceneCatalog, QualityOption, StreamConfig/Health, chat, Socials, BroadcastMetadata, Music, MicStatus, BackgroundEffect |
|
||||
| `ViewModels/` | MainViewModel (partial classes per area) + dialogs: GoLive, CameraPicker, MicPicker, HotkeyConfig, Socials, LiveBroadcastForm |
|
||||
| `Services/` | YouTube (OAuth, streams/broadcasts, chat polling), LayoutStore (SQLite), capture (camera/screen-capture/media-file via FFmpeg), SceneCompositor + FramePump, FFmpeg encoder + RTMP, audio pipeline (WASAPI loopback + mic, mixer, filters, ducker, limiter, TRAX), background effects (ONNX person segmentation), SocialValidator, notifications, hotkeys, SceneGraph, ChatOverlayLayer |
|
||||
| `Helpers/` | ViewModelBase, RelayCommand, ImageCache, AppLog, TokenStore (DPAPI), OAuthCredentials (gitignored), converters |
|
||||
| `Themes/` | `Controls.xaml` — the dark-theme control styles, merged once in `App.xaml` |
|
||||
| `MainWindow.xaml` | Dark-theme main UI: scene/source panel, preview, chat, status bar |
|
||||
| `SocialsDialog.xaml` | Social Media Site Promotion dialog — 6-slot social bar editor with sign-in gate and validation |
|
||||
| `MainWindow.xaml` | Dark-theme control surface: thumbnails + central preview, two-state left panel (layers/properties ↔ live chat), top bar, footer |
|
||||
| `tools/` | Offline helper tools (e.g. `selfie-seg/` — converts and verifies the background-effects model) |
|
||||
|
||||
## Docs (memory map)
|
||||
|
||||
`schema.md` defines the conventions; `ai.md` is the AI/session guide;
|
||||
`TASKS.md` is the task queue. Each code directory carries an `index.md`.
|
||||
`schema.md` defines the conventions; `ai.md` is the AI/session guide; `TASKS.md` is the task
|
||||
queue; `HANDOFF.md` is the current state. Each code directory carries an `index.md`.
|
||||
|
||||
## License
|
||||
|
||||
LlamaCasty is free software, released under the **GNU General Public License v3.0 or later**
|
||||
([`LICENSE`](LICENSE)) with three additional terms permitted by GPL-3.0 section 7
|
||||
([`ADDITIONAL-TERMS.md`](ADDITIONAL-TERMS.md)):
|
||||
|
||||
- **Keep the credit** — any copy, modified or not, shows "Based on LlamaCasty by Gramps —
|
||||
https://llamacasty.com" in its About screen.
|
||||
- **Rename your fork** — modified versions are marked as modified and use a different name.
|
||||
- **No trademark rights** — the LlamaCasty name and llama logo aren't licensed. The icon and logo
|
||||
files are all rights reserved and aren't covered by the GPL.
|
||||
|
||||
The scene backgrounds and default alert video are AI-generated placeholders, licensed with the
|
||||
code — replace them with your own. Bundled third-party components keep their own licenses: see
|
||||
[`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt).
|
||||
|
||||
Source code: https://llgit.llamachile.tube/gramps/LlamaCasty
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<Window x:Class="ytLive.RenameRecordingDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Rename Recording" Height="230" Width="460"
|
||||
Icon="/Assets/llamacasty-icon.png"
|
||||
Title="Rename Recording" Height="304" Width="460"
|
||||
Icon="/ytLive;component/Assets/llamacasty-icon.png"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||
ShowInTaskbar="False" Background="#1a1a2e">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
Margin="0,20,0,0">
|
||||
<Button Content="Cancel" Click="Cancel_Click"
|
||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||
<Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90"/>
|
||||
<Button Content="Save" Click="Save_Click" Style="{StaticResource YtButton}" MinWidth="90" x:Name="SaveRecordingButton"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -6,12 +6,16 @@ namespace ytLive;
|
||||
|
||||
/// <summary>Modal that lets the creator rename an auto-named recording before it is
|
||||
/// finalized. Returns the chosen file stem (no extension) via <see cref="ResultFileName"/>,
|
||||
/// or <c>null</c> on Cancel / keep-auto-name.</summary>
|
||||
/// or <c>null</c> when the creator cleared the box and hit Save (keep the auto name).
|
||||
/// <para><c>DialogResult == false</c> means DISCARD — the caller deletes the footage
|
||||
/// rather than saving it (CREATOR RULING 2026-09-26). Enter on the pre-filled default
|
||||
/// is a Save.</para></summary>
|
||||
public partial class RenameRecordingDialog : Window
|
||||
{
|
||||
private readonly string _autoStem;
|
||||
|
||||
/// <summary>Stem (no extension) the creator chose, or <c>null</c> to keep auto-name.</summary>
|
||||
/// <summary>Stem (no extension) the creator chose, or <c>null</c> when the box was
|
||||
/// cleared on an explicit Save. Never set on Cancel.</summary>
|
||||
public string? ResultFileName { get; private set; }
|
||||
|
||||
public RenameRecordingDialog(string autoStem)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 47: one alert clip's playback — video frames + on-stream audio from the
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IAlertClipDecoder : IDisposable
|
||||
{
|
||||
/// <summary>Starts the decode loop (video + audio); raises the events off the
|
||||
/// calling thread. Paced to real time so playback matches wall clock.</summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>Stops and kills any running decode subprocesses.</summary>
|
||||
void Stop();
|
||||
|
||||
/// <summary>One decoded video frame (box-sized, straight-alpha BGRA).</summary>
|
||||
event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
/// <summary>One chunk of on-stream audio (48 kHz stereo float).</summary>
|
||||
event Action<AudioSample>? AudioReady;
|
||||
|
||||
/// <summary>Raised once when both streams have fully played out.</summary>
|
||||
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>
|
||||
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;
|
||||
|
||||
public AlertClipDecoder(
|
||||
string path,
|
||||
int width,
|
||||
int height,
|
||||
IFfmpegLocator locator,
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||
{
|
||||
_path = path;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
_probe = frameRateProbe;
|
||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_run != null) return;
|
||||
_run = Task.Run(RunAsync, CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Stop() => _cts.Cancel();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.Audio;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The native alert box (OBS-style celebration zone, no third party). Owns the
|
||||
/// event queue and the animation clock: <see cref="ChatEventKind"/> events from
|
||||
/// the chat feed are enqueued one-at-a-time and played through a celebration
|
||||
/// animation (<see cref="AlertRenderer"/>) OR — when the alert-box video is
|
||||
/// configured and resolves — through a decoded clip. Frames are served to the
|
||||
/// stream compositor via <see cref="RenderFrame"/> and mirrored to the preview
|
||||
/// via <see cref="UpdatePreview"/>. Idle = transparent (null frame); playback is
|
||||
/// advanced by a 33ms dispatch ticker. The clock is exposed as
|
||||
/// <see cref="Advance"/> so tests drive playback deterministically without the
|
||||
/// timer. Ordinary chat rows (<see cref="ChatEventKind.None"/>) never enqueue —
|
||||
/// a subscriber mention is a chat row, not an alert.
|
||||
///
|
||||
/// TASK 47 (Stream Alerts video): a video-backed alert replaces the animation
|
||||
/// while a clip decodes (<see cref="IAlertClipDecoder"/>, one per play). The
|
||||
/// clip plays at its own media pace; the layer applies a ~300ms fade-in/out
|
||||
/// alpha envelope to the frames AND the audio samples (freeze-frame fade-out on
|
||||
/// the tail), clips the alert to the clip length, and forwards the scaled audio
|
||||
/// to the mixer's alert ring via <paramref name="audioSink"/> (no duck). The
|
||||
/// per-source config (path/default/ticker/volume) is resolved through
|
||||
/// <paramref name="clipPathResolver"/>; the ticker strip is served via
|
||||
/// <see cref="AlertTickerFrame"/>.
|
||||
/// </summary>
|
||||
public sealed class AlertOverlayLayer : IDisposable
|
||||
{
|
||||
/// <summary>The fade-in/out ramp length (video alpha AND audio gain). Measured
|
||||
/// against a real clip in the AlertLayerVideoTests unit; keep the ramp short
|
||||
/// enough to read as a "pop-in" on stream (see ai.md).</summary>
|
||||
internal const double FadeDurationSeconds = 0.30;
|
||||
|
||||
/// <summary>How long an alert waits for the first decoded frame or audio slab
|
||||
/// before treating the clip as unplayable and falling back to the animations
|
||||
/// (a silent decoder must never blank the alert box).</summary>
|
||||
internal const double NoFrameFallbackSeconds = 1.0;
|
||||
|
||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33);
|
||||
|
||||
private readonly AlertRenderer _renderer;
|
||||
private readonly Func<string, int, int, IAlertClipDecoder>? _clipDecoderFactory;
|
||||
private readonly Func<Source, string?>? _clipPathResolver;
|
||||
private readonly Action<AudioSample>? _audioSink;
|
||||
private readonly Action<VideoFrame?>? _tickerPreviewSink;
|
||||
private readonly Queue<ChatMessage> _queue = new();
|
||||
private readonly DispatcherTimer? _ticker;
|
||||
|
||||
private ChatMessage? _current;
|
||||
private Source? _currentBox;
|
||||
private IAlertClipDecoder? _clip;
|
||||
private VideoFrame? _latestClipFrame;
|
||||
private bool _clipEof;
|
||||
private double _clipEofElapsed;
|
||||
private int _clipWidth;
|
||||
private int _clipHeight;
|
||||
private bool _clipSawFrame;
|
||||
private bool _clipAudioHeard;
|
||||
private string? _tickerText;
|
||||
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
|
||||
private double _elapsed;
|
||||
private IEnumerable<Scene>? _scenes;
|
||||
|
||||
private int _contentVersion;
|
||||
private int _frameVersion = -1;
|
||||
private string? _frameKey;
|
||||
private VideoFrame? _cachedFrame;
|
||||
private DateTime _lastPreviewLog = DateTime.MinValue;
|
||||
|
||||
public AlertOverlayLayer(
|
||||
AlertRenderer renderer,
|
||||
Func<string, int, int, IAlertClipDecoder>? clipDecoderFactory = null,
|
||||
Func<Source, string?>? clipPathResolver = null,
|
||||
Action<AudioSample>? audioSink = null,
|
||||
Action<VideoFrame?>? tickerPreviewSink = null)
|
||||
{
|
||||
_renderer = renderer;
|
||||
_clipDecoderFactory = clipDecoderFactory;
|
||||
_clipPathResolver = clipPathResolver;
|
||||
_audioSink = audioSink;
|
||||
_tickerPreviewSink = tickerPreviewSink;
|
||||
if (Application.Current != null)
|
||||
{
|
||||
_ticker = new DispatcherTimer { Interval = TickInterval };
|
||||
_ticker.Tick += (_, _) =>
|
||||
{
|
||||
Advance(TickInterval.TotalSeconds);
|
||||
RefreshAlertPreviews();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Production entry from the chat feed: enqueue one event. Messages
|
||||
/// arrive on the chat poller thread, so this marshals to the UI thread (same
|
||||
/// rule as <see cref="ChatOverlayLayer.OnMessageReceived"/>) — the preview
|
||||
/// writes create WPF <see cref="WriteableBitmap"/> instances, and creating a
|
||||
/// DependencySource off the UI thread crashes the WPF binding engine
|
||||
/// (ArgumentException "Must create DependencySource on same Thread").</summary>
|
||||
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
|
||||
{
|
||||
var app = Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
app.Dispatcher.Invoke(() => Ingest(message, scenes));
|
||||
return;
|
||||
}
|
||||
Ingest(message, scenes);
|
||||
}
|
||||
|
||||
private void Ingest(ChatMessage message, IEnumerable<Scene> scenes)
|
||||
{
|
||||
_scenes = scenes;
|
||||
Enqueue(message);
|
||||
_ticker?.Start();
|
||||
RefreshAlertPreviews();
|
||||
}
|
||||
|
||||
/// <summary>Queue an event (pure — also the test seam). Chat rows carry
|
||||
/// <see cref="ChatEventKind.None"/> and are ignored: no alert for a mention.</summary>
|
||||
public void Enqueue(ChatMessage message)
|
||||
{
|
||||
if (message.Kind == ChatEventKind.None) return;
|
||||
if (_queue.Count >= 10) return; // back-to-back floods drop the tail, never stall playback
|
||||
_queue.Enqueue(message);
|
||||
if (_current == null)
|
||||
{
|
||||
_current = _queue.Dequeue();
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
}
|
||||
_contentVersion++;
|
||||
}
|
||||
|
||||
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
|
||||
/// tests call this directly and never start the timer). Draining the queue to
|
||||
/// nothing stops the ticker and leaves the box transparent.</summary>
|
||||
public void Advance(double deltaSeconds)
|
||||
{
|
||||
// Video-backed alert: duration is clip-driven. The clip plays at media
|
||||
// pace; once it EOFs the last frame sweeps through the fade-out before
|
||||
// the alert moves on (a hard cut at EOF would pop, not fade).
|
||||
if (_clip != null)
|
||||
{
|
||||
_elapsed += deltaSeconds;
|
||||
// A decoder that starts but produces not a single frame (or slab of
|
||||
// audio) is dead weight — the box would sit transparent for the whole
|
||||
// alert. Give it a short grace, then fall back to the animations so an
|
||||
// alert ALWAYS renders (the live decode failure at 2026-09-26 11:44 was
|
||||
// this exact void, invisible in the log).
|
||||
if (!_clipSawFrame && !_clipAudioHeard && _elapsed >= NoFrameFallbackSeconds)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine("Alert clip yielded no frames in 1s — falling back to the animation");
|
||||
StopClip();
|
||||
_elapsed = 0;
|
||||
_contentVersion++;
|
||||
return;
|
||||
}
|
||||
if (_clipEof && _elapsed - _clipEofElapsed >= FadeDurationSeconds)
|
||||
{
|
||||
_clipEof = false;
|
||||
StopClip();
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
}
|
||||
_contentVersion++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_current == null)
|
||||
{
|
||||
if (_queue.Count == 0) return;
|
||||
_current = _queue.Dequeue();
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
_contentVersion++;
|
||||
return;
|
||||
}
|
||||
|
||||
_elapsed += deltaSeconds;
|
||||
if (_elapsed >= DurationOf(_current))
|
||||
{
|
||||
_current = null;
|
||||
_elapsed = 0;
|
||||
AdvanceToNext();
|
||||
}
|
||||
_contentVersion++;
|
||||
}
|
||||
|
||||
/// <summary>Whether an alert is currently playing or queued.</summary>
|
||||
public bool IsPlaying => _current != null || _queue.Count > 0;
|
||||
|
||||
/// <summary>The live-output frame for one AlertBox element: the decoded clip
|
||||
/// frame (faded) while a clip is active, else the animation frame, null
|
||||
/// (transparent) when idle.</summary>
|
||||
public VideoFrame? RenderFrame(Source alertBox)
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_frameKey = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_clip != null)
|
||||
return RenderClipFrame(alertBox);
|
||||
|
||||
var width = (int)Math.Max(1, alertBox.Width);
|
||||
var height = (int)Math.Max(1, alertBox.Height);
|
||||
var key = $"{width}x{height}|{_current.Id}|{Progress()}";
|
||||
|
||||
if (_frameKey == key && _frameVersion == _contentVersion)
|
||||
return _cachedFrame;
|
||||
|
||||
var app = Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
var capturedKey = key;
|
||||
var capturedVersion = _contentVersion;
|
||||
return app.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
|
||||
return _cachedFrame;
|
||||
return RenderCore(alertBox, width, height, capturedKey, capturedVersion);
|
||||
});
|
||||
}
|
||||
|
||||
return RenderCore(alertBox, width, height, key, _contentVersion);
|
||||
}
|
||||
|
||||
/// <summary>The ticker strip as the alert box shows it: a marquee, a pulse or a
|
||||
/// static pill naming the event while an alert plays, else null. Pure byte-math
|
||||
/// per call so the frame pump can read it on any thread (the text strip is
|
||||
/// rasterized once on the UI thread and cached).
|
||||
/// <para><b>Creator ruling 2026-09-26:</b> the strip is composited INSIDE the
|
||||
/// Stream Alerts box, not as a full-width bar at the top edge of the frame — "the
|
||||
/// ticker should appear over the stream alerts video, not over the entire preview
|
||||
/// window". So the frame is rendered at the box's own size and carries the box's
|
||||
/// origin on <see cref="VideoFrame.Placement"/>; every blit site reads
|
||||
/// <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;
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many times the marquee must cross the frame per alert, so the
|
||||
/// announcement is actually readable inside a 10s clip (2026-09-26: the old
|
||||
/// fixed 140px/s took ~17s per pass, so a 10s alert showed it barely once).</summary>
|
||||
internal const int TickerReadsPerAlert = 3;
|
||||
|
||||
/// <summary>Assumed alert length for the marquee rhythm. The clip's real length
|
||||
/// isn't known until it EOFs, and the shipped default clip is 10s — the number
|
||||
/// the creator's "three reads" target is stated against.</summary>
|
||||
internal const double AssumedAlertSeconds = 10.0;
|
||||
|
||||
private double AlertSeconds() => _clip != null ? AssumedAlertSeconds : DurationOf(_current!);
|
||||
|
||||
|
||||
private VideoFrame? RenderClipFrame(Source alertBox)
|
||||
{
|
||||
var frame = _latestClipFrame;
|
||||
if (frame == null) return null;
|
||||
|
||||
var w = (int)Math.Max(1, alertBox.Width);
|
||||
var h = (int)Math.Max(1, alertBox.Height);
|
||||
if (frame.Width != w || frame.Height != h)
|
||||
frame = StretchMath.BilinearScale(frame, w, h);
|
||||
|
||||
var fade = FadeAt();
|
||||
if (fade >= 1.0) return frame;
|
||||
|
||||
// Straight-alpha source-over on the compositor side: scale the source
|
||||
// alpha by the envelope so the clip blends out (a 680x200 copy per fade
|
||||
// tick is trivial; outside the ramps fade==1 returns the raw frame).
|
||||
var copy = new byte[frame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
|
||||
var a = (byte)(fade * 255 + 0.5);
|
||||
for (var i = 3; i < copy.Length; i += 4)
|
||||
copy[i] = (byte)((copy[i] * a) >> 8);
|
||||
return new VideoFrame(frame.Width, frame.Height, copy);
|
||||
}
|
||||
|
||||
private double FadeAt()
|
||||
{
|
||||
if (_clip == null) return 1.0;
|
||||
if (_clipEof)
|
||||
return Math.Clamp(1.0 - (_elapsed - _clipEofElapsed) / FadeDurationSeconds, 0.0, 1.0);
|
||||
return Math.Clamp(_elapsed / FadeDurationSeconds, 0.0, 1.0);
|
||||
}
|
||||
|
||||
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 || _clipDecoderFactory == null) return;
|
||||
var path = _clipPathResolver?.Invoke(box);
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
|
||||
_clipWidth = (int)Math.Max(1, box.Width);
|
||||
_clipHeight = (int)Math.Max(1, box.Height);
|
||||
try
|
||||
{
|
||||
_clipSawFrame = false;
|
||||
_clipAudioHeard = false;
|
||||
_clip = _clipDecoderFactory(path, _clipWidth, _clipHeight);
|
||||
_clip.FrameAvailable += OnClipFrame;
|
||||
_clip.AudioReady += OnClipAudio;
|
||||
_clip.Completed += OnClipCompleted;
|
||||
_clip.Start();
|
||||
AppLog.Write($"Alert clip start: box {box.Id} path '{path}' {_clipWidth}x{_clipHeight}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert clip setup failed");
|
||||
StopClip(); // decode setup failed (bad path) — fall back to animations
|
||||
}
|
||||
}
|
||||
|
||||
private void StopClip()
|
||||
{
|
||||
var clip = _clip;
|
||||
_clip = null;
|
||||
_latestClipFrame = null;
|
||||
_clipEof = false;
|
||||
_clipSawFrame = false;
|
||||
_clipAudioHeard = false;
|
||||
if (clip == null) return;
|
||||
clip.FrameAvailable -= OnClipFrame;
|
||||
clip.AudioReady -= OnClipAudio;
|
||||
clip.Completed -= OnClipCompleted;
|
||||
try { clip.Stop(); } catch { /* already stopped */ }
|
||||
clip.Dispose();
|
||||
}
|
||||
|
||||
private void OnClipFrame(VideoFrame frame)
|
||||
{
|
||||
_clipSawFrame = true;
|
||||
_latestClipFrame = frame;
|
||||
}
|
||||
|
||||
private void OnClipCompleted()
|
||||
{
|
||||
_clipEof = true;
|
||||
_clipEofElapsed = _elapsed;
|
||||
}
|
||||
|
||||
private void OnClipAudio(AudioSample sample)
|
||||
{
|
||||
if (_audioSink == null) return;
|
||||
_clipAudioHeard = true;
|
||||
var volume = (float)Math.Clamp(_currentBox?.AlertVideoVolume ?? 1.0, 0.0, 1.0);
|
||||
if (volume <= 0f) return;
|
||||
var fade = FadeAt();
|
||||
if (fade <= 0.0) return;
|
||||
|
||||
var data = sample.Samples;
|
||||
if (fade < 1.0 || Math.Abs(volume - 1f) > 0.001f)
|
||||
{
|
||||
data = new float[data.Length];
|
||||
var gain = volume * (float)fade;
|
||||
for (var i = 0; i < sample.Samples.Length; i++)
|
||||
data[i] = sample.Samples[i] * gain;
|
||||
}
|
||||
_audioSink(new AudioSample(data, sample.SampleRate, sample.Channels));
|
||||
}
|
||||
|
||||
private void AdvanceToNext()
|
||||
{
|
||||
if (_queue.Count > 0)
|
||||
{
|
||||
_current = _queue.Dequeue();
|
||||
_elapsed = 0;
|
||||
BeginClip();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ticker?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private VideoFrame? RenderCore(Source alertBox, int width, int height, string key, int version)
|
||||
{
|
||||
_frameKey = key;
|
||||
_frameVersion = version;
|
||||
if (_current == null) return _cachedFrame = null;
|
||||
return _cachedFrame = _renderer.Render(_current, Progress(), width, height);
|
||||
}
|
||||
|
||||
private double Progress()
|
||||
{
|
||||
if (_current == null) return 0;
|
||||
return Math.Clamp(_elapsed / DurationOf(_current), 0.0, 1.0);
|
||||
}
|
||||
|
||||
private static double DurationOf(ChatMessage m) => m.Kind switch
|
||||
{
|
||||
ChatEventKind.SuperChat => 4.6,
|
||||
ChatEventKind.SuperSticker => 4.2,
|
||||
ChatEventKind.NewMember => 4.0,
|
||||
ChatEventKind.MemberGift => 5.0,
|
||||
ChatEventKind.GiftReceived => 4.4,
|
||||
ChatEventKind.MemberMilestone => 4.8,
|
||||
_ => 4.0,
|
||||
};
|
||||
|
||||
private static string ComposeTicker(ChatMessage m)
|
||||
{
|
||||
var label = m.Kind switch
|
||||
{
|
||||
ChatEventKind.SuperChat => "Super Chat",
|
||||
ChatEventKind.SuperSticker => "Super Sticker",
|
||||
ChatEventKind.NewMember => "New Member",
|
||||
ChatEventKind.MemberGift => "Membership Gift",
|
||||
ChatEventKind.GiftReceived => "Gifted Membership",
|
||||
ChatEventKind.MemberMilestone => "Member Milestone",
|
||||
_ => "Stream Alert",
|
||||
};
|
||||
var amount = m.Kind is ChatEventKind.SuperChat or ChatEventKind.SuperSticker
|
||||
&& m.SuperChatDisplayString is { Length: > 0 } s
|
||||
? $" · {s}"
|
||||
: "";
|
||||
return $"{m.AuthorName} — {label}{amount}";
|
||||
}
|
||||
|
||||
/// <summary>Render the current state onto one AlertBox preview (mirrors chat):
|
||||
/// the playing frame, or null when idle so the source shows nothing until an
|
||||
/// event actually fires.</summary>
|
||||
public void UpdatePreview(Source alertBox)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frame = RenderFrame(alertBox);
|
||||
// Preview eyes (TASK 47, 2026-09-26): a healthy decode painted nothing on
|
||||
// the canvas. UpdatePreview swallows its failures to Debug, so say once a
|
||||
// second whether the box is being handed a frame at all.
|
||||
if (IsPlaying)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastPreviewLog >= TimeSpan.FromSeconds(1))
|
||||
{
|
||||
_lastPreviewLog = now;
|
||||
AppLog.Write($"Alert preview: box {alertBox.Id} frame="
|
||||
+ (frame == null ? "null" : $"{frame.Width}x{frame.Height} a{frame.BgraPixels[3]}"));
|
||||
}
|
||||
}
|
||||
if (frame == null)
|
||||
{
|
||||
alertBox.VideoImageSource = null;
|
||||
return;
|
||||
}
|
||||
var width = (int)Math.Max(1, alertBox.Width);
|
||||
var height = (int)Math.Max(1, alertBox.Height);
|
||||
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
||||
alertBox.VideoImageSource = bitmap;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Alert preview failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wire property-change subscriptions for all AlertBox sources across
|
||||
/// every scene (geometry changes re-render), and clear/reset their previews.</summary>
|
||||
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
|
||||
{
|
||||
_scenes = scenes;
|
||||
foreach (var scene in scenes)
|
||||
foreach (var alertBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.AlertBox))
|
||||
{
|
||||
alertBox.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(Source.Width) or nameof(Source.Height))
|
||||
UpdatePreview(alertBox);
|
||||
};
|
||||
UpdatePreview(alertBox);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshAlertPreviews()
|
||||
{
|
||||
// The announcement strip is a GLOBAL 1920px overlay, not a per-box frame, so
|
||||
// it gets its own sink: until this existed the strip went only to the frame
|
||||
// pump and was therefore invisible in the preview pane the creator watches
|
||||
// (2026-09-26 — "I do not see the scrolling text").
|
||||
_tickerPreviewSink?.Invoke(AlertTickerFrame);
|
||||
|
||||
if (_scenes == null) return;
|
||||
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.AlertBox))
|
||||
UpdatePreview(alertBox);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ticker?.Stop();
|
||||
_queue.Clear();
|
||||
_current = null;
|
||||
StopClip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders one event's celebration animation into a BGRA8 frame for the AlertBox.
|
||||
/// Six event kinds, six UNIQUE animations — each a distinct gesture, palette and
|
||||
/// layout (the creator's ruling: these are the monetization shout-outs and free
|
||||
/// branded product placement; a one-template recoloring loses all interest in two
|
||||
/// iterations). Each card carries the bottom-right brand line so every broadcast
|
||||
/// of an event is an in-video ad placement.
|
||||
///
|
||||
/// <paramref name="t01"/> walks 0→1 over the whole animation; each kind splits it
|
||||
/// into enter / hold / exit phases with its own easing.
|
||||
/// </summary>
|
||||
public sealed class AlertRenderer
|
||||
{
|
||||
private const double EnterEnd = 0.16;
|
||||
private const double ExitStart = 0.80;
|
||||
private const string Brand = "made with LlamaCasty!";
|
||||
|
||||
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
|
||||
private static readonly Color White = Colors.White;
|
||||
private static readonly Color[] ConfettiPalette =
|
||||
{
|
||||
Color.FromRgb(0xf4, 0x2f, 0x5f), Color.FromRgb(0xff, 0xd1, 0x66),
|
||||
Color.FromRgb(0x38, 0xb6, 0xf8), Color.FromRgb(0xa7, 0x8b, 0xfa),
|
||||
Color.FromRgb(0x39, 0xd3, 0x53),
|
||||
};
|
||||
|
||||
public VideoFrame Render(ChatMessage e, double t01, int width, int height)
|
||||
{
|
||||
return e.Kind switch
|
||||
{
|
||||
ChatEventKind.SuperChat => SuperChat(e, t01, width, height),
|
||||
ChatEventKind.SuperSticker => SuperSticker(e, t01, width, height),
|
||||
ChatEventKind.NewMember => NewMember(e, t01, width, height),
|
||||
ChatEventKind.MemberGift => MemberGift(e, t01, width, height),
|
||||
ChatEventKind.GiftReceived => GiftReceived(e, t01, width, height),
|
||||
ChatEventKind.MemberMilestone => Milestone(e, t01, width, height),
|
||||
_ => SuperChat(e, t01, width, height),
|
||||
};
|
||||
}
|
||||
|
||||
private static VideoFrame SuperChat(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.03;
|
||||
var cardX = margin;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = cardX + cardW / 2;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var exitEase = EaseInCubic(exit);
|
||||
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
|
||||
cardY = Lerp(cardY, -cardH, exitEase);
|
||||
|
||||
DrawCard(ctx, cardX, cardY, cardW, cardH, "#0d3b2b", "#14532d", 14);
|
||||
DrawBrand(ctx, cardX, cardY, cardW, cardH);
|
||||
|
||||
var shownAmount = e.SuperChatDisplayString
|
||||
?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}");
|
||||
var fontSize = cardH * 0.24;
|
||||
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW);
|
||||
|
||||
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW);
|
||||
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xdd, 0xff, 0xf0), cardW * 0.92);
|
||||
|
||||
if (t >= EnterEnd && t <= ExitStart)
|
||||
{
|
||||
var sweep = (t - EnterEnd) / (ExitStart - EnterEnd);
|
||||
var shineX = cardX + (sweep * 2 - 0.5) * cardW;
|
||||
ctx.DrawRectangle(
|
||||
new LinearGradientBrush(
|
||||
Colors.Transparent,
|
||||
Color.FromArgb(0x30, 0xff, 0xff, 0xff),
|
||||
new Point(0, 0),
|
||||
new Point(1, 0)),
|
||||
null,
|
||||
new Rect(shineX - cardW * 0.25, cardY, cardW * 0.5, cardH));
|
||||
}
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame SuperSticker(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.04;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var scale = EaseOutBack(enter) * (0.85 + 0.15 * (1 - exit));
|
||||
if (t > EnterEnd && t <= ExitStart)
|
||||
scale = 1.0 + 0.02 * Math.Sin(t * 26.0);
|
||||
var alpha = 1.0 - exit;
|
||||
|
||||
ctx.PushOpacity(alpha);
|
||||
|
||||
var slick = new RotateTransform(-6, w / 2.0, h / 2.0);
|
||||
ctx.PushTransform(slick);
|
||||
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
|
||||
|
||||
DrawCard(ctx, margin, margin, cardW, cardH, "#4a044e", "#7e22ce", 20);
|
||||
DrawBrand(ctx, margin, margin, cardW, cardH);
|
||||
|
||||
var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant();
|
||||
DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9);
|
||||
|
||||
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, Color.FromRgb(0xff, 0xb3, 0xd9), cardW * 0.9);
|
||||
var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null);
|
||||
if (!string.IsNullOrWhiteSpace(tier))
|
||||
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
|
||||
|
||||
ctx.Pop();
|
||||
ctx.Pop();
|
||||
ctx.Pop();
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame NewMember(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.035;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var cardY = Lerp(-cardH, margin, EaseOutCubic(enter));
|
||||
cardY = Lerp(cardY, h, EaseInCubic(exit));
|
||||
|
||||
DrawCard(ctx, margin, cardY, cardW, cardH, "#2e1065", "#5b21b6", 16);
|
||||
DrawBrand(ctx, margin, cardY, cardW, cardH);
|
||||
|
||||
if (t <= ExitStart)
|
||||
{
|
||||
var bandP = EaseOutCubic(Clamp01(t / 0.22));
|
||||
ctx.DrawRectangle(
|
||||
new LinearGradientBrush(Color.FromArgb(0, 0xff, 0xff, 0xff), Colors.Transparent, new Point(0, 0), new Point(0, 1)),
|
||||
null,
|
||||
new Rect(margin, cardY + cardH * 0.30, cardW * bandP, cardH * 0.18));
|
||||
}
|
||||
|
||||
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9);
|
||||
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, Color.FromRgb(0xf5, 0xd0, 0xfe), cardW * 0.9);
|
||||
|
||||
var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant();
|
||||
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame MemberGift(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.035;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var cardX = Lerp(-cardW, margin, EaseOutCubic(enter));
|
||||
cardX = Lerp(cardX, -cardW, EaseInCubic(exit));
|
||||
|
||||
DrawCard(ctx, cardX, margin, cardW, cardH, "#422006", "#a16207", 16);
|
||||
DrawBrand(ctx, cardX, margin, cardW, cardH);
|
||||
|
||||
var count = Math.Max(1, e.GiftNewMembersCount);
|
||||
var spread = 1.0 - exit;
|
||||
var hero = count > 1 ? $"×{count}" : "MEMBERSHIP";
|
||||
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.8);
|
||||
DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
|
||||
|
||||
var chipCount = Math.Min(count, 6);
|
||||
for (var i = 0; i < chipCount; i++)
|
||||
{
|
||||
var slot = (chipCount - 1) * 0.5;
|
||||
var gap = cardH * 0.13 * EaseOutCubic(Clamp01(t / 0.30)) * spread;
|
||||
var chipX = cardCX + (i - slot) * gap;
|
||||
var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015;
|
||||
ctx.DrawRoundedRectangle(
|
||||
new SolidColorBrush(Color.FromRgb(0xff, 0xf3, 0xc4)),
|
||||
null,
|
||||
new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10),
|
||||
cardH * 0.03, cardH * 0.03);
|
||||
}
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame GiftReceived(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.04;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / (EnterEnd * 1.4));
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var scale = 0.5 + 0.5 * EaseOutBack(enter);
|
||||
var alpha = Math.Min(1.0, enter * 1.6) * (1 - exit);
|
||||
|
||||
ctx.PushOpacity(alpha);
|
||||
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
|
||||
|
||||
DrawCard(ctx, margin, margin, cardW, cardH, "#14532d", "#4c1d95", 18);
|
||||
DrawBrand(ctx, margin, margin, cardW, cardH);
|
||||
|
||||
// Confetti falling inside the card during the hold (deterministic seed).
|
||||
if (t >= EnterEnd && t <= ExitStart)
|
||||
{
|
||||
var hold = (t - EnterEnd) / (ExitStart - EnterEnd);
|
||||
for (var i = 0; i < 14; i++)
|
||||
{
|
||||
var seed = i * 37 + 11;
|
||||
var dotX = margin + cardW * (((seed * 53) % 97) / 97.0);
|
||||
var dotY = margin + cardH * (((seed + hold * 0.85) % 1.0));
|
||||
var size = cardH * 0.02 + (i % 3) * cardH * 0.006;
|
||||
ctx.DrawRectangle(
|
||||
new SolidColorBrush(ConfettiPalette[i % ConfettiPalette.Length]),
|
||||
null,
|
||||
new Rect(dotX, dotY, size, size));
|
||||
}
|
||||
}
|
||||
|
||||
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
|
||||
var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName;
|
||||
DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9);
|
||||
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, Color.FromRgb(0xdb, 0xff, 0xf0), cardW * 0.9);
|
||||
|
||||
ctx.Pop();
|
||||
ctx.Pop();
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
private static VideoFrame Milestone(ChatMessage e, double t, int w, int h)
|
||||
{
|
||||
var dpi = 96.0;
|
||||
var visual = new DrawingVisual();
|
||||
using (var ctx = visual.RenderOpen())
|
||||
{
|
||||
var margin = Math.Max(w, h) * 0.04;
|
||||
var cardW = w - margin * 2;
|
||||
var cardH = h - margin * 2;
|
||||
var cardCX = w / 2.0;
|
||||
|
||||
var enter = Clamp01(t / EnterEnd);
|
||||
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
|
||||
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
|
||||
cardY = Lerp(cardY, -cardH, EaseInCubic(exit));
|
||||
|
||||
DrawCard(ctx, margin, cardY, cardW, cardH, "#431407", "#c2410c", 14);
|
||||
DrawBrand(ctx, margin, cardY, cardW, cardH);
|
||||
|
||||
var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE";
|
||||
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, Color.FromRgb(0xff, 0xdd, 0x66), cardW * 0.8);
|
||||
|
||||
var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM";
|
||||
DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
|
||||
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xff, 0xe8, 0xd0), cardW * 0.9);
|
||||
|
||||
// Tenure bar: grows through the first half, retracts on exit.
|
||||
var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit);
|
||||
var barW = cardW * 0.6 * barP;
|
||||
ctx.DrawRoundedRectangle(
|
||||
new SolidColorBrush(Color.FromRgb(0xff, 0xd1, 0x66)),
|
||||
null,
|
||||
new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045),
|
||||
4, 4);
|
||||
}
|
||||
return CreateFrame(visual, w, h, dpi);
|
||||
}
|
||||
|
||||
// ─── Shared drawing ───
|
||||
|
||||
private static void DrawCard(DrawingContext ctx, double x, double y, double w, double h, string fromHex, string toHex, double radius)
|
||||
{
|
||||
var brush = new LinearGradientBrush(ParseColor(fromHex), ParseColor(toHex), new Point(0, 0), new Point(1, 1));
|
||||
ctx.DrawRoundedRectangle(brush, new Pen(new SolidColorBrush(Color.FromArgb(0x66, 0xff, 0xff, 0xff)), Math.Max(1, radius * 0.06)), new Rect(x, y, w, h), radius, radius);
|
||||
}
|
||||
|
||||
private static void DrawBrand(DrawingContext ctx, double x, double y, double w, double h)
|
||||
{
|
||||
var formatted = new FormattedText(
|
||||
Brand, Invariant, FlowDirection.LeftToRight,
|
||||
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
|
||||
Math.Max(9, h * 0.055),
|
||||
new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)),
|
||||
96.0);
|
||||
ctx.DrawText(formatted, new Point(x + w - formatted.Width - h * 0.08, y + h - formatted.Height - h * 0.05));
|
||||
}
|
||||
|
||||
private static void DrawCentered(DrawingContext ctx, string text, double cx, double cy, double size, FontWeight weight, Color color, double maxWidth)
|
||||
{
|
||||
var formatted = new FormattedText(
|
||||
text, Invariant, FlowDirection.LeftToRight,
|
||||
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal),
|
||||
size,
|
||||
new SolidColorBrush(color),
|
||||
96.0);
|
||||
formatted.MaxTextWidth = maxWidth;
|
||||
formatted.MaxLineCount = 1;
|
||||
formatted.Trimming = TextTrimming.CharacterEllipsis;
|
||||
ctx.DrawText(formatted, new Point(cx - formatted.Width / 2, cy - formatted.Height / 2));
|
||||
}
|
||||
|
||||
private static Color ParseColor(string hex)
|
||||
{
|
||||
hex = hex.Trim().TrimStart('#');
|
||||
if (hex.Length == 6)
|
||||
{
|
||||
var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
|
||||
var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
|
||||
var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
|
||||
return Color.FromRgb(r, g, b);
|
||||
}
|
||||
if (hex.Length == 8)
|
||||
{
|
||||
var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
|
||||
var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
|
||||
var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
|
||||
var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber);
|
||||
return Color.FromArgb(a, r, g, b);
|
||||
}
|
||||
return Colors.White;
|
||||
}
|
||||
|
||||
private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi)
|
||||
{
|
||||
var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32);
|
||||
rtb.Render(visual);
|
||||
|
||||
var stride = width * 4;
|
||||
var pixels = new byte[stride * height];
|
||||
rtb.CopyPixels(pixels, stride, 0);
|
||||
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
(pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
|
||||
}
|
||||
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
|
||||
private static double Clamp01(double v) => Math.Clamp(v, 0.0, 1.0);
|
||||
|
||||
private static double Lerp(double a, double b, double p) => a + (b - a) * p;
|
||||
|
||||
private static double EaseInCubic(double p) => p * p * p;
|
||||
|
||||
private static double EaseOutCubic(double p) => 1 - Math.Pow(1 - p, 3);
|
||||
|
||||
private static double EaseOutBack(double p)
|
||||
{
|
||||
const double c1 = 1.70158;
|
||||
const double c3 = c1 + 1;
|
||||
return 1 + c3 * Math.Pow(p - 1, 3) + c1 * Math.Pow(p - 1, 2);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,12 @@ public sealed class AudioGainProvider
|
||||
/// <summary>Mic gain for the live mix (0 = muted).</summary>
|
||||
public double MicGain() => _micMuted() ? 0 : _micVolume();
|
||||
|
||||
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted, 1 = unity).
|
||||
/// Unity because the slider now drives system volume via PushSystemVolume,
|
||||
/// so the WASAPI loopback signal is already scaled by the user's volume choice.</summary>
|
||||
public double LoopbackGain() => _gameMuted() ? 0 : 1.0;
|
||||
/// <summary>Loopback (desktop/game) gain for the live mix (0 = muted).
|
||||
/// Honesty rule (2026-09-01): WASAPI loopback capture does NOT shrink with the
|
||||
/// endpoint volume — the creator dropped system volume to 20% and the captured
|
||||
/// level stayed pegged — so the mixer must multiply by GameAudioVolume itself
|
||||
/// for the stream to follow the knob (the old unity assumption left the stream
|
||||
/// at full desktop volume while the headphones went quiet; ai.md always
|
||||
/// specified loopbackGain = GameAudioVolume — the code had drifted).</summary>
|
||||
public double LoopbackGain() => _gameMuted() ? 0 : _gameVolume();
|
||||
}
|
||||
|
||||
@@ -16,18 +16,20 @@ public sealed class AudioLevelMeter
|
||||
|
||||
/// <summary>
|
||||
/// Maps a linear level (0..1) onto the meter's display scale: -60 dBFS..0 dBFS
|
||||
/// spread linearly across 0..1, with +10 dB of input amplification. Linear RMS
|
||||
/// of real speech or game audio is ~0.01..0.1 (-40..-20 dBFS), which leaves a
|
||||
/// flat (linear) meter looking dead; the log scale makes typical levels occupy
|
||||
/// the bar and the amplification pushes real speech peaks into the red zone
|
||||
/// (0.8+) at maxed volume instead of hovering at its edge. Inputs at or below
|
||||
/// -60 dBFS (0.001 linear) read as zero — the meter never idles on background noise.
|
||||
/// spread linearly across 0..1 — an HONEST dBFS scale. Linear RMS of real speech is
|
||||
/// ~0.01..0.1 (-40..-20 dBFS), which leaves a flat (linear) meter looking dead; the
|
||||
/// log scale makes typical levels occupy the bar. Inputs at or below -60 dBFS
|
||||
/// (0.001 linear) read as zero — the meter never idles on background noise.
|
||||
/// <para>2026-10-09: the +10 dB "input amplification" that used to sit here is gone.
|
||||
/// It made a −30 dBFS voice look dialed in, and a creator's upload came out at
|
||||
/// −28.5 LUFS. The speech leveler now puts voice at −20 dBFS, which lands at 2/3 of
|
||||
/// the bar (the green/yellow edge); loud peaks reach the red.</para>
|
||||
/// </summary>
|
||||
public static float ToDisplay(float linear)
|
||||
{
|
||||
if (linear <= 0.001f)
|
||||
return 0f;
|
||||
var db = 20f * MathF.Log10(linear) + 10f;
|
||||
var db = 20f * MathF.Log10(linear);
|
||||
return Math.Clamp(1f + db / 60f, 0f, 1f);
|
||||
}
|
||||
|
||||
|
||||
+154
-11
@@ -1,3 +1,4 @@
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Services.Audio;
|
||||
@@ -13,7 +14,9 @@ namespace ytLive.Services.Audio;
|
||||
/// queues, applies the honest gains (mic = MicVolume, loopback = unity
|
||||
/// × auto-duck when the mic is hot — the slider drives system volume via mixes them to stereo float, runs the
|
||||
/// master limiter (a -1 dBFS ceiling so the sum never clips the encoder), and
|
||||
/// writes the chunk to the encoder's audio pipe.
|
||||
/// writes the chunk to the encoder's audio pipe. Since TASK 47 the alert layer's
|
||||
/// clip audio joins the same mix (pre-scaled, unity-added, never ducked) through
|
||||
/// <see cref="EnqueueAlertAudio"/>.
|
||||
/// </summary>
|
||||
public sealed class AudioMixer : IDisposable
|
||||
{
|
||||
@@ -29,12 +32,16 @@ public sealed class AudioMixer : IDisposable
|
||||
private readonly Func<double>? _micGain;
|
||||
private readonly Func<double>? _loopbackGain;
|
||||
private readonly TimeSpan _mixInterval;
|
||||
private readonly Func<int>? _syncOffsetMs;
|
||||
private readonly AudioSyncDelay _syncDelay;
|
||||
private int _advanceSamplesRemaining;
|
||||
private bool _started;
|
||||
|
||||
// Live path (TASK 9): per-source resampling → voice chain on the mic →
|
||||
// thread-safe queues → the ducker + gain/mix loop → the encoder's pipe.
|
||||
private readonly AudioRingBuffer _micBuffer;
|
||||
private readonly AudioRingBuffer _loopbackBuffer;
|
||||
private readonly AudioRingBuffer _alertBuffer;
|
||||
private readonly VoiceFilterChain _voiceChain;
|
||||
private readonly AutoDucker _ducker;
|
||||
private readonly MasterLimiter _masterLimiter = new();
|
||||
@@ -44,7 +51,14 @@ public sealed class AudioMixer : IDisposable
|
||||
private NamedPipeAudioWriter _pipe = new();
|
||||
private float[]? _micChunk;
|
||||
private float[]? _loopbackChunk;
|
||||
private float[]? _alertChunk;
|
||||
private float[]? _mixBuffer;
|
||||
private float[]? _delayedMix;
|
||||
private DateTime _lastLoopErrorLogged = DateTime.MinValue;
|
||||
private DateTime _nextStatsLog = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
private long _micDrainedTotal;
|
||||
private long _loopDrainedTotal;
|
||||
private float _peakMix;
|
||||
|
||||
public AudioMixer(
|
||||
IAudioSource mic,
|
||||
@@ -52,6 +66,7 @@ public sealed class AudioMixer : IDisposable
|
||||
Action<string>? log = null,
|
||||
Func<double>? micGain = null,
|
||||
Func<double>? loopbackGain = null,
|
||||
Func<int>? syncOffsetMs = null,
|
||||
TimeSpan? mixInterval = null)
|
||||
{
|
||||
_mic = mic;
|
||||
@@ -61,17 +76,22 @@ public sealed class AudioMixer : IDisposable
|
||||
_log = log;
|
||||
_micGain = micGain;
|
||||
_loopbackGain = loopbackGain;
|
||||
_syncOffsetMs = syncOffsetMs;
|
||||
_syncDelay = new AudioSyncDelay(OutputSampleRate);
|
||||
_mixInterval = mixInterval ?? DefaultMixInterval;
|
||||
|
||||
var seconds = _mixInterval.TotalSeconds;
|
||||
var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds));
|
||||
_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];
|
||||
|
||||
_mic.Started += OnMicStarted;
|
||||
_mic.SampleReady += OnMicSample;
|
||||
@@ -146,19 +166,48 @@ public sealed class AudioMixer : IDisposable
|
||||
if (_liveCts != null)
|
||||
return;
|
||||
|
||||
// Capture runs from app STARTUP for the meters, so both rings carry up
|
||||
// to a full ring depth (~2s) of pre-live backlog by the time the user
|
||||
// hits record; draining it made every recorded event land ~2s late in
|
||||
// the audio track (2026-09-14, two confirmed takes). Discard it so the
|
||||
// recording starts at go-live. The writers keep running concurrently;
|
||||
// Clear under the ring's lock, so only the in-flight chunk is dropped.
|
||||
_micBuffer.Clear();
|
||||
_loopbackBuffer.Clear();
|
||||
_alertBuffer.Clear();
|
||||
|
||||
// Negative sync offset (audio runs BEHIND video): OBS's fix is to "eat
|
||||
// the head of the buffer" — discard the first |N| ms of the live stream
|
||||
// so every subsequent audio event lands |N| ms EARLIER in the recording.
|
||||
// Armed once at go-live (you can't eat the head mid-stream); positive
|
||||
// offsets stay live-reactive through the delay line in FillAndMix.
|
||||
var syncMs = _syncOffsetMs?.Invoke() ?? 0;
|
||||
_advanceSamplesRemaining = syncMs < 0
|
||||
? (int)Math.Round(OutputSampleRate / 1000.0 * -syncMs) * 2
|
||||
: 0;
|
||||
if (_advanceSamplesRemaining > 0)
|
||||
_log?.Invoke($"Audio live: advance armed ({-syncMs} ms ahead)");
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_liveCts = cts;
|
||||
_pipe = new NamedPipeAudioWriter();
|
||||
_pipe.Start(pipeName);
|
||||
_log?.Invoke($"Audio live: started, pipe '{pipeName}'");
|
||||
_ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token));
|
||||
}
|
||||
|
||||
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
|
||||
/// EOF on the audio input. Safe when not live.</summary>
|
||||
/// EOF on the audio input. Safe when not live: the writer's Stop() is idempotent
|
||||
/// (StopStream now calls this on every rollback path).</summary>
|
||||
public void StopLive()
|
||||
{
|
||||
_liveCts?.Cancel();
|
||||
var cts = _liveCts;
|
||||
_liveCts = null;
|
||||
if (cts != null)
|
||||
{
|
||||
_log?.Invoke("Audio live: stopped");
|
||||
cts.Cancel();
|
||||
}
|
||||
_pipe.Stop();
|
||||
}
|
||||
|
||||
@@ -204,6 +253,34 @@ public sealed class AudioMixer : IDisposable
|
||||
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
|
||||
/// alert must be heard over the mic, so the Creator's per-alert volume
|
||||
/// slider is the honest gain and nothing else is stacked on top. Monno and
|
||||
/// non-48k sources are normalized (the decoder already emits 48k stereo).</summary>
|
||||
public void EnqueueAlertAudio(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);
|
||||
_alertBuffer.Write(data);
|
||||
}
|
||||
|
||||
private static float[] UpmixToStereo(float[] mono)
|
||||
{
|
||||
var stereo = new float[mono.Length * 2];
|
||||
for (var i = 0; i < mono.Length; i++)
|
||||
{
|
||||
stereo[i * 2] = mono[i];
|
||||
stereo[i * 2 + 1] = mono[i];
|
||||
}
|
||||
return stereo;
|
||||
}
|
||||
|
||||
private void OnMicFailed(Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Mic capture failed: {ex.Message}");
|
||||
@@ -263,8 +340,42 @@ public sealed class AudioMixer : IDisposable
|
||||
var nextTick = DateTime.UtcNow + _mixInterval;
|
||||
try
|
||||
{
|
||||
FillAndMix(micChunk, loopbackChunk, mixBuffer);
|
||||
await pipe.WriteAsync(mixBuffer, cancellationToken).ConfigureAwait(false);
|
||||
var (_, micDrained, loopDrained) = FillAndMix(micChunk, loopbackChunk, mixBuffer);
|
||||
|
||||
// Negative offset: skip the armed |N| ms of stream head. The pipe
|
||||
// writer tolerates a partial chunk (any float length is valid);
|
||||
// dropping the head re-anchors the audio stream against the video
|
||||
// stream, making each event land earlier — OBS's eat-the-head fix.
|
||||
var writeFrom = 0;
|
||||
if (_advanceSamplesRemaining > 0)
|
||||
{
|
||||
var drop = Math.Min(_advanceSamplesRemaining, mixBuffer.Length);
|
||||
_advanceSamplesRemaining -= drop;
|
||||
writeFrom = drop;
|
||||
}
|
||||
|
||||
if (writeFrom < mixBuffer.Length)
|
||||
await pipe.WriteAsync(mixBuffer.AsMemory(writeFrom), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_micDrainedTotal += micDrained;
|
||||
_loopDrainedTotal += loopDrained;
|
||||
for (var i = writeFrom; i < mixBuffer.Length; i++)
|
||||
{
|
||||
var abs = Math.Abs(mixBuffer[i]);
|
||||
if (abs > _peakMix) _peakMix = abs;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (now >= _nextStatsLog && _log != null)
|
||||
{
|
||||
_nextStatsLog = now + TimeSpan.FromSeconds(5);
|
||||
_log($"Audio live: pipe connected={pipe.IsConnected} dropped writes={pipe.DroppedWrites} " +
|
||||
$"micLevel={_meter.Level:F3} loopLevel={_loopbackMeter.Level:F3} " +
|
||||
$"drained {_micDrainedTotal:N0}/{_loopDrainedTotal:N0} samples peakMix {_peakMix:F3} (per 5s)");
|
||||
_micDrainedTotal = 0;
|
||||
_loopDrainedTotal = 0;
|
||||
_peakMix = 0;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -272,7 +383,18 @@ public sealed class AudioMixer : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"Audio live loop error: {ex.Message}");
|
||||
// Throttled + never on a dead session: the 2026-09-01 first-launch flood
|
||||
// was one logged NRE every 10ms tick (see _delayedMix fix). Log at most
|
||||
// once per 5s, and stop looping if the pipe writer is gone for good.
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastLoopErrorLogged >= TimeSpan.FromSeconds(5))
|
||||
{
|
||||
_lastLoopErrorLogged = now;
|
||||
AppLog.Write(ex, "Audio live loop error");
|
||||
_log?.Invoke($"Audio live loop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var delay = nextTick - DateTime.UtcNow;
|
||||
@@ -291,11 +413,16 @@ public sealed class AudioMixer : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Drains one tick's worth of mic + loopback, silence-fills any
|
||||
/// underrun, applies the ducker and the honest gains, and mixes to stereo.</summary>
|
||||
private float FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix)
|
||||
/// underrun, applies the ducker and the honest gains, and mixes to stereo.
|
||||
/// 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)
|
||||
{
|
||||
var micCount = _micBuffer.Read(micChunk, micChunk.Length);
|
||||
var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length);
|
||||
var alertChunk = _alertChunk!;
|
||||
var alertCount = _alertBuffer.Read(alertChunk, alertChunk.Length);
|
||||
|
||||
float micRms = 0;
|
||||
if (micCount == 0)
|
||||
@@ -321,6 +448,15 @@ public sealed class AudioMixer : IDisposable
|
||||
Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount);
|
||||
}
|
||||
|
||||
if (alertCount == 0)
|
||||
{
|
||||
Array.Clear(alertChunk, 0, alertChunk.Length);
|
||||
}
|
||||
else if (alertCount < alertChunk.Length)
|
||||
{
|
||||
Array.Clear(alertChunk, alertCount, alertChunk.Length - alertCount);
|
||||
}
|
||||
|
||||
var duck = _ducker.Update(micRms);
|
||||
var micGain = (float)(_micGain?.Invoke() ?? 1.0);
|
||||
var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck;
|
||||
@@ -329,10 +465,17 @@ public sealed class AudioMixer : IDisposable
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var m = micChunk[i] * micGain;
|
||||
mix[i * 2] = m + loopbackChunk[i * 2] * loopGain;
|
||||
mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain;
|
||||
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);
|
||||
return micRms;
|
||||
|
||||
_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);
|
||||
return (micRms, micCount, loopCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
/// "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
|
||||
/// state outside its own ring.
|
||||
///
|
||||
/// Positive offsets only (0..500 ms). Advancing audio (the audio runs BEHIND
|
||||
/// the video) is handled upstream in <see cref="AudioMixer"/>, not here: the
|
||||
/// mixer eats the first |N| ms of the live stream head (OBS's negative sync —
|
||||
/// see <see cref="AudioMixer.StartLive"/>), keeping this line a pure delay.
|
||||
/// Changing the delay flushes the line, so a live slider change clicks rather
|
||||
/// than smearing. Safe to call from any thread — the mixer owns it.
|
||||
/// </summary>
|
||||
public sealed class AudioSyncDelay
|
||||
{
|
||||
private readonly int _sampleRate;
|
||||
|
||||
private float[] _buffer = Array.Empty<float>();
|
||||
private int _delaySamples;
|
||||
private int _writePos;
|
||||
|
||||
public AudioSyncDelay(int sampleRate)
|
||||
{
|
||||
_sampleRate = Math.Max(1, sampleRate);
|
||||
Configure(0);
|
||||
}
|
||||
|
||||
/// <summary>Sets the delay in whole milliseconds (clamped to 0..500). A no-op
|
||||
/// when the delay hasn't actually changed — <see cref="AudioMixer.FillAndMix"/>
|
||||
/// calls this every ~10ms tick (it re-reads the live UI setting each mix), and
|
||||
/// unconditionally reallocating+zeroing the line every tick (the 2026-09-12
|
||||
/// total-silence bug: any non-zero saved offset wiped the ring before its own
|
||||
/// delayed samples could ever be read back — mic/loop meters, sampled BEFORE
|
||||
/// this stage, kept showing real levels while the pipe stayed pure silence)
|
||||
/// discarded the entire buffered history every 10ms. Changing the delay still
|
||||
/// flushes the line (a live slider change clicks rather than smearing).</summary>
|
||||
public void Configure(int delayMs)
|
||||
{
|
||||
var delaySamples = Frames(delayMs);
|
||||
if (delaySamples == _delaySamples && _buffer.Length > 0) return;
|
||||
_delaySamples = delaySamples;
|
||||
_buffer = new float[(_delaySamples + _sampleRate) * 2 + 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>
|
||||
public void Process(ReadOnlySpan<float> input, Span<float> output)
|
||||
{
|
||||
if (output.Length < input.Length)
|
||||
throw new ArgumentException("output must be at least as long as input", nameof(output));
|
||||
|
||||
var len = input.Length;
|
||||
if (_delaySamples == 0)
|
||||
{
|
||||
input.CopyTo(output);
|
||||
return;
|
||||
}
|
||||
|
||||
var line = _buffer;
|
||||
var lineLen = line.Length;
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
line[_writePos] = input[i];
|
||||
_writePos++;
|
||||
if (_writePos == lineLen)
|
||||
_writePos = 0;
|
||||
}
|
||||
|
||||
var readStart = (_writePos - _delaySamples - len + lineLen * 2) % lineLen;
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
var idx = readStart + i;
|
||||
if (idx >= lineLen)
|
||||
idx -= lineLen;
|
||||
output[i] = line[idx];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ytLive.Services.Audio;
|
||||
|
||||
/// <summary>Seam over the TRAX background-music player (<see cref="MusicPlayer"/>) so the
|
||||
/// toggle/status-light logic is testable without a real audio device.</summary>
|
||||
public interface ITraxPlayer : IDisposable
|
||||
{
|
||||
event Action<Exception?>? PlaybackEnded;
|
||||
string? TrackName { get; }
|
||||
bool IsPlaying { get; }
|
||||
float LocalGain { get; set; }
|
||||
void Load(string path);
|
||||
void Play();
|
||||
void Pause();
|
||||
}
|
||||
@@ -11,12 +11,17 @@ namespace ytLive.Services.Audio;
|
||||
/// The desktop/game bar's gain and mute scale <see cref="LocalGain"/>, so the
|
||||
/// creator hears the game-audio controls work on the music too.
|
||||
/// </summary>
|
||||
public sealed class MusicPlayer : IDisposable
|
||||
public sealed class MusicPlayer : ITraxPlayer
|
||||
{
|
||||
/// <summary>The one and only music bed — quiet, no slider (the creator's
|
||||
/// voice must stay on top; game/music loudness is the ducker's job). The
|
||||
/// desktop audio bar scales this via <see cref="LocalGain"/>.</summary>
|
||||
public const float MusicVolume = 0.20f;
|
||||
/// <summary>The one and only music bed — no slider (the creator's voice must stay on
|
||||
/// top; that's the ducker's job, −12 dB while they talk). The desktop audio bar scales
|
||||
/// this via <see cref="LocalGain"/>.
|
||||
/// <para>2026-10-09: was 0.20 (−14 dB) — "even at max volume I can barely discern it".
|
||||
/// The creator's track masters at −11.3 LUFS (typical: −9..−14), so 0.20 put the bed at
|
||||
/// ~−25 LUFS, and ducked under speech ~−37 LUFS: inaudible on stream too, because the
|
||||
/// loopback carries exactly what plays locally. 0.5 (−6 dB) lands a typical master at
|
||||
/// ~−15..−20 LUFS — beside the leveled voice (≈ −17 LUFS), and well under it when ducked.</para></summary>
|
||||
public const float MusicVolume = 0.5f;
|
||||
|
||||
private MediaFoundationReader? _reader;
|
||||
private VolumeWaveProvider16? _volume;
|
||||
|
||||
@@ -17,6 +17,12 @@ public interface IAudioPipeWriter : IDisposable
|
||||
/// <summary>True once ffmpeg has connected and the pipe is writable.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>Count of <see cref="WriteAsync"/> calls that wrote nothing because
|
||||
/// the pipe wasn't connected yet or was already closed. Part of the silent-
|
||||
/// recording diagnosis: nonzero means audio was dropped before ffmpeg ever
|
||||
/// 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);
|
||||
|
||||
@@ -29,6 +35,9 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
private readonly object _sync = new();
|
||||
private NamedPipeServerStream? _pipe;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long DroppedWrites { get; private set; }
|
||||
|
||||
public void Start(string pipeName)
|
||||
{
|
||||
lock (_sync)
|
||||
@@ -64,7 +73,11 @@ public sealed class NamedPipeAudioWriter : IAudioPipeWriter
|
||||
pipe = _pipe;
|
||||
}
|
||||
if (pipe == null || !pipe.IsConnected || samples.IsEmpty)
|
||||
{
|
||||
if (pipe?.IsConnected == false || pipe == null)
|
||||
DroppedWrites++;
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = MemoryMarshal.AsBytes(samples.Span).ToArray();
|
||||
await pipe.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -3,15 +3,21 @@ namespace ytLive.Services.Audio;
|
||||
/// <summary>
|
||||
/// The always-on voice processing chain for the mic (TASK 9 audio milestone).
|
||||
/// Pure, per-sample, unit-tested: bass boost → treble lift → noise gate →
|
||||
/// compressor. Applied BEFORE the meter and the stream mix so what the creator
|
||||
/// hears on the meter is what viewers hear — and so background hum is gated
|
||||
/// out before it reaches the encoder.
|
||||
/// <b>speech leveler</b> → compressor. Applied BEFORE the meter and the stream mix so
|
||||
/// what the creator sees on the meter is what viewers hear — and so background hum
|
||||
/// is gated out before it reaches the encoder.
|
||||
/// <para>The leveler (2026-10-09) is the gain stage the chain was missing: a creator's
|
||||
/// upload measured −28.5 LUFS against YouTube's −14 reference because nothing ever
|
||||
/// brought quiet speech UP — the compressor only folds peaks above −6 dBFS and the
|
||||
/// master limiter only caps at −1 dBFS. Compressor → gain → limiter is the standard
|
||||
/// streaming voice chain.</para>
|
||||
/// </summary>
|
||||
public sealed class VoiceFilterChain
|
||||
{
|
||||
private readonly LowShelfFilter _bass;
|
||||
private readonly HighShelfFilter _treble;
|
||||
private readonly NoiseGate _gate;
|
||||
private readonly SpeechLeveler _leveler;
|
||||
private readonly Compressor _compressor;
|
||||
|
||||
/// <param name="sampleRate">The (post-resample) mic rate — the RBJ shelf
|
||||
@@ -21,15 +27,20 @@ public sealed class VoiceFilterChain
|
||||
_bass = new LowShelfFilter(sampleRate, 120f, 4f);
|
||||
_treble = new HighShelfFilter(sampleRate, 8000f, 3f);
|
||||
_gate = new NoiseGate();
|
||||
_leveler = new SpeechLeveler(sampleRate);
|
||||
_compressor = new Compressor();
|
||||
}
|
||||
|
||||
/// <summary>The leveler's current gain (linear) — for diagnostics and tests.</summary>
|
||||
public float LevelerGain => _leveler.Gain;
|
||||
|
||||
public float Process(float sample)
|
||||
{
|
||||
var bass = _bass.Process(sample);
|
||||
var treble = _treble.Process(bass);
|
||||
var gated = _gate.Process(treble);
|
||||
return _compressor.Process(gated);
|
||||
var leveled = _leveler.Process(gated, _gate.IsOpen);
|
||||
return _compressor.Process(leveled);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
@@ -37,10 +48,87 @@ public sealed class VoiceFilterChain
|
||||
_bass.Reset();
|
||||
_treble.Reset();
|
||||
_gate.Reset();
|
||||
_leveler.Reset();
|
||||
_compressor.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Automatic gain for speech: tracks the voice's RMS over ~0.3 s and slowly steers a
|
||||
/// gain so speech sits at <see cref="TargetRms"/> (−20 dBFS mono ≈ −17 LUFS once the mic
|
||||
/// is mixed to stereo — close to YouTube's −14 reference while leaving the limiter room).
|
||||
/// Different mics differ by 20 dB+ in sensitivity; this is what makes "it just sounds
|
||||
/// right" true for all of them.
|
||||
/// <list type="bullet">
|
||||
/// <item>Only learns while the noise gate is open and the voice is above a silence floor —
|
||||
/// pauses and background hum never get pumped up.</item>
|
||||
/// <item>Gain rises at most <see cref="RiseDbPerSecond"/> and falls at most
|
||||
/// <see cref="FallDbPerSecond"/> (faster down than up, so a sudden shout is tamed quickly
|
||||
/// and a quiet stretch isn't chased), within <see cref="MinGain"/>..<see cref="MaxGain"/>.</item>
|
||||
/// <item>Peaks it pushes up are caught by the <see cref="Compressor"/> after it and the
|
||||
/// master limiter at the end of the mix.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class SpeechLeveler
|
||||
{
|
||||
public const float TargetRms = 0.1f; // −20 dBFS
|
||||
public const float MaxGain = 10f; // +20 dB
|
||||
public const float MinGain = 0.5f; // −6 dB
|
||||
public const float SilenceFloorRms = 0.002f; // −54 dBFS: below this, hold the gain
|
||||
public const float RiseDbPerSecond = 10f;
|
||||
public const float FallDbPerSecond = 30f;
|
||||
private const float WindowSeconds = 0.3f;
|
||||
private const float UpdateSeconds = 0.01f;
|
||||
|
||||
private readonly float _meanAlpha;
|
||||
private readonly int _updateEvery;
|
||||
private readonly float _maxRiseDb;
|
||||
private readonly float _maxFallDb;
|
||||
private float _meanSquare;
|
||||
private float _gainDb;
|
||||
private float _gain = 1f;
|
||||
private int _sinceUpdate;
|
||||
|
||||
public SpeechLeveler(int sampleRate)
|
||||
{
|
||||
_meanAlpha = 1f / (WindowSeconds * sampleRate);
|
||||
_updateEvery = Math.Max(1, (int)(UpdateSeconds * sampleRate));
|
||||
_maxRiseDb = RiseDbPerSecond * UpdateSeconds;
|
||||
_maxFallDb = FallDbPerSecond * UpdateSeconds;
|
||||
}
|
||||
|
||||
public float Gain => _gain;
|
||||
|
||||
public float Process(float sample, bool voiceActive)
|
||||
{
|
||||
if (voiceActive)
|
||||
_meanSquare += (sample * sample - _meanSquare) * _meanAlpha;
|
||||
|
||||
if (++_sinceUpdate >= _updateEvery)
|
||||
{
|
||||
_sinceUpdate = 0;
|
||||
var rms = MathF.Sqrt(_meanSquare);
|
||||
if (voiceActive && rms > SilenceFloorRms)
|
||||
{
|
||||
var desiredDb = 20f * MathF.Log10(Math.Clamp(TargetRms / rms, MinGain, MaxGain));
|
||||
var step = Math.Clamp(desiredDb - _gainDb, -_maxFallDb, _maxRiseDb);
|
||||
_gainDb += step;
|
||||
_gain = MathF.Pow(10f, _gainDb / 20f);
|
||||
}
|
||||
}
|
||||
|
||||
return sample * _gain;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_meanSquare = 0;
|
||||
_gainDb = 0;
|
||||
_gain = 1f;
|
||||
_sinceUpdate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RBJ audio EQ cookbook low-shelf biquad (bass boost). Transposed direct
|
||||
/// form II — stable, cheap, standard.
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.BackgroundEffects;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a webcam's <see cref="BackgroundEffect"/> to one captured frame (TASK 51).
|
||||
/// Pure pixel work over the <see cref="VideoFrame"/> seam — WPF-free and thread-agnostic,
|
||||
/// so the camera's processing worker can call it before the frame reaches the preview bitmap
|
||||
/// and the compositor.
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BackgroundEffect.None"/> returns the SAME frame instance — a true
|
||||
/// passthrough, no copy.</item>
|
||||
/// <item><see cref="BackgroundEffect.Remove"/> returns a copy whose alpha is the person mask,
|
||||
/// RGB untouched, <c>IsOpaque = false</c> — the compositor's straight-alpha blend then shows
|
||||
/// the layers below the webcam. Strength = edge softness (0 = crisp cut-out, 1 = the model's
|
||||
/// raw soft mask).</item>
|
||||
/// <item><see cref="BackgroundEffect.Blur"/> returns a copy where the background is replaced
|
||||
/// by a blurred version of itself and the person stays sharp. Strength = blur amount.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The mask is computed ONCE per camera frame (<see cref="TrySegment"/>) and then rendered for
|
||||
/// every effect variant in use (<see cref="Render"/>). A segmenter that returns no mask (or
|
||||
/// throws) never drops the layer: the frame passes through and the failure is logged once.
|
||||
/// </summary>
|
||||
public sealed class BackgroundEffectProcessor
|
||||
{
|
||||
public const double DefaultStrength = 0.5;
|
||||
|
||||
/// <summary>The background is blurred at 1/<see cref="BlurDownscale"/> resolution and
|
||||
/// upsampled while blending — a strong blur at a fraction of the cost.</summary>
|
||||
internal const int BlurDownscale = 4;
|
||||
|
||||
/// <summary>Box-blur radius (in downscaled pixels) at strength 0 and 1. The maximum was halved
|
||||
/// from 12 (2026-10-09): the creator found full range too heavy — the old max spread ≈48 full
|
||||
/// px. The established tools blur far less: StreamFX defaults its Box blur size to 5 and its
|
||||
/// author stops downscale-based blurs around 8 px.</summary>
|
||||
internal const int MinBlurRadius = 1;
|
||||
internal const int MaxBlurRadius = 6;
|
||||
|
||||
private readonly IPersonSegmenter _segmenter;
|
||||
private bool _loggedFailure;
|
||||
|
||||
public BackgroundEffectProcessor(IPersonSegmenter segmenter)
|
||||
{
|
||||
_segmenter = segmenter;
|
||||
}
|
||||
|
||||
public VideoFrame Apply(VideoFrame frame, BackgroundEffect effect, double strength = DefaultStrength)
|
||||
{
|
||||
if (effect == BackgroundEffect.None) return frame;
|
||||
var mask = TrySegment(frame);
|
||||
return mask == null ? frame : Render(frame, mask, effect, strength);
|
||||
}
|
||||
|
||||
/// <summary>Runs the segmenter once; null (logged once) when it can't produce a usable mask.</summary>
|
||||
public SegmentationMask? TrySegment(VideoFrame frame)
|
||||
{
|
||||
SegmentationMask? mask;
|
||||
try
|
||||
{
|
||||
mask = _segmenter.Segment(frame);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogFailureOnce($"{ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mask == null || mask.Width < 1 || mask.Height < 1 || mask.Values.Length < mask.Width * mask.Height)
|
||||
{
|
||||
LogFailureOnce("segmenter returned no usable mask");
|
||||
return null;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
public static VideoFrame Render(VideoFrame frame, SegmentationMask mask, BackgroundEffect effect, double strength)
|
||||
{
|
||||
var s = Math.Clamp(strength, 0, 1);
|
||||
return effect switch
|
||||
{
|
||||
BackgroundEffect.Remove => ApplyRemove(frame, mask, s),
|
||||
BackgroundEffect.Blur => ApplyBlur(frame, mask, s),
|
||||
_ => frame,
|
||||
};
|
||||
}
|
||||
|
||||
internal static VideoFrame ApplyRemove(VideoFrame frame, SegmentationMask mask, double strength)
|
||||
{
|
||||
var src = frame.BgraPixels;
|
||||
var dst = new byte[src.Length];
|
||||
Buffer.BlockCopy(src, 0, dst, 0, src.Length);
|
||||
|
||||
// Edge feather: map the mask's [0.5 - w, 0.5 + w] band to alpha 0..1, as a 256-entry LUT
|
||||
// over the quantized mask value (so the per-pixel work is a lookup + one multiply).
|
||||
var w = 0.05 + 0.45 * strength;
|
||||
var lo = 0.5 - w;
|
||||
var span = 2 * w;
|
||||
var alphaLut = new int[256];
|
||||
for (var i = 0; i < 256; i++)
|
||||
alphaLut[i] = (int)Math.Round(Math.Clamp((i / 255.0 - lo) / span, 0, 1) * 256);
|
||||
|
||||
var width = frame.Width;
|
||||
var height = frame.Height;
|
||||
var stride = frame.Stride;
|
||||
var maskRows = UpsampleMaskRows(mask, width);
|
||||
var (r0, r1, fy) = AxisLut(mask.Height, height);
|
||||
|
||||
Parallel.For(0, height, RowParallelism, y =>
|
||||
{
|
||||
var a0 = maskRows[r0[y]];
|
||||
var a1 = maskRows[r1[y]];
|
||||
var t = fy[y];
|
||||
var row = y * stride + 3;
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var m = a0[x] + (a1[x] - a0[x]) * t;
|
||||
var q = (int)(m * 255f + 0.5f);
|
||||
q = q < 0 ? 0 : q > 255 ? 255 : q;
|
||||
var p = row + x * 4;
|
||||
dst[p] = (byte)((src[p] * alphaLut[q] + 128) >> 8);
|
||||
}
|
||||
});
|
||||
|
||||
return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = false };
|
||||
}
|
||||
|
||||
internal static VideoFrame ApplyBlur(VideoFrame frame, SegmentationMask mask, double strength)
|
||||
{
|
||||
var width = frame.Width;
|
||||
var height = frame.Height;
|
||||
var src = frame.BgraPixels;
|
||||
var stride = frame.Stride;
|
||||
|
||||
// 1. Downscale by box-averaging BlurDownscale×BlurDownscale blocks (3 float channels).
|
||||
var f = BlurDownscale;
|
||||
var sw = (width + f - 1) / f;
|
||||
var sh = (height + f - 1) / f;
|
||||
var small = new float[sw * sh * 3];
|
||||
Parallel.For(0, sh, RowParallelism, sy =>
|
||||
{
|
||||
var y0 = sy * f;
|
||||
var y1 = Math.Min(height, y0 + f);
|
||||
for (var sx = 0; sx < sw; sx++)
|
||||
{
|
||||
var x0 = sx * f;
|
||||
var x1 = Math.Min(width, x0 + f);
|
||||
float b = 0, g = 0, r = 0;
|
||||
var n = 0;
|
||||
for (var y = y0; y < y1; y++)
|
||||
{
|
||||
var p = y * stride + x0 * 4;
|
||||
for (var x = x0; x < x1; x++, p += 4)
|
||||
{
|
||||
b += src[p]; g += src[p + 1]; r += src[p + 2];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
var o = (sy * sw + sx) * 3;
|
||||
small[o] = b / n; small[o + 1] = g / n; small[o + 2] = r / n;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Three separable box-blur passes ≈ a gaussian.
|
||||
var radius = BlurRadius(strength);
|
||||
var scratch = new float[small.Length];
|
||||
for (var pass = 0; pass < 3; pass++)
|
||||
{
|
||||
BoxBlurHorizontal(small, scratch, sw, sh, radius);
|
||||
BoxBlurVertical(scratch, small, sw, sh, radius);
|
||||
}
|
||||
|
||||
// 3. Upsample: horizontal interpolation precomputed once per small row (and per mask row),
|
||||
// then each output row lerps two precomputed rows — no per-pixel 2-D bilinear.
|
||||
var blurRows = UpsampleColorRows(small, sw, sh, width);
|
||||
var (b0, b1, bfy) = AxisLut(sh, height);
|
||||
var maskRows = UpsampleMaskRows(mask, width);
|
||||
var (m0, m1, mfy) = AxisLut(mask.Height, height);
|
||||
|
||||
// 4. Blend: person (mask) keeps the source pixel, background takes the blur.
|
||||
var dst = new byte[src.Length];
|
||||
Parallel.For(0, height, RowParallelism, y =>
|
||||
{
|
||||
var ma = maskRows[m0[y]];
|
||||
var mb = maskRows[m1[y]];
|
||||
var mt = mfy[y];
|
||||
var ba = blurRows[b0[y]];
|
||||
var bb = blurRows[b1[y]];
|
||||
var bt = bfy[y];
|
||||
var row = y * stride;
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var p = row + x * 4;
|
||||
var m = ma[x] + (mb[x] - ma[x]) * mt;
|
||||
if (m >= 0.999f)
|
||||
{
|
||||
dst[p] = src[p]; dst[p + 1] = src[p + 1]; dst[p + 2] = src[p + 2]; dst[p + 3] = src[p + 3];
|
||||
continue;
|
||||
}
|
||||
if (m < 0f) m = 0f;
|
||||
var k = 1f - m;
|
||||
var c = x * 3;
|
||||
var vb = ba[c] + (bb[c] - ba[c]) * bt;
|
||||
var vg = ba[c + 1] + (bb[c + 1] - ba[c + 1]) * bt;
|
||||
var vr = ba[c + 2] + (bb[c + 2] - ba[c + 2]) * bt;
|
||||
dst[p] = ClampByte(src[p] * m + vb * k);
|
||||
dst[p + 1] = ClampByte(src[p + 1] * m + vg * k);
|
||||
dst[p + 2] = ClampByte(src[p + 2] * m + vr * k);
|
||||
dst[p + 3] = src[p + 3];
|
||||
}
|
||||
});
|
||||
|
||||
return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = frame.IsOpaque };
|
||||
}
|
||||
|
||||
/// <summary>Blur radius in downscaled pixels for a strength. Quadratic (s²), not linear, so the
|
||||
/// default 0.5 stays gentle (2 small px ≈ ~8–10 full px of perceived spread, ~3× lighter than
|
||||
/// the old linear 6) while only the top of the slider approaches the full range.</summary>
|
||||
internal static int BlurRadius(double strength)
|
||||
{
|
||||
var s = Math.Clamp(strength, 0, 1);
|
||||
return (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * s * s);
|
||||
}
|
||||
|
||||
/// <summary>Row-parallel pixel loops, capped so the effect worker can't starve the compositor
|
||||
/// and encoder threads.</summary>
|
||||
private static readonly ParallelOptions RowParallelism = new()
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount / 2, 1, 4),
|
||||
};
|
||||
|
||||
private static byte ClampByte(float v)
|
||||
{
|
||||
var i = (int)(v + 0.5f);
|
||||
return (byte)(i < 0 ? 0 : i > 255 ? 255 : i);
|
||||
}
|
||||
|
||||
/// <summary>For each destination index along one axis: the two source indices and the
|
||||
/// fractional weight — pixel-center aligned, edge-clamped (the same mapping a bilinear
|
||||
/// sample uses).</summary>
|
||||
private static (int[] I0, int[] I1, float[] F) AxisLut(int srcSize, int dstSize)
|
||||
{
|
||||
var i0 = new int[dstSize];
|
||||
var i1 = new int[dstSize];
|
||||
var fr = new float[dstSize];
|
||||
for (var d = 0; d < dstSize; d++)
|
||||
{
|
||||
var s = (d + 0.5f) * srcSize / dstSize - 0.5f;
|
||||
var a = (int)MathF.Floor(s);
|
||||
var t = s - a;
|
||||
i0[d] = Math.Clamp(a, 0, srcSize - 1);
|
||||
i1[d] = Math.Clamp(a + 1, 0, srcSize - 1);
|
||||
fr[d] = a < 0 ? 0f : a + 1 > srcSize - 1 ? 0f : t;
|
||||
}
|
||||
return (i0, i1, fr);
|
||||
}
|
||||
|
||||
/// <summary>Each mask row, horizontally interpolated (and clamped to 0..1) to the frame width.</summary>
|
||||
private static float[][] UpsampleMaskRows(SegmentationMask mask, int width)
|
||||
{
|
||||
var (x0, x1, fx) = AxisLut(mask.Width, width);
|
||||
var rows = new float[mask.Height][];
|
||||
var v = mask.Values;
|
||||
for (var r = 0; r < mask.Height; r++)
|
||||
{
|
||||
var row = new float[width];
|
||||
var o = r * mask.Width;
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var a = v[o + x0[x]];
|
||||
var m = a + (v[o + x1[x]] - a) * fx[x];
|
||||
row[x] = m < 0f ? 0f : m > 1f ? 1f : m;
|
||||
}
|
||||
rows[r] = row;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>Each row of a 3-channel float image, horizontally interpolated to <paramref name="width"/>.</summary>
|
||||
private static float[][] UpsampleColorRows(float[] img, int w, int h, int width)
|
||||
{
|
||||
var (x0, x1, fx) = AxisLut(w, width);
|
||||
var rows = new float[h][];
|
||||
Parallel.For(0, h, RowParallelism, r =>
|
||||
{
|
||||
var row = new float[width * 3];
|
||||
var o = r * w;
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var a = (o + x0[x]) * 3;
|
||||
var b = (o + x1[x]) * 3;
|
||||
var t = fx[x];
|
||||
row[x * 3] = img[a] + (img[b] - img[a]) * t;
|
||||
row[x * 3 + 1] = img[a + 1] + (img[b + 1] - img[a + 1]) * t;
|
||||
row[x * 3 + 2] = img[a + 2] + (img[b + 2] - img[a + 2]) * t;
|
||||
}
|
||||
rows[r] = row;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static void BoxBlurHorizontal(float[] input, float[] output, int w, int h, int r)
|
||||
{
|
||||
var norm = 1f / (2 * r + 1);
|
||||
for (var y = 0; y < h; y++)
|
||||
{
|
||||
var row = y * w;
|
||||
for (var c = 0; c < 3; c++)
|
||||
{
|
||||
float sum = 0;
|
||||
for (var i = -r; i <= r; i++)
|
||||
sum += input[(row + Math.Clamp(i, 0, w - 1)) * 3 + c];
|
||||
for (var x = 0; x < w; x++)
|
||||
{
|
||||
output[(row + x) * 3 + c] = sum * norm;
|
||||
var add = Math.Min(w - 1, x + r + 1);
|
||||
var sub = Math.Max(0, x - r);
|
||||
sum += input[(row + add) * 3 + c] - input[(row + sub) * 3 + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BoxBlurVertical(float[] input, float[] output, int w, int h, int r)
|
||||
{
|
||||
var norm = 1f / (2 * r + 1);
|
||||
for (var x = 0; x < w; x++)
|
||||
{
|
||||
for (var c = 0; c < 3; c++)
|
||||
{
|
||||
float sum = 0;
|
||||
for (var i = -r; i <= r; i++)
|
||||
sum += input[(Math.Clamp(i, 0, h - 1) * w + x) * 3 + c];
|
||||
for (var y = 0; y < h; y++)
|
||||
{
|
||||
output[(y * w + x) * 3 + c] = sum * norm;
|
||||
var add = Math.Min(h - 1, y + r + 1);
|
||||
var sub = Math.Max(0, y - r);
|
||||
sum += input[(add * w + x) * 3 + c] - input[(sub * w + x) * 3 + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private void LogFailureOnce(string reason)
|
||||
{
|
||||
if (_loggedFailure) return;
|
||||
_loggedFailure = true;
|
||||
AppLog.Write($"BackgroundEffectProcessor: passing webcam through — {reason}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ytLive.Services.BackgroundEffects;
|
||||
|
||||
/// <summary>A person-probability mask at the segmenter's own resolution: row-major,
|
||||
/// one value per pixel, 0 = background, 1 = person.</summary>
|
||||
public sealed record SegmentationMask(int Width, int Height, float[] Values);
|
||||
|
||||
/// <summary>Seam over the person-segmentation model (TASK 51) so tests inject a fake and
|
||||
/// never run real inference. Returns null when no mask can be produced; callers then pass
|
||||
/// the frame through untouched.</summary>
|
||||
public interface IPersonSegmenter
|
||||
{
|
||||
SegmentationMask? Segment(VideoFrame frame);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace ytLive.Services.BackgroundEffects;
|
||||
|
||||
/// <summary>
|
||||
/// Temporal smoothing for the person mask (TASK 51): each raw frame's segmentation is blended
|
||||
/// with the previous smoothed mask so the cut-out edge doesn't flutter from frame to frame when
|
||||
/// the model's per-frame verdict wavers.
|
||||
///
|
||||
/// Blend = <see cref="CurrentWeight"/> × new + (1 − <see cref="CurrentWeight"/>) × previous.
|
||||
/// Weights come from the established tools rather than a guess: OBS's obs-backgroundremoval
|
||||
/// smoke-tests `temporal_smooth_factor` at its 0.85 default, and Jitsi's webcam background
|
||||
/// filter (jitsi/jitsi-meet#17080) found 0.7/0.3 transformed best — a midpoint that still
|
||||
/// reacts to real movement. 0.5/0.5 is worse: on motion it leaves a smearing edge ghost.
|
||||
///
|
||||
/// State lives per camera session (the <see cref="CameraManager"/>'s one-per-frame worker), NOT
|
||||
/// in the segmenter — the segmenter is shared app-wide and its contract test pins the raw
|
||||
/// per-frame mask.
|
||||
/// </summary>
|
||||
public sealed class MaskSmoother
|
||||
{
|
||||
/// <summary>Weight of the newest raw mask; the rest is the previous smoothed mask.</summary>
|
||||
internal const double CurrentWeight = 0.75;
|
||||
|
||||
private float[]? _current;
|
||||
private float[]? _scratch;
|
||||
private int _width;
|
||||
private int _height;
|
||||
private bool _hasHistory;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the temporally-smoothed mask. A null input (segmenter hiccup) resets history so
|
||||
/// the next real mask starts moving again; the first frame and any size change pass through
|
||||
/// un-blended, and the returned mask never aliases the caller's array (the first frame copies
|
||||
/// its history). The two buffers ping-pong so no per-frame allocation is needed — a 256×144
|
||||
/// mask is 147 KB, past the 85 KB LOH threshold.
|
||||
/// </summary>
|
||||
public SegmentationMask? Smooth(SegmentationMask? raw)
|
||||
{
|
||||
if (raw == null)
|
||||
{
|
||||
_hasHistory = false;
|
||||
_width = 0;
|
||||
_height = 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_hasHistory || _width != raw.Width || _height != raw.Height
|
||||
|| _current == null || _scratch == null)
|
||||
{
|
||||
_width = raw.Width;
|
||||
_height = raw.Height;
|
||||
_current = new float[raw.Values.Length];
|
||||
_scratch = new float[raw.Values.Length];
|
||||
Array.Copy(raw.Values, _current, raw.Values.Length);
|
||||
_hasHistory = true;
|
||||
return raw;
|
||||
}
|
||||
|
||||
var v = raw.Values;
|
||||
var next = _scratch;
|
||||
var keep = 1f - (float)CurrentWeight;
|
||||
for (var i = 0; i < v.Length; i++)
|
||||
next[i] = keep * _current[i] + (float)CurrentWeight * v[i];
|
||||
|
||||
_scratch = _current;
|
||||
_current = next;
|
||||
return new SegmentationMask(_width, _height, next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.IO;
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services.BackgroundEffects;
|
||||
|
||||
/// <summary>
|
||||
/// The default <see cref="IPersonSegmenter"/>: Google's MediaPipe Selfie Segmenter
|
||||
/// (landscape, Apache-2.0) converted to ONNX by <c>tools/selfie-seg/</c> and shipped as an
|
||||
/// embedded resource — nothing is downloaded at runtime and no GPU is required.
|
||||
///
|
||||
/// Contract: input [1,144,256,3] RGB in [0,1] (NHWC), output [1,144,256,1] person
|
||||
/// probability. The whole frame is resampled to 256×144 (stretched, not cropped), so the
|
||||
/// mask maps back to the frame by normalized coordinates.
|
||||
///
|
||||
/// Never throws from <see cref="Segment"/>: a model that fails to load or run logs once
|
||||
/// and returns null, and the caller passes the webcam frame through.
|
||||
/// </summary>
|
||||
public sealed class OnnxPersonSegmenter : IPersonSegmenter, IDisposable
|
||||
{
|
||||
public const int ModelWidth = 256;
|
||||
public const int ModelHeight = 144;
|
||||
internal const string ModelResourceName = "ytLive.Assets.Models.selfie_segmenter_landscape.onnx";
|
||||
|
||||
private static readonly long[] InputShape = { 1, ModelHeight, ModelWidth, 3 };
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly float[] _input = new float[ModelWidth * ModelHeight * 3];
|
||||
private readonly RunOptions _runOptions = new();
|
||||
private InferenceSession? _session;
|
||||
private bool _failed;
|
||||
private bool _disposed;
|
||||
|
||||
public SegmentationMask? Segment(VideoFrame frame)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_failed || _disposed) return null;
|
||||
try
|
||||
{
|
||||
_session ??= CreateSession();
|
||||
FillInput(frame, _input);
|
||||
using var input = OrtValue.CreateTensorValueFromMemory(_input, InputShape);
|
||||
using var outputs = _session.Run(_runOptions, new[] { "input" }, new[] { input }, new[] { "mask" });
|
||||
var mask = outputs[0].GetTensorDataAsSpan<float>().ToArray();
|
||||
return new SegmentationMask(ModelWidth, ModelHeight, mask);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_failed = true;
|
||||
AppLog.Write($"OnnxPersonSegmenter: background effects disabled — {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_disposed = true;
|
||||
_session?.Dispose();
|
||||
_session = null;
|
||||
_runOptions.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static InferenceSession CreateSession()
|
||||
{
|
||||
using var stream = typeof(OnnxPersonSegmenter).Assembly.GetManifestResourceStream(ModelResourceName)
|
||||
?? throw new InvalidOperationException($"embedded model '{ModelResourceName}' is missing");
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
// Two intra-op threads: the model is tiny, and the webcam must not starve the
|
||||
// compositor/encoder threads (obs-backgroundremoval reports 2 as the sweet spot).
|
||||
using var options = new SessionOptions
|
||||
{
|
||||
IntraOpNumThreads = 2,
|
||||
InterOpNumThreads = 1,
|
||||
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,
|
||||
};
|
||||
return new InferenceSession(buffer.ToArray(), options);
|
||||
}
|
||||
|
||||
/// <summary>BGRA frame → model input: nearest-sample stretch to 256×144, RGB order,
|
||||
/// values /255. Nearest is enough — the model input is ~5× smaller than a 720p frame
|
||||
/// and the mask is the only thing that comes back.</summary>
|
||||
internal static void FillInput(VideoFrame frame, float[] input)
|
||||
{
|
||||
var src = frame.BgraPixels;
|
||||
var stride = frame.Stride;
|
||||
var o = 0;
|
||||
for (var y = 0; y < ModelHeight; y++)
|
||||
{
|
||||
var sy = Math.Min(frame.Height - 1, (int)((y + 0.5) * frame.Height / ModelHeight));
|
||||
var row = sy * stride;
|
||||
for (var x = 0; x < ModelWidth; x++)
|
||||
{
|
||||
var sx = Math.Min(frame.Width - 1, (int)((x + 0.5) * frame.Width / ModelWidth));
|
||||
var p = row + sx * 4;
|
||||
input[o++] = src[p + 2] / 255f;
|
||||
input[o++] = src[p + 1] / 255f;
|
||||
input[o++] = src[p] / 255f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.BackgroundEffects;
|
||||
|
||||
/// <summary>Identifies one rendered variant of a camera's frames (TASK 51): the effect plus its
|
||||
/// strength, rounded to 2 decimals so a slider drag doesn't spawn a variant per pixel of travel.
|
||||
/// <see cref="BackgroundEffect.None"/> always normalizes to strength 0 — the raw path.</summary>
|
||||
public readonly record struct WebcamEffectKey(BackgroundEffect Effect, double Strength)
|
||||
{
|
||||
public static WebcamEffectKey None => new(BackgroundEffect.None, 0);
|
||||
|
||||
public static WebcamEffectKey For(BackgroundEffect effect, double strength)
|
||||
=> effect == BackgroundEffect.None
|
||||
? None
|
||||
: new WebcamEffectKey(effect, Math.Round(Math.Clamp(strength, 0, 1), 2));
|
||||
}
|
||||
+223
-1
@@ -6,6 +6,8 @@ using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services.BackgroundEffects;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
@@ -21,9 +23,24 @@ namespace ytLive.Services;
|
||||
/// <paramref name="firstFrameTimeout"/>), and an async <see cref="SourceFailed"/>
|
||||
/// tears the session down and surfaces <see cref="CameraFailed"/> — no silent
|
||||
/// empty box.
|
||||
///
|
||||
/// Background effects (TASK 51) are the ONE processing point on the camera path: per device,
|
||||
/// the set of requested <see cref="WebcamEffectKey"/>s (<see cref="SetRequestedEffects"/>) is
|
||||
/// rendered from each raw frame on a background worker — one segmentation per frame, smoothed
|
||||
/// per camera across frames (see <see cref="MaskSmoother"/>) and shared by every variant,
|
||||
/// latest-frame-drop when the worker is busy — and each variant keeps its own
|
||||
/// latest frame (the compositor) and shared WriteableBitmap (the preview). The raw path is
|
||||
/// untouched, so a slow or failing model never stalls the plain webcam.
|
||||
/// </summary>
|
||||
public sealed class CameraManager : IDisposable
|
||||
{
|
||||
private sealed class EffectVariant
|
||||
{
|
||||
public WriteableBitmap? PreviewBitmap;
|
||||
public VideoFrame? LatestFrame;
|
||||
public bool FramePending;
|
||||
}
|
||||
|
||||
private sealed class CameraSession
|
||||
{
|
||||
public string DeviceId { get; }
|
||||
@@ -36,6 +53,10 @@ public sealed class CameraManager : IDisposable
|
||||
public VideoFrame? LatestFrame;
|
||||
public bool FramePending;
|
||||
public TaskCompletionSource<bool>? FirstFrame;
|
||||
public readonly Dictionary<WebcamEffectKey, EffectVariant> Effects = new();
|
||||
public bool EffectWorkerRunning;
|
||||
public VideoFrame? LastEffectSource;
|
||||
public readonly MaskSmoother Smoother = new();
|
||||
|
||||
public CameraSession(string deviceId, ICameraFrameSource source)
|
||||
{
|
||||
@@ -50,11 +71,18 @@ public sealed class CameraManager : IDisposable
|
||||
private readonly Dispatcher? _uiDispatcher;
|
||||
private readonly TimeSpan _firstFrameTimeout;
|
||||
private readonly Dictionary<string, CameraSession> _sessions = new();
|
||||
private readonly Dictionary<string, HashSet<WebcamEffectKey>> _requestedEffects = new();
|
||||
private readonly object _gate = new();
|
||||
private readonly BackgroundEffectProcessor? _effects;
|
||||
private readonly EffectTimer _effectTimer = new();
|
||||
|
||||
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
|
||||
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||
|
||||
/// <summary>Raised on the UI thread when an effect variant's preview bitmap is first created
|
||||
/// (device id, effect key, bitmap) — the effect counterpart of <see cref="PreviewBitmapChanged"/>.</summary>
|
||||
public event Action<string, WebcamEffectKey, WriteableBitmap>? EffectPreviewBitmapChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a capture fails: device in use, access denied, no preview source,
|
||||
/// or a started reader that never delivers a first frame within the timeout.
|
||||
@@ -62,12 +90,55 @@ public sealed class CameraManager : IDisposable
|
||||
public event Action<string, string>? CameraFailed;
|
||||
|
||||
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
|
||||
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null)
|
||||
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null,
|
||||
BackgroundEffectProcessor? effects = null)
|
||||
{
|
||||
_enumerator = enumerator;
|
||||
_frameSourceFactory = frameSourceFactory;
|
||||
_uiDispatcher = uiDispatcher;
|
||||
_firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4);
|
||||
_effects = effects;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Declares which background-effect variants a device must render (the union over every
|
||||
/// scene's webcam config). <see cref="BackgroundEffect.None"/> keys are ignored — that's the
|
||||
/// raw path. Variants no longer requested are dropped (their worker cost stops); new ones
|
||||
/// start rendering from the next camera frame. Survives the session starting later.
|
||||
/// </summary>
|
||||
public void SetRequestedEffects(string deviceId, IEnumerable<WebcamEffectKey> keys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) return;
|
||||
lock (_gate)
|
||||
{
|
||||
var set = new HashSet<WebcamEffectKey>();
|
||||
foreach (var key in keys)
|
||||
if (key.Effect != BackgroundEffect.None) set.Add(key);
|
||||
_requestedEffects[deviceId] = set;
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
SyncVariants(session, set);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The latest frame for a variant: the raw frame for <see cref="BackgroundEffect.None"/>,
|
||||
/// otherwise the processed frame — null until the first one is rendered (an un-processed
|
||||
/// frame is never shown in its place, so Remove never flashes the real room).</summary>
|
||||
public VideoFrame? GetLatestFrame(string deviceId, WebcamEffectKey key)
|
||||
{
|
||||
if (key.Effect == BackgroundEffect.None) return GetLatestFrame(deviceId);
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(deviceId, out var session)
|
||||
&& session.Effects.TryGetValue(key, out var variant) ? variant.LatestFrame : null;
|
||||
}
|
||||
|
||||
/// <summary>The shared preview bitmap for a variant (raw bitmap for None), or null until
|
||||
/// its first frame — then <see cref="EffectPreviewBitmapChanged"/> fires.</summary>
|
||||
public WriteableBitmap? GetPreviewBitmap(string deviceId, WebcamEffectKey key)
|
||||
{
|
||||
if (key.Effect == BackgroundEffect.None) return GetPreviewBitmap(deviceId);
|
||||
lock (_gate)
|
||||
return _sessions.TryGetValue(deviceId, out var session)
|
||||
&& session.Effects.TryGetValue(key, out var variant) ? variant.PreviewBitmap : null;
|
||||
}
|
||||
|
||||
public ICameraEnumerator Enumerator => _enumerator;
|
||||
@@ -94,6 +165,8 @@ public sealed class CameraManager : IDisposable
|
||||
session.Source.FrameAvailable += session.FrameHandler;
|
||||
session.FailureHandler = message => OnSourceFailed(session, message);
|
||||
session.Source.SourceFailed += session.FailureHandler;
|
||||
if (_requestedEffects.TryGetValue(deviceId, out var requested))
|
||||
SyncVariants(session, requested);
|
||||
_sessions[deviceId] = session;
|
||||
shouldStart = true;
|
||||
}
|
||||
@@ -171,6 +244,18 @@ public sealed class CameraManager : IDisposable
|
||||
toStop.PreviewBitmap = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True while a session exists for the device — started, or still starting.
|
||||
/// Used by the app's startup resource check to avoid double-acquiring a lock
|
||||
/// that the saved identity's scene configs already hold. A rolled-back (failed)
|
||||
/// session is removed, so this goes false and the startup pass can retry.
|
||||
/// </summary>
|
||||
public bool IsRunning(string deviceId)
|
||||
{
|
||||
lock (_gate)
|
||||
return _sessions.ContainsKey(deviceId);
|
||||
}
|
||||
|
||||
public VideoFrame? GetLatestFrame(string deviceId)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -265,6 +350,7 @@ public sealed class CameraManager : IDisposable
|
||||
if (!TryGetActiveSession(session)) return;
|
||||
session.LatestFrame = frame;
|
||||
session.FirstFrame?.TrySetResult(true);
|
||||
ScheduleEffects(session);
|
||||
|
||||
if (session.PreviewBitmap == null)
|
||||
{
|
||||
@@ -296,6 +382,142 @@ public sealed class CameraManager : IDisposable
|
||||
}, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
// Caller holds _gate. A new key inherits the last frame + bitmap of a dropped variant with
|
||||
// the SAME effect (a strength change), so neither the stream nor the preview blanks for a
|
||||
// frame while the creator drags the strength slider.
|
||||
private static void SyncVariants(CameraSession session, HashSet<WebcamEffectKey> keys)
|
||||
{
|
||||
var dropped = session.Effects.Where(kv => !keys.Contains(kv.Key)).ToList();
|
||||
foreach (var (stale, _) in dropped)
|
||||
session.Effects.Remove(stale);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (session.Effects.ContainsKey(key)) continue;
|
||||
var heir = dropped.FirstOrDefault(kv => kv.Key.Effect == key.Effect).Value;
|
||||
session.Effects[key] = new EffectVariant
|
||||
{
|
||||
LatestFrame = heir?.LatestFrame,
|
||||
PreviewBitmap = heir?.PreviewBitmap,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleEffects(CameraSession session)
|
||||
{
|
||||
if (_effects == null) return;
|
||||
lock (_gate)
|
||||
{
|
||||
if (session.Effects.Count == 0 || session.EffectWorkerRunning) return;
|
||||
session.EffectWorkerRunning = true;
|
||||
}
|
||||
_ = Task.Run(() => RunEffectWorker(session));
|
||||
}
|
||||
|
||||
/// <summary>Renders every requested variant from the newest raw frame, then loops while a
|
||||
/// newer frame has arrived; exits (and re-arms on the next frame) when caught up. One
|
||||
/// segmentation per processed frame, whatever the number of variants.</summary>
|
||||
private void RunEffectWorker(CameraSession session)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
VideoFrame? source;
|
||||
List<WebcamEffectKey> keys;
|
||||
lock (_gate)
|
||||
{
|
||||
source = session.LatestFrame;
|
||||
if (!TryGetActiveSessionLocked(session) || session.Effects.Count == 0
|
||||
|| source == null || ReferenceEquals(source, session.LastEffectSource))
|
||||
{
|
||||
session.EffectWorkerRunning = false;
|
||||
return;
|
||||
}
|
||||
session.LastEffectSource = source;
|
||||
keys = session.Effects.Keys.ToList();
|
||||
}
|
||||
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var mask = session.Smoother.Smooth(_effects!.TrySegment(source));
|
||||
var rendered = new List<(WebcamEffectKey Key, VideoFrame Frame)>(keys.Count);
|
||||
if (mask != null)
|
||||
foreach (var key in keys)
|
||||
rendered.Add((key, BackgroundEffectProcessor.Render(source, mask, key.Effect, key.Strength)));
|
||||
_effectTimer.Record(System.Diagnostics.Stopwatch.GetElapsedTime(started), source, keys.Count);
|
||||
|
||||
foreach (var (key, frame) in rendered)
|
||||
{
|
||||
EffectVariant? variant;
|
||||
lock (_gate)
|
||||
{
|
||||
if (session.Effects.TryGetValue(key, out variant))
|
||||
variant.LatestFrame = frame;
|
||||
}
|
||||
if (variant != null) PublishVariantPreview(session, key, variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate) session.EffectWorkerRunning = false;
|
||||
AppLog.Write($"CameraManager: background effects worker failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PublishVariantPreview(CameraSession session, WebcamEffectKey key, EffectVariant variant)
|
||||
{
|
||||
if (_uiDispatcher == null) return;
|
||||
lock (_gate)
|
||||
{
|
||||
if (variant.FramePending) return;
|
||||
variant.FramePending = true;
|
||||
}
|
||||
_uiDispatcher.BeginInvoke(() =>
|
||||
{
|
||||
VideoFrame? frame;
|
||||
lock (_gate)
|
||||
{
|
||||
variant.FramePending = false;
|
||||
if (!TryGetActiveSessionLocked(session) || !session.Effects.TryGetValue(key, out var current)
|
||||
|| !ReferenceEquals(current, variant)) return;
|
||||
frame = variant.LatestFrame;
|
||||
}
|
||||
if (frame == null) return;
|
||||
var bitmap = variant.PreviewBitmap;
|
||||
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
|
||||
{
|
||||
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);
|
||||
variant.PreviewBitmap = bitmap;
|
||||
EffectPreviewBitmapChanged?.Invoke(session.DeviceId, key, bitmap);
|
||||
return;
|
||||
}
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
||||
}, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private bool TryGetActiveSessionLocked(CameraSession session)
|
||||
=> _sessions.TryGetValue(session.DeviceId, out var current) && ReferenceEquals(current, session);
|
||||
|
||||
/// <summary>Perf record for the creator's-box check (TASK 51): logs the average
|
||||
/// segment + render cost once per session after 120 processed frames.</summary>
|
||||
private sealed class EffectTimer
|
||||
{
|
||||
private int _count;
|
||||
private double _totalMs;
|
||||
private bool _logged;
|
||||
|
||||
public void Record(TimeSpan elapsed, VideoFrame frame, int variants)
|
||||
{
|
||||
if (_logged) return;
|
||||
_totalMs += elapsed.TotalMilliseconds;
|
||||
if (++_count < 120) return;
|
||||
_logged = true;
|
||||
AppLog.Write($"CameraManager: background effects avg {_totalMs / _count:0.0} ms/frame " +
|
||||
$"({frame.Width}x{frame.Height}, {variants} variant(s), {_count} frames)");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePreviewBitmap(CameraSession session)
|
||||
{
|
||||
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using ytLive.Models;
|
||||
using AppLog = ytLive.Helpers.AppLog;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API
|
||||
/// (TASK 39, slice 1): subscriptions/views/videos via channels.list, and the
|
||||
/// count of public uploads in the trailing 90 days via playlistItems.list on the
|
||||
/// uploads playlist. Long-form watch hours and Shorts-feed views need the
|
||||
/// Analytics API (a later slice behind the IChannelStatsProvider seam).
|
||||
///
|
||||
/// NOTE (root cause, 2026-09-23): channels.list must NOT request the
|
||||
/// `auditDetails` part — that part alone requires the
|
||||
/// `youtubepartner-channel-audit` scope the app doesn't (and mustn't) hold, and
|
||||
/// the whole request 403s `insufficientPermissions` over it. Standing flags are
|
||||
/// not exposed to ordinary apps; the drawer deep-links to YouTube Studio instead.
|
||||
/// </summary>
|
||||
public class ChannelStatsService
|
||||
{
|
||||
private readonly YouTubeAuthService _auth;
|
||||
private readonly HttpClient _http;
|
||||
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
|
||||
|
||||
public ChannelStatsService(YouTubeAuthService auth, HttpClient? http = null)
|
||||
{
|
||||
_auth = auth;
|
||||
_http = http ?? new HttpClient();
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureToken()
|
||||
{
|
||||
if (_auth.CurrentChannel == null) return false;
|
||||
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
||||
return await _auth.RefreshToken();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Captures the channel's current stats into a snapshot, or null when
|
||||
/// there's no session, a request fails, or the API returns nothing. Never throws.</summary>
|
||||
public virtual async Task<YppStatSnapshot?> CaptureCurrentAsync()
|
||||
{
|
||||
if (!await EnsureToken()) return null;
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/channels?mine=true&part=statistics,contentDetails");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Log the response body: YouTube's error.reason names the failure
|
||||
// (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}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||
return null;
|
||||
|
||||
var channel = items[0];
|
||||
var statistics = channel.GetProperty("statistics");
|
||||
|
||||
var uploadsPlaylistId = channel.TryGetProperty("contentDetails", out var contentDetails)
|
||||
&& contentDetails.TryGetProperty("relatedPlaylists", out var related)
|
||||
&& related.TryGetProperty("uploads", out var uploads)
|
||||
? uploads.GetString()
|
||||
: null;
|
||||
|
||||
var uploadsLast90Days = uploadsPlaylistId != null
|
||||
? await CountUploadsLast90DaysAsync(uploadsPlaylistId)
|
||||
: 0;
|
||||
|
||||
return new YppStatSnapshot
|
||||
{
|
||||
CapturedAtUtc = DateTime.UtcNow,
|
||||
SubscriberCount = ReadInt64(statistics, "subscriberCount"),
|
||||
VideoCount = ReadInt64(statistics, "videoCount"),
|
||||
TotalViews = ReadInt64(statistics, "viewCount"),
|
||||
UploadsLast90Days = uploadsLast90Days,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>YouTube serializes statistics as JSON STRINGS ("350"); a tolerant
|
||||
/// read that also accepts a number (the real 403 hid this parse bug until the
|
||||
/// auditDetails removal surfaced the string shape in 2026-09-23's Good Dog).
|
||||
/// NOTE: JsonElement.TryGetInt64 THROWS on a non-Number token — ValueKind must
|
||||
/// be checked first (try-type-in, not try-catch).</summary>
|
||||
private static long ReadInt64(JsonElement parent, string name)
|
||||
{
|
||||
if (!parent.TryGetProperty(name, out var value)) return 0;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var n)) return n;
|
||||
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out n)) return n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Counts uploads published within the trailing 90 days, scanning the
|
||||
/// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP
|
||||
/// requirement; the YPP tab labels it as the recent-50 window).</summary>
|
||||
private async Task<int> CountUploadsLast90DaysAsync(string uploadsPlaylistId)
|
||||
{
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items)) return 0;
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-90);
|
||||
var count = 0;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("snippet", out var snippet)
|
||||
|| !snippet.TryGetProperty("publishedAt", out var published)
|
||||
|| !DateTime.TryParse(published.GetString(), out var publishedAt))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (publishedAt.ToUniversalTime() >= cutoff)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>Owns the YouTube-chat overlay behavior: the message buffer, the
|
||||
/// renderer, and the fade/mock-preview timers. The MainViewModel keeps only the
|
||||
/// thin binding surface (the shared <see cref="Messages"/> collection + two
|
||||
/// computed gate props) and hands the scene list in at each call — this class
|
||||
/// owns all chat logic so an AI can read one self-contained unit.</summary>
|
||||
public sealed class ChatOverlayLayer
|
||||
{
|
||||
private readonly ChatBoxRenderer _renderer;
|
||||
|
||||
// Mock chat messages for the ChatBox preview — branded placeholders so the
|
||||
// creator sees where the overlay lands before going live.
|
||||
private static readonly IReadOnlyList<ChatMessage> MockChatMessages = new[]
|
||||
{
|
||||
new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true },
|
||||
new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true },
|
||||
new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) },
|
||||
new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true },
|
||||
new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true },
|
||||
};
|
||||
|
||||
// Chat fade timer: clears chat overlay after N seconds of inactivity.
|
||||
private DispatcherTimer? _fadeTimer;
|
||||
private CancellationTokenSource? _mockCts;
|
||||
|
||||
/// <summary>The live chat message buffer. Shared with the ViewModel (which
|
||||
/// binds it to the panel) via <c>ChatMessages => _chatLayer.Messages</c>.</summary>
|
||||
public ObservableCollection<ChatMessage> Messages { get; } = new();
|
||||
|
||||
// Output-frame cache (take-5 starvation fix): the pump called RenderFrame
|
||||
// every tick and the renderer did a FULL WPF raster (FormattedText +
|
||||
// RenderTargetBitmap + CopyPixels + channel swap) each time — 15-25ms of a
|
||||
// 25.5ms render. Established answer (OBS text sources): raster on change,
|
||||
// blit the cache every tick. _contentVersion bumps on every buffer mutation
|
||||
// (CollectionChanged — covers adds, the 500-cap removal, and the fade
|
||||
// Clear from any caller); the config key covers the box geometry/appearance.
|
||||
// The cached VideoFrame is treated as immutable: the compositor only reads.
|
||||
private int _contentVersion;
|
||||
private int _frameVersion = -1;
|
||||
private string? _frameKey;
|
||||
private VideoFrame? _cachedFrame;
|
||||
|
||||
public ChatOverlayLayer(ChatBoxRenderer renderer)
|
||||
{
|
||||
_renderer = renderer;
|
||||
Messages.CollectionChanged += (_, _) => _contentVersion++;
|
||||
}
|
||||
|
||||
/// <summary>Ingest a message from the poller. UI-thread marshalled: appended
|
||||
/// to the buffer (capped at 500), mock preview stopped, swipe to real
|
||||
/// messages, and the fade timer reset. Scenes are handed in so the layer can
|
||||
/// refresh every ChatBox preview without owning the scene graph.</summary>
|
||||
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Messages.Add(message);
|
||||
if (Messages.Count > 500)
|
||||
Messages.RemoveAt(0);
|
||||
|
||||
// Reset chat fade timer on every message
|
||||
ResetChatFadeTimer(scenes);
|
||||
|
||||
StopMockPreview();
|
||||
|
||||
// Switch ChatBox previews from mock to real messages
|
||||
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.ChatBox))
|
||||
UpdatePreview(chatBox);
|
||||
});
|
||||
}
|
||||
|
||||
private void ResetChatFadeTimer(IEnumerable<Scene> scenes)
|
||||
{
|
||||
_fadeTimer?.Stop();
|
||||
var chatBoxes = scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList();
|
||||
if (chatBoxes.Count == 0) return;
|
||||
|
||||
var delay = chatBoxes.Min(s => s.ChatFadeSeconds);
|
||||
_fadeTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) };
|
||||
_fadeTimer.Tick += (_, _) =>
|
||||
{
|
||||
_fadeTimer?.Stop();
|
||||
_fadeTimer = null;
|
||||
Messages.Clear();
|
||||
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.ChatBox))
|
||||
UpdatePreview(chatBox);
|
||||
StartMockPreview(scenes);
|
||||
};
|
||||
_fadeTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>Renders the live-output frame for one ChatBox element (the
|
||||
/// compositor path), or null when no messages are buffered yet. Mirrors the
|
||||
/// preview render but without writing onto the source's VideoImageSource.</summary>
|
||||
public VideoFrame? RenderFrame(Source chatBox)
|
||||
{
|
||||
var width = (int)Math.Max(1, chatBox.Width);
|
||||
var height = (int)Math.Max(1, chatBox.Height);
|
||||
var key =
|
||||
$"{width}x{height}|{chatBox.ChatFontSize}|{chatBox.ChatFontFamily}|{chatBox.ChatFontColor}"
|
||||
+ $"|{chatBox.ChatBackgroundColor}|{chatBox.ChatMaxMessages}|{chatBox.ChatShowTimestamps}"
|
||||
+ $"|{chatBox.ChatShowBadges}|{chatBox.ChatMemberColor}|{chatBox.ChatSuperChatColor}";
|
||||
|
||||
if (_frameKey == key && _frameVersion == _contentVersion)
|
||||
return _cachedFrame; // same content + same config — the cached raster stands
|
||||
|
||||
// The raster is WPF (DrawingVisual + RenderTargetBitmap = UI-thread objects)
|
||||
// and the frame pump now renders from its own thread (take-8 off-UI fix) —
|
||||
// so only the cache CHECK runs off-thread; the rare miss marshals to the
|
||||
// dispatcher and re-validates there (a second message landing mid-hop must
|
||||
// not be answered by a snapshot taken before it).
|
||||
var app = System.Windows.Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
var capturedKey = key;
|
||||
var capturedVersion = _contentVersion;
|
||||
var frame = app.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
|
||||
return _cachedFrame; // someone already re-rasterized on the UI thread
|
||||
return RenderCore(chatBox, width, height, capturedKey, capturedVersion);
|
||||
});
|
||||
return frame;
|
||||
}
|
||||
|
||||
return RenderCore(chatBox, width, height, key, _contentVersion);
|
||||
}
|
||||
|
||||
private VideoFrame? RenderCore(Source chatBox, int width, int height, string key, int version)
|
||||
{
|
||||
_frameKey = key;
|
||||
_frameVersion = version;
|
||||
if (Messages.Count == 0) return _cachedFrame = null;
|
||||
|
||||
return _cachedFrame = _renderer.Render(
|
||||
Messages,
|
||||
width,
|
||||
height,
|
||||
chatBox.ChatFontSize,
|
||||
chatBox.ChatFontFamily,
|
||||
chatBox.ChatFontColor,
|
||||
chatBox.ChatBackgroundColor,
|
||||
chatBox.ChatMaxMessages,
|
||||
chatBox.ChatShowTimestamps,
|
||||
chatBox.ChatShowBadges,
|
||||
chatBox.ChatMemberColor,
|
||||
chatBox.ChatSuperChatColor);
|
||||
}
|
||||
|
||||
/// <summary>Renders one ChatBox preview: real messages when connected, mock
|
||||
/// when not. Writes the resulting bitmap onto the source's
|
||||
/// <c>VideoImageSource</c>.</summary>
|
||||
public void UpdatePreview(Source chatBox, IReadOnlyList<ChatMessage>? messages = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
messages ??= Messages.Count > 0 ? (IReadOnlyList<ChatMessage>)Messages : MockChatMessages;
|
||||
var width = (int)Math.Max(32, chatBox.Width);
|
||||
var height = (int)Math.Max(32, chatBox.Height);
|
||||
|
||||
var frame = _renderer.Render(
|
||||
messages,
|
||||
width,
|
||||
height,
|
||||
chatBox.ChatFontSize,
|
||||
chatBox.ChatFontFamily,
|
||||
chatBox.ChatFontColor,
|
||||
chatBox.ChatBackgroundColor,
|
||||
chatBox.ChatMaxMessages,
|
||||
chatBox.ChatShowTimestamps,
|
||||
chatBox.ChatShowBadges,
|
||||
chatBox.ChatMemberColor,
|
||||
chatBox.ChatSuperChatColor);
|
||||
|
||||
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
|
||||
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
||||
chatBox.VideoImageSource = bitmap;
|
||||
}
|
||||
catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private async Task RunMockPreviewAsync(Source chatBox, CancellationToken ct)
|
||||
{
|
||||
var n = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
UpdatePreview(chatBox, MockChatMessages.Take(n + 1).ToList());
|
||||
await Task.Delay(1000, ct);
|
||||
n++;
|
||||
if (n == MockChatMessages.Count)
|
||||
{
|
||||
await Task.Delay(3000, ct);
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StartMockPreview(IEnumerable<Scene> scenes)
|
||||
{
|
||||
StopMockPreview();
|
||||
_mockCts = new CancellationTokenSource();
|
||||
var ct = _mockCts.Token;
|
||||
foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled))
|
||||
_ = RunMockPreviewAsync(chatBox, ct);
|
||||
}
|
||||
|
||||
public void StopMockPreview()
|
||||
{
|
||||
_mockCts?.Cancel();
|
||||
_mockCts?.Dispose();
|
||||
_mockCts = null;
|
||||
}
|
||||
|
||||
/// <summary>Render previews + wire property-change subscriptions for all
|
||||
/// ChatBox sources across every scene. Called after layout load so existing
|
||||
/// ChatBox sources show mock content and respond to config changes.</summary>
|
||||
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
|
||||
{
|
||||
foreach (var scene in scenes)
|
||||
foreach (var chatBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.ChatBox))
|
||||
{
|
||||
chatBox.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor)
|
||||
or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity)
|
||||
or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps)
|
||||
or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor)
|
||||
or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily)
|
||||
or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height))
|
||||
UpdatePreview(chatBox);
|
||||
};
|
||||
UpdatePreview(chatBox);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Rasterizes the stream-alert ticker into a transparent BGRA strip that the
|
||||
/// output compositor overlays along the very top edge of the master frame
|
||||
/// (TASK 47). The strip is text ("Viewer — Super Chat · $12.34") inside a dark
|
||||
/// pill with the LlamaCasty accent bar, rendered ONCE per unique text on the UI
|
||||
/// thread (WPF <c>RenderTargetBitmap</c>, Pbgra32→straight-alpha like
|
||||
/// <see cref="SocialBarRenderer"/>), then cached. Each <see cref="Render"/> call
|
||||
/// is pure byte-math on that cached raster producing a scrolling marquee frame,
|
||||
/// so the frame pump can call it from any thread for every output frame at
|
||||
/// 30fps without a Dispatcher round-trip.
|
||||
/// </summary>
|
||||
public static class AlertTickerRenderer
|
||||
{
|
||||
/// <summary>Master-frame width — the ticker spans the full 1920px output.</summary>
|
||||
public const int Width = 1920;
|
||||
|
||||
/// <summary>Strip height (pill + vertical padding).</summary>
|
||||
public const int Height = 48;
|
||||
|
||||
private const int RepeatGap = 120;
|
||||
private const int CacheLimit = 48;
|
||||
|
||||
/// <summary>How long each blink half lasts in <see cref="AlertDisplayMethod.Flash"/>
|
||||
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
|
||||
public const double FlashHalfSeconds = 0.5;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly Dictionary<string, VideoFrame> StripCache = new();
|
||||
|
||||
/// <summary>A frame of <paramref name="text"/> presented per
|
||||
/// <paramref name="method"/> at <paramref name="elapsedSeconds"/> into the alert,
|
||||
/// or null when there is nothing to show (no text, or a Flash in its off half).
|
||||
/// Thread-safe: reads the cached raster and does row copies only.
|
||||
/// <para><see cref="AlertDisplayMethod.TickerScroll"/> paces itself from
|
||||
/// <paramref name="readsPerAlert"/> over <paramref name="alertSeconds"/> rather
|
||||
/// than a fixed pixels-per-second: the old constant (140px/s ≈ a 17s pass) meant
|
||||
/// a 10s alert showed the text barely once, entering from the right and never
|
||||
/// crossing. The creator's target is three readable passes per alert.</para>
|
||||
/// <para><paramref name="width"/>/<paramref name="height"/> default to the
|
||||
/// full-master strip. They are parameters because the ticker now renders INTO the
|
||||
/// Stream Alerts box (creator ruling 2026-09-26) and therefore has to be the box's
|
||||
/// size: a 1920-wide strip clipped to an 800px box would hide the announcement
|
||||
/// off the right edge for most of its pass, which is the opposite of readable.</para></summary>
|
||||
public static VideoFrame? Render(string? text, double elapsedSeconds,
|
||||
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
|
||||
double alertSeconds = 0.0, int readsPerAlert = 3,
|
||||
int width = Width, int height = Height)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
var strip = GetStrip(text!);
|
||||
if (strip == null) return null;
|
||||
|
||||
if (method == AlertDisplayMethod.Flash
|
||||
&& elapsedSeconds % (FlashHalfSeconds * 2) >= FlashHalfSeconds)
|
||||
return null; // off half of the pulse
|
||||
|
||||
var x = method == AlertDisplayMethod.TickerScroll
|
||||
? ScrollX(strip.Width, width, elapsedSeconds, alertSeconds, readsPerAlert)
|
||||
: (width - strip.Width) / 2; // Solid + Flash: centred, still
|
||||
|
||||
var pixels = new byte[width * height * 4];
|
||||
CopyStrip(pixels, width, height, strip, x);
|
||||
if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
|
||||
CopyStrip(pixels, width, height, strip, x + width + RepeatGap); // wrapped tail enters
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
|
||||
/// <summary>Left edge of the pill for a marquee that must complete
|
||||
/// <paramref name="readsPerAlert"/> passes in <paramref name="alertSeconds"/>:
|
||||
/// one pass (off the right edge, across, out of sight) takes that long.
|
||||
/// <para>The run is phase-started half a frame in, so the very first frame of an
|
||||
/// alert already shows the announcement instead of a blank strip sitting exactly
|
||||
/// off the right edge.</para></summary>
|
||||
private static int ScrollX(int stripWidth, int width, double elapsedSeconds,
|
||||
double alertSeconds, int readsPerAlert)
|
||||
{
|
||||
var cycle = width + stripWidth + RepeatGap;
|
||||
var pass = Math.Max(0.5, alertSeconds) / Math.Max(1, readsPerAlert);
|
||||
var speed = cycle / pass;
|
||||
var pos = (width / 2 + (int)(elapsedSeconds * speed)) % cycle;
|
||||
return width - pos;
|
||||
}
|
||||
|
||||
|
||||
private static void CopyStrip(byte[] dst, int dstW, int dstH, VideoFrame strip, int dstX)
|
||||
{
|
||||
if (dstX >= dstW || dstX + strip.Width <= 0) return;
|
||||
var x0 = Math.Max(0, dstX);
|
||||
var x1 = Math.Min(dstW, dstX + strip.Width);
|
||||
var sx0 = x0 - dstX;
|
||||
var copyBytes = (x1 - x0) * 4;
|
||||
var sw = strip.Width;
|
||||
// The strip is rasterised at its natural 48px; the target box can be shorter,
|
||||
// and copying strip.Height rows into a shorter dst would run off the end of the
|
||||
// array. Clip the rows, not the buffer.
|
||||
var rows = Math.Min(strip.Height, dstH);
|
||||
for (var y = 0; y < rows; y++)
|
||||
Buffer.BlockCopy(strip.BgraPixels, (y * sw + sx0) * 4, dst, (y * dstW + x0) * 4, copyBytes);
|
||||
}
|
||||
|
||||
private static VideoFrame? GetStrip(string text)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (StripCache.TryGetValue(text, out var cached)) return cached;
|
||||
}
|
||||
var strip = RenderStrip(text);
|
||||
if (strip == null) return null;
|
||||
lock (Gate)
|
||||
{
|
||||
if (StripCache.Count >= CacheLimit) StripCache.Clear();
|
||||
if (!StripCache.TryGetValue(text, out var existing))
|
||||
StripCache[text] = strip;
|
||||
return StripCache[text];
|
||||
}
|
||||
}
|
||||
|
||||
private static VideoFrame? RenderStrip(string text)
|
||||
{
|
||||
// WPF rendering is single-threaded; the pump thread resolves to the UI
|
||||
// thread at most once per unique text (then hits the cache).
|
||||
var app = Application.Current;
|
||||
if (app != null && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
VideoFrame? result = null;
|
||||
app.Dispatcher.Invoke(() => result = RenderStrip(text));
|
||||
return result;
|
||||
}
|
||||
|
||||
const double pillHeight = 34;
|
||||
const double accentW = 6;
|
||||
const double padX = 16;
|
||||
const double gap = 8;
|
||||
|
||||
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
|
||||
var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
||||
font, 22, Brushes.White, 1.0);
|
||||
|
||||
var pillW = (int)Math.Ceiling(padX + accentW + gap + ft.Width + padX);
|
||||
var top = (Height - pillHeight) / 2.0;
|
||||
var textTop = top + (pillHeight - ft.Height) / 2.0;
|
||||
|
||||
var visual = new DrawingVisual();
|
||||
using (var dc = visual.RenderOpen())
|
||||
{
|
||||
var pill = new Rect(0, top, pillW, pillHeight);
|
||||
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xE6, 0x10, 0x13, 0x24)), null, pill, pillHeight / 2, pillHeight / 2);
|
||||
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromRgb(0xe9, 0x45, 0x60)), null,
|
||||
new Rect(padX, top + (pillHeight - 12) / 2.0, accentW, 12), 2, 2);
|
||||
dc.DrawText(ft, new Point(padX + accentW + gap, textTop));
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(pillW, Height, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
|
||||
var pixels = new byte[pillW * Height * 4];
|
||||
bitmap.CopyPixels(pixels, pillW * 4, 0);
|
||||
|
||||
// Pbgra32 is premultiplied — unpremultiply so the compositor's
|
||||
// straight-alpha source-over doesn't halo the text edges.
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var a = pixels[i + 3];
|
||||
if (a == 0 || a == 255) continue;
|
||||
pixels[i] = (byte)(pixels[i] * 255 / a);
|
||||
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
||||
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
||||
}
|
||||
return new VideoFrame(pillW, Height, pixels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The free-tier branding flash: a "made with LlamaCasty!" credit that is COMPOSITED
|
||||
/// INTO THE OUTPUT (recording or stream), not merely shown in the preview.
|
||||
/// <para>2026-09-26. This used to exist only as a WPF <c>BrandFlashLayer</c> in
|
||||
/// <c>PreviewPane.xaml</c> — a 0.25-opacity centred TextBlock the creator could see and
|
||||
/// no viewer ever could. The compositor has carried an unused <c>flashFrame</c> slot
|
||||
/// since then; this type is the producer that finally feeds it. It is wired on the
|
||||
/// PER-FRAME path only: <c>SceneGraph.GetBakedBase</c> also accepts a <c>flashFrame</c>,
|
||||
/// and that one is a trap — <c>SceneRegion.Matches</c> compares element ids only, so a
|
||||
/// flash folded into the baked static base would freeze there and never expire.</para>
|
||||
/// <para>The credit is deliberately obnoxious (creator directive 2026-09-26): full-opacity
|
||||
/// neon, a different random position every presentation, and a hard sell every 30–60s.
|
||||
/// It is gated on LICENCE state, never on a UI toggle — see <see cref="Enabled"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class BrandFlashPresenter : IDisposable
|
||||
{
|
||||
/// <summary>The credit line, kept identical to the old preview TextBlock so the
|
||||
/// wording can never drift between the preview and the broadcast.</summary>
|
||||
public const string Text = "made with LlamaCasty!";
|
||||
|
||||
// Presentation envelope (creator-specified 2026-09-26): 500ms fade in, the credit
|
||||
// held at full opacity for 1000ms, then a 500ms fade out — 2000ms end to end.
|
||||
public const double FadeInSeconds = 0.5;
|
||||
public const double HoldSeconds = 1.0;
|
||||
public const double FadeOutSeconds = 0.5;
|
||||
public const double PresentationSeconds = FadeInSeconds + HoldSeconds + FadeOutSeconds;
|
||||
|
||||
/// <summary>Cadence: <c>rand(0, 30s) + 30s</c> ⇒ a flash every 30–60s, so it never
|
||||
/// lands on a beat a viewer could tune out.</summary>
|
||||
public const int IntervalFloorMs = 30_000;
|
||||
public const int IntervalJitterMs = 30_000;
|
||||
|
||||
/// <summary>The first flash lands shortly after go-live (the stream-start
|
||||
/// impression); the random cadence takes over from there.</summary>
|
||||
public const double FirstFlashDelaySeconds = 5.0;
|
||||
|
||||
/// <summary>Master-space canvas. Global overlays in this codebase are authored at
|
||||
/// master size and scaled by the compositor.</summary>
|
||||
public const int MasterWidth = 1920;
|
||||
public const int MasterHeight = 1080;
|
||||
|
||||
/// <summary>Neon recipe taken from the established overlay look (see commit
|
||||
/// message): a bright near-white CORE plus a feathered multi-radius HALO, with the
|
||||
/// red and blue channels pushed in OPPOSITE directions — Maxon's "Chromatic Glow"
|
||||
/// notes the green channel is all but invisible behind white text once R and B are
|
||||
/// split, which is exactly the look.</summary>
|
||||
private static readonly Color NeonRed = Color.FromRgb(0xe9, 0x45, 0x60); // brand red, exact
|
||||
private static readonly Color NeonBlue = Color.FromRgb(0x4d, 0x8b, 0xff); // brand blue #0f3460 lifted for luminance
|
||||
private static readonly Color CoreColor = Colors.White;
|
||||
|
||||
/// <summary>Font size chosen so the credit spans roughly half the master width:
|
||||
/// big enough to read as advertising, small enough that the random placement has
|
||||
/// real travel room (a full-width line could only ever sit at x≈0).</summary>
|
||||
private const double FontSize = 120;
|
||||
private const double GlowPadding = 34;
|
||||
private const double TickSeconds = 0.033;
|
||||
|
||||
private readonly Random _random;
|
||||
private readonly DispatcherTimer? _timer;
|
||||
private readonly object _gate = new();
|
||||
|
||||
private bool _enabled = true;
|
||||
private bool _running;
|
||||
private double _sinceStart;
|
||||
private double _nextFlashAt; // absolute time of the next flash, _sinceStart's reference
|
||||
private double _elapsed; // seconds into the presentation; >= PresentationSeconds means idle
|
||||
private VideoFrame? _raster; // tight, full-opacity glowing credit (straight alpha)
|
||||
private byte[]? _buffer; // reused master-space BGRA — hence the Epoch bump below
|
||||
private int _atX, _atY;
|
||||
private long _epoch;
|
||||
|
||||
public BrandFlashPresenter(Random? random = null)
|
||||
{
|
||||
_random = random ?? new Random();
|
||||
if (Application.Current is { } app)
|
||||
{
|
||||
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(TickSeconds) };
|
||||
_timer.Tick += (_, _) => Advance(TickSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The licence gate, driven by <c>MainViewModel.IsPremium</c> (Polar
|
||||
/// validation) and NOT by a user-facing toggle: once this credit is composited into
|
||||
/// the broadcast it is the thing standing between a paying customer's stream and an
|
||||
/// advertisement, so it is re-checked on every read. A premium user mid-presentation
|
||||
/// loses the flash on the very next tick rather than at the end of the credit.</summary>
|
||||
public bool Enabled
|
||||
{
|
||||
get { lock (_gate) return _enabled; }
|
||||
set
|
||||
{
|
||||
bool restart;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_enabled == value) return;
|
||||
_enabled = value;
|
||||
if (!value)
|
||||
{
|
||||
// Licence state only ever goes premium → no flash. Cut the current
|
||||
// presentation short instead of running it out ungated.
|
||||
_elapsed = PresentationSeconds;
|
||||
_timer?.Stop();
|
||||
restart = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// …but a DOWNGRADE (key entered mid-session) must bring the cadence
|
||||
// back. The presenter now runs for the life of the app — Start() is
|
||||
// called once at startup, not per go-live — so stopping the timer on
|
||||
// the premium edge without restarting it here would leave the credit
|
||||
// dead until the process is restarted, even after a valid key.
|
||||
restart = _running;
|
||||
}
|
||||
}
|
||||
if (restart) _timer?.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while a credit is on screen (drives the preview's visibility).</summary>
|
||||
public bool IsPresenting
|
||||
{
|
||||
get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; }
|
||||
}
|
||||
|
||||
/// <summary>Test-only: is the <see cref="DispatcherTimer"/> that drives
|
||||
/// <see cref="Advance"/> actually running? The premium/downgrade edge is the only
|
||||
/// thing that ever stops and restarts it now that the presenter is started once for
|
||||
/// the life of the app, and a synchronous test body cannot observe a real timer
|
||||
/// tick — so assert the decision directly instead of sleeping through a 30–60s
|
||||
/// interval. Never call this from product code.</summary>
|
||||
internal bool IsCadenceTimerEnabled => _timer?.IsEnabled ?? false;
|
||||
|
||||
/// <summary>Begin the cadence: first flash <see cref="FirstFlashDelaySeconds"/>
|
||||
/// after the call, then every <c>rand(30s) + 30s</c>.</summary>
|
||||
public void Start()
|
||||
{
|
||||
EnsureRaster();
|
||||
lock (_gate)
|
||||
{
|
||||
_running = true;
|
||||
_sinceStart = 0;
|
||||
_nextFlashAt = FirstFlashDelaySeconds;
|
||||
}
|
||||
if (_enabled) _timer?.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_running = false;
|
||||
_elapsed = PresentationSeconds;
|
||||
}
|
||||
_timer?.Stop();
|
||||
}
|
||||
|
||||
/// <summary>Advance the presentation clock. Also the deterministic test seam: tests
|
||||
/// drive this directly and never call <see cref="Start"/>, so no timer is running
|
||||
/// and the clock only moves when the test moves it (mirrors <c>AlertOverlayLayer.Advance</c>).</summary>
|
||||
public void Advance(double deltaSeconds)
|
||||
{
|
||||
// Rasterise BEFORE taking the lock. WPF text formatting is UI-thread affine, so
|
||||
// this can hop to the dispatcher — and a blocking hop while holding _gate would
|
||||
// deadlock against a timer tick that wants the same lock.
|
||||
EnsureRaster();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled) return;
|
||||
|
||||
if (_running)
|
||||
{
|
||||
_sinceStart += deltaSeconds;
|
||||
if (_sinceStart >= _nextFlashAt)
|
||||
{
|
||||
// The interval runs FROM THIS FLASH, not from Start(). An absolute
|
||||
// deadline here made the gaps shrink every cycle (5s, then whatever
|
||||
// was left of the previous interval) and collapse toward zero.
|
||||
_nextFlashAt = _sinceStart + NextIntervalSeconds();
|
||||
BeginPresentation();
|
||||
}
|
||||
}
|
||||
|
||||
if (_elapsed < PresentationSeconds)
|
||||
{
|
||||
_elapsed += deltaSeconds;
|
||||
if (_elapsed >= PresentationSeconds) _elapsed = PresentationSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
Notify();
|
||||
}
|
||||
|
||||
/// <summary>Invoked on the UI thread at the end of every tick with the frame that
|
||||
/// tick produced (null when nothing is due) — the preview's repaint hook, so the
|
||||
/// preview and the broadcast are driven by ONE clock.</summary>
|
||||
public Action<VideoFrame?>? OnFrame { get; set; }
|
||||
|
||||
private void Notify()
|
||||
{
|
||||
var cb = OnFrame;
|
||||
if (cb == null) return;
|
||||
// Read OUTSIDE the state lock: the callback is arbitrary code and must never
|
||||
// run while _gate is held.
|
||||
var frame = Frame;
|
||||
if (frame == null) { cb(null); return; }
|
||||
|
||||
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
// Off the UI thread (a test driving the clock). The frame's buffer is
|
||||
// recycled, so hand the deferred copy its own pixels.
|
||||
var copy = new byte[frame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
|
||||
app.Dispatcher.BeginInvoke(() => cb(new VideoFrame(frame.Width, frame.Height, copy)));
|
||||
}
|
||||
else
|
||||
{
|
||||
cb(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Force a presentation now — the "show me the flash" path and the test seam.</summary>
|
||||
public void Show()
|
||||
{
|
||||
EnsureRaster();
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled) return;
|
||||
BeginPresentation();
|
||||
}
|
||||
}
|
||||
|
||||
private double NextIntervalSeconds()
|
||||
=> (IntervalFloorMs + _random.Next(IntervalJitterMs)) / 1000.0;
|
||||
|
||||
private void BeginPresentation()
|
||||
{
|
||||
if (_raster == null) return;
|
||||
_buffer ??= new byte[MasterWidth * MasterHeight * 4];
|
||||
// A new presentation can land anywhere, so the previous credit's pixels must go
|
||||
// before this one is written or the old spot ghosts through the fade. One 8MB
|
||||
// clear per presentation (not per tick) keeps that cheap.
|
||||
Array.Clear(_buffer);
|
||||
var spot = ComputePlacement(_raster.Width, _raster.Height, MasterWidth, MasterHeight, _random);
|
||||
_atX = (int)spot.X;
|
||||
_atY = (int)spot.Y;
|
||||
_elapsed = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The credit for the output compositor and the preview: a master-space frame holding
|
||||
/// the glowing text at this presentation's random spot, alpha-scaled to the current
|
||||
/// point on the fade envelope. Null when idle, or unlicensed.
|
||||
/// <para><b>The frame's pixel buffer is recycled across ticks</b> (only the text rect
|
||||
/// is rewritten) and its <see cref="VideoFrame.Epoch"/> is bumped every read, so the
|
||||
/// compositor's buffer-identity paste cache can never false-hit a stale credit.
|
||||
/// Consumers blit synchronously — copy the pixels if you need to keep them.</para></summary>
|
||||
public VideoFrame? Frame
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || _elapsed >= PresentationSeconds || _raster == null || _buffer == null)
|
||||
return null;
|
||||
var opacity = OpacityAt(_elapsed);
|
||||
if (opacity <= 0) return null;
|
||||
Paint(_buffer, _raster, _atX, _atY, opacity);
|
||||
return new VideoFrame(MasterWidth, MasterHeight, _buffer) { Epoch = ++_epoch };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Full → hold → empty. Public and static so the preview and the tests
|
||||
/// agree with the renderer instead of re-deriving the curve.</summary>
|
||||
public static double OpacityAt(double elapsedSeconds)
|
||||
{
|
||||
if (elapsedSeconds < 0 || elapsedSeconds >= PresentationSeconds) return 0;
|
||||
if (elapsedSeconds < FadeInSeconds) return elapsedSeconds / FadeInSeconds;
|
||||
if (elapsedSeconds < FadeInSeconds + HoldSeconds) return 1.0;
|
||||
return 1.0 - ((elapsedSeconds - (FadeInSeconds + HoldSeconds)) / FadeOutSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A random top-left offset that keeps the whole credit ON the canvas. The text is a
|
||||
/// single pre-measured line, so it can never wrap — this is the "how far can the
|
||||
/// left margin travel" limit the creator described: <c>0 … (canvas - creditWidth)</c>
|
||||
/// horizontally, likewise vertically. Pure + static, so the rule is directly testable.
|
||||
/// </summary>
|
||||
public static Point ComputePlacement(int creditWidth, int creditHeight,
|
||||
int canvasWidth, int canvasHeight, Random random)
|
||||
{
|
||||
var maxX = Math.Max(0, canvasWidth - creditWidth);
|
||||
var maxY = Math.Max(0, canvasHeight - creditHeight);
|
||||
return new Point(random.Next(0, maxX + 1), random.Next(0, maxY + 1));
|
||||
}
|
||||
|
||||
/// <summary>Rewrite the credit rect into the master buffer with alpha scaled to
|
||||
/// <paramref name="opacity"/>. The rect is fully overwritten each tick and the spot
|
||||
/// is fixed for the whole presentation, so nothing stale accumulates and no clear
|
||||
/// pass is needed here.</summary>
|
||||
private static void Paint(byte[] buffer, VideoFrame raster, int atX, int atY, double opacity)
|
||||
{
|
||||
for (var y = 0; y < raster.Height; y++)
|
||||
{
|
||||
var dst = ((atY + y) * MasterWidth + atX) * 4;
|
||||
var src = y * raster.Stride;
|
||||
for (var x = 0; x < raster.Width; x++)
|
||||
{
|
||||
var a = raster.BgraPixels[src + x * 4 + 3];
|
||||
if (a == 0) continue;
|
||||
buffer[dst + x * 4] = raster.BgraPixels[src + x * 4];
|
||||
buffer[dst + x * 4 + 1] = raster.BgraPixels[src + x * 4 + 1];
|
||||
buffer[dst + x * 4 + 2] = raster.BgraPixels[src + x * 4 + 2];
|
||||
buffer[dst + x * 4 + 3] = (byte)Math.Round(a * opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Build the credit raster once, on the UI thread, and publish it under
|
||||
/// the lock. Called from every clock entry point, so it is a no-op after the first.</summary>
|
||||
private void EnsureRaster()
|
||||
{
|
||||
lock (_gate) { if (_raster != null) return; }
|
||||
var raster = RenderNeonText(Text);
|
||||
lock (_gate) { if (raster != null) { _raster = raster; } }
|
||||
}
|
||||
|
||||
// ── rasterisation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Rasterize the credit once, tight to the glyphs plus glow, on the UI thread (WPF
|
||||
/// <c>RenderTargetBitmap</c>, Pbgra32→straight alpha exactly like
|
||||
/// <see cref="AlertTickerRenderer"/>) and cached — this is per-text work, not
|
||||
/// per-frame, so the pump can afford to run every tick at 30fps.
|
||||
/// <para>The neon is a bright core plus a feathered halo: the glyphs redrawn at
|
||||
/// shrinking radii with rising alpha, the two colours offset in opposite directions,
|
||||
/// which is what makes it read as a lit tube rather than a drop shadow.</para></summary>
|
||||
private static VideoFrame? RenderNeonText(string text)
|
||||
{
|
||||
if (Application.Current is { } app && !app.Dispatcher.CheckAccess())
|
||||
{
|
||||
VideoFrame? result = null;
|
||||
app.Dispatcher.Invoke(() => result = RenderNeonText(text));
|
||||
return result;
|
||||
}
|
||||
|
||||
var font = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal,
|
||||
FontWeights.Bold, FontStretches.Normal);
|
||||
var probe = new FormattedText(text, CultureInfo.InvariantCulture,
|
||||
FlowDirection.LeftToRight, font, FontSize, Brushes.White, 1.0);
|
||||
|
||||
var w = (int)Math.Ceiling(probe.Width) + (int)(GlowPadding * 2);
|
||||
var h = (int)Math.Ceiling(probe.Height) + (int)(GlowPadding * 2);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
|
||||
var visual = new DrawingVisual();
|
||||
using (var dc = visual.RenderOpen())
|
||||
{
|
||||
// Three halo rings per colour. Wide+dim first, then progressively tighter and
|
||||
// brighter, so the falloff is feathered instead of a hard-edged blob.
|
||||
foreach (var (radius, alpha) in new[] { (30.0, 0.16), (20.0, 0.26), (11.0, 0.40) })
|
||||
{
|
||||
DrawHalo(dc, text, font, radius, alpha, NeonRed, +1);
|
||||
DrawHalo(dc, text, font, radius, alpha, NeonBlue, -1);
|
||||
}
|
||||
// A tight white bloom hugging the glyphs sells the brightness of the tube.
|
||||
DrawHalo(dc, text, font, 4.0, 0.55, CoreColor, +1);
|
||||
// The core itself, sharp and opaque, drawn last.
|
||||
dc.DrawText(new FormattedText(text, CultureInfo.InvariantCulture,
|
||||
FlowDirection.LeftToRight, font, FontSize, new SolidColorBrush(CoreColor), 1.0),
|
||||
new Point(GlowPadding, GlowPadding));
|
||||
}
|
||||
|
||||
var bitmap = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
|
||||
var pixels = new byte[w * h * 4];
|
||||
bitmap.CopyPixels(pixels, w * 4, 0);
|
||||
|
||||
// Pbgra32 is premultiplied — unpremultiply, or straight-alpha source-over in the
|
||||
// compositor haloes the glyph edges.
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
var a = pixels[i + 3];
|
||||
if (a == 0 || a == 255) continue;
|
||||
pixels[i] = (byte)(pixels[i] * 255 / a);
|
||||
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
||||
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
||||
}
|
||||
return new VideoFrame(w, h, pixels);
|
||||
}
|
||||
|
||||
private static void DrawHalo(DrawingContext dc, string text, Typeface font,
|
||||
double radius, double alpha, Color color, int direction)
|
||||
{
|
||||
var brush = new SolidColorBrush(color);
|
||||
const int spokes = 12;
|
||||
for (var i = 0; i < spokes; i++)
|
||||
{
|
||||
var angle = 2 * Math.PI * i / spokes;
|
||||
brush.Opacity = alpha;
|
||||
var ft = new FormattedText(text, CultureInfo.InvariantCulture,
|
||||
FlowDirection.LeftToRight, font, FontSize, brush, 1.0);
|
||||
dc.DrawText(ft, new Point(
|
||||
GlowPadding + Math.Cos(angle) * radius * direction,
|
||||
GlowPadding + Math.Sin(angle) * radius * direction));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Stop();
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
@@ -14,12 +17,32 @@ namespace ytLive.Services.Compositor;
|
||||
/// </summary>
|
||||
public sealed class SceneCompositor
|
||||
{
|
||||
// Paste cache (take-8 fix, 2026-09-04): the Live scene re-sampled ~680k bilinear
|
||||
// pixels EVERY tick even though only the camera actually changes at that rate —
|
||||
// chat/web/image layers are identical frame after frame. Same shape as OBS (the
|
||||
// source surface is cached, the compositor blits): a scaled/round-clipped/mirrored
|
||||
// layer rasterizes once and is pasted (row-copy/blend) on every later tick.
|
||||
// Keyed by the SOURCE FRAME IDENTITY (array + Epoch + CropBounds) — producers hand
|
||||
// out fresh immutable byte[], or recycled-ring arrays whose monotonic Epoch distinguishes
|
||||
// each generation (screen capture, take-11 fix). CropBounds must be in the key because
|
||||
// the same frame (same Pixels/Epoch/Width/Height) can have different CropBounds as
|
||||
// the element is resized — a stale cached raster with wrong crop pixels would corrupt
|
||||
// the output. Bounds-checked eviction clears wholesale.
|
||||
private sealed record PasteKey(byte[] Pixels, long Epoch, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror, (int X, int Y, int W, int H)? CropBounds);
|
||||
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
|
||||
private static readonly Dictionary<SceneElement, VideoFrame> _elementRasters = new(ReferenceEqualityComparer.Instance);
|
||||
private static readonly object _pasteGate = new();
|
||||
private const int MaxPasteEntries = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
|
||||
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||
/// branding flash → social bar (a global overlay; spans the master width at
|
||||
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
|
||||
/// <paramref name="socialBarTop"/>) → stream-alert ticker (drawn INSIDE the Stream
|
||||
/// Alerts box, at the origin the frame carries on <see cref="VideoFrame.Placement"/>;
|
||||
/// creator ruling 2026-09-26 — it used to be a global bar pinned to the very top).
|
||||
/// Transparent regions read opaque black.
|
||||
/// </summary>
|
||||
public VideoFrame Render(
|
||||
Scene scene,
|
||||
@@ -27,7 +50,11 @@ public sealed class SceneCompositor
|
||||
VideoFrame? flashFrame,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0)
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? staticBase = null,
|
||||
int split = -1,
|
||||
byte[]? scratch = null,
|
||||
VideoFrame? tickerFrame = null)
|
||||
{
|
||||
if (scene == null) throw new ArgumentNullException(nameof(scene));
|
||||
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
|
||||
@@ -39,46 +66,122 @@ public sealed class SceneCompositor
|
||||
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
var buffer = new byte[cropW * cropH * 4];
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255; // opaque black base — video frames are never transparent
|
||||
var buffer = scratch != null && scratch.Length == cropW * cropH * 4
|
||||
? scratch
|
||||
: new byte[cropW * cropH * 4];
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
// staticBase != null: the caller pre-baked all static layers below `split`.
|
||||
// We copy it (never mutate the cached base) and composite elements[split..] on top.
|
||||
if (staticBase != null)
|
||||
{
|
||||
if (staticBase.Width != cropW || staticBase.Height != cropH)
|
||||
throw new ArgumentException("The static base must match the source rect.", nameof(staticBase));
|
||||
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (split < 0) split = scene.Elements.Count;
|
||||
|
||||
var elements = scene.Elements;
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
|
||||
// A master-sized live backdrop paints every pixel of the base — the
|
||||
// opaque-black pre-fill (2M byte writes) would be dead work. Capture
|
||||
// frames are opaque BGRA by contract (the WinRT path ignores alpha),
|
||||
// so the 1:1 row walk covers the full rect.
|
||||
var covered = liveCaptureFrame != null
|
||||
&& liveCaptureFrame.Width == cropW && liveCaptureFrame.Height == cropH;
|
||||
if (!covered)
|
||||
{
|
||||
if (liveCaptureFrame != null)
|
||||
{
|
||||
var (uScale, uOx, uOy) = StretchMath.UniformToFill(cropW, cropH, liveCaptureFrame.Width, liveCaptureFrame.Height);
|
||||
var drawnW = liveCaptureFrame.Width * uScale;
|
||||
var drawnH = liveCaptureFrame.Height * uScale;
|
||||
var ox = (int)Math.Floor(uOx);
|
||||
var oy = (int)Math.Floor(uOy);
|
||||
var oh = (int)Math.Ceiling(oy + drawnH);
|
||||
var ow = cropW;
|
||||
if (oy > 0)
|
||||
{
|
||||
var topCount = oy * ow;
|
||||
for (var i = 3; i < topCount * 4; i += 4)
|
||||
buffer[i] = 255;
|
||||
}
|
||||
if (oh < cropH)
|
||||
{
|
||||
var start = oh * ow;
|
||||
var bottomCount = (cropH - oh) * ow;
|
||||
for (var i = start * 4 + 3; i < (start + bottomCount) * 4; i += 4)
|
||||
buffer[i] = 255;
|
||||
}
|
||||
if (ox > 0 || (int)Math.Ceiling(uOx + drawnW) < cropW)
|
||||
{
|
||||
var x0s = (int)Math.Floor(uOx);
|
||||
var x1s = (int)Math.Ceiling(uOx + drawnW);
|
||||
for (var y = oy; y < oh && y < cropH; y++)
|
||||
{
|
||||
var di = (y * ow + 0) * 4;
|
||||
for (var x = 0; x < x0s && x < ow; x++)
|
||||
{
|
||||
buffer[di + x * 4 + 3] = 255;
|
||||
}
|
||||
for (var x = x1s; x < ow; x++)
|
||||
{
|
||||
buffer[di + x * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255; // opaque black base
|
||||
}
|
||||
}
|
||||
|
||||
if (liveCaptureFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var fullFrame = frameFor(element);
|
||||
if (fullFrame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame);
|
||||
}
|
||||
}
|
||||
|
||||
// Composite the above-split layers (dynamic + any static above them) per frame.
|
||||
for (var i = Math.Max(0, split); i < scene.Elements.Count; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue; // background/background are their own layers; Text isn't shipped
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
|
||||
var ex = (float)(element.X - options.SourceRectX);
|
||||
var ey = (float)(element.Y - options.SourceRectY);
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
|
||||
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
|
||||
(float)element.Opacity, isRound, element.IsMirrored);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
|
||||
}
|
||||
|
||||
if (flashFrame != null)
|
||||
@@ -87,10 +190,255 @@ public sealed class SceneCompositor
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
|
||||
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
// The zoom box is layer n+1: a live 2x view of a FIXED capture window sampled
|
||||
// from the frame just composited (all layers). Drawn LAST — above everything.
|
||||
// The box/2 capture is anchored at the click, so dragging the box only moves
|
||||
// the box; the content it shows never changes (creator ruling 2026-10-08).
|
||||
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
|
||||
if (zoom != null)
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
|
||||
return StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the static base of a scene: the opaque black layer, the live-capture
|
||||
/// background, the background image, and every <paramref name="split"/> z-layers
|
||||
/// below the split point (all of which are static by construction — see
|
||||
/// <see cref="SceneElement.Kind"/>). The result is a source-rect sized frame that
|
||||
/// <see cref="Render"/> can start from and composite the dynamic/above-split layers
|
||||
/// onto each tick. Returns null when there is no static layer at all.
|
||||
/// </summary>
|
||||
public static VideoFrame? BakeStaticBase(
|
||||
Scene scene,
|
||||
int split,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
Func<CompositorOptions> options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? flashFrame = null)
|
||||
{
|
||||
if (split <= 0) return null;
|
||||
var opts = options();
|
||||
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
|
||||
|
||||
var cropW = opts.SourceRectWidth;
|
||||
var cropH = opts.SourceRectHeight;
|
||||
var buffer = new byte[cropW * cropH * 4];
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255;
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (liveCapture != null)
|
||||
{
|
||||
var liveCaptureFrame = liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null && split > elements.IndexOf(liveCapture))
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
}
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
if (background != null)
|
||||
{
|
||||
var backgroundFrame = frameFor(background);
|
||||
if (backgroundFrame != null && split > elements.IndexOf(background))
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
}
|
||||
|
||||
for (var i = 0; i < split; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, opts, element, frame);
|
||||
}
|
||||
|
||||
var baked = new VideoFrame(cropW, cropH, buffer);
|
||||
if (flashFrame != null)
|
||||
baked = Overlay(baked, opts, flashFrame, 0, 0);
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
baked = Overlay(baked, opts, socialBarFrame, 0, socialBarTop);
|
||||
return baked;
|
||||
}
|
||||
|
||||
/// <summary>The compositor's dynamic/above-split path: start from an existing frame
|
||||
/// buffer and composite the z-layers from <paramref name="split"/> onward on top.
|
||||
/// When <paramref name="baseFrame"/> is null the caller must fall back to full render.
|
||||
/// The social bar never reaches here (it IS baked into the static base) but the alert
|
||||
/// ticker must: it is dynamic by construction — only ever present while an alert plays —
|
||||
/// so it overlays LAST, pinned to the very top (TASK 47).</summary>
|
||||
public static VideoFrame CompositeLayers(
|
||||
VideoFrame baseFrame,
|
||||
Scene scene,
|
||||
int split,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? flashFrame = null,
|
||||
byte[]? scratch = null,
|
||||
VideoFrame? tickerFrame = null)
|
||||
{
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
// We must not mutate the cached base: copy it into a fresh (or pooled) buffer.
|
||||
var buffer = scratch != null && scratch.Length == baseFrame.BgraPixels.Length
|
||||
? scratch
|
||||
: new byte[baseFrame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, baseFrame.BgraPixels.Length);
|
||||
|
||||
for (var i = split; i < scene.Elements.Count; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
|
||||
}
|
||||
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
|
||||
if (zoom != null)
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
|
||||
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
private static void BlitBakedElement(
|
||||
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame)
|
||||
{
|
||||
var ex = (float)(element.X - options.SourceRectX);
|
||||
var ey = (float)(element.Y - options.SourceRectY);
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
|
||||
// All rect-placed elements route through the paste cache (take-9 lesson): the
|
||||
// IsOpaque bypass kept re-sampling the webcam (~156k px) EVERY tick even when
|
||||
// the device frame had not changed; an opaque source rasterizes to an opaque
|
||||
// raster whose paste is a per-pixel copy branch, so hits beat the sampler and
|
||||
// misses cost exactly what the bypass cost.
|
||||
BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
}
|
||||
|
||||
/// <summary>Non-opaque layer blit through the paste cache: the element-space raster
|
||||
/// (scaled to the element rect, round-clipped, mirrored) depends only on the source
|
||||
/// frame + rect + shape/mirror — NOT on position or opacity. Static content (chat,
|
||||
/// web overlays, images, the media frame between updates) is sampled ONCE and pasted
|
||||
/// at integer position with per-tixel blend; only layers whose source frame actually
|
||||
/// changed (webcam at device fps) pay resampling.</summary>
|
||||
private static void BlitCachedLayer(
|
||||
byte[] buffer, int cropW, int cropH,
|
||||
float ex, float ey, float ew, float eh,
|
||||
VideoFrame frame, SceneElement element, bool isRound)
|
||||
{
|
||||
if (ew <= 0 || eh <= 0) return;
|
||||
var dw = (int)System.Math.Ceiling(ew);
|
||||
var dh = (int)System.Math.Ceiling(eh);
|
||||
if (dw <= 0 || dh <= 0) return;
|
||||
var mirror = element.IsMirrored;
|
||||
|
||||
PasteKey? key = null;
|
||||
VideoFrame? raster = null;
|
||||
lock (_pasteGate)
|
||||
{
|
||||
key = new PasteKey(frame.BgraPixels, frame.Epoch, frame.Width, frame.Height, dw, dh, isRound, mirror, frame.CropBounds);
|
||||
_pasteCache.TryGetValue(key, out raster);
|
||||
}
|
||||
|
||||
if (raster == null)
|
||||
{
|
||||
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
|
||||
// alpha only where it actually draws, so untouched margins (outside the
|
||||
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
|
||||
// reveals the master beneath instead of black. The ARRAY is reused per
|
||||
// element (see _elementRasters note) — new source generations re-raster
|
||||
// in place, so the steady state allocates nothing.
|
||||
lock (_pasteGate)
|
||||
{
|
||||
if (_elementRasters.TryGetValue(element, out var existing)
|
||||
&& existing.Width == dw && existing.Height == dh)
|
||||
raster = existing;
|
||||
}
|
||||
if (raster == null) raster = new VideoFrame(dw, dh, new byte[dw * dh * 4]);
|
||||
Array.Clear(raster.BgraPixels);
|
||||
BlitContentRaw(raster.BgraPixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror, transparentDst: true);
|
||||
lock (_pasteGate)
|
||||
{
|
||||
// Only the SOURCE frame's identity is a real key owner; drop any older
|
||||
// entry that shared this element's reused raster so a stale key can never
|
||||
// paste a mid-update array.
|
||||
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
|
||||
_pasteCache[key!] = raster;
|
||||
_elementRasters[element] = raster;
|
||||
}
|
||||
}
|
||||
|
||||
// Paste at integer position; opacity blends. The raster is never flagged
|
||||
// IsOpaque — round clips/margins carry alpha 0 — so the row blend always runs.
|
||||
var op = (int)(System.Math.Clamp(element.Opacity, 0f, 1f) * 256);
|
||||
if (op <= 0) return;
|
||||
var px = (int)System.Math.Floor(ex);
|
||||
var py = (int)System.Math.Floor(ey);
|
||||
var x0 = System.Math.Max(0, px);
|
||||
var y0 = System.Math.Max(0, py);
|
||||
var x1 = System.Math.Min(cropW, px + dw);
|
||||
var y1 = System.Math.Min(cropH, py + dh);
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
var s = raster.BgraPixels;
|
||||
StripPool.For(y0, y1, y =>
|
||||
{
|
||||
var si = ((y - py) * dw + (x0 - px)) * 4;
|
||||
var len = x1 - x0;
|
||||
if (op == 256)
|
||||
BlendRowOpaque(buffer, (y * cropW + x0) * 4, s, si, len);
|
||||
else
|
||||
BlendRowWeighted(buffer, (y * cropW + x0) * 4, s, si, len, op);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Copy <paramref name="frame"/> and blit a global overlay onto the copy
|
||||
/// at (sx0, sy0) in crop space. internal (not private) because the frame pump's
|
||||
/// fully-static path needs it: that path stretches the cached bake straight to the
|
||||
/// output, so any per-frame overlay — social bar, alert ticker, brand flash — has to
|
||||
/// be composited here first. Copying is the whole point: the bake is cached, and a
|
||||
/// flash written straight into it would freeze there and never expire.</summary>
|
||||
internal static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
|
||||
{
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
var buffer = new byte[frame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
BlitOverlay(buffer, cropW, cropH, options, overlay, sx0, sy0);
|
||||
return new VideoFrame(cropW, cropH, buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a cross-fade blend between two scenes. Used by <see cref="TransitionService"/>
|
||||
/// during fade/move transitions: the caller renders each scene individually, then blends
|
||||
@@ -131,26 +479,144 @@ public sealed class SceneCompositor
|
||||
{
|
||||
if (ew <= 0 || eh <= 0) return;
|
||||
|
||||
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||
|
||||
// 1:1 aligned fast path (the full-frame backdrop, master-sized overlays):
|
||||
// bilinear sampling at scale 1 with integer offsets is the identity, so the
|
||||
// blit is a row walk, not 2M float samples + rounds. Research-first (2026-09-03,
|
||||
// take-3 fix; libyuv's row-based BLEND_NONE pattern —
|
||||
// https://chromium.googlesource.com/libyuv/libyuv/): the 258ms/frame render
|
||||
// that time-lapsed take 3 lived in the general loop below.
|
||||
if (!isRound && !isMirror && scale == 1f && ox == 0f && oy == 0f
|
||||
&& ex == MathF.Floor(ex) && ey == MathF.Floor(ey))
|
||||
{
|
||||
Blit1To1Rows(dst, dstW, dstH, (int)ex, (int)ey, src, opacity);
|
||||
return;
|
||||
}
|
||||
|
||||
BlitContentRaw(dst, dstW, dstH, ex, ey, ew, eh, src, opacity, isRound, isMirror);
|
||||
}
|
||||
|
||||
/// <summary>The general scaled/clipped/mirrored sampler (integer fixed-point
|
||||
/// bilinear + source-over). Also rasterizes paste-cache entries in ELEMENT space
|
||||
/// onto a TRANSPARENT base (alpha 0) so pasting over the master keeps the round
|
||||
/// clip and transparency of the untouched margins.</summary>
|
||||
private static void BlitContentRaw(
|
||||
byte[] dst, int dstW, int dstH,
|
||||
float ex, float ey, float ew, float eh,
|
||||
VideoFrame src, float opacity, bool isRound, bool isMirror,
|
||||
bool transparentDst = false)
|
||||
{
|
||||
if (ew <= 0 || eh <= 0) return;
|
||||
|
||||
float scale, ox, oy, invScale;
|
||||
float drawnW, drawnH;
|
||||
// No CropBounds means "the whole source canvas is the crop region" — cbW/cbH
|
||||
// MUST default to the full source dims, not 0. A zero cbW/cbH makes every
|
||||
// sxCrop/syCrop below evaluate to 0 regardless of position, collapsing the
|
||||
// entire sampled rect to source pixel (0,0) (the webcam gray-block regression,
|
||||
// 2026-09-12: introduced by the CropBounds Fill-style scaling work the same
|
||||
// day — CropBounds-bearing sources, i.e. the web widget, were never affected).
|
||||
var cbX = 0;
|
||||
var cbY = 0;
|
||||
var cbW = src.Width;
|
||||
var cbH = src.Height;
|
||||
|
||||
if (src.CropBounds is { } cb)
|
||||
{
|
||||
cbX = cb.X;
|
||||
cbY = cb.Y;
|
||||
cbW = cb.W;
|
||||
cbH = cb.H;
|
||||
scale = Math.Max(ew / cbW, eh / cbH);
|
||||
invScale = 1f / scale;
|
||||
drawnW = cbW * scale;
|
||||
drawnH = cbH * scale;
|
||||
ox = (ew - drawnW) / 2f;
|
||||
oy = (eh - drawnH) / 2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
var (uScale, uOx, uOy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||
scale = uScale;
|
||||
invScale = 1f / uScale;
|
||||
ox = uOx;
|
||||
oy = uOy;
|
||||
drawnW = src.Width * uScale;
|
||||
drawnH = src.Height * uScale;
|
||||
}
|
||||
|
||||
var radius = Math.Min(ew, eh) / 2f;
|
||||
var cx = ew / 2f;
|
||||
var cy = eh / 2f;
|
||||
|
||||
var x0 = Math.Max(0, (int)Math.Floor(ex));
|
||||
var y0 = Math.Max(0, (int)Math.Floor(ey));
|
||||
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
|
||||
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh));
|
||||
if (x0 > x1 || y0 > y1) return;
|
||||
|
||||
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||||
var drawnW = src.Width * scale;
|
||||
var drawnH = src.Height * scale;
|
||||
var radius = Math.Min(ew, eh) / 2f;
|
||||
var cx = ew / 2f;
|
||||
var cy = eh / 2f;
|
||||
var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256);
|
||||
if (op <= 0) return;
|
||||
var s = src.BgraPixels;
|
||||
var srcStride = src.Width; // canvas stride in pixels (not bytes)
|
||||
|
||||
for (var y = y0; y <= y1; y++)
|
||||
// Build horizontal LUT for scaled blit to reduce per-pixel divides. The table is
|
||||
// READ by the strip workers while it is being sampled, so it must not be shared
|
||||
// mutable state: it comes from the thread-safe ArrayPool and goes back in a finally
|
||||
// (the static single-buffer version raced the moment the strips went parallel).
|
||||
var lutCount = x1 - x0 + 1;
|
||||
var sxLut = ArrayPool<float>.Shared.Rent(lutCount);
|
||||
try
|
||||
{
|
||||
for (var x = x0; x <= x1; x++)
|
||||
var pxBase = x0 - ex;
|
||||
for (var xi = 0; xi < lutCount; xi++)
|
||||
{
|
||||
var px = x - ex; // element space
|
||||
var py = y - ey;
|
||||
if (px < ox || px > ox + drawnW || py < oy || py > oy + drawnH) continue;
|
||||
var px = pxBase + xi;
|
||||
float sxCanvas;
|
||||
if (px < ox || px > ox + drawnW)
|
||||
{
|
||||
sxCanvas = float.NaN;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sxNorm = (px - ox) / drawnW;
|
||||
sxNorm = sxNorm < 0f ? 0f : sxNorm > 1f ? 1f : sxNorm;
|
||||
var sxCrop = sxNorm * cbW;
|
||||
sxCanvas = cbX + sxCrop;
|
||||
if (sxCanvas < 0f) sxCanvas = 0f;
|
||||
if (sxCanvas > src.Width - 1f) sxCanvas = src.Width - 1f;
|
||||
if (isMirror) sxCanvas = src.Width - 1f - sxCanvas;
|
||||
}
|
||||
sxLut[xi] = sxCanvas;
|
||||
}
|
||||
|
||||
StripPool.For(y0, y1 + 1, y =>
|
||||
{
|
||||
var py = y - ey;
|
||||
if (py < oy || py > oy + drawnH) return;
|
||||
|
||||
// syNorm: normalized [0,1] position within the drawn content (vertically)
|
||||
var syNorm = (py - oy) / drawnH;
|
||||
if (syNorm < 0f) syNorm = 0f;
|
||||
if (syNorm > 1f) syNorm = 1f;
|
||||
// syCrop: pixel position within the crop region
|
||||
var syCrop = syNorm * cbH;
|
||||
// syCanvas: canvas pixel row (for buffer indexing)
|
||||
var syCanvas = cbY + syCrop;
|
||||
if (syCanvas < 0f) syCanvas = 0f;
|
||||
if (syCanvas > src.Height - 1f) syCanvas = src.Height - 1f;
|
||||
var syi = (int)syCanvas;
|
||||
var sy1i = Math.Min(syi + 1, src.Height - 1);
|
||||
var fy8 = (int)((syCanvas - syi) * 256);
|
||||
var fyInv = 256 - fy8;
|
||||
|
||||
var di = (y * dstW + x0) * 4;
|
||||
for (var xi = 0; xi < lutCount; xi++, di += 4)
|
||||
{
|
||||
var sxCanvas = sxLut[xi];
|
||||
if (float.IsNaN(sxCanvas)) continue;
|
||||
var px = pxBase + xi;
|
||||
if (isRound)
|
||||
{
|
||||
var dx = px - cx;
|
||||
@@ -158,12 +624,176 @@ public sealed class SceneCompositor
|
||||
if (dx * dx + dy * dy > radius * radius) continue;
|
||||
}
|
||||
|
||||
var sx = (px - ox) / scale;
|
||||
var sy = (py - oy) / scale;
|
||||
if (isMirror) sx = src.Width - 1 - sx;
|
||||
var sample = StretchMath.SampleBgra(src.BgraPixels, src.Width, src.Height, sx, sy);
|
||||
BlendPixel(dst, (y * dstW + x) * 4, sample, opacity);
|
||||
var sxi = (int)sxCanvas;
|
||||
var sx1i = Math.Min(sxi + 1, src.Width - 1);
|
||||
var fx8 = (int)((sxCanvas - sxi) * 256);
|
||||
var fxInv = 256 - fx8;
|
||||
|
||||
// ALL pixel indexing uses srcStride (canvas stride), NOT crop width
|
||||
var p00 = (syi * srcStride + sxi) * 4;
|
||||
var p10 = (syi * srcStride + sx1i) * 4;
|
||||
var p01 = (sy1i * srcStride + sxi) * 4;
|
||||
var p11 = (sy1i * srcStride + sx1i) * 4;
|
||||
var sb = Bilinear(s[p00], s[p10], s[p01], s[p11], fx8, fxInv, fy8, fyInv);
|
||||
var sg = Bilinear(s[p00 + 1], s[p10 + 1], s[p01 + 1], s[p11 + 1], fx8, fxInv, fy8, fyInv);
|
||||
var sr = Bilinear(s[p00 + 2], s[p10 + 2], s[p01 + 2], s[p11 + 2], fx8, fxInv, fy8, fyInv);
|
||||
var saFull = Bilinear(s[p00 + 3], s[p10 + 3], s[p01 + 3], s[p11 + 3], fx8, fxInv, fy8, fyInv);
|
||||
|
||||
var sa = saFull * op >> 8;
|
||||
if (sa >= 255)
|
||||
{
|
||||
dst[di] = (byte)sb;
|
||||
dst[di + 1] = (byte)sg;
|
||||
dst[di + 2] = (byte)sr;
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
else if (sa > 0)
|
||||
{
|
||||
if (transparentDst)
|
||||
{
|
||||
// Element-raster pass on a TRANSPARENT base (the paste cache):
|
||||
// keep straight color + straight alpha so the paste rows can
|
||||
// source-over onto the opaque master. Premultiplying here (the
|
||||
// opaque-dst blend) stamped alpha 255 onto already-alpha-scaled
|
||||
// color — the paste over-wrote the scene with darkened ink, the
|
||||
// web widget's "black box" in recordings.
|
||||
dst[di] = (byte)sb;
|
||||
dst[di + 1] = (byte)sg;
|
||||
dst[di + 2] = (byte)sr;
|
||||
dst[di + 3] = (byte)sa;
|
||||
}
|
||||
else
|
||||
{
|
||||
var inv = 255 - sa;
|
||||
dst[di] = (byte)((sb * sa + dst[di] * inv + 127) / 255);
|
||||
dst[di + 1] = (byte)((sg * sa + dst[di + 1] * inv + 127) / 255);
|
||||
dst[di + 2] = (byte)((sr * sa + dst[di + 2] * inv + 127) / 255);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<float>.Shared.Return(sxLut);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Two-stage bilinear in 8.8 fixed point; result within ±1 of the
|
||||
/// float StretchMath.SampleBgra (well inside every pixel test's ±2 tolerance).
|
||||
/// The first stage must NOT shift back to 8 bits — shifting twice collapsed
|
||||
/// every solid-255 sample to 1 (green overwrites silently degraded to no-ops;
|
||||
/// caught by the SceneCompositor/SceneGraph pixel probes 2026-09-04).</summary>
|
||||
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
|
||||
{
|
||||
// Both stages in one 16.8 scale: top/bot ≤ 65280, ×256 + round ≤ 16.8M — int-safe.
|
||||
var top = tl * fxInv + tr * fx8;
|
||||
var bot = bl * fxInv + br * fx8;
|
||||
return (top * fyInv + bot * fy8 + 32768) >> 16;
|
||||
}
|
||||
|
||||
/// <summary>Row walk for a 1:1 aligned blit at integer offset (ex, ey): per-pixel
|
||||
/// branch on source alpha — opaque rows copy straight, transparent pixels are
|
||||
/// skipped, partial alpha blends in integer fixed-point (libyuv's pattern). The
|
||||
/// master buffer's destination alpha is always opaque (black base paints 255 and
|
||||
/// every blend writes 255), so dst alpha is never read — matching BlendPixel's
|
||||
/// source-over result for opaque dst within ±1.</summary>
|
||||
private static void Blit1To1Rows(byte[] dst, int dstW, int dstH, int ex, int ey, VideoFrame src, float opacity)
|
||||
{
|
||||
var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256);
|
||||
if (op <= 0) return;
|
||||
|
||||
var x0 = Math.Max(0, ex);
|
||||
var y0 = Math.Max(0, ey);
|
||||
var x1 = Math.Min(dstW, ex + src.Width);
|
||||
var y1 = Math.Min(dstH, ey + src.Height);
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
|
||||
// The take-4 fix: a full-canvas opaque capture (the Live backdrop) is ONE
|
||||
// memcpy, not 2.07M managed loop iterations — the row walk below still cost
|
||||
// ~58ms/frame dominated by bounds-check arithmetic and GC of the per-pixel
|
||||
// loop's slowness, i.e. the same starvation shape as take 3, one floor lower.
|
||||
if (op == 256 && src.IsOpaque && x0 == 0 && y0 == 0 && x1 == dstW && y1 == dstH)
|
||||
{
|
||||
Buffer.BlockCopy(src.BgraPixels, 0, dst, 0, dstW * dstH * 4);
|
||||
return;
|
||||
}
|
||||
// Opaque source with 1:1 aligned region covering full canvas
|
||||
if (op == 256 && src.IsOpaque && ex == 0 && ey == 0 && src.Width == dstW && src.Height == dstH)
|
||||
{
|
||||
Buffer.BlockCopy(src.BgraPixels, 0, dst, 0, dstW * dstH * 4);
|
||||
return;
|
||||
}
|
||||
|
||||
var d = dst.AsSpan();
|
||||
var s = src.BgraPixels.AsSpan();
|
||||
var rowLen = x1 - x0;
|
||||
if (op == 256)
|
||||
{
|
||||
for (var y = y0; y < y1; y++)
|
||||
{
|
||||
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
|
||||
BlendRowOpaque(d, (y * dstW + x0) * 4, s, si, rowLen);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var y = y0; y < y1; y++)
|
||||
{
|
||||
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
|
||||
BlendRowWeighted(d, (y * dstW + x0) * 4, s, si, rowLen, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BlendRowOpaque(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count)
|
||||
{
|
||||
for (var n = 0; n < count; n++)
|
||||
{
|
||||
var a = src[si + 3];
|
||||
if (a == 255)
|
||||
{
|
||||
dst[di] = src[si];
|
||||
dst[di + 1] = src[si + 1];
|
||||
dst[di + 2] = src[si + 2];
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
else if (a != 0)
|
||||
{
|
||||
var inv = 255 - a;
|
||||
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
|
||||
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
|
||||
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
si += 4;
|
||||
di += 4;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BlendRowWeighted(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count, int op)
|
||||
{
|
||||
for (var n = 0; n < count; n++)
|
||||
{
|
||||
var a = src[si + 3] * op >> 8;
|
||||
if (a >= 255)
|
||||
{
|
||||
dst[di] = src[si];
|
||||
dst[di + 1] = src[si + 1];
|
||||
dst[di + 2] = src[si + 2];
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
else if (a > 0)
|
||||
{
|
||||
var inv = 255 - a;
|
||||
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
|
||||
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
|
||||
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
si += 4;
|
||||
di += 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,22 +831,40 @@ public sealed class SceneCompositor
|
||||
}
|
||||
|
||||
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
|
||||
/// active source rect. The overlay is positioned in master space by (sx0, sy0).</summary>
|
||||
/// active source rect. The overlay is positioned in master space by (sx0, sy0).
|
||||
/// Iterates only the intersection rect — the old loop walked every destination
|
||||
/// pixel to blit a ~64px strip (2M iterations of bounds checks per overlay).</summary>
|
||||
private static void BlitOverlay(
|
||||
byte[] dst, int dstW, int dstH, CompositorOptions options,
|
||||
VideoFrame overlay, int sx0, int sy0)
|
||||
{
|
||||
for (var y = 0; y < dstH; y++)
|
||||
var dx0 = Math.Max(0, sx0 - options.SourceRectX);
|
||||
var dy0 = Math.Max(0, sy0 - options.SourceRectY);
|
||||
var dx1 = Math.Min(dstW, sx0 - options.SourceRectX + overlay.Width);
|
||||
var dy1 = Math.Min(dstH, sy0 - options.SourceRectY + overlay.Height);
|
||||
StripPool.For(dy0, dy1, y =>
|
||||
{
|
||||
for (var x = 0; x < dstW; x++)
|
||||
{
|
||||
var sx = x + options.SourceRectX - sx0;
|
||||
var sy = y + options.SourceRectY - sy0;
|
||||
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
|
||||
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
|
||||
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
|
||||
}
|
||||
}
|
||||
var si = ((y + options.SourceRectY - sy0) * overlay.Width
|
||||
+ dx0 + options.SourceRectX - sx0) * 4;
|
||||
BlendRowOpaque(dst, (y * dstW + dx0) * 4, overlay.BgraPixels, si, dx1 - dx0);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zoom box layer (n+1): map the element's master-space box and capture to
|
||||
/// crop space and 2x-magnify the capture onto the box from the composed frame.
|
||||
/// The active zoom element is never blitted by the normal layer loops — the
|
||||
/// switch skips it and this pass draws it above everything, last.
|
||||
/// </summary>
|
||||
private static void BlitZoom(
|
||||
byte[] dst, int cropW, int cropH, CompositorOptions options, ZoomSceneElement zoom)
|
||||
{
|
||||
ZoomMagnifier.MagnifyInto(
|
||||
dst, cropW, cropH,
|
||||
zoom.X - options.SourceRectX, zoom.Y - options.SourceRectY,
|
||||
zoom.Width, zoom.Height,
|
||||
zoom.CaptureX - options.SourceRectX, zoom.CaptureY - options.SourceRectY,
|
||||
zoom.ClipShape == ClipShape.Round);
|
||||
}
|
||||
|
||||
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
|
||||
|
||||
@@ -16,14 +16,19 @@ public static class StaticPixelCache
|
||||
public static VideoFrame? Get(string assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||
if (Cache.TryGetValue(assetId, out var frame)) return frame;
|
||||
// Locked: the frame pump decodes from its own thread now (take-8 off-UI fix),
|
||||
// so the dictionary genuinely races with UI-path callers (snapshots, heals).
|
||||
lock (Cache)
|
||||
{
|
||||
if (Cache.TryGetValue(assetId, out var frame)) return frame;
|
||||
|
||||
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||
if (bytes == null || bytes.Length == 0) return null;
|
||||
var bytes = LayoutStore.Instance?.GetAssetBytes(assetId);
|
||||
if (bytes == null || bytes.Length == 0) return null;
|
||||
|
||||
var decoded = Decode(bytes);
|
||||
if (decoded != null) Cache[assetId] = decoded;
|
||||
return decoded;
|
||||
var decoded = Decode(bytes);
|
||||
if (decoded != null) Cache[assetId] = decoded;
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
public static VideoFrame? Decode(byte[] bytes)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The zoom box's magnifier (creator-corrected model, 2026-10-08): the box is a
|
||||
/// detached 2x view of a FIXED capture window — the box/2 region centred on the
|
||||
/// spot the creator clicked — sampled from the ALREADY-COMPOSITED frame (every
|
||||
/// layer: live backdrop, art, webcam, sources, alerts). Moving the box relocates
|
||||
/// it on screen without changing the capture; the box is painted LAST (layer n+1)
|
||||
/// so it sits above everything. The capture snapshot is taken before the box is
|
||||
/// drawn, so a box overlapping its own source never recurses.
|
||||
/// </summary>
|
||||
internal static class ZoomMagnifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Magnify a box/2 capture window onto a box/2 ² box in <paramref name="dst"/>
|
||||
/// (an opaque, already-composited crop-space buffer). dst pixel centres map to
|
||||
/// capture coords as <c>cap + (dstLocal + 0.5) / 2</c> — the exact 2x the preview's
|
||||
/// VisualBrush shows via a box/2 Viewbox with Stretch=Fill. <paramref name="round"/>
|
||||
/// clips the box to a true circle (zoom shape Circle).
|
||||
/// </summary>
|
||||
internal static void MagnifyInto(
|
||||
byte[] dst, int dstW, int dstH,
|
||||
double boxX, double boxY, double boxW, double boxH,
|
||||
double capX, double capY,
|
||||
bool round)
|
||||
{
|
||||
if (boxW <= 0 || boxH <= 0) return;
|
||||
if (dstW <= 0 || dstH <= 0) return;
|
||||
|
||||
// Integer box draw bounds (clamped to the buffer).
|
||||
var bx0 = Math.Max(0, (int)Math.Floor(boxX));
|
||||
var by0 = Math.Max(0, (int)Math.Floor(boxY));
|
||||
var bx1 = Math.Min(dstW, (int)Math.Ceiling(boxX + boxW));
|
||||
var by1 = Math.Min(dstH, (int)Math.Ceiling(boxY + boxH));
|
||||
if (bx0 >= bx1 || by0 >= by1) return;
|
||||
var bw = bx1 - bx0;
|
||||
var bh = by1 - by0;
|
||||
|
||||
// Integer capture-window read bounds (the 2x window is box/2, clamped).
|
||||
var rx0 = Math.Max(0, (int)Math.Floor(capX));
|
||||
var ry0 = Math.Max(0, (int)Math.Floor(capY));
|
||||
var rx1 = Math.Min(dstW, (int)Math.Ceiling(capX + boxW / 2.0));
|
||||
var ry1 = Math.Min(dstH, (int)Math.Ceiling(capY + boxH / 2.0));
|
||||
var rw = rx1 - rx0;
|
||||
var rh = ry1 - ry0;
|
||||
if (rw <= 0 || rh <= 0) return;
|
||||
|
||||
// Snapshot the capture first: the box may overlap its own source region.
|
||||
var src = ArrayPool<byte>.Shared.Rent(rw * rh * 4);
|
||||
try
|
||||
{
|
||||
for (var y = 0; y < rh; y++)
|
||||
Buffer.BlockCopy(dst, ((ry0 + y) * dstW + rx0) * 4, src, y * rw * 4, rw * 4);
|
||||
|
||||
var radius = Math.Min(boxW, boxH) / 2.0;
|
||||
var ccx = boxX + boxW / 2.0;
|
||||
var ccy = boxY + boxH / 2.0;
|
||||
|
||||
for (var y = 0; y < bh; y++)
|
||||
{
|
||||
var dy = by0 + y;
|
||||
var sy = capY + (dy + 0.5 - boxY) * 0.5 - ry0;
|
||||
if (sy < 0.0) sy = 0.0;
|
||||
if (sy > rh - 1.0) sy = rh - 1.0;
|
||||
var syi = (int)sy;
|
||||
var syInv = Math.Min(syi + 1, rh - 1);
|
||||
var fy8 = (int)((sy - syi) * 256);
|
||||
var fyInv = 256 - fy8;
|
||||
|
||||
var di = (dy * dstW + bx0) * 4;
|
||||
for (var x = 0; x < bw; x++, di += 4)
|
||||
{
|
||||
var dx = bx0 + x;
|
||||
if (round)
|
||||
{
|
||||
var ox = dx - ccx;
|
||||
var oy = dy - ccy;
|
||||
if (ox * ox + oy * oy > radius * radius) continue;
|
||||
}
|
||||
var sx = capX + (dx + 0.5 - boxX) * 0.5 - rx0;
|
||||
if (sx < 0.0) sx = 0.0;
|
||||
if (sx > rw - 1.0) sx = rw - 1.0;
|
||||
var sxi = (int)sx;
|
||||
var sxInv = Math.Min(sxi + 1, rw - 1);
|
||||
var fx8 = (int)((sx - sxi) * 256);
|
||||
var fxInv = 256 - fx8;
|
||||
|
||||
var p00 = (syi * rw + sxi) * 4;
|
||||
var p10 = (syi * rw + sxInv) * 4;
|
||||
var p01 = (syInv * rw + sxi) * 4;
|
||||
var p11 = (syInv * rw + sxInv) * 4;
|
||||
dst[di] = (byte)Bilinear(src[p00], src[p10], src[p01], src[p11], fx8, fxInv, fy8, fyInv);
|
||||
dst[di + 1] = (byte)Bilinear(src[p00 + 1], src[p10 + 1], src[p01 + 1], src[p11 + 1], fx8, fxInv, fy8, fyInv);
|
||||
dst[di + 2] = (byte)Bilinear(src[p00 + 2], src[p10 + 2], src[p01 + 2], src[p11 + 2], fx8, fxInv, fy8, fyInv);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(src);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Two-stage bilinear in 8.8 fixed point (same precision as
|
||||
/// <see cref="SceneCompositor"/>'s sampler).</summary>
|
||||
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
|
||||
{
|
||||
var top = tl * fxInv + tr * fx8;
|
||||
var bot = bl * fxInv + br * fx8;
|
||||
return (top * fyInv + bot * fy8 + 32768) >> 16;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the FFmpeg command line for a live RTMP push (TASK 4 ship step 3):
|
||||
/// raw BGRA frames via stdin (paced <c>-re</c>), real audio via the named pipe
|
||||
/// raw BGRA frames via stdin (paced by the frame pump — the pump holds a strict
|
||||
/// CFR cadence, so <c>-re</c> would only add a second, fighting pacer), real
|
||||
/// audio via the named pipe
|
||||
/// (TASK 9 — the mixer streams f32le at 48 kHz stereo into <c>\\.\pipe\<name></c>;
|
||||
/// WASAPI loopback + the mic ride the pipe instead of the old anullsrc silence),
|
||||
/// H.264 + AAC encoding, FLV muxing to the ingestion URL. Pure — the encoder
|
||||
@@ -16,7 +18,6 @@ public static class FfmpegArgs
|
||||
{
|
||||
"-hide_banner", "-loglevel", "info",
|
||||
"-stats", "-stats_period", "0.5",
|
||||
"-re",
|
||||
"-f", "rawvideo", "-pix_fmt", "bgra",
|
||||
"-video_size", $"{options.Width}x{options.Height}",
|
||||
"-framerate", options.Fps.ToString(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
|
||||
@@ -30,6 +32,21 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
private Task? _stderrLoop;
|
||||
private bool _stopRequested;
|
||||
|
||||
// Pipe decoupling (slice 10, 2026-09-10): a bounded pending-frame queue drained
|
||||
// by its own task (the OBS video-thread → encoder-queue model — the encoder's
|
||||
// thread never couples back into the video thread; libobs obs-encoder.c). The
|
||||
// pump's SubmitFrameAsync now ENQUEUES (copying into a pooled buffer) instead of
|
||||
// blocking on WriteAsync when ffmpeg lags the pipe; overflow DROPS the newest
|
||||
// frame (skip-newest) and counts it. BoundedChannelFullMode.Wait + TryWrite gives
|
||||
// exactly that: when full, TryWrite returns false and the caller drops the item.
|
||||
private const int QueueCapacity = 120; // ~2s at the tier's 60fps
|
||||
private readonly Channel<PendingWrite> _frames = Channel.CreateBounded<PendingWrite>(
|
||||
new BoundedChannelOptions(QueueCapacity) { SingleReader = true });
|
||||
private Task? _drainTask;
|
||||
private int _droppedBackpressure;
|
||||
|
||||
private readonly record struct PendingWrite(byte[] Buffer, int Length);
|
||||
|
||||
public FfmpegEncoder(
|
||||
IFfmpegLocator locator,
|
||||
Func<IEncoderProcess>? processFactory = null)
|
||||
@@ -40,6 +57,13 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>Frames dropped by the bounded queue because the drain task couldn't
|
||||
/// keep the pipe fed (the encoder lagging the real-time capture rate). The pump
|
||||
/// reports this in its stats; the burned-in frame counter in the recording
|
||||
/// shows the identical jumps. Distinct from <see cref="StreamHealth.DroppedFrames"/>
|
||||
/// (ffmpeg's own progress-derived estimate).</summary>
|
||||
public int DroppedFrames => Volatile.Read(ref _droppedBackpressure);
|
||||
|
||||
public async Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
@@ -93,26 +117,40 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
_stopRequested = false;
|
||||
_stderrLoop = RunStderrLoopAsync(process);
|
||||
StartDrainLoop(process);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write one raw BGRA frame to ffmpeg's stdin. Serialized internally; callers
|
||||
/// (the compositor pump) may race freely. Frames are written as-is — the caller
|
||||
/// paces to capture rate (the compositor's job, ship step 5).
|
||||
/// Enqueue one raw BGRA frame for ffmpeg's stdin (slice 10). The caller races
|
||||
/// freely (the compositor pump); this never blocks on the pipe. The pixels are
|
||||
/// copied into a pooled buffer first because — unlike the old direct write — the
|
||||
/// write happens later on the drain thread, so the caller may (and does) recycle
|
||||
/// its scratch buffer the moment this returns. When the bounded queue is full the
|
||||
/// NEWEST frame is dropped and counted (freshness over coverage; the encoder is
|
||||
/// already behind, so the stale-content failure mode never encodes).
|
||||
/// </summary>
|
||||
public async Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (frame == null) throw new ArgumentNullException(nameof(frame));
|
||||
IEncoderProcess? process;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) throw new InvalidOperationException("The encoder is not running.");
|
||||
process = _process;
|
||||
}
|
||||
|
||||
var bytes = frame.BgraPixels;
|
||||
await process!.StandardInput.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return Task.FromCanceled(cancellationToken);
|
||||
|
||||
var pooled = ArrayPool<byte>.Shared.Rent(frame.BgraPixels.Length);
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, pooled, 0, frame.BgraPixels.Length);
|
||||
if (_frames.Writer.TryWrite(new PendingWrite(pooled, frame.BgraPixels.Length)))
|
||||
return Task.CompletedTask;
|
||||
|
||||
// Queue full (encoder lagging) or the channel completed during stop — drop the
|
||||
// newest and count it. The caller never blocks; the encoder catches up or the
|
||||
// session ends with a (reported) short gap instead of a frozen stall.
|
||||
ArrayPool<byte>.Shared.Return(pooled);
|
||||
Interlocked.Increment(ref _droppedBackpressure);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||||
@@ -127,13 +165,17 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
loop = _stderrLoop;
|
||||
}
|
||||
|
||||
// Flush every queued frame, then EOF: TryComplete lets the drain task write
|
||||
// the buffered frames, close stdin → ffmpeg finalizes and exits by itself
|
||||
// (the "did not exit after EOF" kill below is only the backstop).
|
||||
try
|
||||
{
|
||||
process!.StandardInput.Dispose(); // EOF → ffmpeg finalizes + exits
|
||||
_frames.Writer.TryComplete();
|
||||
if (_drainTask != null) await _drainTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: closing stdin failed");
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted during stop");
|
||||
}
|
||||
|
||||
try
|
||||
@@ -178,6 +220,7 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_frames.Writer.TryComplete(); // unblock a stuck drain loop alongside the kill
|
||||
lock (_gate)
|
||||
{
|
||||
if (!IsRunning) return;
|
||||
@@ -188,6 +231,54 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The drain task (slice 10): owns every stdin write, so pipe backpressure — the
|
||||
/// pump's old stall — lives HERE, on a thread the pump never touches. Frames are
|
||||
/// written exactly as queued (the pooled array through its recorded length —
|
||||
/// 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 void StartDrainLoop(IEncoderProcess process)
|
||||
{
|
||||
_drainTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await _frames.Reader.WaitToReadAsync().ConfigureAwait(false))
|
||||
{
|
||||
while (_frames.Reader.TryRead(out var pending))
|
||||
{
|
||||
await process.StandardInput.WriteAsync(pending.Buffer, 0, pending.Length)
|
||||
.ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
ArrayPool<byte>.Shared.Return(pending.Buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
// channel completed while waiting — fall through to EOF
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop write faulted");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// EOF on every path — a byte that sits in the pool when the pipe
|
||||
// breaks is garbage anyway, and ffmpeg MUST see the close to finalize.
|
||||
process.StandardInput.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg encoder: drain loop faulted");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> ProbeEncoderAsync(string ffmpegPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -230,14 +321,35 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
OnStderrLine(process, line);
|
||||
}
|
||||
|
||||
var code = process.ExitCode;
|
||||
var stillRunning = false;
|
||||
lock (_gate) stillRunning = IsRunning;
|
||||
if (stillRunning && !_stopRequested && code != 0)
|
||||
// Read the exit code defensively: the stop path disposes the process
|
||||
// while this loop may still be waking (2026-09-01 "No process is
|
||||
// associated with this object" — a cosmetic race that poisoned the log).
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
AppLog.Write($"FFmpeg encoder: subprocess exited unexpectedly ({code})");
|
||||
_health.LastError = $"FFmpeg exited with code {code}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {code}");
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
exitCode = -1;
|
||||
}
|
||||
|
||||
var stillRunning = false;
|
||||
var stopping = false;
|
||||
lock (_gate)
|
||||
{
|
||||
stillRunning = IsRunning;
|
||||
stopping = _stopRequested;
|
||||
}
|
||||
if (stillRunning && !stopping)
|
||||
{
|
||||
// ANY exit we did not ask for is a failure — including code 0.
|
||||
// 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})");
|
||||
_health.LastError = $"FFmpeg exited with code {exitCode}";
|
||||
ProcessFailed?.Invoke(this, $"FFmpeg exited with code {exitCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -250,7 +362,17 @@ public sealed class FfmpegEncoder : IFfmpegEncoder
|
||||
private void OnStderrLine(IEncoderProcess process, string line)
|
||||
{
|
||||
var progress = FfmpegProgressParser.TryParse(line);
|
||||
if (progress == null) return;
|
||||
if (progress == null)
|
||||
{
|
||||
// THE EYES: everything ffmpeg says that isn't a progress line — startup
|
||||
// config, warnings, the real reason it quit. Was silently discarded
|
||||
// (2026-09-01: encoder died at ~0.9s, log silent). Truncated so a
|
||||
// \r-choked stats blob can't flood; noise now beats blindness.
|
||||
var text = line.Trim('\r', ' ', '\t');
|
||||
if (text.Length > 0)
|
||||
AppLog.Write($"ffmpeg: {(text.Length > 400 ? text[..400] + "…" : text)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var dropped = Math.Max(0, (long)Math.Round(progress.Value.Fps * progress.Value.Duration.TotalSeconds) - progress.Value.Frame);
|
||||
|
||||
|
||||
@@ -1,125 +1,81 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Services.Encoder;
|
||||
|
||||
/// <summary>
|
||||
/// The default <see cref="IFfmpegLocator"/>: probe PATH first (the user's own
|
||||
/// install wins), then the cache in <c>%APPDATA%\ytLlive\tools</c>, then pull the
|
||||
/// pinned BtbN **lgpl-shared** build (TASK 4 ship step 2). The shared variant is a
|
||||
/// deliberate licensing choice: dynamic linking means LGPL compliance is "license
|
||||
/// text + source offer", with no static-relink (LGPL §6) material required. The
|
||||
/// shared zip puts <c>ffmpeg.exe</c> plus the <c>libav*.dll</c> family in <c>bin/</c>,
|
||||
/// so both are extracted — Windows resolves the DLLs from the exe's own directory.
|
||||
/// Search dirs, tools dir, and the downloader are constructor-injected so tests
|
||||
/// fake the network and stay on a temp directory.
|
||||
/// The default <see cref="IFfmpegLocator"/>: resolve the <b>bundled</b> FFmpeg that ships
|
||||
/// next to the app, falling back to PATH for anyone who deliberately installed their own.
|
||||
///
|
||||
/// <para><b>Nothing is downloaded.</b> This type used to pin a BtbN release URL, download
|
||||
/// the zip on first run, and execute it — which is Windows Store policy 10.2.2 (dynamic
|
||||
/// code inclusion, cited in <c>TASKS/research-store-certification.md</c> §4) and already
|
||||
/// cost a real failure: the pin aged out of BtbN's 14-day retention and the download
|
||||
/// <b>404'd on the creator's first real recording attempt</b> (2026-09-01). The binaries
|
||||
/// are now fetched at BUILD time by <c>scripts/fetch-ffmpeg.sh</c> (pinned URL + SHA-256)
|
||||
/// and copied into <c>ffmpeg/</c> beside the executable.</para>
|
||||
///
|
||||
/// <para><b>Order is bundled-first, deliberately.</b> The old code probed PATH first
|
||||
/// ("the user's own install wins"). With a bundle in the package that is the wrong way
|
||||
/// round: a random PATH <c>ffmpeg</c> may be a different major version or a GPL build, and
|
||||
/// the bundle is the one we pinned, license-vetted, and tested against. PATH stays as the
|
||||
/// escape hatch, which is what it is actually good for.</para>
|
||||
///
|
||||
/// <para>The lgpl-<b>shared</b> variant is a licensing choice, not a packaging one: dynamic
|
||||
/// linking makes LGPL compliance "license text + source offer" with no static-relink
|
||||
/// (LGPL §6) material required. That means the <c>libav*.dll</c> family must sit in the
|
||||
/// same folder as the exe — Windows resolves them from the executable's own directory.</para>
|
||||
/// </summary>
|
||||
public sealed class FfmpegLocator : IFfmpegLocator
|
||||
{
|
||||
public const string FileName = "ffmpeg.exe";
|
||||
|
||||
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
|
||||
public const string PinnedUrl =
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
|
||||
/// <summary>Sibling probe binary — bundled alongside ffmpeg so the media
|
||||
/// frame-rate probe (<see cref="ytLive.Services.FfmpegFrameRateProbe"/>) can
|
||||
/// derive its path from the located ffmpeg directory.</summary>
|
||||
public const string ProbeFileName = "ffprobe.exe";
|
||||
|
||||
/// <summary>Folder, relative to the app base directory, that the build copies the
|
||||
/// bundled build into. One folder for the exe and every DLL.</summary>
|
||||
public const string BundleFolderName = "ffmpeg";
|
||||
|
||||
/// <summary>Base directory the bundle is resolved against. Injectable so tests can
|
||||
/// point at a temp tree; defaults to the running app's base directory.</summary>
|
||||
private readonly string _bundleRoot;
|
||||
|
||||
private readonly string[] _searchDirs;
|
||||
private readonly string _toolsDir;
|
||||
private readonly Func<string, CancellationToken, Task<byte[]>> _downloader;
|
||||
|
||||
public FfmpegLocator(
|
||||
string[]? searchDirs = null,
|
||||
string? toolsDir = null,
|
||||
Func<string, CancellationToken, Task<byte[]>>? downloader = null)
|
||||
public FfmpegLocator(string[]? searchDirs = null, string? bundleRoot = null)
|
||||
{
|
||||
_searchDirs = searchDirs ?? ParsePath();
|
||||
_toolsDir = toolsDir ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools");
|
||||
_downloader = downloader ?? DefaultDownload;
|
||||
_bundleRoot = bundleRoot ?? AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
public async Task<string> LocateAsync(CancellationToken cancellationToken = default)
|
||||
public Task<string> LocateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundled = Path.Combine(_bundleRoot, BundleFolderName, FileName);
|
||||
if (IsUsable(bundled)) return Task.FromResult(bundled);
|
||||
|
||||
foreach (var dir in _searchDirs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dir)) continue;
|
||||
var candidate = Path.Combine(dir, FileName);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
if (IsUsable(candidate)) return Task.FromResult(candidate);
|
||||
}
|
||||
|
||||
var cached = Path.Combine(_toolsDir, FileName);
|
||||
if (File.Exists(cached) && new FileInfo(cached).Length > 0) return cached;
|
||||
|
||||
try
|
||||
{
|
||||
var zip = await _downloader(PinnedUrl, cancellationToken).ConfigureAwait(false);
|
||||
if (zip.Length == 0)
|
||||
throw new IOException($"FFmpeg download from {PinnedUrl} returned an empty payload.");
|
||||
Directory.CreateDirectory(_toolsDir);
|
||||
ExtractBinaries(zip, _toolsDir);
|
||||
return cached;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
|
||||
{
|
||||
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
|
||||
throw;
|
||||
}
|
||||
// Deliberately loud. The previous cache-then-download ladder had a "found
|
||||
// something on disk" path that could satisfy a missing bundle with a stale copy,
|
||||
// which is exactly the silent-drift a certification-gated dependency must not
|
||||
// have. If the package forgot ffmpeg/, the app says so and stops.
|
||||
throw new IOException(
|
||||
$"FFmpeg was not found. The bundled copy is missing from " +
|
||||
$"'{Path.Combine(_bundleRoot, BundleFolderName)}' — this build is incomplete " +
|
||||
"(reinstall or re-fetch with scripts/fetch-ffmpeg.sh). Installing an LGPL-shared " +
|
||||
"FFmpeg and putting 'ffmpeg' on PATH also works.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract <c>ffmpeg.exe</c> and every <c>*.dll</c> into <c>toolsDir</c> via a
|
||||
/// staging directory, so a failed extract never leaves a partially-populated
|
||||
/// cache behind (the previous good cache stays until every move succeeds).
|
||||
/// </summary>
|
||||
private static void ExtractBinaries(byte[] zip, string toolsDir)
|
||||
{
|
||||
var staging = toolsDir + ".stage";
|
||||
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||
Directory.CreateDirectory(staging);
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(zip, writable: false);
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
var exe = archive.Entries.FirstOrDefault(
|
||||
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
|
||||
ExtractOne(exe, Path.Combine(staging, FileName));
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
ExtractOne(entry, Path.Combine(staging, Path.GetFileName(entry.FullName)));
|
||||
}
|
||||
foreach (var file in Directory.GetFiles(staging))
|
||||
File.Move(file, Path.Combine(toolsDir, Path.GetFileName(file)), overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractOne(ZipArchiveEntry entry, string destination)
|
||||
{
|
||||
var temp = destination + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var source = entry.Open())
|
||||
using (var target = File.Create(temp))
|
||||
source.CopyTo(target);
|
||||
File.Move(temp, destination);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temp)) File.Delete(temp);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> DefaultDownload(string url, CancellationToken ct)
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
return await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
|
||||
}
|
||||
private static bool IsUsable(string path) =>
|
||||
File.Exists(path) && new FileInfo(path).Length > 0;
|
||||
|
||||
private static string[] ParsePath()
|
||||
{
|
||||
|
||||
+629
-20
@@ -29,15 +29,92 @@ 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<VideoFrame?>? _brandFlash;
|
||||
private readonly TransitionService? _transition;
|
||||
private readonly Func<Scene?>? _fromSceneProvider;
|
||||
private readonly SceneGraph? _sceneGraph;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
// Master-buffer scratch pool (take-4 starvation fix, slice 2 of 2): a fresh
|
||||
// 8.3MB byte[] every tick is ~500MB/s of LOH churn — GC stalls masquerading
|
||||
// as render cost. The pump pools ONLY buffers it handed out (reference-equality
|
||||
// set), so bake-cache / social-bar / static-cache frames are never touched;
|
||||
// SubmitFrameAsync copies the bytes to the encoder's stdin before returning,
|
||||
// so recycling after submit is safe (MyMistakes recipe).
|
||||
private readonly HashSet<byte[]> _ownedScratch = new(ReferenceEqualityComparer.Instance);
|
||||
private readonly List<byte[]> _freeScratch = new();
|
||||
private const int MaxScratchPooled = 4;
|
||||
|
||||
private byte[] AcquireScratch(int size)
|
||||
{
|
||||
for (var i = _freeScratch.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_freeScratch[i].Length != size) continue;
|
||||
var buffer = _freeScratch[i];
|
||||
_freeScratch.RemoveAt(i);
|
||||
return buffer;
|
||||
}
|
||||
var fresh = new byte[size];
|
||||
_ownedScratch.Add(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private void ReleaseScratch(byte[]? buffer)
|
||||
{
|
||||
if (buffer == null || !_ownedScratch.Contains(buffer)) return;
|
||||
if (_freeScratch.Count >= MaxScratchPooled || _freeScratch.Contains(buffer)) return;
|
||||
_freeScratch.Add(buffer);
|
||||
}
|
||||
|
||||
// Windows sleep quantum (take-9 finding, 2026-09-04): Task.Delay rounds every
|
||||
// request up to the system clock tick (~15.6ms default — learn.microsoft.com/en-us/
|
||||
// dotnet/api/system.threading.tasks.task.delay: "approximately 15 milliseconds on
|
||||
// Windows systems"), so a pacer requesting 3-15ms actually sleeps 15.6ms. Takes
|
||||
// 5-9 measured work ~25ms but period ~37ms: one padded wait per frame hid every
|
||||
// compositor improvement. Established media-app practice (game-loop/OBS canon —
|
||||
// stackoverflow.com/questions/5441464; and raise the resolution for the session —
|
||||
// learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod):
|
||||
// timeBeginPeriod(1) while the pump runs, sleep only the BULK of the remainder,
|
||||
// and spin the last ~2ms across the deadline.
|
||||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||||
private static extern uint timeBeginPeriod(uint uMilliseconds);
|
||||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||||
private static extern uint timeEndPeriod(uint uMilliseconds);
|
||||
private static readonly long SpinTailTicks = System.Diagnostics.Stopwatch.Frequency * 2 / 1000; // 2ms
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _pumpTask;
|
||||
private bool _started;
|
||||
private long _outputIndex;
|
||||
|
||||
/// <summary>Captured by <see cref="ProbeRender"/> when a render exceeds ~20ms —
|
||||
/// the 2026-09-12 half-speed-render probe. Appended (once) to the next stats or
|
||||
/// stall line, then cleared.</summary>
|
||||
private string _renderDetail = "";
|
||||
|
||||
/// <summary>C4 blit-on-change cache (2026-09-15): the FULL-render path (split=0
|
||||
/// when the live-capture backdrop sits at element 0, or no SceneGraph) re-composites
|
||||
/// the ENTIRE frame every tick even when no input changed — the ty-1841 take logged
|
||||
/// a render stall on EVERY iteration (totalMs 21-44, one composite per ~30ms while
|
||||
/// the content moved ~20 updates/s). The SceneGraph split can never help here: the
|
||||
/// backdrop is element 0 and must stay dynamic (a baked capture goes stale), so the
|
||||
/// cache lives at the pump: the last full-render output plus the input identity that
|
||||
/// produced it. Unchanged identity → ONE BlockCopy (~3ms) reuses the composite;
|
||||
/// changed identity → re-composite. The cache buffer is a SEPARATE long-lived array,
|
||||
/// never the scratch pool — the caller burns the frame counter and recycles the
|
||||
/// scratch AFTER RenderScene returns, so the cache is written from the rendered
|
||||
/// scratch BEFORE returning (pre-burn, pre-recycle). The identity mirror is
|
||||
/// <see cref="BuildFullRenderSignature"/>: it resolves the same frames the compositor
|
||||
/// will (same resolver seam), so a changed frame (new capture, new webcam, new chat
|
||||
/// raster) forces a re-render and an unchanged one never serves stale bytes. Internal
|
||||
/// counters feed the Good Dog test + the 5s cache detail in the stats line.</summary>
|
||||
private byte[]? _fullCachePixels;
|
||||
private bool _fullCacheValid;
|
||||
private ulong _lastFullSignature;
|
||||
internal long CacheHits;
|
||||
internal long CacheRenders;
|
||||
|
||||
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
@@ -54,8 +131,10 @@ 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<VideoFrame?>? brandFlash = null,
|
||||
TransitionService? transition = null,
|
||||
Func<Scene?>? fromSceneProvider = null)
|
||||
SceneGraph? sceneGraph = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||
@@ -65,12 +144,19 @@ public sealed class FramePump : IDisposable
|
||||
_log = log;
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
_alertTicker = alertTicker;
|
||||
_brandFlash = brandFlash;
|
||||
_transition = transition;
|
||||
_fromSceneProvider = fromSceneProvider;
|
||||
_sceneGraph = sceneGraph;
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>The burned counter of the LAST frame submitted to the encoder. Stable
|
||||
/// across a tick's multiple resolver passes (it advances only at burn/submit, which
|
||||
/// happens AFTER render) — tests key per-tick content on it.</summary>
|
||||
internal long OutputIndex => _outputIndex;
|
||||
|
||||
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||||
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
@@ -107,8 +193,20 @@ public sealed class FramePump : IDisposable
|
||||
// synchronously on this thread before PumpAsync even returns.
|
||||
IsRunning = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
_pumpTask = PumpAsync(options, _cts.Token);
|
||||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
|
||||
// The loop runs on the thread pool ON PURPOSE (take-8 finding, 2026-09-04):
|
||||
// Task.Run installs no SynchronizationContext, so every await continuation
|
||||
// stays off the UI dispatcher. Before this, the pump inherited the UI
|
||||
// thread's sync context (StartAsync is fired from a command handler), so
|
||||
// "render 22ms, wait 10ms" was the producer sitting in the dispatcher
|
||||
// queue behind the live preview it is meant to be independent of — the
|
||||
// stats quantum fix made the wait VISIBLE; this removes its cause.
|
||||
// OBS's video threads are dedicated for exactly this reason.
|
||||
_pumpTask = Task.Run(() => PumpAsync(options, _cts.Token));
|
||||
var scene = _sceneProvider();
|
||||
var comp = _compositorOptions();
|
||||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)" +
|
||||
(scene != null ? $" scene='{scene.Name}'" : "") +
|
||||
$" comp={comp.SourceRectWidth}x{comp.SourceRectHeight}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -208,6 +306,8 @@ public sealed class FramePump : IDisposable
|
||||
{
|
||||
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||||
}
|
||||
_freeScratch.Clear();
|
||||
_ownedScratch.Clear();
|
||||
}
|
||||
|
||||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||
@@ -215,6 +315,121 @@ public sealed class FramePump : IDisposable
|
||||
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||||
var lastTick = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// Deadline pacing (2026-09-03, take-3 fix): the frame interval is a DEADLINE,
|
||||
// not an afterthought sleep — the OBS libobs video-io.c pattern (researched
|
||||
// before coding; see https://github.com/obsproject/obs-studio/blob/master/
|
||||
// libobs/media-io/video-io.c). The old loop slept the FULL interval after
|
||||
// each render, so period = render + submit + interval: at take-3's 258ms
|
||||
// render that was 3.6fps stamped into a 60fps container — rawvideo stamps by
|
||||
// arrival, so 30 wall-seconds muxed as a 2.1s time-lapse, no error anywhere.
|
||||
var intervalTicks = Math.Max(1, (long)Math.Round(interval.TotalSeconds * System.Diagnostics.Stopwatch.Frequency));
|
||||
var nextTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
|
||||
// Stage timing (2026-09-01, take two): rawvideo carries no per-frame
|
||||
// timestamps — ffmpeg stamps frames by ARRIVAL at the declared fps. A producer
|
||||
// slower than the declared rate yields a time-lapsed, short file (observed:
|
||||
// 39 frames in 27 wall-seconds ≈ 30x at 60fps) with no error anywhere.
|
||||
// Log the render/submit split every 5s so the next take names the stage.
|
||||
var renderSw = new System.Diagnostics.Stopwatch();
|
||||
var submitSw = new System.Diagnostics.Stopwatch();
|
||||
var waitSw = new System.Diagnostics.Stopwatch();
|
||||
// Resolve-vs-composite split (2026-09-04, take-6 ambiguity): "render" was a
|
||||
// black box — the stats line now reports resolver time separately so a take
|
||||
// names the stage (get-frame vs blit) instead of feeding another guess.
|
||||
var resolveSw = new System.Diagnostics.Stopwatch();
|
||||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0;
|
||||
long socialBarTicks = 0, tickerTicks = 0, flashTicks = 0, acquireTicks = 0;
|
||||
long worstRender = 0, worstSubmit = 0;
|
||||
int statFrames = 0;
|
||||
int stalls = 0;
|
||||
long lastCacheHits = 0, lastCacheRenders = 0;
|
||||
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
// bool _loggedStartDetail = false;
|
||||
|
||||
// Real-render accounting (UNIT 0, 2026-10-05): every average above was
|
||||
// divided by statFrames — the number of frames SUBMITTED, which deadline
|
||||
// padding always fills to the full target even when the renderer only
|
||||
// produced a fifth of them. A 60fps container therefore reported "300/300
|
||||
// frames, avg render 12ms" while the source actually fed it 60 distinct
|
||||
// images. Render cost is now divided by the render count, and the render
|
||||
// count is reported beside the submit count — the two numbers side by side
|
||||
// ARE the duplicate-on-lag ratio, which is the only honest reading of how
|
||||
// much of the stream is a repeat.
|
||||
int statRenders = 0;
|
||||
|
||||
// Webcam cadence (UNIT 0, 2026-10-05): the pump sees each element's resolved
|
||||
// frame, and VideoFrame.Epoch is the webcam ring's monotonic generation — so a
|
||||
// NEW epoch means the webcam actually delivered something new between two of
|
||||
// our ticks. This is the counterpart to MediaCaptureFrameSource's arrivals
|
||||
// line: it measures what reached the OUTPUT, not what the camera produced. If
|
||||
// the camera reports 30/s and this reports 25/s, the loss is between them.
|
||||
var webcamCadence = new Helpers.FrameCadence();
|
||||
var seenWebcamEpochs = new HashSet<long>();
|
||||
|
||||
void ReportStats()
|
||||
{
|
||||
if (DateTime.UtcNow < statsNext) return;
|
||||
var target = 5d / interval.TotalSeconds; // frames expected per window
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
var dropped = encoder == null ? 0 : encoder.DroppedFrames;
|
||||
var detail = _renderDetail;
|
||||
_renderDetail = "";
|
||||
var cacheDetail = "";
|
||||
if (statFrames > 0)
|
||||
{
|
||||
// C4 window detail: rendered composites (R) vs cache reuses (H) since the
|
||||
// last report — hard numeric proof the blit-on-change cache engaged.
|
||||
var hits = CacheHits - lastCacheHits;
|
||||
var renders = CacheRenders - lastCacheRenders;
|
||||
lastCacheHits = CacheHits;
|
||||
lastCacheRenders = CacheRenders;
|
||||
if (hits > 0 || renders > 0) cacheDetail = $", cache {renders}R/{hits}H";
|
||||
}
|
||||
double freq = System.Diagnostics.Stopwatch.Frequency;
|
||||
_log?.Invoke(statFrames == 0
|
||||
? "FramePump stats: NO frames produced in 5s (loop stalled?)" + (detail.Length > 0 ? " | " + detail : "")
|
||||
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s from {statRenders} renders, " +
|
||||
$"avg render {renderTicks / freq * 1000 / Math.Max(1, statRenders):F1}ms " +
|
||||
$"(resolve {resolveTicks / freq * 1000 / Math.Max(1, statRenders):F1}, " +
|
||||
$"social {socialBarTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
|
||||
$"ticker {tickerTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
|
||||
$"flash {flashTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
|
||||
$"acq {acquireTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms), " +
|
||||
$"avg submit {submitTicks / freq * 1000 / statFrames:F1}ms, "
|
||||
+ $"avg wait {waitTicks / freq * 1000 / Math.Max(1, statRenders):F1}ms, "
|
||||
+ $"worst render {worstRender / freq * 1000:F1}ms, "
|
||||
+ $"worst submit {worstSubmit / freq * 1000:F1}ms, "
|
||||
+ $"dropped {dropped}, stalls {stalls}{cacheDetail}, {webcamCadence.Summarize().Describe("webcam")}"
|
||||
+ (detail.Length > 0 ? " | " + detail : ""));
|
||||
worstRender = 0;
|
||||
worstSubmit = 0;
|
||||
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
|
||||
socialBarTicks = tickerTicks = flashTicks = acquireTicks = 0;
|
||||
statFrames = 0;
|
||||
statRenders = 0;
|
||||
stalls = 0;
|
||||
webcamCadence.Reset();
|
||||
seenWebcamEpochs.Clear();
|
||||
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
// One wrapper shared by every render of the run — resolve time accumulates
|
||||
// inside the render measurement, and the stats line reports the split.
|
||||
timeBeginPeriod(1); // pairs with timeEndPeriod in the finally — see field note
|
||||
var previousGcMode = System.Runtime.GCSettings.LatencyMode;
|
||||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||||
VideoFrame? TimedResolver(SceneElement element)
|
||||
{
|
||||
resolveSw.Restart();
|
||||
var frame = _frameResolver(element);
|
||||
resolveSw.Stop();
|
||||
resolveTicks += resolveSw.ElapsedTicks;
|
||||
if (element.IsWebcam && frame != null && seenWebcamEpochs.Add(frame.Epoch))
|
||||
webcamCadence.Add(System.Diagnostics.Stopwatch.GetTimestamp());
|
||||
return frame;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -222,6 +437,7 @@ public sealed class FramePump : IDisposable
|
||||
var scene = _sceneProvider();
|
||||
if (scene != null)
|
||||
{
|
||||
var iterStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var compositorOptions = _compositorOptions();
|
||||
VideoFrame? socialBarFrame = null;
|
||||
var socialBarTop = 0;
|
||||
@@ -235,30 +451,125 @@ public sealed class FramePump : IDisposable
|
||||
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||||
}
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
VideoFrame? tickerFrame = _alertTicker?.Invoke();
|
||||
tickerTicks += sw.ElapsedTicks;
|
||||
sw.Restart();
|
||||
VideoFrame? flashFrame = _brandFlash?.Invoke();
|
||||
flashTicks += sw.ElapsedTicks;
|
||||
sw.Restart();
|
||||
var (barFrame2, position2) = (_socialBar == null ? (null, SocialBarPosition.Bottom) : _socialBar());
|
||||
socialBarFrame = barFrame2;
|
||||
socialBarTop = barFrame2 == null ? 0 : (position2 == SocialBarPosition.Top ? 0 : compositorOptions.SourceRectHeight - barFrame2.Height);
|
||||
socialBarTicks += sw.ElapsedTicks;
|
||||
|
||||
VideoFrame frame;
|
||||
if (_transition is { Active: true } transition && _fromSceneProvider != null)
|
||||
var scratchSize = compositorOptions.SourceRectWidth
|
||||
* compositorOptions.SourceRectHeight * 4;
|
||||
renderSw.Restart();
|
||||
sw.Restart();
|
||||
var scratch = AcquireScratch(scratchSize);
|
||||
acquireTicks += sw.ElapsedTicks;
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver);
|
||||
if (_transition is { Active: true } transition)
|
||||
{
|
||||
var fromScene = _fromSceneProvider();
|
||||
var fromFrame = fromScene != null
|
||||
? _compositor.Render(fromScene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop)
|
||||
: _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
var toFrame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
frame = transition.BlendFrame(toFrame);
|
||||
frame = transition.BlendFrame(frame);
|
||||
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
|
||||
lastTick.Restart();
|
||||
}
|
||||
else
|
||||
{
|
||||
frame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
lastTick.Restart();
|
||||
}
|
||||
// Restarted EVERY frame (transition or not): a transition begun
|
||||
// after idle must not inherit a giant ElapsedMs and complete
|
||||
// instantly on its first Tick.
|
||||
lastTick.Restart();
|
||||
renderSw.Stop();
|
||||
renderTicks += renderSw.ElapsedTicks;
|
||||
if (renderSw.ElapsedTicks > worstRender) worstRender = renderSw.ElapsedTicks;
|
||||
statRenders++;
|
||||
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
if (encoder == null) break;
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
|
||||
// Deadline pacing, OBS duplicate-on-lag (slice 15): emit ONE frame per
|
||||
// deadline slot — a fresh composite when the render kept up, a REPEAT
|
||||
// of this iteration's latest composite for every slot the render
|
||||
// overran. The OBS model never leaves a wall-time hole: libobs
|
||||
// media-io/video-io.c runs on its own clock and duplicates the latest
|
||||
// frame when the video thread lags, logging "lagged frames due to
|
||||
// rendering lag/stalls" — never skipped time (docs.obsproject.com/
|
||||
// backend-design: "If the video frame queue is full, it will duplicate
|
||||
// the last frame"). Slice 10 chose the opposite for freshness: an
|
||||
// overrun SKIPPED the missed slots, so a 60fps-authoring pump emitting
|
||||
// one frame per 35ms overrun authored ~1.7x playback (ty-1726: 163
|
||||
// video frames for 2.93s of audio) and ended the video 0.22s before the
|
||||
// audio tail. Counting duplicates instead of skips keeps recording
|
||||
// duration == wall duration (no acceleration, no audio tail cut) at the
|
||||
// cost of a short judder during a stall — the accepted trade. The burst
|
||||
// is microseconds (the enqueue never blocks, and the loop is clamped to
|
||||
// deadlineNow) so it can't smear the way the take-9 blocking burst did.
|
||||
var deadlineNow = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
while (!ct.IsCancellationRequested && deadlineNow >= nextTick)
|
||||
{
|
||||
// Burned-in frame counter (slice 10 judge): a clock-independent
|
||||
// pacing witness burned literally into the composite. Decode the
|
||||
// recording and read the bottom-right strip: the number must
|
||||
// advance +1 per authored frame and jump only by counted drops
|
||||
// (queue overflow). Burned here — on the buffer finally submitted,
|
||||
// which the enqueue's snapshot copies before the next iteration
|
||||
// rewrites it — so every file frame carries its own index and a
|
||||
// fast-render iteration that submitted nothing leaves no phantom
|
||||
// gap (the slice-10 unconditional bump before the gate could).
|
||||
_outputIndex++;
|
||||
BurnFrameIndex(frame.BgraPixels, frame.Width, frame.Height, _outputIndex);
|
||||
|
||||
submitSw.Restart();
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
submitSw.Stop();
|
||||
statFrames++;
|
||||
submitTicks += submitSw.ElapsedTicks;
|
||||
if (submitSw.ElapsedTicks > worstSubmit) worstSubmit = submitSw.ElapsedTicks;
|
||||
nextTick += intervalTicks;
|
||||
}
|
||||
|
||||
// SubmitFrameAsync copied the bytes into the encoder's queue — the
|
||||
// tick's buffers are recyclable once the enqueue snapshot them.
|
||||
// The free-list Contains guard keeps the Cut path (BlendFrame
|
||||
// returns toFrame itself, aliasing scratch) safe.
|
||||
ReleaseScratch(frame.BgraPixels);
|
||||
ReleaseScratch(scratch);
|
||||
|
||||
// Sleep the BULK of the remainder, SPIN the 2ms tail — never hand
|
||||
// a sub-tick remainder to the sleep quantum (the timeBeginPeriod
|
||||
// note). If the renderer already ate the budget there is nothing
|
||||
// left to sleep and the loop renders the next frame straight away.
|
||||
waitSw.Restart();
|
||||
var ahead = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
if (ahead > SpinTailTicks)
|
||||
await _pacingDelay(TimeSpan.FromSeconds(
|
||||
(ahead - SpinTailTicks) / (double)System.Diagnostics.Stopwatch.Frequency), ct);
|
||||
while (!ct.IsCancellationRequested
|
||||
&& System.Diagnostics.Stopwatch.GetTimestamp() < nextTick)
|
||||
Thread.SpinWait(400);
|
||||
waitSw.Stop();
|
||||
waitTicks += waitSw.ElapsedTicks;
|
||||
|
||||
// Stall logger (slice 10): an iteration spanning more than two full
|
||||
// intervals is the old bug's fingerprint — name the stage instead of
|
||||
// guessing. With the queue, submit should be ~1ms, so a stall here
|
||||
// means RENDER or RESOLVE (the stage terms of the last take).
|
||||
var iterWall = System.Diagnostics.Stopwatch.GetTimestamp() - iterStart;
|
||||
if (iterWall > 2 * intervalTicks)
|
||||
{
|
||||
stalls++;
|
||||
var detail = _renderDetail;
|
||||
_renderDetail = "";
|
||||
_log?.Invoke($"FramePump stall: iteration {iterWall / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms " +
|
||||
$"(> 2× the {interval.TotalMilliseconds:F0}ms interval): worst render " +
|
||||
$"{worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, worst submit " +
|
||||
$"{worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, dropped {encoder.DroppedFrames}" +
|
||||
(detail.Length > 0 ? " | " + detail : ""));
|
||||
}
|
||||
ReportStats();
|
||||
}
|
||||
await _pacingDelay(interval, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -281,12 +592,310 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Runtime.GCSettings.LatencyMode = previousGcMode;
|
||||
timeEndPeriod(1);
|
||||
lock (_gate) IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
|
||||
/// <summary>Render a scene, using the baked-crust optimization when a <see cref="SceneGraph"/>
|
||||
/// is wired in: bake/cache the static layers below the split point, then composite the
|
||||
/// dynamic/above-split layers per frame. Without a SceneGraph, falls back to a full render
|
||||
/// (identical output — see SceneCompositorTests). <paramref name="scratch"/> is a pooled
|
||||
/// master buffer when supplied; the fully-static path returns the bake-cache frame itself
|
||||
/// (never pooled — the release side checks owned-by-reference).</summary>
|
||||
private VideoFrame RenderScene(
|
||||
Scene scene,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame,
|
||||
int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
VideoFrame? flashFrame,
|
||||
byte[]? scratch = null,
|
||||
Func<SceneElement, VideoFrame?>? resolver = null)
|
||||
{
|
||||
resolver ??= _frameResolver;
|
||||
if (_sceneGraph == null)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
|
||||
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
|
||||
var split = _sceneGraph.GetSplitPoint(scene);
|
||||
if (split == scene.Elements.Count)
|
||||
{
|
||||
// Fully static scene: bake once, reuse.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
|
||||
if (baked != null)
|
||||
{
|
||||
// BUG (pre-existing, found while wiring the branding credit 2026-09-26):
|
||||
// this path returned the stretched bake and silently DROPPED the social
|
||||
// bar and the alert ticker. Overlay() copies before blitting, so the
|
||||
// cached bake is not mutated and the flash still cannot get sticky in it.
|
||||
if (socialBarFrame != null || tickerFrame != null || flashFrame != null)
|
||||
{
|
||||
var withOverlays = baked;
|
||||
if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0);
|
||||
if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0);
|
||||
baked = withOverlays;
|
||||
}
|
||||
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
|
||||
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
|
||||
return stretched;
|
||||
}
|
||||
}
|
||||
|
||||
var swBase = System.Diagnostics.Stopwatch.StartNew();
|
||||
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
|
||||
if (baseFrame != null)
|
||||
{
|
||||
var frame = SceneCompositor.CompositeLayers(
|
||||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch);
|
||||
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// No static base (first layer is dynamic or empty scene) — full render.
|
||||
var swFull = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
|
||||
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
|
||||
return frame2;
|
||||
}
|
||||
|
||||
/// <summary>The full-render path (no SceneGraph, or no static base) wrapped in the
|
||||
/// C4 blit-on-change cache: when every resolved input plus every element's
|
||||
/// layout/visual bits match the last composite, reuse it with one BlockCopy instead
|
||||
/// of re-compositing. Mirror the compositor's resolution <i>before</i> deciding so a
|
||||
/// hit costs ~3ms of pure copy and a miss costs exactly the old full render.</summary>
|
||||
private VideoFrame RenderFull(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame, VideoFrame? flashFrame,
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
// 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.
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, resolver)
|
||||
: 0UL;
|
||||
|
||||
if (useCache && _fullCacheValid && signature == _lastFullSignature
|
||||
&& _fullCachePixels!.Length == scratch!.Length)
|
||||
{
|
||||
Buffer.BlockCopy(_fullCachePixels, 0, scratch, 0, scratch.Length);
|
||||
CacheHits++;
|
||||
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
|
||||
}
|
||||
|
||||
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
|
||||
CacheRenders++;
|
||||
if (useCache)
|
||||
{
|
||||
var pixels = rendered.BgraPixels;
|
||||
if (_fullCachePixels == null || _fullCachePixels.Length != pixels.Length)
|
||||
_fullCachePixels = new byte[pixels.Length];
|
||||
// PRE-burn / PRE-recycle: the caller burns the counter and recycles this
|
||||
// scratch after RenderScene returns — the cache keeps a clean copy, and the
|
||||
// burn on the next hit writes to a FRESH scratch copy, never the cache.
|
||||
Buffer.BlockCopy(pixels, 0, _fullCachePixels, 0, pixels.Length);
|
||||
_lastFullSignature = signature;
|
||||
_fullCacheValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_fullCacheValid = false; // size changed — never reuse a wrong-size buffer
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/// <summary>Hash the complete set of inputs the compositor's FULL render consumes:
|
||||
/// the crop/output dimensions, the social bar, and — in element order, mirroring
|
||||
/// <c>SceneCompositor.Render</c>'s iteration — every element's layout/visual fields
|
||||
/// plus the frame each one resolves (through the SAME resolver seam the render will
|
||||
/// use). A changed frame (new capture, new webcam Epoch, new chat raster) changes the
|
||||
/// hash → re-composite; an unchanged one reuses the cache. Buffer-array identity +
|
||||
/// Epoch + CropBounds is the compositor's own paste-key shape — producers hand out
|
||||
/// fresh immutable arrays, or recycled-ring arrays whose monotonic Epoch
|
||||
/// distinguishes generations (the capture take-11 fix), so address-identity alone is
|
||||
/// never trusted to key recycled content.</summary>
|
||||
private static ulong BuildFullRenderSignature(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
VideoFrame? flashFrame,
|
||||
Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
var h = 14695981039346656037UL; // FNV-1a offset basis
|
||||
h = Mix(h, (ulong)options.SourceRectX);
|
||||
h = Mix(h, (ulong)options.SourceRectY);
|
||||
h = Mix(h, (ulong)options.SourceRectWidth);
|
||||
h = Mix(h, (ulong)options.SourceRectHeight);
|
||||
h = Mix(h, (ulong)options.OutputWidth);
|
||||
h = Mix(h, (ulong)options.OutputHeight);
|
||||
h = Mix(h, (ulong)socialBarTop);
|
||||
if (socialBarFrame != null) h = MixFrame(h, socialBarFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
if (tickerFrame != null) h = MixFrame(h, tickerFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
// BrandFlashPresenter recycles one buffer and bumps Epoch every read, so this
|
||||
// invalidates the cache on every tick of a credit — a cached frame would freeze
|
||||
// the fade and pin a stale spot.
|
||||
if (flashFrame != null) h = MixFrame(h, flashFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
|
||||
var backdropResolved = false;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(element));
|
||||
h = Mix(h, element.IsVisible ? 1UL : 0UL);
|
||||
h = MixDouble(h, element.X);
|
||||
h = MixDouble(h, element.Y);
|
||||
h = MixDouble(h, element.Width);
|
||||
h = MixDouble(h, element.Height);
|
||||
h = MixDouble(h, element.Opacity);
|
||||
h = Mix(h, (ulong)element.ClipShape);
|
||||
h = Mix(h, element.IsMirrored ? 1UL : 0UL);
|
||||
h = Mix(h, (ulong)element.BorderWidth);
|
||||
h = MixDouble(h, element.BorderOpacity);
|
||||
if (element.TryGetBorderColor(out var bcR, out var bcG, out var bcB))
|
||||
h = Mix(h, (ulong)((bcR << 16) | (bcG << 8) | bcB));
|
||||
else
|
||||
h = Mix(h, 0UL);
|
||||
|
||||
// Mirror the compositor's per-element frame resolution EXACTLY, so the hash
|
||||
// reacts to the same frames a render uses and never to a frame a render
|
||||
// ignores (a false-positive change only wastes one render; a false NEGATIVE
|
||||
// would serve stale bytes — that is what this mirror prevents).
|
||||
VideoFrame? frame = null;
|
||||
if (element is Source { IsBackground: true })
|
||||
{
|
||||
h = Mix(h, 1UL); // the live backdrop — first visible one is resolved, once
|
||||
if (element.IsVisible && !backdropResolved)
|
||||
{
|
||||
frame = resolver(element);
|
||||
backdropResolved = true;
|
||||
}
|
||||
}
|
||||
else if (element is Source { Type: SourceType.TextOverlay })
|
||||
{
|
||||
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
|
||||
}
|
||||
else if (element is Source { Type: SourceType.Background })
|
||||
{
|
||||
h = Mix(h, 3UL); // background image — resolved even when invisible (Render does)
|
||||
frame = resolver(element);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = Mix(h, 4UL); // regular layer — resolved only when visible
|
||||
if (element.IsVisible) frame = resolver(element);
|
||||
}
|
||||
if (frame != null) h = MixFrame(h, frame);
|
||||
else h = Mix(h, 0UL);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private static ulong Mix(ulong h, ulong v) => (h ^ v) * 0x9E3779B97F4A7C15UL;
|
||||
|
||||
private static ulong MixDouble(ulong h, double v)
|
||||
=> Mix(h, unchecked((ulong)BitConverter.DoubleToInt64Bits(v)));
|
||||
|
||||
private static ulong MixFrame(ulong h, VideoFrame frame)
|
||||
{
|
||||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(frame.BgraPixels));
|
||||
h = Mix(h, (ulong)frame.Epoch);
|
||||
h = Mix(h, (ulong)frame.Width);
|
||||
h = Mix(h, (ulong)frame.Height);
|
||||
if (frame.CropBounds is { } cb)
|
||||
{
|
||||
h = Mix(h, (ulong)cb.X);
|
||||
h = Mix(h, (ulong)cb.Y);
|
||||
h = Mix(h, (ulong)cb.W);
|
||||
h = Mix(h, (ulong)cb.H);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>2026-09-12 half-speed-render probe: the pump only produces ~27fps
|
||||
/// (render ~35ms vs the 16.7ms deadline), and rawvideo muxes at the DECLARED fps,
|
||||
/// so every take muxes at ~half its wall length (the truncation complaint). This
|
||||
/// names WHICH compositor path ate the slow frame so the fix targets the real
|
||||
/// stage. Records only when a frame takes ≥20ms (or carries the previous detail).</summary>
|
||||
private void ProbeRender(string path, Scene scene, int split, long totalMs, int bakeMs)
|
||||
{
|
||||
if (totalMs < 20 && _renderDetail.Length == 0) return;
|
||||
var dynamics = 0;
|
||||
foreach (var e in scene.Elements)
|
||||
if (e.Kind == ElementKind.Dynamic) dynamics++;
|
||||
_renderDetail = $"render={path} split={split} elements={scene.Elements.Count} dynamic={dynamics}" +
|
||||
(bakeMs > 0 ? $" bakeMs={bakeMs}" : "") + $" totalMs={totalMs}";
|
||||
}
|
||||
|
||||
// Dot-matrix digits (5×7, one row per raster line, '1' = lit) burned into the
|
||||
// bottom-right of every composite. Basic OCR-safe shapes, sized so the strip is
|
||||
// a 36×8 white box in the corner of a 1920×1080 frame — readable with a zoomed
|
||||
// player, invisible at normal size.
|
||||
private static readonly string[][] DigitGlyphs =
|
||||
{
|
||||
new[] { "01110","10001","10001","10001","10001","10001","01110" }, // 0
|
||||
new[] { "00100","01100","00100","00100","00100","00100","01110" }, // 1
|
||||
new[] { "01110","10001","00001","00010","00100","01000","11111" }, // 2
|
||||
new[] { "11111","00001","00010","00110","00001","10001","01110" }, // 3
|
||||
new[] { "00010","00110","01010","10010","11111","00010","00010" }, // 4
|
||||
new[] { "11111","10000","11110","00001","00001","10001","01110" }, // 5
|
||||
new[] { "01110","10001","10000","11110","10001","10001","01110" }, // 6
|
||||
new[] { "11111","00001","00010","00100","01000","01000","01000" }, // 7
|
||||
new[] { "01110","10001","10001","01110","10001","10001","01110" }, // 8
|
||||
new[] { "01110","10001","10001","01111","00001","10001","01110" }, // 9
|
||||
};
|
||||
|
||||
private static void BurnFrameIndex(byte[] bgra, int width, int height, long index)
|
||||
{
|
||||
const int digitW = 5, digitH = 7, gap = 1, margin = 2, digitCount = 6;
|
||||
var stripW = digitCount * (digitW + gap) - gap;
|
||||
var left = width - margin - stripW;
|
||||
var top = height - margin - digitH;
|
||||
|
||||
// Solid white box under the digits — the underlying scene can be anything.
|
||||
for (var y = top; y < top + digitH; y++)
|
||||
for (var x = left; x < left + stripW; x++)
|
||||
{
|
||||
var i = (y * width + x) * 4;
|
||||
bgra[i] = 255;
|
||||
bgra[i + 1] = 255;
|
||||
bgra[i + 2] = 255;
|
||||
}
|
||||
|
||||
var text = index.ToString("D6");
|
||||
for (var d = 0; d < digitCount; d++)
|
||||
{
|
||||
var glyph = DigitGlyphs[text[d] - '0'];
|
||||
for (var row = 0; row < digitH; row++)
|
||||
for (var col = 0; col < digitW; col++)
|
||||
{
|
||||
if (glyph[row][col] != '1') continue;
|
||||
var i = ((top + row) * width + (left + d * (digitW + gap) + col)) * 4;
|
||||
bgra[i] = 0;
|
||||
bgra[i + 1] = 0;
|
||||
bgra[i + 2] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||
|
||||
@@ -22,4 +22,9 @@ public interface IFfmpegEncoder : IDisposable
|
||||
Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default);
|
||||
Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default);
|
||||
Task StopAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Frames dropped by the encoder's bounded queue since start (the encoder
|
||||
/// lagging the real-time capture rate). Zero in a healthy session; fed to the pump's
|
||||
/// stats and mirrored by gaps in the burned-in frame counter of the recording.</summary>
|
||||
int DroppedFrames { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="IDecodeProcess"/>: a <see cref="Process"/> with stdout
|
||||
/// redirected as raw bytes for reading <c>rawvideo</c> frames. Constructed by
|
||||
/// <see cref="MediaVideoSource"/> for the media-file decoder subprocess.
|
||||
/// </summary>
|
||||
public sealed class FfmpegDecodeProcess : IDecodeProcess
|
||||
{
|
||||
private readonly Process _process;
|
||||
|
||||
public FfmpegDecodeProcess() => _process = new Process { EnableRaisingEvents = true };
|
||||
|
||||
public void Start(ProcessStartInfo startInfo)
|
||||
{
|
||||
_process.StartInfo = startInfo;
|
||||
_process.Start();
|
||||
}
|
||||
|
||||
public Stream StandardOutput => _process.StandardOutput.BaseStream;
|
||||
|
||||
public bool HasExited => _process.HasExited;
|
||||
public int ExitCode => _process.ExitCode;
|
||||
public void Kill()
|
||||
{
|
||||
if (!_process.HasExited) _process.Kill();
|
||||
}
|
||||
|
||||
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
|
||||
=> _process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
public void Dispose() => _process.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21 (slice 2): parses the native frame rate of a video from ffprobe
|
||||
/// <c>-show_entries stream=avg_frame_rate,r_frame_rate</c> output, which reports
|
||||
/// each as a rational like <c>30/1</c> or <c>60000/1001</c> (or <c>0/0</c> when
|
||||
/// unknown). Prefers <c>avg_frame_rate</c> (the true display rate over the whole
|
||||
/// file, correct for VFR) and falls back to <c>r_frame_rate</c> (the container/
|
||||
/// codec rate). Pure: no ffprobe, no I/O, unit-testable.
|
||||
/// </summary>
|
||||
public static class FfmpegFrameRateParser
|
||||
{
|
||||
public static double? ParseFrameRate(string probeOutput)
|
||||
{
|
||||
foreach (var key in new[] { "avg_frame_rate=", "r_frame_rate=" })
|
||||
{
|
||||
var value = FindValue(probeOutput, key);
|
||||
if (value is not null && TryParseRational(value, out var fps))
|
||||
return fps;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The trimmed value of the first line beginning with <paramref name="key"/>.</summary>
|
||||
private static string? FindValue(string text, string key)
|
||||
{
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith(key, StringComparison.Ordinal))
|
||||
return trimmed.Substring(key.Length).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Parses <c>v</c> as <c>N</c> (integer) or <c>N/M</c> (rational) into
|
||||
/// a frames-per-second double. <c>0</c> and <c>0/M</c> count as unknown.</summary>
|
||||
private static bool TryParseRational(string value, out double fps)
|
||||
{
|
||||
fps = 0;
|
||||
var parts = value.Split('/');
|
||||
if (parts.Length == 0 || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var num))
|
||||
return false;
|
||||
var den = 1;
|
||||
if (parts.Length > 1 && !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out den))
|
||||
return false;
|
||||
if (num <= 0 || den <= 0) return false;
|
||||
fps = (double)num / den;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21 (slice 2): resolves the native frame rate of a media file so the
|
||||
/// decoder can pace frames to real time. Seam so <see cref="MediaVideoSource"/>
|
||||
/// never touches ffprobe directly and tests can fake the whole probe.
|
||||
/// </summary>
|
||||
public interface IFrameRateProbe
|
||||
{
|
||||
/// <summary>Native video FPS for <paramref name="path"/>, or null when unknown
|
||||
/// (no video stream, ffprobe unavailable, unparsable).</summary>
|
||||
Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default probe: locates ffmpeg (via <see cref="IFfmpegLocator"/>), derives the
|
||||
/// sibling <c>ffprobe.exe</c> from its directory, and asks ffprobe for the
|
||||
/// <c>avg_frame_rate</c>/<c>r_frame_rate</c>. Reuses the binary-stdout
|
||||
/// <see cref="IDecodeProcess"/> seam for the subprocess (ffprobe text is read from
|
||||
/// the same raw stdout). Returns null (no pacing) if ffprobe is unavailable.
|
||||
/// </summary>
|
||||
public sealed class FfmpegFrameRateProbe : IFrameRateProbe
|
||||
{
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _probeProcessFactory;
|
||||
|
||||
public FfmpegFrameRateProbe(IFfmpegLocator locator, Func<IDecodeProcess> probeProcessFactory)
|
||||
{
|
||||
_locator = locator;
|
||||
_probeProcessFactory = probeProcessFactory;
|
||||
}
|
||||
|
||||
public async Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var ffprobePath = Path.Combine(Path.GetDirectoryName(ffmpegPath) ?? string.Empty, "ffprobe.exe");
|
||||
if (!File.Exists(ffprobePath)) return null;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffprobePath,
|
||||
Arguments =
|
||||
$"-v error -select_streams v:0 " +
|
||||
$"-show_entries stream=avg_frame_rate,r_frame_rate " +
|
||||
$"-of default=noprint_wrappers=1 \"{path}\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = _probeProcessFactory();
|
||||
process.Start(startInfo);
|
||||
string output;
|
||||
using (var reader = new StreamReader(process.StandardOutput))
|
||||
output = reader.ReadToEnd();
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return FfmpegFrameRateParser.ParseFrameRate(output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A depth-bounded ring of scratch frame buffers that never writes into memory a live
|
||||
/// consumer may still be reading. A slot's byte[] is only rewritten after at least
|
||||
/// <paramref name="redLine"/> rents have cycled since it was last handed out; a slot
|
||||
/// still inside its red line is skipped, and if the whole ring is inside its red line a
|
||||
/// fresh buffer is handed out rather than lapping a loaned slot. Length-mismatched
|
||||
/// buffers are always replaced by a fresh allocation (the previous array is orphaned,
|
||||
/// never overwritten), so a hand-out keeps its bytes for as long as anyone holds it.
|
||||
///
|
||||
/// The screen capture's consumer contract makes this structural necessity (slice 16,
|
||||
/// 2026-09-14): <c>ScreenCaptureManager</c> keeps each delivered frame as
|
||||
/// <c>session.LatestFrame</c> across conversions and the dispatcher preview copy lags,
|
||||
/// so a held frame can survive several conversions. The 1742 take recorded one torn
|
||||
/// frame (new-top/old-bottom at the webcam split) — a ring slot rewritten under the
|
||||
/// consumer's read. Depth 8 × the conversion interval already exceeded the worst
|
||||
/// observed hold (~50ms, take-14 rule); the red line makes the guarantee structural
|
||||
/// instead of a sizing coincidence.
|
||||
/// </summary>
|
||||
internal sealed class FrameRingBuffer
|
||||
{
|
||||
private readonly byte[]?[] _slots;
|
||||
private readonly long[] _lastHandout;
|
||||
private readonly int _redLine;
|
||||
private readonly object _lock = new();
|
||||
private int _next;
|
||||
private long _seq;
|
||||
private long _allocations;
|
||||
|
||||
public FrameRingBuffer(int depth, int redLine)
|
||||
{
|
||||
_slots = new byte[depth][];
|
||||
_lastHandout = new long[depth];
|
||||
for (var i = 0; i < depth; i++) _lastHandout[i] = -redLine;
|
||||
_redLine = redLine;
|
||||
}
|
||||
|
||||
/// <summary>Buffers freshly allocated after the last <see cref="ConsumeAllocations"/>.</summary>
|
||||
public long Allocations
|
||||
{
|
||||
get { lock (_lock) return _allocations; }
|
||||
}
|
||||
|
||||
/// <summary>Returns the allocation count since the last call and resets it.</summary>
|
||||
public long ConsumeAllocations()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var count = _allocations;
|
||||
_allocations = 0;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hands out a scratch buffer of <paramref name="size"/> bytes.</summary>
|
||||
public byte[] Rent(int size)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var seq = ++_seq;
|
||||
for (var tries = 0; tries < _slots.Length; tries++)
|
||||
{
|
||||
var idx = (_next + tries) % _slots.Length;
|
||||
// Red line: rewriting this slot could hit a frame a consumer still reads.
|
||||
if (seq - _lastHandout[idx] < _redLine) continue;
|
||||
_next = (idx + 1) % _slots.Length;
|
||||
if (_slots[idx] is { Length: var len } buf && len == size)
|
||||
{
|
||||
_lastHandout[idx] = seq;
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Length mismatch (or never allocated): a fresh array, never an in-place
|
||||
// overwrite — the previous loan's bytes stay valid for whoever holds it.
|
||||
_allocations++;
|
||||
var fresh = new byte[size];
|
||||
_slots[idx] = fresh;
|
||||
_lastHandout[idx] = seq;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// Defensive: the whole ring is inside its red line — do not lap a loaned slot.
|
||||
_allocations++;
|
||||
return new byte[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: seam around the media decoder's subprocess (ffmpeg <c>rawvideo</c>).
|
||||
/// Exposes the raw binary stdout (frames) and exit control, so
|
||||
/// <see cref="MediaVideoSource"/> never touches <c>System.Diagnostics.Process</c>
|
||||
/// directly and tests can fake the whole decode. Mirrors the encoder's
|
||||
/// <c>IEncoderProcess</c> but with a binary <see cref="StandardOutput"/>.
|
||||
/// </summary>
|
||||
public interface IDecodeProcess : IDisposable
|
||||
{
|
||||
void Start(ProcessStartInfo startInfo);
|
||||
|
||||
/// <summary>Raw binary stdout — the <c>rawvideo</c> BGRA frame bytes.</summary>
|
||||
Stream StandardOutput { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
int ExitCode { get; }
|
||||
void Kill();
|
||||
Task WaitForExitAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21: a media file decoder exposed to <see cref="MediaVideoSourceManager"/>.
|
||||
/// Mirrors <c>IScreenCaptureSource</c>/<c>ICameraFrameSource</c> so the manager
|
||||
/// (and tests) treat a local video file like any other frame source: start/stop
|
||||
/// and a per-frame <see cref="FrameAvailable"/> event consumers coalesce.
|
||||
/// </summary>
|
||||
public interface IMediaFrameSource : IDisposable
|
||||
{
|
||||
string Key { get; }
|
||||
|
||||
/// <summary>When true, the decoder restarts from the beginning on natural EOF
|
||||
/// instead of raising <see cref="Completed"/> (used for BRB/starting-soon loops).</summary>
|
||||
bool Looping { get; set; }
|
||||
|
||||
/// <summary>Raised (on a worker thread) for each decoded frame.</summary>
|
||||
event Action<VideoFrame>? FrameAvailable;
|
||||
|
||||
/// <summary>Raised when the file finishes decoding (natural EOF) — not raised
|
||||
/// while <see cref="Looping"/> is true.</summary>
|
||||
event Action? Completed;
|
||||
|
||||
Task StartAsync(CancellationToken cancellationToken = default);
|
||||
Task StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>Session-scoped seam that lists open top-level windows for the
|
||||
/// "Capture Window…" submenu. Mirrors <see cref="IFullScreenDetector"/> so the
|
||||
/// VM never depends on a specific Win32 implementation (and tests hand in a
|
||||
/// fake enumerator).</summary>
|
||||
public interface IWindowEnumerator
|
||||
{
|
||||
/// <summary>Visible, user-facing top-level windows belonging to other
|
||||
/// processes, ordered for display (title, process name).</summary>
|
||||
IReadOnlyList<WindowInfo> Enumerate();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public byte[]? GetAssetBytes(string assetId)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Data FROM Asset WHERE Id = $id";
|
||||
cmd.Parameters.AddWithValue("$id", assetId);
|
||||
return cmd.ExecuteScalar() as byte[];
|
||||
}
|
||||
|
||||
public string UpsertAsset(byte[] data, int pixelWidth, int pixelHeight)
|
||||
{
|
||||
var hash = Convert.ToHexString(SHA256.HashData(data));
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Asset (Id, Hash, Data, PixelWidth, PixelHeight)
|
||||
VALUES ($id, $hash, $data, $w, $h)
|
||||
ON CONFLICT(Hash) DO NOTHING;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$hash", hash);
|
||||
cmd.Parameters.AddWithValue("$data", data);
|
||||
cmd.Parameters.AddWithValue("$w", pixelWidth);
|
||||
cmd.Parameters.AddWithValue("$h", pixelHeight);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
cmd.CommandText = "SELECT Id FROM Asset WHERE Hash = $hash;";
|
||||
return (string)cmd.ExecuteScalar()!;
|
||||
}
|
||||
|
||||
// ─── License (TASK 10 — Polar billing) ───
|
||||
|
||||
/// <summary>The saved license key, or null if never entered.</summary>
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public List<Scene> Load()
|
||||
{
|
||||
Webcam = null;
|
||||
Socials = null;
|
||||
Music = null;
|
||||
var scenes = new List<Scene>();
|
||||
var sourcesByScene = new Dictionary<string, List<(Source Source, int Sort)>>();
|
||||
var configsByScene = new Dictionary<string, List<(WebcamSceneConfig Config, int Sort)>>();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
scenes.Add(new Scene
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
Name = reader.GetString(1),
|
||||
IsHidden = reader.GetInt32(2) != 0,
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Webcam = new Webcam
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
DeviceId = reader.GetString(1),
|
||||
Name = reader.GetString(2),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, BarPosition, BarEnabled FROM Socials LIMIT 1;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Socials = new SocialsConfig
|
||||
{
|
||||
BarPosition = Enum.TryParse<SocialBarPosition>(reader.GetString(1), out var pos) ? pos : SocialBarPosition.Bottom,
|
||||
BarEnabled = reader.FieldCount > 2 && !reader.IsDBNull(2) && reader.GetInt32(2) != 0,
|
||||
};
|
||||
var socialsId = reader.GetString(0);
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = "SELECT Service, Handle, ProfileUrl, Software FROM SocialEntry WHERE SocialsId = $id ORDER BY SortOrder;";
|
||||
entryCmd.Parameters.AddWithValue("$id", socialsId);
|
||||
using var entryReader = entryCmd.ExecuteReader();
|
||||
while (entryReader.Read())
|
||||
{
|
||||
Socials.Entries.Add(new SocialEntry
|
||||
{
|
||||
Service = Enum.TryParse<SocialService>(entryReader.GetString(0), out var svc) ? svc : SocialService.Link,
|
||||
Handle = entryReader.GetString(1),
|
||||
ProfileUrl = entryReader.GetString(2),
|
||||
FediverseSoftware = entryReader.FieldCount > 3 && !entryReader.IsDBNull(3) ? entryReader.GetString(3) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT TrackPath, IsEnabled FROM Music LIMIT 1;";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
Music = new Music
|
||||
{
|
||||
TrackPath = reader.GetString(0),
|
||||
IsEnabled = reader.GetInt32(1) != 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
||||
IsBackground, CaptureKey, ShowDesktop,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(1);
|
||||
var type = Enum.Parse<SourceType>(reader.GetString(3));
|
||||
var source = new Source
|
||||
{
|
||||
Id = reader.GetString(0),
|
||||
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
Type = type,
|
||||
Name = reader.GetString(4),
|
||||
IsEnabled = reader.GetInt32(5) != 0,
|
||||
X = reader.GetDouble(6),
|
||||
Y = reader.GetDouble(7),
|
||||
Width = reader.GetDouble(8),
|
||||
Height = reader.GetDouble(9),
|
||||
Opacity = reader.GetDouble(10),
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(13) != 0,
|
||||
// Rows saved before the Type setter derived the flag carry
|
||||
// IsBackground=0; derive from Type, keeping the persisted bit
|
||||
// for legacy DisplayCapture backdrops.
|
||||
IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
|
||||
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
ShowDesktop = reader.GetInt32(16) != 0,
|
||||
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
|
||||
ChatFontColor = reader.IsDBNull(18) ? "#FFFFFF" : reader.GetString(18),
|
||||
ChatBackgroundColor = reader.IsDBNull(19) ? "#80000000" : reader.GetString(19),
|
||||
ChatMaxMessages = reader.IsDBNull(20) ? 5 : reader.GetInt32(20),
|
||||
ChatShowTimestamps = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
|
||||
ChatShowBadges = reader.IsDBNull(22) ? true : reader.GetInt32(22) != 0,
|
||||
ChatMemberColor = reader.IsDBNull(23) ? "#e94560" : reader.GetString(23),
|
||||
ChatSuperChatColor = reader.IsDBNull(24) ? "#eab308" : reader.GetString(24),
|
||||
WebUri = reader.IsDBNull(25) ? null : reader.GetString(25),
|
||||
MediaPath = reader.IsDBNull(26) ? null : reader.GetString(26),
|
||||
MediaIsLooping = reader.IsDBNull(27) ? false : reader.GetInt32(27) != 0,
|
||||
MediaVolume = reader.IsDBNull(28) ? 1.0 : reader.GetDouble(28),
|
||||
MediaPlaybackState = reader.IsDBNull(29)
|
||||
? MediaPlaybackState.Stopped
|
||||
: Enum.TryParse<MediaPlaybackState>(reader.GetString(29), out var ps)
|
||||
? ps
|
||||
: MediaPlaybackState.Stopped,
|
||||
AlertVideoAssetId = reader.IsDBNull(30) ? null : reader.GetString(30),
|
||||
AlertVideoPath = reader.IsDBNull(31) ? null : reader.GetString(31),
|
||||
AlertUseDefaultVideo = reader.IsDBNull(32) ? true : reader.GetInt32(32) != 0,
|
||||
AlertShowTicker = reader.IsDBNull(33) ? true : reader.GetInt32(33) != 0,
|
||||
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
|
||||
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||
list.Add((source, reader.GetInt32(35)));
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||
RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength
|
||||
FROM WebcamSceneConfig ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var sceneId = reader.GetString(0);
|
||||
var config = new WebcamSceneConfig
|
||||
{
|
||||
WebcamId = reader.GetString(1),
|
||||
Name = Webcam?.Name ?? "Webcam",
|
||||
IsVisible = reader.GetInt32(2) != 0,
|
||||
X = reader.GetDouble(3),
|
||||
Y = reader.GetDouble(4),
|
||||
Width = reader.GetDouble(5),
|
||||
Height = reader.GetDouble(6),
|
||||
Opacity = reader.GetDouble(7),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(9) != 0,
|
||||
BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
|
||||
BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
|
||||
BorderAnimation = Enum.TryParse<BorderAnimation>(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
|
||||
RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
|
||||
RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
|
||||
BackgroundEffect = Enum.TryParse<BackgroundEffect>(reader.GetString(17), out var effect) ? effect : BackgroundEffect.None,
|
||||
BackgroundStrength = reader.GetDouble(18),
|
||||
};
|
||||
if (!configsByScene.TryGetValue(sceneId, out var list))
|
||||
configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>();
|
||||
list.Add((config, reader.GetInt32(16)));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
// Source and WebcamSceneConfig rows share ONE z-order space per scene
|
||||
// (both SortOrders = the element's index within scene.Elements). Merge
|
||||
// the two tables' rows by that shared z rather than appending all
|
||||
// sources before all configs — a webcam slotted between sources must
|
||||
// reload in place. Legacy rows (written before the unified space) may
|
||||
// tie at 0: break ties sources-first, so old data reads back stacked
|
||||
// the same way it did pre-unification.
|
||||
var ordered = new List<(int Sort, SceneElement Element)>();
|
||||
if (sourcesByScene.TryGetValue(scene.Id, out var sources))
|
||||
foreach (var (source, sort) in sources)
|
||||
ordered.Add((sort, source));
|
||||
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||
foreach (var (config, sort) in configs)
|
||||
ordered.Add((sort, config));
|
||||
|
||||
foreach (var (_, element) in ordered.OrderBy(e => e.Sort).ThenBy(e => e.Element is Source ? 0 : 1))
|
||||
scene.Elements.Add(element);
|
||||
|
||||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (bg != null)
|
||||
{
|
||||
// Load from source-keyed settings (new) or scene-keyed (legacy migration)
|
||||
var path = GetSetting($"BackgroundPath_{bg.Id}");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
path = GetSetting($"BackgroundPath_{scene.Id}");
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
bg.CustomBackgroundPath = path;
|
||||
|
||||
var useDefault = GetSetting($"BackgroundUseDefault_{bg.Id}");
|
||||
if (string.IsNullOrWhiteSpace(useDefault))
|
||||
useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
|
||||
if (useDefault == "0")
|
||||
bg.UseDefaultBackground = false;
|
||||
}
|
||||
}
|
||||
|
||||
return scenes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
private void EnsureSchema()
|
||||
{
|
||||
string[] statements =
|
||||
{
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Scene (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Name TEXT NOT NULL,
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
HasBackground INTEGER NOT NULL DEFAULT 1,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Asset (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Hash TEXT NOT NULL UNIQUE,
|
||||
Data BLOB NOT NULL,
|
||||
PixelWidth INTEGER NOT NULL DEFAULT 0,
|
||||
PixelHeight INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Source (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
AssetId TEXT REFERENCES Asset(Id),
|
||||
Type TEXT NOT NULL,
|
||||
Name TEXT NOT NULL,
|
||||
IsEnabled INTEGER NOT NULL DEFAULT 1,
|
||||
X REAL NOT NULL DEFAULT 0,
|
||||
Y REAL NOT NULL DEFAULT 0,
|
||||
Width REAL NOT NULL DEFAULT 0,
|
||||
Height REAL NOT NULL DEFAULT 0,
|
||||
Opacity REAL NOT NULL DEFAULT 1,
|
||||
MonitorIndex INTEGER,
|
||||
DeviceId TEXT,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
IsBackground INTEGER NOT NULL DEFAULT 0,
|
||||
CaptureKey TEXT,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Webcam (
|
||||
Id TEXT PRIMARY KEY,
|
||||
DeviceId TEXT NOT NULL UNIQUE,
|
||||
Name TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
|
||||
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
|
||||
WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
|
||||
IsVisible INTEGER NOT NULL DEFAULT 1,
|
||||
X REAL NOT NULL DEFAULT 0,
|
||||
Y REAL NOT NULL DEFAULT 0,
|
||||
Width REAL NOT NULL DEFAULT 0,
|
||||
Height REAL NOT NULL DEFAULT 0,
|
||||
Opacity REAL NOT NULL DEFAULT 1,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
RectWidth REAL,
|
||||
RectHeight REAL,
|
||||
BorderColor TEXT,
|
||||
BorderOpacity REAL NOT NULL DEFAULT 1,
|
||||
BorderWidth INTEGER NOT NULL DEFAULT 0,
|
||||
BorderAnimation TEXT NOT NULL DEFAULT 'None',
|
||||
BackgroundEffect TEXT NOT NULL DEFAULT 'None',
|
||||
BackgroundStrength REAL NOT NULL DEFAULT 0.5,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (SceneId, WebcamId)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Socials (
|
||||
Id TEXT PRIMARY KEY,
|
||||
BarPosition TEXT NOT NULL DEFAULT 'Bottom',
|
||||
BarJustify TEXT NOT NULL DEFAULT 'Center'
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS SocialEntry (
|
||||
Id TEXT PRIMARY KEY,
|
||||
SocialsId TEXT NOT NULL REFERENCES Socials(Id) ON DELETE CASCADE,
|
||||
Service TEXT NOT NULL,
|
||||
Handle TEXT NOT NULL,
|
||||
ProfileUrl TEXT NOT NULL,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Music (
|
||||
Id TEXT PRIMARY KEY,
|
||||
TrackPath TEXT NOT NULL,
|
||||
IsEnabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS Settings (
|
||||
Key TEXT PRIMARY KEY,
|
||||
Value TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
// v9 → v10 (TASK 39): YPP journey-tracker snapshots, appended on every
|
||||
// stats refresh. The history is the raw data a later analytics slice
|
||||
// reads to compute velocity/(de)acceleration/ETA.
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS YppSnapshot (
|
||||
CapturedAtUtc TEXT NOT NULL PRIMARY KEY,
|
||||
SubscriberCount INTEGER NOT NULL,
|
||||
VideoCount INTEGER NOT NULL,
|
||||
TotalViews INTEGER NOT NULL,
|
||||
UploadsLast90Days INTEGER NOT NULL,
|
||||
CommunityGuidelinesGoodStanding INTEGER NOT NULL,
|
||||
CopyrightStrikesGoodStanding INTEGER NOT NULL,
|
||||
ContentIdClaimsGoodStanding INTEGER NOT NULL,
|
||||
OverallGoodStanding INTEGER NOT NULL
|
||||
);
|
||||
""",
|
||||
};
|
||||
foreach (var sql in statements)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
MigrateSourceTable();
|
||||
MigrateWebcamConfigTable();
|
||||
MigrateSceneTable();
|
||||
MigrateSceneSocialBarColumn();
|
||||
MigrateSceneThumbnailColumn();
|
||||
MigrateSocialsTable();
|
||||
MigrateSocialEntryTable();
|
||||
if (GetUserVersion() < 3)
|
||||
MigrateToV3();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA user_version = 10;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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.
|
||||
private void MigrateSourceTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Source);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (!columns.Contains("ClipShape"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("AlertDisplayMethod"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertDisplayMethod INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("IsMirrored"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (columns.Contains("IsBackdrop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
else if (!columns.Contains("IsBackground"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("CaptureKey"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 3.18: ChatBox overlay source
|
||||
if (!columns.Contains("ChatFontSize"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatFontColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatBackgroundColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatMaxMessages"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatShowTimestamps"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatShowBadges"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatMemberColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChatSuperChatColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ShowDesktop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("WebUri"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN WebUri TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 21: Media source (video/audio file playback)
|
||||
if (!columns.Contains("MediaPath"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPath TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("MediaIsLooping"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaIsLooping INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("MediaVolume"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaVolume REAL NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("MediaPlaybackState"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN MediaPlaybackState TEXT NOT NULL DEFAULT 'Stopped';";
|
||||
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,
|
||||
// ticker toggle and volume live on the row.
|
||||
if (!columns.Contains("AlertVideoAssetId"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoAssetId TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertVideoPath"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoPath TEXT;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertUseDefaultVideo"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertUseDefaultVideo INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertShowTicker"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertShowTicker INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("AlertVideoVolume"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN AlertVideoVolume REAL NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Rename legacy "Backdrop" display name to "Background" in existing rows
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
|
||||
// so a reloaded Round webcam restores its aspect on toggle-back).
|
||||
private void MigrateWebcamConfigTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (!columns.Contains("RectWidth"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("RectHeight"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 51: per-webcam background effect + strength (additive, no version bump).
|
||||
if (!columns.Contains("BackgroundEffect"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN BackgroundEffect TEXT NOT NULL DEFAULT 'None';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("BackgroundStrength"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN BackgroundStrength REAL NOT NULL DEFAULT 0.5;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
|
||||
// one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
|
||||
// per scene that had one, then the webcam rows are deleted. No backfill:
|
||||
// scenes without the webcam stay webcam-free.
|
||||
private void MigrateToV3()
|
||||
{
|
||||
var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
|
||||
FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add((
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetDouble(3),
|
||||
reader.GetDouble(4),
|
||||
reader.GetDouble(5),
|
||||
reader.GetDouble(6),
|
||||
reader.GetDouble(7),
|
||||
reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
|
||||
reader.GetString(9),
|
||||
reader.GetInt32(10) != 0));
|
||||
}
|
||||
}
|
||||
if (rows.Count == 0) return;
|
||||
|
||||
using var tx = _connection.BeginTransaction();
|
||||
var webcamId = Guid.NewGuid().ToString();
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", webcamId);
|
||||
cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
|
||||
cmd.Parameters.AddWithValue("$name", rows[0].Name);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO WebcamSceneConfig
|
||||
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
|
||||
VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
|
||||
$clip, $mirrored, NULL, 1, 0, 'None', 0);
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
|
||||
cmd.Parameters.AddWithValue("$webcamId", webcamId);
|
||||
cmd.Parameters.AddWithValue("$x", row.X);
|
||||
cmd.Parameters.AddWithValue("$y", row.Y);
|
||||
cmd.Parameters.AddWithValue("$w", row.W);
|
||||
cmd.Parameters.AddWithValue("$h", row.H);
|
||||
cmd.Parameters.AddWithValue("$opacity", row.Opacity);
|
||||
cmd.Parameters.AddWithValue("$clip", row.ClipShape);
|
||||
cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
|
||||
// ONE-TIME backfill (runs only when the column is first added) turns every
|
||||
// non-Live canonical scene off and drops their background sources — the fix for
|
||||
// DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
|
||||
// re-runs the same normalization on every load for DBs that miss the backfill.
|
||||
private void MigrateSceneTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("HasBackdrop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
else if (columns.Contains("HasBackground"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!columns.Contains("HasBackground"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
const string noBackground = "('starting', 'brb', 'chat', 'ending')";
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"""
|
||||
DELETE FROM Source
|
||||
WHERE IsBackground = 1 AND SceneId IN
|
||||
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v6 → v7: Scene gains HasSocialBar (the per-scene social-bar toggle).
|
||||
// Defaults to 0 (off) — the creator adds it explicitly via the footer [+].
|
||||
private void MigrateSceneSocialBarColumn()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("HasSocialBar")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Scene ADD COLUMN HasSocialBar INTEGER NOT NULL DEFAULT 0;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// 2026-10-10: Scene gains ThumbnailAssetId (the top-strip tile artwork, stored
|
||||
// as an Asset row like every image). Additive, no version bump — old DBs just
|
||||
// get a NULL column and the strip falls back to the scene's background art.
|
||||
private void MigrateSceneThumbnailColumn()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Scene);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("ThumbnailAssetId")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Scene ADD COLUMN ThumbnailAssetId TEXT;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old
|
||||
// BarJustify column stays for back-compat but is no longer read or written.
|
||||
private void MigrateSocialsTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(Socials);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("BarEnabled")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE Socials ADD COLUMN BarEnabled INTEGER NOT NULL DEFAULT 1;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// v8 → v9: SocialEntry gains Software (the fediverse instance's nodeinfo
|
||||
// software name) so a reloaded entry still shows the right service logo.
|
||||
private void MigrateSocialEntryTable()
|
||||
{
|
||||
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "PRAGMA table_info(SocialEntry);";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("Software")) return;
|
||||
|
||||
using var alter = _connection.CreateCommand();
|
||||
alter.CommandText = "ALTER TABLE SocialEntry ADD COLUMN Software TEXT;";
|
||||
alter.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
public void Save(IEnumerable<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
|
||||
{
|
||||
using var tx = _connection.BeginTransaction();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Source;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Webcam;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM SocialEntry;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Socials;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Music;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM Scene;";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
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)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
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 sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var sort = 0;
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
idP.Value = scene.Id;
|
||||
nameP.Value = scene.Name;
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
backgroundP.Value = scene.HasBackground ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
|
||||
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
|
||||
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||
var assetIdP = cmd.Parameters.Add("$assetId", SqliteType.Text);
|
||||
var typeP = cmd.Parameters.Add("$type", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var enabledP = cmd.Parameters.Add("$isEnabled", SqliteType.Integer);
|
||||
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||
var isBackgroundP = cmd.Parameters.Add("$isBackground", SqliteType.Integer);
|
||||
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
|
||||
var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
|
||||
var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
|
||||
var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text);
|
||||
var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer);
|
||||
var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer);
|
||||
var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
|
||||
var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
|
||||
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
|
||||
var webUriP = cmd.Parameters.Add("$webUri", SqliteType.Text);
|
||||
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 mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
|
||||
var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text);
|
||||
var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text);
|
||||
var alertUseDefaultVideoP = cmd.Parameters.Add("$alertUseDefaultVideo", SqliteType.Integer);
|
||||
var alertShowTickerP = cmd.Parameters.Add("$alertShowTicker", SqliteType.Integer);
|
||||
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
|
||||
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
|
||||
|
||||
// Source and WebcamSceneConfig share ONE z-order space per scene:
|
||||
// each element gets its index within scene.Elements regardless of
|
||||
// table, so a webcam slotted between sources survives save→load.
|
||||
// (Pre-unified rows tie at 0 — load breaks ties sources-first.)
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var z = 0;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
if (element is not Source source) { z++; continue; }
|
||||
idP.Value = source.Id;
|
||||
sceneIdP.Value = scene.Id;
|
||||
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
|
||||
typeP.Value = source.Type.ToString();
|
||||
nameP.Value = source.Name;
|
||||
enabledP.Value = source.IsEnabled ? 1 : 0;
|
||||
xP.Value = source.X;
|
||||
yP.Value = source.Y;
|
||||
wP.Value = source.Width;
|
||||
hP.Value = source.Height;
|
||||
opacityP.Value = source.Opacity;
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||
clipP.Value = source.ClipShape.ToString();
|
||||
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||
isBackgroundP.Value = source.IsBackground ? 1 : 0;
|
||||
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
|
||||
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
|
||||
sortP.Value = z++;
|
||||
chatFontSizeP.Value = source.ChatFontSize;
|
||||
chatFontColorP.Value = source.ChatFontColor;
|
||||
chatBgColorP.Value = source.ChatBackgroundColor;
|
||||
chatMaxMsgsP.Value = source.ChatMaxMessages;
|
||||
chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0;
|
||||
chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
|
||||
chatMemberColorP.Value = source.ChatMemberColor;
|
||||
chatSuperChatColorP.Value = source.ChatSuperChatColor;
|
||||
webUriP.Value = (object?)source.WebUri ?? DBNull.Value;
|
||||
mediaPathP.Value = (object?)source.MediaPath ?? DBNull.Value;
|
||||
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
|
||||
mediaVolumeP.Value = source.MediaVolume;
|
||||
mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
|
||||
alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value;
|
||||
alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value;
|
||||
alertUseDefaultVideoP.Value = source.AlertUseDefaultVideo ? 1 : 0;
|
||||
alertShowTickerP.Value = source.AlertShowTicker ? 1 : 0;
|
||||
alertVideoVolumeP.Value = source.AlertVideoVolume;
|
||||
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (webcam != null)
|
||||
{
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", webcam.Id);
|
||||
cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$name", webcam.Name);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO WebcamSceneConfig
|
||||
(SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
|
||||
ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
|
||||
RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength)
|
||||
VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
|
||||
$clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
|
||||
$rectWidth, $rectHeight, $sort, $bgEffect, $bgStrength)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
|
||||
var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
|
||||
var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
|
||||
var xP = cmd.Parameters.Add("$x", SqliteType.Real);
|
||||
var yP = cmd.Parameters.Add("$y", SqliteType.Real);
|
||||
var wP = cmd.Parameters.Add("$w", SqliteType.Real);
|
||||
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
|
||||
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
|
||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||
var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
|
||||
var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
|
||||
var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
|
||||
var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
|
||||
var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
|
||||
var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
var bgEffectP = cmd.Parameters.Add("$bgEffect", SqliteType.Text);
|
||||
var bgStrengthP = cmd.Parameters.Add("$bgStrength", SqliteType.Real);
|
||||
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var z = 0;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
if (element is not WebcamSceneConfig config) { z++; continue; }
|
||||
bgEffectP.Value = config.BackgroundEffect.ToString();
|
||||
bgStrengthP.Value = config.BackgroundStrength;
|
||||
sceneIdP.Value = scene.Id;
|
||||
webcamIdP.Value = config.WebcamId;
|
||||
visibleP.Value = config.IsVisible ? 1 : 0;
|
||||
xP.Value = config.X;
|
||||
yP.Value = config.Y;
|
||||
wP.Value = config.Width;
|
||||
hP.Value = config.Height;
|
||||
opacityP.Value = config.Opacity;
|
||||
clipP.Value = config.ClipShape.ToString();
|
||||
mirroredP.Value = config.IsMirrored ? 1 : 0;
|
||||
colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
|
||||
borderOpacityP.Value = config.BorderOpacity;
|
||||
borderWidthP.Value = config.BorderWidth;
|
||||
animationP.Value = config.BorderAnimation.ToString();
|
||||
rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
|
||||
rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
|
||||
sortP.Value = z++;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (socials != null && socials.Entries.Count > 0)
|
||||
{
|
||||
var socialsId = Guid.NewGuid().ToString();
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "INSERT INTO Socials (Id, BarPosition, BarEnabled) VALUES ($id, $pos, $enabled);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", socialsId);
|
||||
cmd.Parameters.AddWithValue("$pos", socials.BarPosition.ToString());
|
||||
cmd.Parameters.AddWithValue("$enabled", socials.BarEnabled ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var entryCmd = _connection.CreateCommand();
|
||||
entryCmd.CommandText = """
|
||||
INSERT INTO SocialEntry (Id, SocialsId, Service, Handle, ProfileUrl, Software, SortOrder)
|
||||
VALUES ($id, $socialsId, $service, $handle, $url, $software, $sort)
|
||||
""";
|
||||
entryCmd.Transaction = tx;
|
||||
var eId = entryCmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var eSocialsId = entryCmd.Parameters.Add("$socialsId", SqliteType.Text);
|
||||
var eService = entryCmd.Parameters.Add("$service", SqliteType.Text);
|
||||
var eHandle = entryCmd.Parameters.Add("$handle", SqliteType.Text);
|
||||
var eUrl = entryCmd.Parameters.Add("$url", SqliteType.Text);
|
||||
var eSoftware = entryCmd.Parameters.Add("$software", SqliteType.Text);
|
||||
var eSort = entryCmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
var entrySort = 0;
|
||||
foreach (var entry in socials.Entries)
|
||||
{
|
||||
eId.Value = Guid.NewGuid().ToString();
|
||||
eSocialsId.Value = socialsId;
|
||||
eService.Value = entry.Service.ToString();
|
||||
eHandle.Value = entry.Handle;
|
||||
eUrl.Value = entry.ProfileUrl;
|
||||
eSoftware.Value = (object?)entry.FediverseSoftware ?? DBNull.Value;
|
||||
eSort.Value = entrySort++;
|
||||
entryCmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
if (music != null && !string.IsNullOrWhiteSpace(music.TrackPath))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO Music (Id, TrackPath, IsEnabled) VALUES ($id, $path, $enabled);";
|
||||
cmd.Transaction = tx;
|
||||
cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString());
|
||||
cmd.Parameters.AddWithValue("$path", music.TrackPath);
|
||||
cmd.Parameters.AddWithValue("$enabled", music.IsEnabled ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
DELETE FROM Asset WHERE Id NOT IN (
|
||||
SELECT AssetId FROM Source WHERE AssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL
|
||||
UNION
|
||||
SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId'
|
||||
UNION
|
||||
SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId'
|
||||
);
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Save custom background paths and use-default flags per background source
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (bg == null) continue;
|
||||
|
||||
var pathKey = $"BackgroundPath_{bg.Id}";
|
||||
if (!string.IsNullOrWhiteSpace(bg.CustomBackgroundPath))
|
||||
UpsertSetting(pathKey, bg.CustomBackgroundPath);
|
||||
else
|
||||
{
|
||||
using var del = _connection.CreateCommand();
|
||||
del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
del.Parameters.AddWithValue("$key", pathKey);
|
||||
del.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var defaultKey = $"BackgroundUseDefault_{bg.Id}";
|
||||
UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0");
|
||||
}
|
||||
|
||||
// Purge background settings left behind by deleted/duplicated rows
|
||||
// (TASK 25 heal drops extras; their keys must not accumulate).
|
||||
using (var purge = _connection.CreateCommand())
|
||||
{
|
||||
purge.Transaction = tx;
|
||||
purge.CommandText =
|
||||
@"DELETE FROM Settings
|
||||
WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
|
||||
OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
|
||||
AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);";
|
||||
purge.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
/// <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()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'MicSourceName';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
public void SaveMicSourceName(string? name)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'MicSourceName';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('MicSourceName', $name);";
|
||||
insert.Parameters.AddWithValue("$name", name);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>The global audio-sync offset in milliseconds (TASK 22), −500..500,
|
||||
/// default 0: positive delays the mix (audio ahead of video), negative
|
||||
/// advances it (audio behind video). Restored at startup so the mixer starts
|
||||
/// on the user's offset.</summary>
|
||||
public int LoadAudioSyncOffsetMs()
|
||||
{
|
||||
var value = GetSetting("Audio.SyncOffsetMs");
|
||||
return int.TryParse(value, out var ms) ? Math.Clamp(ms, -500, 500) : 0;
|
||||
}
|
||||
|
||||
public void SaveAudioSyncOffsetMs(int ms)
|
||||
{
|
||||
UpsertSetting("Audio.SyncOffsetMs", Math.Clamp(ms, -500, 500).ToString());
|
||||
}
|
||||
|
||||
/// <summary>The user's chosen record output folder (TASK 18), or null to use the
|
||||
/// default <c>%APPDATA%\ytLlive\recordings</c>. Restored at startup.</summary>
|
||||
public string? LoadRecordFolder()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'RecordFolder';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
public void SaveRecordFolder(string? folder)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'RecordFolder';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('RecordFolder', $folder);";
|
||||
insert.Parameters.AddWithValue("$folder", folder);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>The cached reusable stream (TASK 5), or null if never created.
|
||||
/// Survives restarts so the pump has its RTMP URL the moment go-live starts
|
||||
/// instead of waiting on the YouTube round-trip.</summary>
|
||||
public ReusableStream? LoadReusableStream()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'ReusableStream';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
var parts = value.Split('\u001F');
|
||||
return parts.Length == 3
|
||||
? new ReusableStream(parts[0], parts[1], parts[2])
|
||||
: null;
|
||||
}
|
||||
|
||||
public void SaveReusableStream(ReusableStream? stream)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'ReusableStream';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (stream == null) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('ReusableStream', $value);";
|
||||
insert.Parameters.AddWithValue("$value", $"{stream.Id}\u001F{stream.IngestionAddress}\u001F{stream.StreamName}");
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public string? LoadLicenseKey()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
public void SaveLicenseKey(string? key)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
|
||||
insert.Parameters.AddWithValue("$value", key);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>When the license was last validated online (ISO 8601), or null if never.</summary>
|
||||
public DateTime? LoadLicenseValidatedAt()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return DateTime.TryParse(value, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
public void SaveLicenseValidatedAt(DateTime? dt)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
|
||||
cmd.ExecuteNonQuery();
|
||||
if (dt == null) return;
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
|
||||
insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Cached premium state (1 = premium, 0 = free). Used for offline
|
||||
/// grace period — the app checks online at startup but uses this cached
|
||||
/// value when offline.</summary>
|
||||
public bool LoadIsPremium()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
|
||||
var value = cmd.ExecuteScalar() as string;
|
||||
return value == "1";
|
||||
}
|
||||
|
||||
public void SaveIsPremium(bool isPremium)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
|
||||
insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public (string TransitionType, double DurationMs) LoadTransitionSettings()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
|
||||
var type = cmd.ExecuteScalar() as string;
|
||||
|
||||
using var cmd2 = _connection.CreateCommand();
|
||||
cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
|
||||
var dur = cmd2.ExecuteScalar() as string;
|
||||
|
||||
return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
|
||||
}
|
||||
|
||||
public void SaveTransitionSettings(string transitionType, double durationMs)
|
||||
{
|
||||
UpsertSetting("TransitionType", transitionType);
|
||||
UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
|
||||
}
|
||||
|
||||
// ─── Broadcast metadata (broadcast pull-out) + window geometry ───
|
||||
|
||||
public Models.BroadcastMetadata LoadBroadcastMetadata()
|
||||
{
|
||||
return new Models.BroadcastMetadata
|
||||
{
|
||||
Title = GetSetting("Broadcast.Title") ?? string.Empty,
|
||||
Description = GetSetting("Broadcast.Description") ?? string.Empty,
|
||||
TagsCsv = GetSetting("Broadcast.TagsCsv") ?? string.Empty,
|
||||
Visibility = GetSetting("Broadcast.Visibility") ?? "private",
|
||||
MadeForKids = GetSetting("Broadcast.MadeForKids") == "1",
|
||||
ScheduledStartTime = DateTime.TryParse(GetSetting("Broadcast.ScheduledStartTime"), out var t) ? t : null,
|
||||
};
|
||||
}
|
||||
|
||||
public void SaveBroadcastMetadata(Models.BroadcastMetadata meta)
|
||||
{
|
||||
UpsertSetting("Broadcast.Title", meta.Title);
|
||||
UpsertSetting("Broadcast.Description", meta.Description);
|
||||
UpsertSetting("Broadcast.TagsCsv", meta.TagsCsv);
|
||||
UpsertSetting("Broadcast.Visibility", meta.Visibility);
|
||||
UpsertSetting("Broadcast.MadeForKids", meta.MadeForKids ? "1" : "0");
|
||||
UpsertSetting("Broadcast.ScheduledStartTime",
|
||||
meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
|
||||
}
|
||||
|
||||
// ─── Hotkey bindings (TASK 20 step 2) ───
|
||||
|
||||
/// <summary>
|
||||
/// Load saved hotkey bindings. Returns only entries that have been explicitly
|
||||
/// saved — missing HotkeyIds are unbound (not in the returned dictionary).
|
||||
/// Caller fills in defaults for any missing entries as needed.
|
||||
/// </summary>
|
||||
public Dictionary<HotkeyId, HotkeyChord> LoadHotkeyBindings()
|
||||
{
|
||||
var result = new Dictionary<HotkeyId, HotkeyChord>();
|
||||
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
|
||||
{
|
||||
var raw = GetSetting($"Hotkey.{id}");
|
||||
if (raw == null) continue; // not saved = unbound
|
||||
if (raw == "none") continue; // explicitly unbound
|
||||
try
|
||||
{
|
||||
result[id] = HotkeyChord.ParseStorage(raw);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// corrupt entry — skip, will fall back to default
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
|
||||
/// as "none"). Only the provided entries are saved; previously saved entries
|
||||
/// for HotkeyIds not in the dictionary are deleted.
|
||||
/// </summary>
|
||||
public void SaveHotkeyBindings(Dictionary<HotkeyId, HotkeyChord?> bindings)
|
||||
{
|
||||
// Delete all existing hotkey settings first
|
||||
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Save the new bindings
|
||||
foreach (var (id, chord) in bindings)
|
||||
{
|
||||
var value = chord?.ToString() ?? "none";
|
||||
UpsertSetting($"Hotkey.{id}", value);
|
||||
}
|
||||
}
|
||||
|
||||
public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
|
||||
{
|
||||
var left = GetSetting("Window.Left");
|
||||
var top = GetSetting("Window.Top");
|
||||
var width = GetSetting("Window.Width");
|
||||
var height = GetSetting("Window.Height");
|
||||
var state = GetSetting("Window.State");
|
||||
if (left == null || top == null || width == null || height == null) return null;
|
||||
if (!double.TryParse(left, out var l) || !double.TryParse(top, out var t) ||
|
||||
!double.TryParse(width, out var w) || !double.TryParse(height, out var h))
|
||||
return null;
|
||||
return (l, t, w, h, state ?? "Normal");
|
||||
}
|
||||
|
||||
public void SaveWindowState(double left, double top, double width, double height, string state)
|
||||
{
|
||||
UpsertSetting("Window.Left", left.ToString("F0"));
|
||||
UpsertSetting("Window.Top", top.ToString("F0"));
|
||||
UpsertSetting("Window.Width", width.ToString("F0"));
|
||||
UpsertSetting("Window.Height", height.ToString("F0"));
|
||||
UpsertSetting("Window.State", state);
|
||||
}
|
||||
|
||||
public string? GetSetting(string key)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
public void UpsertSetting(string key, string value)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
|
||||
insert.Parameters.AddWithValue("$key", key);
|
||||
insert.Parameters.AddWithValue("$value", value);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
internal const string AlertDefaultVideoKey = "AlertDefaultVideoAssetId";
|
||||
|
||||
/// <summary>Stamps the shipped stream-alert placeholder clip (TASK 47): like any
|
||||
/// source image it lives as a BLOB in the internal Asset table, tracked by this
|
||||
/// Setting so the Save pruner keeps it alive. The creator's OWN replacement
|
||||
/// videos are never stored — they are plain path references (the 2026-09-26
|
||||
/// decision: the file the user picks stays at its real location on disk).
|
||||
/// <c>UpsertAsset</c> is hash-keyed, so re-stamping the same bytes returns the
|
||||
/// same id.</summary>
|
||||
public string EnsureAlertDefaultVideo(byte[] data)
|
||||
{
|
||||
var saved = GetSetting(AlertDefaultVideoKey);
|
||||
if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null)
|
||||
return saved;
|
||||
var id = UpsertAsset(data, 0, 0);
|
||||
UpsertSetting(AlertDefaultVideoKey, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>The stored default clip's Asset id, or null when never stamped.</summary>
|
||||
public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey);
|
||||
|
||||
internal const string OnAirImageKey = "OnAirImageAssetId";
|
||||
|
||||
/// <summary>The strip tile that labels the scene currently PREVIEWED (creator
|
||||
/// ruling 2026-10-10 — the "on-air background screen"). One shared image, not
|
||||
/// per-scene: whichever scene is staged shows it in the top bar, so it "jumps
|
||||
/// around" as the creator swaps scenes. Lives as a BLOB in the internal Asset
|
||||
/// table, tracked by this Setting so the Save pruner keeps it alive — the same
|
||||
/// contract as the alert default clip. <c>UpsertAsset</c> is hash-keyed, so
|
||||
/// re-stamping the same bytes returns the same id.</summary>
|
||||
public string EnsureOnAirImage(byte[] data)
|
||||
{
|
||||
var saved = GetSetting(OnAirImageKey);
|
||||
if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null)
|
||||
return saved;
|
||||
var id = UpsertAsset(data, 0, 0);
|
||||
UpsertSetting(OnAirImageKey, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
|
||||
public string? GetOnAirImageId() => GetSetting(OnAirImageKey);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
public partial class LayoutStore : IDisposable
|
||||
{
|
||||
// ─── YPP journey-tracker snapshots (TASK 39) ───
|
||||
|
||||
/// <summary>Appends one YPP stats snapshot (append-only history; velocity/ETA
|
||||
/// read it in a later analytics slice).</summary>
|
||||
public void SaveYppSnapshot(YppStatSnapshot snapshot)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO YppSnapshot (CapturedAtUtc, SubscriberCount, VideoCount, TotalViews,
|
||||
UploadsLast90Days, CommunityGuidelinesGoodStanding,
|
||||
CopyrightStrikesGoodStanding, ContentIdClaimsGoodStanding,
|
||||
OverallGoodStanding)
|
||||
VALUES ($captured, $subs, $videos, $views, $uploads, $cg, $cs, $cic, $overall);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$captured", snapshot.CapturedAtUtc.ToString("o"));
|
||||
cmd.Parameters.AddWithValue("$subs", snapshot.SubscriberCount);
|
||||
cmd.Parameters.AddWithValue("$videos", snapshot.VideoCount);
|
||||
cmd.Parameters.AddWithValue("$views", snapshot.TotalViews);
|
||||
cmd.Parameters.AddWithValue("$uploads", snapshot.UploadsLast90Days);
|
||||
cmd.Parameters.AddWithValue("$cg", snapshot.CommunityGuidelinesGoodStanding ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$cs", snapshot.CopyrightStrikesGoodStanding ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$cic", snapshot.ContentIdClaimsGoodStanding ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$overall", snapshot.OverallGoodStanding ? 1 : 0);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Most recent snapshots, newest first (defaults to the full history).</summary>
|
||||
public List<YppStatSnapshot> LoadYppSnapshots(int limit = 365)
|
||||
{
|
||||
var result = new List<YppStatSnapshot>();
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT CapturedAtUtc, SubscriberCount, VideoCount, TotalViews, UploadsLast90Days,
|
||||
CommunityGuidelinesGoodStanding, CopyrightStrikesGoodStanding,
|
||||
ContentIdClaimsGoodStanding, OverallGoodStanding
|
||||
FROM YppSnapshot ORDER BY CapturedAtUtc DESC LIMIT $limit;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$limit", limit);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(new YppStatSnapshot
|
||||
{
|
||||
CapturedAtUtc = DateTime.TryParse(reader.GetString(0), out var captured) ? captured : DateTime.MinValue,
|
||||
SubscriberCount = reader.GetInt64(1),
|
||||
VideoCount = reader.GetInt64(2),
|
||||
TotalViews = reader.GetInt64(3),
|
||||
UploadsLast90Days = reader.GetInt32(4),
|
||||
CommunityGuidelinesGoodStanding = reader.GetInt32(5) != 0,
|
||||
CopyrightStrikesGoodStanding = reader.GetInt32(6) != 0,
|
||||
ContentIdClaimsGoodStanding = reader.GetInt32(7) != 0,
|
||||
OverallGoodStanding = reader.GetInt32(8) != 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The manually self-reported YPP checklist state (2FA / AdSense are
|
||||
/// not readable from any YouTube API — the creator ticks them in-app).</summary>
|
||||
public (bool TwoFactorEnabled, bool AdSenseLinked) LoadYppChecklist()
|
||||
{
|
||||
return (GetSetting("Ypp.TwoFactorEnabled") == "1", GetSetting("Ypp.AdSenseLinked") == "1");
|
||||
}
|
||||
|
||||
public void SaveYppChecklist(bool twoFactorEnabled, bool adsenseLinked)
|
||||
{
|
||||
UpsertSetting("Ypp.TwoFactorEnabled", twoFactorEnabled ? "1" : "0");
|
||||
UpsertSetting("Ypp.AdSenseLinked", adsenseLinked ? "1" : "0");
|
||||
}
|
||||
|
||||
/// <summary>UTC timestamp of the last successful stats refresh (used to gate
|
||||
/// the once-daily auto-refresh against the analytics quota-thin API).</summary>
|
||||
public DateTime? LoadYppLastRefreshedAtUtc()
|
||||
{
|
||||
var value = GetSetting("Ypp.LastRefreshedAtUtc");
|
||||
return DateTime.TryParse(value, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
public void SaveYppLastRefreshedAtUtc(DateTime utc)
|
||||
{
|
||||
UpsertSetting("Ypp.LastRefreshedAtUtc", utc.ToString("o"));
|
||||
}
|
||||
}
|
||||
+1
-1356
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user