Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ae30d4cfc | ||
|
|
a8814b42d0 | ||
|
|
3239808a73 | ||
|
|
24aca7d04a | ||
|
|
b7ab169718 | ||
|
|
818d316477 | ||
|
|
a4dfca6b6c | ||
|
|
096bf6debe | ||
|
|
804b1d2f5f | ||
|
|
ef60f1f3b6 | ||
|
|
c768a8ee49 | ||
|
|
daac7689fd | ||
|
|
20cfadca0c | ||
|
|
b1d6034662 | ||
|
|
826cc17e47 | ||
|
|
8be6a83dab | ||
|
|
987fddac24 | ||
|
|
ef52508b1e | ||
|
|
60b0218fa3 | ||
|
|
6271bf34c9 | ||
|
|
89f6aa3a67 | ||
|
|
2273d20160 | ||
|
|
17aa0273a9 | ||
|
|
3a77662a95 | ||
|
|
ccc1db52d3 | ||
|
|
6be062c45f | ||
|
|
58b9a7d413 | ||
|
|
b1d77ee510 | ||
|
|
fb851f089d | ||
|
|
d1e73a3aad | ||
|
|
2a15be21bd | ||
|
|
cdf82f688d | ||
|
|
84cbec2d19 | ||
|
|
a3a4681aac | ||
|
|
3cb29ea45c | ||
|
|
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 |
@@ -1,12 +1,23 @@
|
||||
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/
|
||||
*.DotSettings.user
|
||||
|
||||
# Build artifacts — the MSIX + its staging tree are produced by scripts/package-msix.sh,
|
||||
# never committed (the package embeds the published app and the bundled ffmpeg).
|
||||
artifacts/
|
||||
|
||||
# Confidential business files (gitignored, not committed)
|
||||
MONETIZATION.md
|
||||
MARCOM.md
|
||||
CREDENTIALS.md
|
||||
.aider*
|
||||
ffmpeg/
|
||||
BuildCounter.txt
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Velopack;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive;
|
||||
@@ -26,20 +25,13 @@ 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).
|
||||
// Update URL is configured per-deployment (self-hosted on DO droplet);
|
||||
// when no URL is set, the check is a no-op.
|
||||
try
|
||||
{
|
||||
VelopackApp.Build()
|
||||
.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"Velopack bootstrap failed (non-fatal): {ex.Message}");
|
||||
}
|
||||
// There is deliberately NO self-update bootstrap here: the Microsoft Store updates the
|
||||
// app (TASK 48 — Store MSIX route), and a second updater would fight it.
|
||||
|
||||
base.OnStartup(e);
|
||||
AppLog.Write("App OnStartup end");
|
||||
|
||||
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 560 KiB |
|
After Width: | Height: | Size: 553 KiB |
|
After Width: | Height: | Size: 378 KiB |
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
<!-- Intentionally empty. The hidden label keeps the bar's height unchanged. -->
|
||||
<TextBlock Style="{StaticResource YtLabel}" Text=" " Visibility="Hidden"/>
|
||||
</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,806 @@
|
||||
<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">
|
||||
<UserControl.Resources>
|
||||
<!-- TASK 63: the border-colour drop-down is the app's own YtComboBox, so the
|
||||
colour entries are drawn by TEMPLATES, in the theme's colours and with the
|
||||
theme's HexToBrush converter — no hand-built menu, no hand-painted brush.
|
||||
The closed box shows the swatch + the colour's NAME; the rows show the
|
||||
swatch + the hex + the name. -->
|
||||
<DataTemplate x:Key="BorderColorClosedTemplate">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
|
||||
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
|
||||
BorderBrush="#2a3a5e" BorderThickness="1"/>
|
||||
<TextBlock Text="{Binding Name}" Margin="6,0,0,0" FontSize="12"
|
||||
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="BorderColorRowTemplate">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Border Width="12" Height="12" CornerRadius="2" VerticalAlignment="Center"
|
||||
Background="{Binding Hex, Converter={StaticResource HexToBrush}}"
|
||||
BorderBrush="#2a3a5e" BorderThickness="1"/>
|
||||
<TextBlock Text="{Binding Hex}" Margin="6,0,0,0" FontSize="12"
|
||||
Foreground="#e0e0e0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Name}" Margin="8,0,0,0" FontSize="12"
|
||||
Foreground="#a0a0b0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<Helpers:ClosedBoxTemplateSelector x:Key="BorderColorTemplates"
|
||||
ClosedTemplate="{StaticResource BorderColorClosedTemplate}"
|
||||
RowTemplate="{StaticResource BorderColorRowTemplate}"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
<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="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- LAYERS header + Add button -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<Path Data="M12,3 L21,8 L12,13 L3,8 Z M3,12 L12,17 L21,12 M3,16 L12,21 L21,16"
|
||||
Stroke="#38bdf8" StrokeThickness="1.8" StrokeLineJoin="Round"
|
||||
Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,-4,6,0"/>
|
||||
<TextBlock Text="SCENE LAYERS" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
<Border Width="20" Height="20" CornerRadius="10" Background="#334155" Margin="6,-4,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding LayerCount}" Foreground="#38bdf8" FontSize="11" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<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" MinHeight="84" MaxHeight="240" 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="8,2,0,2"
|
||||
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<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>
|
||||
|
||||
<Grid Grid.Column="0" Width="16" Height="16" Margin="0,0,6,0" VerticalAlignment="Center">
|
||||
<Path Data="M7,8 H17 A4,4 0 0 1 21,12 V14 A3,3 0 0 1 17,16 L15,14 H9 L7,16 A3,3 0 0 1 3,14 V12 A4,4 0 0 1 7,8 Z M8,11 V13 M7,12 H9 M15,11 H15.01 M17,13 H17.01" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsLiveCapture}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M3,5 H21 V19 H3 Z M3,16 L9,10 L13,14 L16,11 L21,16" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><MultiDataTrigger><MultiDataTrigger.Conditions><Condition Binding="{Binding IsBackground}" Value="True"/><Condition Binding="{Binding IsLiveCapture}" Value="False"/></MultiDataTrigger.Conditions><Setter Property="Visibility" Value="Visible"/></MultiDataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M3,7 H15 V17 H3 Z M15,11 L21,8 V16 L15,13" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsWebcam}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M4,4 H20 V20 H4 Z M4,16 L9,11 L13,15 L16,12 L20,16 M15,8 H15.01" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsImageSource}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M5,5 H19 M12,5 V19 M8,19 H16" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsTextOverlay}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M12,3 A9,9 0 1 0 12.01,3 M12,7 V12 L15,14" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsCountdown}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M12,3 A9,9 0 1 0 12.01,3 M3,12 H21 M12,3 C8,7 8,17 12,21 M12,3 C16,7 16,17 12,21" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsWebSource}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M4,5 H20 V16 H10 L6,20 V16 H4 Z" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsChatBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M4,5 H20 V19 H4 Z M10,9 L15,12 L10,15 Z" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsMediaSource}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
<Path Data="M6,16 V11 A6,6 0 0 1 18,11 V16 L20,18 H4 Z M10,20 H14" Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round" Stretch="Uniform">
|
||||
<Path.Style><Style TargetType="Path"><Setter Property="Visibility" Value="Collapsed"/><Style.Triggers><DataTrigger Binding="{Binding IsAlertBox}" Value="True"><Setter Property="Visibility" Value="Visible"/></DataTrigger></Style.Triggers></Style></Path.Style>
|
||||
</Path>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Column="1" 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="1" Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
|
||||
BorderThickness="0" FontSize="12"
|
||||
IsVisibleChanged="SourceNameBox_IsVisibleChanged"
|
||||
KeyDown="SourceNameBox_KeyDown" LostFocus="SourceNameBox_LostFocus">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox" BasedOn="{StaticResource YtTextBoxCompact}">
|
||||
<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="2" 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="3" 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="4" 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,22,0,10"/>
|
||||
<StackPanel Grid.Row="5" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<Path Data="M12,8 A4,4 0 1 0 12.01,8 M19.4,15 L21,16.2 L19.4,19 L17.2,18.3 A7,7 0 0 1 15,19.4 L14.5,21.5 H9.5 L9,19.4 A7,7 0 0 1 6.8,18.3 L4.6,19 L3,16.2 L4.6,15 A7,7 0 0 1 4.6,9 L3,7.8 L4.6,5 L6.8,5.7 A7,7 0 0 1 9,4.6 L9.5,2.5 H14.5 L15,4.6 A7,7 0 0 1 17.2,5.7 L19.4,5 L21,7.8 L19.4,9 A7,7 0 0 1 19.4,15 Z"
|
||||
Stroke="#38bdf8" StrokeThickness="1.6" StrokeLineJoin="Round"
|
||||
Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Text="PROPERTIES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Row="6" Text="Select an item to edit its properties" Foreground="#94a3b8" FontSize="12"
|
||||
Margin="8,4,0,0" TextWrapping="Wrap"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NullToVis}}"/>
|
||||
<ScrollViewer x:Name="ElementsScrollViewer" Grid.Row="6" MaxHeight="420" VerticalScrollBarVisibility="Auto" Background="Transparent"
|
||||
Visibility="{Binding SelectedElement, Converter={StaticResource NotNullToVis}}">
|
||||
<StackPanel x:Name="ElementsPanel" Margin="8,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>
|
||||
<!-- TASK 59: the shape is a CHOICE (Rect | Circle) under a
|
||||
"Shape" title, with Rect selected by default — the circle
|
||||
is not offered in the vertical Chat layout, so that option
|
||||
is not even shown there (CanToggleClipShape). -->
|
||||
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Shape" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="10,0,0,4">
|
||||
<Button Content="{Binding SelectedElement.MirrorButtonText}"
|
||||
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
|
||||
Click="MirrorButton_Click"/>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="8,0,0,0"
|
||||
IsChecked="{Binding SelectedElement.IsRectShape, Mode=TwoWay}">
|
||||
<TextBlock Text="Rect" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
<ToggleButton Style="{StaticResource SegmentToggle}" Padding="10,4" Margin="4,0,0,0"
|
||||
IsChecked="{Binding SelectedElement.IsRoundShape, Mode=TwoWay}"
|
||||
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}">
|
||||
<TextBlock Text="Circle" Style="{StaticResource SegmentLabel}"/>
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<!-- The drag hint sits under the shape row — indented, so the
|
||||
parentage shows. -->
|
||||
<TextBlock Text="Drag to move · drag the corner dot to resize"
|
||||
Foreground="#a0a0b0" FontSize="10" Margin="10,2,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam geometry (TASK 57): under the vertical tier the camera
|
||||
keeps its OWN placement, so these fields edit the LIVE one —
|
||||
the placement the current tier shows. Gated on the VM's
|
||||
IsWebcamSelected (the same condition as the mirror/shape row
|
||||
above) so the webcam's border/background section stays the
|
||||
one panel bound to SelectedElement.IsWebcam. -->
|
||||
<StackPanel Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Geometry" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,4"/>
|
||||
<!-- TASK 61: the four fields share ONE fixed width — wide enough
|
||||
for the largest geometry value (1920, plus a typed sign) and
|
||||
no wider, so Y/H no longer stretch to fill the row. -->
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="X" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.X, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<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}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4" LastChildFill="False">
|
||||
<TextBlock DockPanel.Dock="Left" Text="W" Foreground="#a0a0b0" FontSize="11" VerticalAlignment="Center" Width="16"/>
|
||||
<TextBox Text="{Binding SelectedElement.Width, UpdateSourceTrigger=PropertyChanged, StringFormat=F0}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
<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}"
|
||||
Style="{StaticResource YtTextBoxCompact}" Width="44"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Webcam border config -->
|
||||
<StackPanel Visibility="{Binding SelectedElement.IsWebcam, Converter={StaticResource BoolToVis}}" Margin="0,0,0,8">
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<!-- TASK 63: the border colour is picked with the app's OWN
|
||||
drop-down (YtComboBox) — the same widget the Display
|
||||
and Font pickers use — not a hand-rolled menu. The hex
|
||||
field stays for a colour the creator types; the
|
||||
drop-down carries the colour's name. -->
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<TextBox DockPanel.Dock="Left"
|
||||
Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
MaxLength="7" ToolTip="#RRGGBB" Width="60"
|
||||
VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding SelectedElement.BorderColorOptions}"
|
||||
SelectedItem="{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}"
|
||||
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
ToolTip="Border colour" MinWidth="100"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,2"/>
|
||||
<DockPanel Margin="10,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). TASK 59:
|
||||
the pills are indented under the Background title, and the
|
||||
strength row one level deeper, under Blur. -->
|
||||
<TextBlock Text="Background" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,8,0,6"/>
|
||||
<DockPanel Margin="10,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="10,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="20,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}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<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}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</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}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
<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}"
|
||||
Style="{StaticResource YtTextBoxCompact}"/>
|
||||
</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}"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
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"/>
|
||||
|
||||
<!-- Border (same controls as the webcam's border) -->
|
||||
<TextBlock Text="Border Color" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<TextBox DockPanel.Dock="Left"
|
||||
Text="{Binding SelectedElement.BorderColor, UpdateSourceTrigger=PropertyChanged}"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
MaxLength="7" ToolTip="#RRGGBB" Width="60"
|
||||
VerticalAlignment="Center"/>
|
||||
<ComboBox ItemsSource="{Binding SelectedElement.BorderColorOptions}"
|
||||
SelectedItem="{Binding SelectedElement.BorderColorEntry, Mode=TwoWay}"
|
||||
ItemTemplateSelector="{StaticResource BorderColorTemplates}"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
ToolTip="Border colour" MinWidth="100"
|
||||
Margin="8,0,0,0" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<TextBlock Text="Border Thickness" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="10,0,0,6">
|
||||
<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>
|
||||
<TextBlock Text="Border Opacity" Foreground="#a0a0b0" FontSize="11" FontWeight="SemiBold" Margin="0,0,0,2"/>
|
||||
<DockPanel Margin="10,0,0,8">
|
||||
<Slider DockPanel.Dock="Left" Width="120" Minimum="0" Maximum="1"
|
||||
Value="{Binding SelectedElement.BorderOpacity, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding SelectedElement.BorderOpacity, StringFormat={}{0:P0}}" Foreground="#a0a0b0" FontSize="11"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0" Width="34"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 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"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
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}"
|
||||
Style="{StaticResource YtTextBoxCompact}" 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}"
|
||||
Style="{StaticResource YtTextBoxCompact}" 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}"
|
||||
Style="{StaticResource YtTextBoxCompact}" 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}"
|
||||
Style="{StaticResource YtTextBoxCompact}" 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" Style="{StaticResource YtCheckBox}" Margin="0,4,0,2"
|
||||
IsChecked="{Binding SelectedElement.ChatShowTimestamps, Mode=TwoWay}"/>
|
||||
<CheckBox Content="Show badges" Style="{StaticResource YtCheckBox}" 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}"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
IsReadOnly="True" Foreground="#a0a0b0" 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"
|
||||
Style="{StaticResource YtComboBox}"
|
||||
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}"
|
||||
Style="{StaticResource YtTextBoxCompact}"
|
||||
IsReadOnly="True" Foreground="#a0a0b0" 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,289 @@
|
||||
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))
|
||||
{
|
||||
var next = child is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: null;
|
||||
child = next
|
||||
?? LogicalTreeHelper.GetParent(child)
|
||||
?? (child as FrameworkElement)?.TemplatedParent;
|
||||
}
|
||||
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 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
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)
|
||||
{
|
||||
// TASK 57: under the vertical tier the camera snaps into the
|
||||
// cropped band (one of six spots) instead of free-floating over
|
||||
// the master — outside the band it is not in the stream at all.
|
||||
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
|
||||
{
|
||||
vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y);
|
||||
}
|
||||
else
|
||||
{
|
||||
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;
|
||||
|
||||
// TASK 57: inside the vertical band the size snaps (the Chat
|
||||
// camera has two sizes: the small tile or one whole area) and the
|
||||
// camera ends up on its spot.
|
||||
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
|
||||
vm.ResizeWebcamInVerticalTier(verticalCam, newW, 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,83 @@
|
||||
<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" Padding="4">
|
||||
<ItemsControl ItemsSource="{Binding Scenes}" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Width="196" Height="130" CornerRadius="4" Margin="2" Padding="2"
|
||||
BorderThickness="2"
|
||||
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="#ff8c3a"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsLive}" Value="True">
|
||||
<Setter Property="BorderBrush" Value="#ff8c3a"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<Grid>
|
||||
<Border CornerRadius="3" Background="#0a0a1a" ClipToBounds="True">
|
||||
<Image Source="{Binding Snapshot}"
|
||||
Stretch="UniformToFill"
|
||||
RenderOptions.BitmapScalingMode="LowQuality"/>
|
||||
</Border>
|
||||
|
||||
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="6" Padding="8,3"
|
||||
CornerRadius="3" Background="#ff8c3a">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsStaged}" Value="True">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="8" Height="8" Fill="#ff5451" VerticalAlignment="Center" Margin="0,0,5,0">
|
||||
<Ellipse.Triggers>
|
||||
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
|
||||
<BeginStoryboard>
|
||||
<Storyboard RepeatBehavior="Forever" AutoReverse="True">
|
||||
<DoubleAnimation Storyboard.TargetProperty="Opacity" From="1" To="0.15" Duration="0:0:0.6"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</EventTrigger>
|
||||
</Ellipse.Triggers>
|
||||
</Ellipse>
|
||||
<TextBlock Text="PROGRAM" Foreground="White" FontSize="11" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border HorizontalAlignment="Right" VerticalAlignment="Top" Margin="6" Padding="8,2"
|
||||
CornerRadius="10" Background="#000000">
|
||||
<TextBlock Text="{Binding HotkeyLabel, Mode=OneWay}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="{Binding Name, Mode=OneWay}" Foreground="White" FontSize="16" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Bottom" Margin="6"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
<TextBlock.Effect>
|
||||
<DropShadowEffect BlurRadius="4" ShadowDepth="0" Opacity="0.9"/>
|
||||
</TextBlock.Effect>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
foreach (var scene in vm.Scenes)
|
||||
{
|
||||
var staged = scene.IsStaged;
|
||||
scene.IsStaged = !staged;
|
||||
scene.IsStaged = staged;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,150 @@
|
||||
<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">
|
||||
<!-- EDIT: explicit toggle. Blue = off (chat), orange = on (layers/properties). -->
|
||||
<ToggleButton IsChecked="{Binding EditingActive, Mode=TwoWay}" Content="EDIT"
|
||||
Cursor="Hand" Height="32" Padding="14,0" Margin="0,0,24,0" VerticalAlignment="Center"
|
||||
FontWeight="Bold" FontSize="12" Foreground="#0b1326" BorderThickness="0">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="EditBody" Background="#38bdf8" CornerRadius="4" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="EditBody" Property="Background" Value="#ff8c3a"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<!-- 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). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
|
||||
| `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). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout. **TASK 58:** every drop-down, check box and field in the properties panel carries the theme's style (`YtComboBox`/`YtCheckBox`/`YtTextBoxCompact`) and paints no colour of its own — the guard is `ytLive.Tests/PropertiesStyleTests.cs`. **TASK 59:** the webcam's properties are a HIERARCHY — a `Shape` title owns the mirror switch and the shape choice (`Rect` | `Circle` as two `SegmentToggle` options, Rect selected by default, the circle option hidden where `CanToggleClipShape` is false), and every titled block (Geometry, Border Color, Border Thickness, Background) owns its content one indent deeper (Strength sits under Blur). **TASK 60:** the border-colour drop-down builds its menu from `Models/BorderColorPalette.cs` — each row is the hex plus the colour's NAME (`#e94560 Crimson`, tooltip "Crimson (#e94560)"); the swatch icon is unchanged. **TASK 61:** the four geometry fields (X/Y/W/H) share one fixed width (44px — the largest value, 1920, plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill the row. **TASK 62:** the border-colour field shows the palette's name beside the hex (`SelectedElement.BorderColorName`, empty for a custom hex) and keeps a fixed width so the name stays next to the hex. **TASK 63:** the border colour is picked with the **app's own drop-down** (`YtComboBox`) — the `...` button and its code-built `ContextMenu` are gone; the closed box shows the swatch + the colour's NAME, the rows show swatch + hex + name (the panel's own `DataTemplate`s + `Helpers/ClosedBoxTemplateSelector.cs`), and the hex field stays for a custom colour; `IsDescendantOf` falls back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent` returns `null` (at `PopupRoot`) so clicking a popup row does not trigger `MainWindow`'s deselection guard |
|
||||
| `OverlayHost.xaml(.cs)` | Root-grid 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.
|
||||
@@ -0,0 +1,67 @@
|
||||
# DESIGN.md — LlamaCasty visual design tokens (redesign exploration)
|
||||
|
||||
> Source: Google Stitch export, 2026-10-26, from the proposed edit-screen mockup. Colors and type
|
||||
> only — no layout decisions are captured here. This is a design reference for the UX redesign
|
||||
> in progress; it is not yet wired into the app's theme (`Themes/Controls.xaml`).
|
||||
|
||||
## Color
|
||||
|
||||
### Surfaces (dark navy scale, darkest to lightest)
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `background` | `#0b1326` | App window background |
|
||||
| `surface-container-lowest` | `#060e20` | Deepest recess (scene strip background, canvas housing) |
|
||||
| `surface-container-low` | `#131b2e` | Panel background (sidebars, headers) |
|
||||
| `surface-container` | `#171f33` | Card / grouped-section background |
|
||||
| `surface-container-high` | `#222a3d` | Hover state, input fields |
|
||||
| `surface-container-highest` | `#2d3449` | Selected/active row background |
|
||||
| `surface-bright` | `#31394d` | Lightest surface, rare use (strong hover) |
|
||||
| `outline` | `#334155` | Borders, dividers |
|
||||
| `outline-variant` | `#1e293b` | Subtle dividers, inactive VU meter segments |
|
||||
|
||||
### Text
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `on-surface` | `#dae2fd` | Primary text |
|
||||
| `on-surface-variant` | `#94a3b8` | Secondary / label text |
|
||||
|
||||
### Accent — one role each, never mixed
|
||||
|
||||
| Token | Hex | Role |
|
||||
|---|---|---|
|
||||
| `tertiary` (electric blue) | `#38bdf8` | The one interactive accent: selection, focus, sliders, primary buttons, transform handles |
|
||||
| `primary` (red) | `#ff5451` | **Reserved for on-air/recording state only** — LIVE badge, program border, End/Go Live |
|
||||
| `primary-container` | `#b91a24` | Red pressed/hover state |
|
||||
| `secondary` (amber) | `#ffb95f` | Game audio channel, Super Chat highlight |
|
||||
| `secondary-container` | `#ee9800` | Amber pressed/hover state |
|
||||
| `error` | `#ef4444` | Error states only (distinct from the on-air red) |
|
||||
|
||||
**Rule carried from the critique sessions:** red must mean "live or recording" and nothing else.
|
||||
Don't reuse it for a Send button, a delete action, or any other control — that was the defect in
|
||||
the first Stitch draft of the live screen.
|
||||
|
||||
## Typography
|
||||
|
||||
| Token | Family | Use |
|
||||
|---|---|---|
|
||||
| `sans` | Inter (400/500/600/700) | All UI text — labels, buttons, panel titles |
|
||||
| `mono` | JetBrains Mono (500/600) | All numeric/telemetry values — bitrate, FPS, dB, coordinates, timers. Keeps digits from shifting width as values change. |
|
||||
|
||||
Icons: Material Symbols Outlined.
|
||||
|
||||
## Color roles (decided 2026-10-26)
|
||||
|
||||
- **Red `#ff5451`:** YouTube references, on-air/GO LIVE, and record only.
|
||||
- **Attention orange `#ff8c3a`:** dropped packets, the live screen indicator, and blinking warning lights.
|
||||
- **Amber `#ffb95f`:** game audio and Super Chat only.
|
||||
- **`error` `#ef4444`:** unchanged for now; it sits close to the on-air red and may need revisiting.
|
||||
|
||||
## Open questions for the next pass
|
||||
|
||||
- These tokens have not been reconciled against the app's current WPF theme
|
||||
(`Themes/Controls.xaml`). That comparison should happen once the redesign direction is locked,
|
||||
not before — do not retrofit the current theme to these values yet.
|
||||
- No elevation/shadow scale, corner-radius scale, or spacing scale has been captured yet. Add them
|
||||
here as they're decided, rather than letting them live only in Stitch exports.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -273,24 +305,34 @@ steps:
|
||||
### 4.1 Files to Ship
|
||||
|
||||
```
|
||||
LlamaCasty.exe (single-file, self-contained, ~80-120MB)
|
||||
LlamaCasty.msix (full-trust x64 package, self-contained, ~159MB)
|
||||
```
|
||||
|
||||
Single-file means everything is inside the `.exe`. No DLLs, no runtime dependency.
|
||||
Built by **`scripts/package-msix.sh`** (TASK 48 item 2, shipped 2026-10-22): it publishes
|
||||
`Release -r win-x64 --self-contained` into `artifacts/msix-stage`, stages
|
||||
`Packaging/Package.appxmanifest` (as `AppxManifest.xml`) + `Packaging/Assets/`, and packs with
|
||||
the Windows SDK's `makeappx` → `artifacts/LlamaCasty.msix` (gitignored). Unsigned by default —
|
||||
**the Store signs submissions**; set `MSIX_PFX`/`MSIX_PFX_PASSWORD` to sign a package for a
|
||||
local test install. The manifest is a full-trust `packagedClassicApp` at `mediumIL` with
|
||||
`runFullTrust` + webcam + microphone (see `TASKS/task-48-distribution-msix.md` item 2).
|
||||
|
||||
**Why not an installer?**
|
||||
- Single `.exe` is simpler to distribute and update.
|
||||
- No admin rights required (installs to `%LOCALAPPDATA%`).
|
||||
- No installer framework to maintain (WiX, Inno Setup, etc.).
|
||||
- User drops it anywhere and runs it.
|
||||
A single-file `.exe` is **no longer the artifact**: the Store needs a package, and the package
|
||||
carries the runtime (~80-120MB of it) plus the bundled ffmpeg (~135MB of DLLs). No installer
|
||||
framework and no admin rights — the Store handles install, update and uninstall.
|
||||
|
||||
**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 — ⛔ OBSOLETE (2026-09-27)
|
||||
|
||||
### 4.2 Distribution via 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.
|
||||
|
||||
**Product setup on Polar:**
|
||||
**Product setup on Polar (as designed, never shipped):**
|
||||
|
||||
1. Create a product: "LlamaCasty" — one-time purchase (or subscription if deferred).
|
||||
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 +355,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 +408,15 @@ 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 that used to sit in
|
||||
> `App.xaml.cs` **downloaded *and executed***, which Store policy **10.2.2** does not permit — its
|
||||
> removal was a **compliance action**, not optional cleanup, and it is **done** (TASK 48 slice 3,
|
||||
> 2026-10-22: the package reference and the bootstrap are gone; `ytLive.Tests/StorePackagingTests.cs`
|
||||
> guards it). 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 +451,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 +475,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.
|
||||
|
||||
|
||||
@@ -1,45 +1,378 @@
|
||||
# HANDOFF — Session State
|
||||
# HANDOFF — 2026-10-26 (ai.md memory split, 3 units landed; see below — everything after this block is the prior 2026-10-22 state, still accurate)
|
||||
|
||||
## Branch / Commit State
|
||||
## At a glance (2026-10-26 docs session)
|
||||
- **`ai.md` is being split into `docs/ai/*.md`** (user-approved: verbatim move, staged units, "ai.md
|
||||
only"). Three units landed as separate commits, **docs only, not pushed**:
|
||||
`804b1d2` (frame pipeline: cadence/timing/budget/compositor/pump),
|
||||
`096bf6d` (capture: backdrop/webcam/background effects/vertical tier),
|
||||
`a4dfca6` (output: ffmpeg/encoder/recording/audio). Each move is byte-identical in the new file;
|
||||
`ai.md` keeps a short stub with the load-bearing invariants + a link. `docs/ai/index.md` routes.
|
||||
**`ai.md` is now 1,289 lines (~32k tokens), down from 2,702 (~64k)** — more than half the
|
||||
mandatory-read cost is gone.
|
||||
- **Also landed (docs only, not pushed):** `ef60f1f` — distribution & packaging deferred until near
|
||||
v1 (creator ruling 2026-10-26); see "Next step" below for the still-open route contradiction.
|
||||
- **Remaining split candidates in `ai.md`** (not yet moved — smaller, more interleaved with law that
|
||||
should stay): Architecture's "Social bar" (46) + "Toast notifications" (24) + "Licensing — do not
|
||||
violate" (60); "YouTube Live API — design constraints" incl. "Native events & alerts" (349 total,
|
||||
99 of it alerts); "Monetization" (136); "Windows packaging & distribution" (135, frozen per the
|
||||
deferral above). None of these is urgent — pick back up only if the user asks to continue the split.
|
||||
- **Scope-check passed on all three commits**; no source touched this session.
|
||||
|
||||
**`main`**, working tree is **CLEAN** — all changes committed and pushed.
|
||||
---
|
||||
|
||||
No feature branches pre-1.0: all work lands on `main` per work unit.
|
||||
## Prior state (2026-10-22) — the creator's properties-panel asks shipped, incl. TASK 63, and pushed; the vv webcam Units 1+2 state below
|
||||
|
||||
**Latest commit:** `6031a56` — gear cleanup + Default Location for Recordings + single Start + socials hover glow.
|
||||
## At a glance
|
||||
- **Branch** main, tracking `origin/main`. **The six-commit properties-panel batch is PUSHED**
|
||||
(2026-10-22, as a sub-milestone): TASK 58 the theme sweep, TASK 59 the properties hierarchy,
|
||||
TASK 60 the colour names, a docs commit, TASK 61+62 the geometry field widths and the colour
|
||||
name beside the hex, and TASK 63 the border-colour picker as the app's own drop-down — the
|
||||
creator's properties-panel asks, done in one batch. `origin/main` is now `987fdda`.
|
||||
- The vv feature (Units 1+2) **was** pushed at its close (2026-10-21 — the creator approved
|
||||
the Unit 1 push after testing it, and the completed feature is a milestone).
|
||||
- **The push path changed:** `origin` is now
|
||||
`https://llgit.llamachile.tube/gramps/LlamaCasty.git` (HTTPS + the credential helper
|
||||
`/home/gramps/bin/gitea-credential`, which reads the password from the gitignored
|
||||
`CREDENTIALS.md`; plain `git push` works). The old SSH remote was broken twice over: the repo
|
||||
was **renamed `gramps/ytLlive` → `gramps/LlamaCasty`**, and `llgit:2214` refuses the publickey
|
||||
of this machine's key even though it IS registered (account key id 6 "Corsair VSC"). Ask the
|
||||
Gitea REST API, don't do key archaeology — recipe in `MyMistakes.md` → 2026-10-21.
|
||||
- **This session shipped vv webcam Units 1+2** (the vertical 9:16 tier keeps its OWN camera placement
|
||||
per scene; 16:9 restores the desktop layout exactly; the Chat scene in vv is rect-only and can be
|
||||
enlarged to fill a whole 607×540 area). Code + memory in ONE commit each; the ONE Good Dog tests
|
||||
are `ytLive.Tests/VerticalWebcamLayoutTests.cs` (Unit 1) and
|
||||
`ytLive.Tests/VerticalChatCameraLayoutTests.cs` (Unit 2). **Only the creator's hands-on check is
|
||||
left** — see the vv section.
|
||||
- **TASK 58 — the properties panel now wears the theme** (shipped 2026-10-21). The creator's
|
||||
"why is the drop-down not in the same style as the others?" was a real defect: those drop-downs
|
||||
(and every check box, and every properties field) had been hand-painted with a `Background`/
|
||||
`Foreground` instead of a style, so WPF drew the **light system template** on top of the dark
|
||||
panel. Now: `ComboBox` → `YtComboBox`, `CheckBox` → `YtCheckBox` (new), properties fields →
|
||||
`YtTextBoxCompact` (new, BasedOn `YtTextBox`). Guard:
|
||||
`ytLive.Tests/PropertiesStyleTests.cs`.
|
||||
- **TASK 59 — the properties panel is a hierarchy** (shipped 2026-10-21). A title owns its
|
||||
content and the content is indented one level (10px): `Shape` (new title) over the
|
||||
Mirror switch and the shape choice, the drag hint indented under that row, Geometry /
|
||||
Border Color / Border Thickness as titles with their fields indented, Background's
|
||||
Remove + Blur indented, and Strength one level deeper under Blur. **The shape became a
|
||||
CHOICE**: `Rect` | `Circle` as two segment options with **Rect selected by default**
|
||||
(the old single button showed the *next* action, "Round"). The mutual exclusion lives
|
||||
in the model (`IsRectShape`/`IsRoundShape` on `SceneElement`) — a WPF `RadioButton`
|
||||
group would kill the bindings. Guard: `ytLive.Tests/PropertiesHierarchyTests.cs`.
|
||||
- **TASK 60 — the border-colour drop-down names every colour** (shipped 2026-10-21). The creator:
|
||||
*"the #hex values are good, but add a corresponding color name/label next to the value"* — each row
|
||||
now reads `#e94560 Crimson` (the name dimmed, the tooltip "Crimson (#e94560)"). The 14 colours
|
||||
moved out of the click handler into `Models/BorderColorPalette.cs` (`NamedColor(Hex, Name)` +
|
||||
`NameOf(hex)`), so the list is testable and extensible. Guard:
|
||||
`ytLive.Tests/BorderColorPaletteTests.cs`.
|
||||
- **TASK 61 + 62 — the geometry fields and the border-colour field** (shipped 2026-10-21).
|
||||
- **61:** the X/Y/W/H fields share ONE fixed width (44px — the largest geometry value, 1920,
|
||||
plus a sign) and their rows set `LastChildFill="False"`, so Y/H no longer stretch to fill
|
||||
the row while X/W sized themselves to their text. Guard: `ytLive.Tests/GeometryFieldWidthTests.cs`
|
||||
(it MEASURES the widest value with WPF's `FormattedText` — measure, don't guess).
|
||||
- **62:** the border-colour field shows the palette's **name beside the hex**
|
||||
(`SceneElement.BorderColorName`, empty for a custom hex), and the hex field keeps a fixed
|
||||
width so the name stays next to the hex. The field stays editable (a custom hex can
|
||||
still be typed). Guard: `ytLive.Tests/BorderColorNameTests.cs`.
|
||||
- **TASK 63 - the border-colour picker IS the app's drop-down** (shipped 2026-10-21; **popup click
|
||||
deselection fixed 2026-10-22**). The creator:
|
||||
*"why does the drop down under the web cam properties not show the same style for the border
|
||||
color drop down list box?"* and *"we established look-and-feel standards. And yet you keep
|
||||
ignoring these and I'm wondering why."* **Root cause of the miss:** the TASK 58 sweep looked for
|
||||
control **types** (`ComboBox`/`CheckBox`/`TextBox`) without a theme style - and the border picker
|
||||
was NONE of those: it was a `...` `Button` whose `Click` handler built a `ContextMenu` in code.
|
||||
It was never in the sweep's scope, and `PropertiesStyleTests.cs` (a type-based guard) could not
|
||||
see it either. Now: the picker is a **`YtComboBox`** bound to the palette
|
||||
(`SelectedElement.BorderColorOptions` / `BorderColorEntry`), the **closed box shows the swatch +
|
||||
the colour's NAME**, the **rows show swatch + hex + name**, the hex field stays for a custom
|
||||
colour, the swatch is painted by the theme's `HexToBrush` converter, and the hand-built menu +
|
||||
`WebcamBorderColorPicker_Click` are **gone**. The closed box != the rows only through
|
||||
`Helpers/ClosedBoxTemplateSelector.cs` (WPF copies `ItemTemplate` into the read-only
|
||||
`SelectionBoxItemTemplate`; the selector is told them apart by the `ContentPresenter`'s
|
||||
templated parent - `ComboBox` = closed box, `ComboBoxItem` = a row). **2026-10-22 creator bug fix:**
|
||||
clicking a colour in the open `ComboBox` popup registered as an exit from the properties listing
|
||||
because `LeftPanel.IsDescendantOf` used `child is Visual ? VisualTreeHelper.GetParent(child) :
|
||||
LogicalTreeHelper.GetParent(child)` — at `PopupRoot` (which IS a `Visual`),
|
||||
`VisualTreeHelper.GetParent` returns `null`, so `IsDescendantOf` stopped without crossing
|
||||
`PopupRoot`'s logical parent (`PART_Popup` → `ComboBox` → `ElementsScrollViewer`), and
|
||||
`MainWindow.Window_PreviewMouseLeftButtonDown` cleared `SelectedElement = null`. Fixed by falling
|
||||
back to `LogicalTreeHelper.GetParent` / `TemplatedParent` when `VisualTreeHelper.GetParent`
|
||||
returns `null`. Guard: `ytLive.Tests/BorderColorPickerTests.cs` (model + panel + the **real
|
||||
widget**, including `LeftPanel.IsClickInside` on an open popup row and clicking a row to change
|
||||
`BorderColor`). The TASK 60
|
||||
guard (`BorderColorPaletteTests`) was updated to guard the new wiring (the drop-down is bound to
|
||||
the palette; no second list of colours anywhere).
|
||||
- **TASK 55 slice 1 — the log is capped (shipped 2026-10-22).** `startup.log` was uncapped (23 MB
|
||||
and growing every few seconds — ffmpeg logs every stderr line). Now it rolls to `startup.1.log`
|
||||
at 5 MB (one rolled copy kept), so the growth is bounded. The policy is
|
||||
`Helpers/LogRotation.cs` (a pure function over a path — `AppLog`'s 156-call-site surface is
|
||||
unchanged; the seams are two `internal` properties). Guard:
|
||||
`ytLive.Tests/AppLogRotationTests.cs`. Slices 2 (temp alert-clip cleanup) and 3 (low-disk
|
||||
guard before recording) are still open.
|
||||
- **TASK 48 slice 2 — the app PACKS as a Store MSIX (shipped 2026-10-22).** `Packaging/Package.appxmanifest`
|
||||
(full trust: `Windows.FullTrustApplication` + `uap10:TrustLevel=mediumIL` +
|
||||
`uap10:RuntimeBehavior=packagedClassicApp` + `rescap runFullTrust`, plus webcam/microphone
|
||||
`DeviceCapability`s, x64, en-US only) + `Packaging/Assets/` (four logo tiles generated from the
|
||||
app's own artwork by `scripts/make-store-logos.py`) + `scripts/package-msix.sh` (publish
|
||||
`Release -r win-x64 --self-contained` → stage → `makeappx pack`, optional `MSIX_PFX` signing).
|
||||
**First real package built: `artifacts/LlamaCasty.msix`, 159 MB, 509 files, `Package creation
|
||||
succeeded`** (`artifacts/` is gitignored). Guard:
|
||||
`StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_microphone`.
|
||||
⚠️ Before submission the manifest's `Identity/@Publisher` and `PublisherDisplayName` must be
|
||||
set to the exact values Partner Center shows — a mismatch is a rejection.
|
||||
- **TASK 48 slice 3 — the self-updater is gone (shipped 2026-10-22).** The Store route (creator
|
||||
ruling 2026-09-27) means the Store updates the app, so the Velopack package + bootstrap were
|
||||
**deleted** (`ytLive.csproj`, `App.xaml.cs`) — a compliance action, not cleanup: the updater
|
||||
downloaded *and executed*, which Store policy 10.2.2 does not permit. The DO update-URL droplet
|
||||
dies with it. Guard: `ytLive.Tests/StorePackagingTests.cs` (no updater package, no updater API
|
||||
in any app source file). The app was smoke-tested after the removal: `MainWindow loaded`, no
|
||||
exceptions. **Still open on TASK 48:** slice 2 (the `Package.appxmanifest` + packaging project),
|
||||
slice 6 (the full-trust recording invariant comment), slice 7 (disqualifier re-check), slice 8
|
||||
(the pre-submission gates) — and slices 4/5 are the creator's (Certification Kit, Partner
|
||||
Center listing, the demo account).
|
||||
- **The previous session (HA lights) produced NO ytLive code** — it was spent on the creator's Home
|
||||
Assistant light alert system (off-repo, `192.168.50.145`). See `MyMistakes.md` → 2026-10-21: the plan
|
||||
was one tweak to a verified-working script; the script was rewritten twice, the alert path broke, and
|
||||
the day was burned. **The HA work landed OK in the end** (recipes + measurements now in
|
||||
`MyMistakes.md`). Records below so the next session does not re-hunt.
|
||||
|
||||
## What's In Flight
|
||||
## HA light alert system — current, verified state (2026-10-21)
|
||||
Five Tapo bulbs: `light.br1`, `light.br2`, `light.officeone`, `light.officetwo`,
|
||||
`light.death_star_light`. Purpose: the creator is **deaf** — lights are the alert channel; the phone
|
||||
(`notify.mobile_app_mis_lx9`) is secondary. **The iPhone is gone — never notify it.**
|
||||
|
||||
Nothing — clean tree, ready for next task.
|
||||
| State | br1 / officeone | br2 / officetwo | Death Star |
|
||||
|---|---|---|---|
|
||||
| Idle | off | off | off |
|
||||
| Pump running | off | **off** | **blue only** (creator directive) |
|
||||
| Leak | warm-white 100% | purple | purple |
|
||||
| Alarm | warm-white 100% | red | red |
|
||||
| Doorbell | green, 8s | green, 8s | green, 8s |
|
||||
| Doorbell during an alert | — | suppressed — | — |
|
||||
| Clear | off | off | off |
|
||||
|
||||
## Landmines
|
||||
**Architecture:** `script.alerts_arm` / `alerts_clear` write only `input_text.light_alert`
|
||||
(`none|leak|alarm|motion`, restart-surviving). **`script.alerts_render_lights` is the single writer of
|
||||
the bulbs** — flat top-level `if`/`else` blocks (nested `if` and `choose:` both silently lit nothing;
|
||||
do not "improve" the shape). Priority: an alert always beats pump status.
|
||||
|
||||
- **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.
|
||||
**⛔ THE ENTITY-ID TRAP (cost this session hours):** the YAML key is `alerts_render` but the entity id is
|
||||
**`script.alerts_render_lights`**. `script.turn_on` on a non-existent entity returns **HTTP 200 and does
|
||||
nothing** — no error, no log. All callers now use the `_lights` id. Check this first on any "script does
|
||||
nothing" symptom.
|
||||
|
||||
## Todo state
|
||||
**Pump status** is a *status*, not an alert (auto-clearing), and is keyed off
|
||||
`sensor.water_pump_current_consumption` — **never** `switch.water_pump_2`, which was observed latched
|
||||
`on` for hours at 0.0A draw and painted the bedroom blue at 5am.
|
||||
|
||||
- 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.
|
||||
**HA files touched** (`/config`, root-owned → writes need `sudo`): `scripts.yaml`, `automations.yaml`,
|
||||
`configuration.yaml` (added `input_text.light_alert` / `_prev`). Backups: `scripts.yaml.bak-alertbuild`,
|
||||
`automations.yaml.bak-alertbuild`, `automations.yaml.bak-notify-20261020`, `configuration.yaml.bak-tplinkfix`.
|
||||
Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbell_flash` (old one),
|
||||
`automation.leak_strobe_notify_clear_restore`, `script.alerts_render_lights_2`.
|
||||
**Leak sensor is healthy** (`binary_sensor.pump_leak_moisture` = off, battery 100, signal 3) — an earlier
|
||||
"unavailable" claim was a bad read during a reload; `alert_leak_on` now also triggers on the latched
|
||||
`sensor.pump_leak_last_water_leak_alert`.
|
||||
|
||||
## Session summary (2026-08-29)
|
||||
## vv webcam feature — locked rules (creator, 2026-10-17) — **Units 1+2 shipped 2026-10-21**
|
||||
| mode | shape | size | position |
|
||||
|---|---|---|---|
|
||||
| 16:9 | as today | as today | as today |
|
||||
| **vv · Live** | **circle OR rect, creator's choice** | small tile | one of six spots: top/bottom × L/C/R |
|
||||
| **vv · Chat** | **rect only — no circle option** | normal, or enlarged to fill one area (607×540) | enlarged → top or bottom |
|
||||
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
|
||||
|
||||
- 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
|
||||
- **Placement is set by tap/click-drag with snap, exactly like the socials today**, plus manual entry in
|
||||
properties. **vv keeps its own camera position, size and shape per scene** — switching back to 16:9
|
||||
restores the desktop layout exactly.
|
||||
- **No explicit borderless/no-background mode is needed** (round + border/bg options already allow it).
|
||||
- Creator's "square" means the rectangular view (the camera's own aspect), not a literal 1:1 box.
|
||||
|
||||
### What the code already gives us (do not re-hunt)
|
||||
- **vv = the active tier's centred band crop.** 1080p60-vertical: master band **x 656.25–1263.75
|
||||
(607.5 wide), full height**, upscaled to 1080×1920 (`MainViewModel.Streaming.cs` `ApplyStreamQuality`
|
||||
→ `OutputRect*`). The band splits into **two 607×540 areas** (top/bottom).
|
||||
- **The Live webcam sits at x≈1425 → entirely outside the band** — in vv it is invisible. That is the
|
||||
whole motivation.
|
||||
- **Geometry only — no render-path change.** Preview (`Controls/PreviewPane.xaml` 144–230) and compositor
|
||||
(`Services/Compositor/SceneCompositor.cs` 389–397) both read the element's own `X/Y/W/H` + `ClipShape`.
|
||||
- **Round / border / background are independent:** `ClipShape.Round`; `HasBorder` = width > 0 AND valid
|
||||
6-hex colour; `BackgroundEffect` = None/Remove/Blur.
|
||||
- **Per-scene camera size policy exists**: `MaxWebcamWidthFor/HeightFor` (Chat 1358×764, others 960×540)
|
||||
+ `ClampWebcamToBounds`. **A 607×540 tile sits inside both → no clamp change.**
|
||||
- **Storage:** `WebcamSceneConfig` table; new columns via `MigrateWebcamConfigTable()` as idempotent
|
||||
`ALTER TABLE … ADD COLUMN` guarded on `PRAGMA table_info` + `LayoutStore.Save.cs` INSERT and
|
||||
`LayoutStore.Load.cs` SELECT.
|
||||
- **Precedent for remembered geometry:** `RectWidth/RectHeight` + `ToggleClipShape`
|
||||
(`Models/SceneElement.cs` 124–152).
|
||||
|
||||
### What shipped in Unit 1 (2026-10-21) — do not re-hunt
|
||||
- **Geometry only — NO render-path change.** The tier switch swaps the element's LIVE geometry; the
|
||||
preview and the compositor read `X/Y/W/H` + `ClipShape` exactly as before.
|
||||
- `Models/VerticalCameraLayout.cs` (new): the pure math — band x 656.25, w 607.5, h 1080; two
|
||||
607×540 areas; six spots; default tile **270×152 master px** (= 480×270 in the 1080×1920 output),
|
||||
**bottom-right** spot; inset 32 master px.
|
||||
- `Models/WebcamSceneConfig.cs`: the vertical placement is persisted in **seven additive columns**
|
||||
(`VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight`, NULL = "this
|
||||
scene has no vertical placement yet"); the 16:9 layout stays in the old columns and is **parked** in
|
||||
the transient `Landscape*` fields while the vertical tier is live. `BeginVerticalLayout` /
|
||||
`EndVerticalLayout` / `CommitLiveLayoutToVertical` do the swap.
|
||||
- `Models/SceneElement.cs`: `X/Y/Width/Height` are now **`virtual`** so the webcam clamps EVERY write
|
||||
(drag, resize, typed fields) into the band — outside the band = invisible in vv.
|
||||
- `ViewModels/MainViewModel.Webcam.cs`: `IsVerticalTier`/`VerticalBand` + `ResolveVerticalCameraLayouts`
|
||||
(the tier swap; the **Live scene gets its tile automatically** on the first vertical switch) +
|
||||
`MoveWebcamInVerticalTier`/`SnapWebcamInVerticalTier` (the six-spot snap, by the tile's CENTRE).
|
||||
`MainViewModel.Streaming.cs` `ApplyStreamQuality` is the tier-change hook.
|
||||
- `Controls/LeftPanel.xaml`: the webcam properties gained a **Geometry** section (X/Y/W/H) bound to
|
||||
the live placement, gated on the VM's `IsWebcamSelected` (NOT `SelectedElement.IsWebcam` — the TASK 51
|
||||
test finds that panel by binding text and would match twice).
|
||||
- `Controls/PreviewPane.xaml.cs`: dragging/resizing a webcam under the vertical tier routes through the
|
||||
VM's snap instead of free-floating over the master.
|
||||
- Test seam: the drag lives in a **VM method**, so the test drives it without synthesising mouse input.
|
||||
|
||||
### What shipped in Unit 2 (the Chat scene, 2026-10-21) — do not re-hunt
|
||||
- **Rect only, and the lock is on the MODEL, not the UI.** `WebcamSceneConfig.VerticalShapeLocked`
|
||||
(set per scene by the VM) **overrides the `ClipShape` property** → a locked camera cannot become
|
||||
Round by any writer (the toggle, the Shape button, the placement swap). `SceneElement.ClipShape`
|
||||
and `SceneElement.ToggleClipShape()` are `virtual` for this. The lock is cleared **before** the
|
||||
16:9 layout comes back, so the desktop shape is restored.
|
||||
- **The circle is not OFFERED:** `MainViewModel.CanToggleClipShape` is false for a Chat camera in the
|
||||
vertical tier → the Shape button in the webcam's properties **disappears** (not just disabled).
|
||||
`MainViewModel.cs` raises it on the selection change; `MainViewModel.Streaming.cs` on the tier change.
|
||||
- **Two sizes, snapped by the nearer one:** `VerticalCameraLayout.SizeFor(band, requestedWidth)` →
|
||||
the small tile (270×152) or one whole area (607.5×540). The resize handle therefore has two
|
||||
detents instead of any size in between.
|
||||
- **An enlarged camera fills the area its centre is in** — top or bottom (`VerticalCameraLayout.AreaFor`).
|
||||
- **The Chat camera is auto-tiled** like the Live one when the vertical tier is picked (otherwise the
|
||||
vertical chat stream has no camera at all). `ResolveVerticalCameraLayout` now auto-tiles Live **and**
|
||||
Chat; Starting/BRB/Ending keep their 16:9 placement.
|
||||
- The preview's resize routes through `MainViewModel.ResizeWebcamInVerticalTier` (the Chat camera's
|
||||
discrete size + the snap); the drag routes through `MoveWebcamInVerticalTier` (assign → clamp →
|
||||
snap, so the enlarged camera can be dragged from the top area to the bottom one).
|
||||
- *Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — **PASSES** (in Chat under the vertical
|
||||
tier, enlarging fills the full top area and the circle option is not offered).
|
||||
|
||||
### Units (each: ONE Good Dog integration test, own commit, memory in the same commit)
|
||||
1. ✅ **vv camera layout (Unit 1)** — shipped 2026-10-21. Stored separately per scene, resolved when the
|
||||
tier changes. Live: small tile (circle or rect) snapped to one of six spots; drag like the socials +
|
||||
properties entry.
|
||||
*Test:* `ytLive.Tests/VerticalWebcamLayoutTests.cs` — with the vertical tier active, dragging the Live
|
||||
camera to top-right places it top-right inside the band; switching back to 16:9 restores the original
|
||||
layout exactly; both placements survive a save + reload. **PASSES.**
|
||||
2. ✅ **vv · Chat (Unit 2)** — shipped 2026-10-21. Rect enforced with the circle option unavailable, plus
|
||||
enlarge-to-one-area (607×540) filling the top or the bottom area by where the camera is.
|
||||
*Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — in Chat under the vertical tier, enlarge sets
|
||||
the full top area and the circle option is not offered. **PASSES.**
|
||||
|
||||
## Next step
|
||||
- **✅ TASK 63 + popup-click fix verified by creator (2026-10-22)** — clicking a colour in the
|
||||
Border Color `YtComboBox` dropdown now stays inside `LeftPanel` (`IsDescendantOf` crosses
|
||||
`PopupRoot` → `PART_Popup` via `LogicalTreeHelper.GetParent`), updates the webcam border colour,
|
||||
and does not exit the properties listing.
|
||||
- **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
|
||||
resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
|
||||
the **Circle** option (TASK 59 turned the Shape button into a Rect | Circle choice) turns the tile
|
||||
into a circle there, open the **Chat** scene and confirm the **Circle** option is **gone** (rect only)
|
||||
and that dragging the tile up and enlarging it fills a whole 607×540 area, then
|
||||
switch back and confirm the desktop layout is untouched. The unit tests cover the geometry, not the
|
||||
eyeball.
|
||||
- **⏸ Distribution & packaging is DEFERRED (creator ruling 2026-10-26) — do not pick it up until
|
||||
we are close to v1.** The creator's reasons: the remaining programming tasks are far from
|
||||
release, and a **complete UX redesign is about to start** — so packaging/signing/updater/route
|
||||
decisions would chase scope that is about to move. Nothing distribution-related is urgent now.
|
||||
Frozen: TASK 48 items 6-8, the signing decision, the updater choice, and the **route contradiction**
|
||||
(`ai.md` says Store route DEAD → direct + Steam; `TASKS.md`/`TASKS/task-48-…`/this file still say
|
||||
Store MSIX + Store IAP). That contradiction is to be settled in ONE distribution sweep at the
|
||||
revisit, not patched piecemeal in the meantime. Detail: `TASKS.md` → Open items → the ⏸ TASK 48
|
||||
bullet.
|
||||
- Nothing else is queued for llamacasty — the creator queues work.
|
||||
|
||||
## Open (operational)
|
||||
- **Follow-up (not fixed — out of scope for TASK 61):** the **web source** block's geometry rows in
|
||||
`Controls/LeftPanel.xaml` (the `SelectedElement.IsWebSource` panel, ~lines 387-402) have the
|
||||
identical shape and the identical defect as the webcam's did — the Y/H fields stretch to fill the
|
||||
row. Same one-line-per-field fix as TASK 61, when the creator wants it.
|
||||
- **✅ FIXED in TASK 63:** the border-colour swatches now carry a 1px `BorderBrush="#2a3a5e"`, so
|
||||
the black (`#000000`) and dark swatches are visible against the dark menu (`#16213e`).
|
||||
- **Follow-up spotted while doing TASK 63 (not fixed — out of scope for that change):** the theme has
|
||||
**no implicit `Button` style**, so every plain `<Button>` in the app still renders with the light
|
||||
WPF system template (`Controls/LeftPanel.xaml` ×7, `Controls/CameraPickerDialog.xaml` ×2). The
|
||||
border picker's `...` button was one of them and is gone; the rest are still there. A theme-level
|
||||
implicit `Button` style would fix them all at once — that is a creator-queued task, not a silent edit.
|
||||
- **GOG Galaxy launcher shows "offline" (2026-10-21) — GOG's own overnight outage, NOT Windows.** GOG
|
||||
put `www.gog.com` behind a maintenance page (503) and their Fastly services for `auth.gog.com` /
|
||||
`api.gog.com` / `remote-config.gog.com` return `500 "Fastly error: unknown domain"`; the page itself
|
||||
says *"Galaxy Launcher will also be down."* **Window: 00:00–08:11 local on 2026-10-21** — the Wayback
|
||||
snapshot of `www.gog.com/en/` at 06:56 UTC is HTTP 200 with the full live storefront. `status.gog.com`
|
||||
is NXDOMAIN globally. Local exoneration chain: genuine `GOG sp. z o.o → DigiCert` cert (no MITM),
|
||||
local DNS == Google's authoritative answers, real Fastly POP headers, and GOG's own non-Fastly servers
|
||||
still answering. **Nothing to fix locally** — wait for GOG. Galaxy's logs (incl. the rotated ones) show
|
||||
no API-status failures between Oct 10 and Oct 20. Diagnosis recipe + the `webfetch`-is-not-an-
|
||||
independent-egress trap: `MyMistakes.md` → 2026-10-21 GOG Galaxy "offline". Galaxy 2.0 logs live in
|
||||
`C:\ProgramData\GOG.com\Galaxy\logs\` (NOT `%LOCALAPPDATA%`). ⚠️ that folder also holds a **2.7 GB
|
||||
`cef.log`** (since Jul 13) — disk hygiene, see TASK 55.
|
||||
- **verify.sh's known-failure whitelist is too loose** (confirmed twice 2026-10-16): require EVERY failure
|
||||
line to be whitelisted, and add `MasterLimiter_CapsTheLiveMix_OnThePipe` +
|
||||
`RealMouseDrag_OnTheLayerList_PersistsTheReorder` to the list.
|
||||
- **A pre-existing stash is still sitting there:** `stash@{0}` = "WIP on main: 0464593 Compositor:
|
||||
fast-path opaque 1:1 when source fills canvas" (NOT this session's; do not drop blindly).
|
||||
- The judder reproduction take in the FAILING geometry (vertical 1080p60 first) is still outstanding — no
|
||||
fix until a take names the stage.
|
||||
- ⛔ Sudo password rotation pending (user; then update both `CREDENTIALS.md` password lines).
|
||||
- ✅ **TASK 56 secret scan — DONE 2026-10-22, CLEAN.** 432 reachable commits (all branches, 35
|
||||
tags, reflog, stash) + 63 dangling commits + the worktree: **0 real secrets**. 4 rule hits, all
|
||||
false positives (`README.md:79` = the `your-client-secret` doc placeholder;
|
||||
`TokenStoreTests.cs:22` = a 9-char test dummy). `CREDENTIALS.md`/`MARCOM.md`/
|
||||
`Helpers/OAuthCredentials.cs` were never tracked. ⚠️ the repo was **already public** (anonymous
|
||||
GET → 200, API `private = false`) when the scan ran — the gate came late, but nothing leaked.
|
||||
Method + verdict: `TASKS/task-56-secret-scan.md`.
|
||||
- TASK 55 disk hygiene: slice 1 (log rotation) **shipped 2026-10-22** — see the At-a-glance bullet.
|
||||
Slices 2 (temp alert-clip cleanup) + 3 (low-disk guard before recording) are open. (The GOG
|
||||
Galaxy `cef.log` at 2.7 GB is NOT ours.)
|
||||
|
||||
## Tests
|
||||
- Last full run: **438/438 passed, 0 warnings** (2026-10-22 — after TASK 48 slice 2's
|
||||
manifest guard; the run before was 437/437 after slice 3's `StorePackagingTests`, then
|
||||
436/436 after TASK 55 slice 1's `AppLogRotationTests` and 435/435 after TASK 63). The one
|
||||
failure seen earlier in the
|
||||
session was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
|
||||
which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression.
|
||||
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
|
||||
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
|
||||
`Round_Clip_Corner…`.
|
||||
- ⚠️ **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` is NOT
|
||||
"passes in isolation" any more.** During this session it FAILED ALONE on a **clean tree** at
|
||||
`58b9a7d` (the vv work stashed) with the same "Expected: ImgC / Actual: ImgA" — i.e. it is an
|
||||
environment/real-mouse flake, **not** a regression from the vv work — and it PASSES in the
|
||||
full-suite run (429/429). Treat it as environment-dependent, and don't chase it as a regression
|
||||
without stashing first.
|
||||
|
||||
## Secrets / paths (do not re-hunt)
|
||||
- Layout DB: `%APPDATA%\ytLlive\ytLlive.db` (SQLite, full-rewrite per save). Auth:
|
||||
`%APPDATA%\ytLlive\ytLlive.auth` (DPAPI). Startup log: `%APPDATA%\ytLlive\startup.log`
|
||||
(23MB — now CAPPED at 5 MB with one rolled copy, TASK 55).
|
||||
- Recordings land in `C:\Users\gramp\Downloads\recordings\` (`ty-<date>-<time>-<ms>-<seq>.mp4`; a
|
||||
duplicate name gets a `-2` suffix).
|
||||
- **Conversation history:** `~/.local/share/opencode/opencode.db`, table **`session_message`** — creator
|
||||
turns are `type='user'` with text in `data.text`; my turns are `type='assistant'` with reasoning AND the
|
||||
rendered reply concatenated into `content[0].text`. The **reply marker's verbatim string is recorded in
|
||||
`MyMistakes.md`**.
|
||||
- LAN credentials: `CREDENTIALS.md` (gitignored) — **⚠️ stale for HA: it says `192.168.50.144`, the real
|
||||
Home Assistant is `192.168.50.145`** (`homeassistant.local`, HAOS 18.2, Core 2026.10.1, SSH
|
||||
`gramps`/`einstein`, `/config` root-owned). `gordito` is `.144`. Fix this file when next editing it.
|
||||
- Google Cloud OAuth project number `198374416003`, owned by `llamachile.shop@gmail.com` — verify at
|
||||
`https://console.cloud.google.com/apis/credentials?project=198374416003`.
|
||||
|
||||
## Open (operational) — officerFriendly / PS5 "DNS errors" (2026-10-17, off-repo)
|
||||
- **Root cause: a ~43 h-wrong system clock on officerFriendly × `dnssec = true`.** Pi 400, **no RTC
|
||||
hardware at all**, healthy oscillator (−7.39 ppm). Journal shows +54 min, +7 h, +42.9 h steps.
|
||||
`pihole-FTL.db`: **1,688,396 `dnssec=3` rows on 10-11, 839,276 on 10-12 vs ≤23 other days**, hitting
|
||||
PSN endpoints = the PS5's "DNS errors". KSK-rollover theory **retracted**.
|
||||
- **Pending creator decision (do not apply unilaterally):** tighten
|
||||
`/etc/systemd/timesyncd.conf.d/lan.conf` to `PollIntervalMinSec=16` / `PollIntervalMaxSec=64` /
|
||||
`ConnectionRetrySec=8` / `SaveIntervalSec=30`, and/or add a DS3231 RTC (`i2c-tools` already installed).
|
||||
- **LAN DNS routing vs creator intent:** DHCP still hands out `.26` + `.1` (clients query Pi-hole
|
||||
directly) whereas the creator wants everything → `.1` with BBR forwarding. Also `dnssec = true` on
|
||||
Pi-hole duplicates dnscrypt-proxy's `require_dnssec = true`. Both offered-for-veto, not done.
|
||||
- Router is the DHCP server (ASUS ROG GT-BE98 Pro `.1`) — **Pi-hole `.26` is DNS only**, its
|
||||
`dhcp.leases` is 0 bytes.
|
||||
|
||||
@@ -5,26 +5,40 @@ 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");
|
||||
/// <summary>The cap for the live log file. Past this, it rolls to <c>startup.1.log</c>.</summary>
|
||||
private const long DefaultMaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
/// <summary>How many rolled copies to keep (startup.1.log, startup.2.log, …).</summary>
|
||||
private const int RetainedFiles = 1;
|
||||
|
||||
private static readonly string DefaultLogPath = Path.Combine(
|
||||
InstanceProfile.DataRoot, "startup.log");
|
||||
|
||||
private static readonly object Gate = new();
|
||||
|
||||
/// <summary>Test seam: point the log somewhere else. Null = the real profile's log.</summary>
|
||||
internal static string? PathOverride { get; set; }
|
||||
|
||||
/// <summary>Test seam: the cap that triggers a roll.</summary>
|
||||
internal static long MaxBytes { get; set; } = DefaultMaxBytes;
|
||||
|
||||
private static string LogPath => PathOverride ?? DefaultLogPath;
|
||||
|
||||
public static void Write(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
|
||||
File.AppendAllText(LogPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
||||
var path = LogPath;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
LogRotation.RollIfNeeded(path, MaxBytes, RetainedFiles);
|
||||
File.AppendAllText(path, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>Gives a ComboBox's CLOSED box a different template from its drop-down
|
||||
/// rows (TASK 63).
|
||||
///
|
||||
/// <para>WPF asks an <see cref="ItemTemplateSelector"/> with the ContentPresenter's
|
||||
/// templated parent: the <see cref="ComboBox"/> itself when it renders the closed
|
||||
/// selection box, the <see cref="ComboBoxItem"/> when it renders a row. That is the
|
||||
/// only way to tell the two apart — <c>ComboBox.SelectionBoxItemTemplate</c> is
|
||||
/// read-only, and WPF copies <c>ItemTemplate</c> into it, so a ComboBox otherwise
|
||||
/// cannot show one thing when shut and another when open.</para>
|
||||
///
|
||||
/// <para>Verified against WPF on .NET 8 (2026-10-21): with the rows' presenter the
|
||||
/// templated parent is the ComboBoxItem, with the closed box's it is the ComboBox.</para>
|
||||
/// </summary>
|
||||
public sealed class ClosedBoxTemplateSelector : DataTemplateSelector
|
||||
{
|
||||
/// <summary>The template for the closed box — what the creator reads while the
|
||||
/// drop-down is shut.</summary>
|
||||
public DataTemplate? ClosedTemplate { get; set; }
|
||||
|
||||
/// <summary>The template for the drop-down rows.</summary>
|
||||
public DataTemplate? RowTemplate { get; set; }
|
||||
|
||||
public override DataTemplate? SelectTemplate(object item, DependencyObject container)
|
||||
=> container is FrameworkElement { TemplatedParent: ComboBox }
|
||||
? ClosedTemplate
|
||||
: RowTemplate;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public class DroppedFramesToBrushConverter : IValueConverter
|
||||
{
|
||||
private static readonly SolidColorBrush Attention = new(Color.FromRgb(0xff, 0x8c, 0x3a));
|
||||
private static readonly SolidColorBrush Clean = new(Colors.White);
|
||||
|
||||
static DroppedFramesToBrushConverter()
|
||||
{
|
||||
Attention.Freeze();
|
||||
Clean.Freeze();
|
||||
}
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is int dropped && dropped > 0 ? Attention : Clean;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
public class DroppedPercentConverter : IMultiValueConverter
|
||||
{
|
||||
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var dropped = values.Length > 0 && values[0] is int d ? d : 0;
|
||||
var fps = values.Length > 1 && values[1] is double f ? f : 0;
|
||||
var duration = values.Length > 2 && values[2] is TimeSpan t ? t : TimeSpan.Zero;
|
||||
var total = fps * duration.TotalSeconds;
|
||||
var percent = total > 0 ? dropped / total * 100 : 0;
|
||||
return $"{dropped} ({percent.ToString("0.0", CultureInfo.InvariantCulture)}%)";
|
||||
}
|
||||
|
||||
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -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,32 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>Turns a "#RRGGBB" hex string into a brush, so a template can paint a colour
|
||||
/// swatch straight from the palette (TASK 63).
|
||||
///
|
||||
/// <para>Without it the UI would have to hand-build a brush in code — the hand-painted
|
||||
/// approach the theme forbids. A colour is data; the theme paints it.</para></summary>
|
||||
public class HexToBrushConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string hex || string.IsNullOrWhiteSpace(hex))
|
||||
return Brushes.Transparent;
|
||||
try
|
||||
{
|
||||
var colour = (Color)ColorConverter.ConvertFromString(hex.Trim());
|
||||
return new SolidColorBrush(colour);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return Brushes.Transparent;
|
||||
}
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("A brush is not a hex value.");
|
||||
}
|
||||
@@ -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,63 @@
|
||||
using System.IO;
|
||||
|
||||
namespace ytLive.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Size-capped log rotation.
|
||||
/// <para>When a log file grows past a cap, it is rolled to <c><name>.1.log</c> and a
|
||||
/// fresh one is started, keeping a bounded number of rolled files. The pattern is the
|
||||
/// one every sized logger uses — Serilog's file sink pairs <c>fileSizeLimitBytes</c>
|
||||
/// (the cap) with <c>retainedFileCountLimit</c> (how many rolled files to keep):
|
||||
/// https://github.com/serilog/serilog-sinks-file</para>
|
||||
/// <para><b>Why it lives here and not in the logger:</b> <see cref="AppLog"/>'s call surface
|
||||
/// (156 call sites) must not change, and the rotation rule is worth testing on its own —
|
||||
/// so the policy is a pure function over a path, and <see cref="AppLog"/> just calls it.</para>
|
||||
/// </summary>
|
||||
public static class LogRotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Rolls <paramref name="logPath"/> if it has reached <paramref name="maxBytes"/>,
|
||||
/// keeping <paramref name="retainedFiles"/> rolled copies
|
||||
/// (<c>name.1.log</c>, <c>name.2.log</c>, …). Does nothing when the file is missing
|
||||
/// or still under the cap.
|
||||
/// <para>Never throws: a failed rotation must not take the app down — the log just
|
||||
/// keeps growing, which is the old behaviour.</para>
|
||||
/// </summary>
|
||||
public static void RollIfNeeded(string logPath, long maxBytes, int retainedFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(logPath);
|
||||
if (!info.Exists || info.Length < maxBytes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Oldest first: name.1 → name.2 → … → name.N, then the live file → name.1.
|
||||
for (var i = retainedFiles; i >= 1; i--)
|
||||
{
|
||||
var source = i == 1 ? logPath : RolledPath(logPath, i - 1);
|
||||
var target = RolledPath(logPath, i);
|
||||
if (!File.Exists(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Move(source, target, overwrite: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never let logging itself crash the app.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The rolled path for a given index: <c>startup.log</c> → <c>startup.1.log</c>.</summary>
|
||||
public static string RolledPath(string logPath, int index)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(logPath) ?? string.Empty;
|
||||
var name = Path.GetFileNameWithoutExtension(logPath);
|
||||
var extension = Path.GetExtension(logPath);
|
||||
return Path.Combine(directory, $"{name}.{index}{extension}");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -6,7 +6,8 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
||||
|------|---------|
|
||||
| `ViewModelBase.cs` | `INotifyPropertyChanged` + `SetProperty<T>()` — base for all ViewModels |
|
||||
| `RelayCommand.cs` | `ICommand` implementation for all button actions |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for) |
|
||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for). Size-capped: rolls to `startup.1.log` at 5 MB (TASK 55) |
|
||||
| `LogRotation.cs` | The size-capped-rotation policy as a pure function over a path: roll `startup.log` → `startup.1.log` when it passes the cap, keeping N rolled copies. Separate from `AppLog` so the logger's 156-call-site surface never changes and the rule is testable alone (TASK 55) |
|
||||
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
|
||||
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
|
||||
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
|
||||
@@ -14,6 +15,10 @@ 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 [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.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 [`docs/ai/frame-pipeline.md`](../docs/ai/frame-pipeline.md) → "Frame cadence" |
|
||||
| `HexToBrushConverter.cs` | A "#RRGGBB" hex string → `SolidColorBrush` (transparent on bad input), so a template can paint a colour swatch straight from the palette. Registered in the theme as `HexToBrush` — the theme paints the colour, never the UI code (TASK 63) |
|
||||
| `ClosedBoxTemplateSelector.cs` | An `ItemTemplateSelector` that gives a ComboBox's CLOSED box a different template from its drop-down rows: WPF calls it with the `ContentPresenter`'s templated parent — the `ComboBox` for the closed box, the `ComboBoxItem` for a row. The only way to tell them apart (`SelectionBoxItemTemplate` is read-only and mirrors `ItemTemplate`) — TASK 63 |
|
||||
|
||||
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>.
|
||||
@@ -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,21 +12,29 @@ 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()
|
||||
{
|
||||
AppLog.Write("MainWindow ctor: before InitializeComponent");
|
||||
InitializeComponent();
|
||||
SizeChanged += (_, e) => Helpers.AppLog.Write($"WIDTHS window={e.NewSize.Width:F0} "
|
||||
+ $"layers={LeftPanel.ActualWidth:F0} strip={SceneStrip.ActualWidth:F0} preview={PreviewPane.ActualWidth:F0}");
|
||||
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 +42,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 +144,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 +169,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;
|
||||
var ypp = _viewModel.Ypp;
|
||||
var test = _viewModel.TestSession;
|
||||
if (!_previewPane.IsClickInsideDrawer(original))
|
||||
{
|
||||
if (form.IsDrawerOpen) form.CloseDrawerCommand.Execute(null);
|
||||
if (ypp.IsDrawerOpen) ypp.CloseDrawerCommand.Execute(null);
|
||||
if (test.IsDrawerOpen) test.CloseDrawerCommand.Execute(null);
|
||||
}
|
||||
if (_previewPane.IsClickInsidePreview(original)) return;
|
||||
if (_leftPanel.IsClickInside(original)) return;
|
||||
_viewModel.SelectedElement = null;
|
||||
}
|
||||
|
||||
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
||||
{
|
||||
while (child != null && !ReferenceEquals(child, ancestor))
|
||||
{
|
||||
child = child is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(child)
|
||||
: LogicalTreeHelper.GetParent(child);
|
||||
}
|
||||
return child != 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,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>A colour the border picker offers: its hex value and the plain-English
|
||||
/// name shown next to it (TASK 60).</summary>
|
||||
/// <param name="Hex">The hex value, e.g. <c>#e94560</c>.</param>
|
||||
/// <param name="Name">The name the creator reads, e.g. <c>Crimson</c>.</param>
|
||||
public readonly record struct NamedColor(string Hex, string Name)
|
||||
{
|
||||
/// <summary>"Crimson (#e94560)" — the tooltip the border drop-down shows.</summary>
|
||||
public override string ToString()
|
||||
=> string.IsNullOrEmpty(Name) ? Hex : $"{Name} ({Hex})";
|
||||
}
|
||||
|
||||
/// <summary>The colours the webcam's border-colour drop-down offers — the SINGLE
|
||||
/// source of truth for the list and for the names.
|
||||
///
|
||||
/// <para>TASK 60 (the creator, 2026-10-21): *"in the drop down box, the #hex values are
|
||||
/// good, but add a corresponding color name/label next to the value."* The list used to
|
||||
/// be a hard-coded array in the click handler, which is why a name could not be added
|
||||
/// to it without touching the UI code.</para>
|
||||
/// </summary>
|
||||
public static class BorderColorPalette
|
||||
{
|
||||
/// <summary>The offered colours, in menu order.</summary>
|
||||
public static IReadOnlyList<NamedColor> All { get; } = new NamedColor[]
|
||||
{
|
||||
new("#FFFFFF", "White"),
|
||||
new("#000000", "Black"),
|
||||
new("#FF0000", "Red"),
|
||||
new("#00FF00", "Lime"),
|
||||
new("#0000FF", "Blue"),
|
||||
new("#FFFF00", "Yellow"),
|
||||
new("#FF00FF", "Magenta"),
|
||||
new("#00FFFF", "Cyan"),
|
||||
new("#FF8800", "Orange"),
|
||||
new("#8800FF", "Violet"),
|
||||
new("#e94560", "Crimson"),
|
||||
new("#22c55e", "Emerald"),
|
||||
new("#3b82f6", "Azure"),
|
||||
new("#f59e0b", "Amber"),
|
||||
};
|
||||
|
||||
/// <summary>The colour's name, or an **empty string** when the palette has no name
|
||||
/// for that hex (a custom hex the creator typed) — the caller decides what to show
|
||||
/// then, and the border field shows the hex alone.</summary>
|
||||
public static string NameOf(string hex) => EntryFor(hex).Name;
|
||||
|
||||
/// <summary>The palette entry for a hex value — the palette's OWN entry (so a
|
||||
/// drop-down bound to it matches by value), or a nameless entry for a custom hex
|
||||
/// the creator typed, so the drop-down always has a selection (TASK 63).</summary>
|
||||
public static NamedColor EntryFor(string hex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hex)) return new NamedColor(string.Empty, string.Empty);
|
||||
var trimmed = hex.Trim();
|
||||
foreach (var colour in All)
|
||||
if (string.Equals(colour.Hex, trimmed, StringComparison.OrdinalIgnoreCase))
|
||||
return colour;
|
||||
return new NamedColor(trimmed, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>The colours a border drop-down offers for a given hex — the palette, plus
|
||||
/// the custom colour the creator typed when it is not one of the palette's, so the
|
||||
/// drop-down always has a selection (TASK 63).</summary>
|
||||
public static IReadOnlyList<NamedColor> OptionsFor(string hex)
|
||||
{
|
||||
var entry = EntryFor(hex);
|
||||
var options = new List<NamedColor>(All);
|
||||
if (!string.IsNullOrEmpty(entry.Hex) && !All.Contains(entry))
|
||||
options.Add(entry);
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ 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 IsTextOverlay => false;
|
||||
public virtual bool IsMediaSource => false;
|
||||
public virtual bool IsLiveCapture => false;
|
||||
public virtual bool IsBackground { get; set; }
|
||||
|
||||
private bool _useDefaultBackground = true;
|
||||
@@ -88,13 +114,45 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
|
||||
|
||||
private ClipShape _clipShape = ClipShape.Traditional;
|
||||
public ClipShape ClipShape
|
||||
// Virtual so a derived element can CONSTRAIN the shape (the webcam is
|
||||
// rect-only in the vertical tier's Chat layout — TASK 57) instead of every
|
||||
// writer re-implementing the rule.
|
||||
public virtual ClipShape ClipShape
|
||||
{
|
||||
get => _clipShape;
|
||||
set
|
||||
{
|
||||
if (Set(ref _clipShape, value))
|
||||
Raise(nameof(ShapeButtonText));
|
||||
{
|
||||
Raise(nameof(IsRectShape));
|
||||
Raise(nameof(IsRoundShape));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the element's clip is the plain rectangle — the shape a
|
||||
/// camera has when none was ever picked (TASK 59: the properties panel shows
|
||||
/// the shape as a CHOICE, and Rect is the selected default).</summary>
|
||||
public bool IsRectShape
|
||||
{
|
||||
get => ClipShape != ClipShape.Round;
|
||||
set
|
||||
{
|
||||
// Round -> rect (restores the pre-round rect, like the toggle does).
|
||||
if (value && ClipShape == ClipShape.Round) ToggleClipShape();
|
||||
Raise(nameof(IsRectShape));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when the element's clip is a circle (TASK 59).</summary>
|
||||
public bool IsRoundShape
|
||||
{
|
||||
get => ClipShape == ClipShape.Round;
|
||||
set
|
||||
{
|
||||
// Rect -> round (stores the pre-round rect, like the toggle does).
|
||||
if (value && ClipShape != ClipShape.Round) ToggleClipShape();
|
||||
Raise(nameof(IsRoundShape));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +165,7 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
|
||||
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
|
||||
|
||||
public void ToggleClipShape()
|
||||
public virtual void ToggleClipShape()
|
||||
{
|
||||
if (ClipShape == ClipShape.Traditional)
|
||||
{
|
||||
@@ -144,7 +202,6 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
|
||||
public double MirrorScale => IsMirrored ? -1 : 1;
|
||||
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
||||
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
||||
|
||||
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
|
||||
private WriteableBitmap? _videoImageSource;
|
||||
@@ -161,15 +218,18 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
|
||||
public abstract ImageSource? DisplaySource { get; }
|
||||
|
||||
// Position/transform (per-scene usage)
|
||||
// Position/transform (per-scene usage). The geometry setters are virtual so
|
||||
// a derived element can CONSTRAIN them (the webcam clamps itself into the
|
||||
// vertical tier's band — TASK 57) instead of every writer re-implementing
|
||||
// the rule.
|
||||
private double _x;
|
||||
public double X { get => _x; set => Set(ref _x, value); }
|
||||
public virtual double X { get => _x; set => Set(ref _x, value); }
|
||||
|
||||
private double _y;
|
||||
public double Y { get => _y; set => Set(ref _y, value); }
|
||||
public virtual double Y { get => _y; set => Set(ref _y, value); }
|
||||
|
||||
private double _width;
|
||||
public double Width
|
||||
public virtual double Width
|
||||
{
|
||||
get => _width;
|
||||
set
|
||||
@@ -180,7 +240,7 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
}
|
||||
|
||||
private double _height;
|
||||
public double Height
|
||||
public virtual double Height
|
||||
{
|
||||
get => _height;
|
||||
set
|
||||
@@ -207,10 +267,26 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
{
|
||||
Raise(nameof(HasBorder));
|
||||
Raise(nameof(BorderBrush));
|
||||
Raise(nameof(BorderColorEntry));
|
||||
Raise(nameof(BorderColorOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The border colour as the drop-down sees it: the palette's entry for the
|
||||
/// current hex — with its NAME — or a nameless entry for a custom hex the creator
|
||||
/// typed. Setting it writes the hex back (TASK 63).</summary>
|
||||
public NamedColor BorderColorEntry
|
||||
{
|
||||
get => BorderColorPalette.EntryFor(BorderColor);
|
||||
set => BorderColor = value.Hex;
|
||||
}
|
||||
|
||||
/// <summary>The colours the border drop-down offers — the palette, plus the custom
|
||||
/// colour the creator typed (so the drop-down always has a selection) (TASK 63).</summary>
|
||||
public IReadOnlyList<NamedColor> BorderColorOptions
|
||||
=> BorderColorPalette.OptionsFor(BorderColor);
|
||||
|
||||
private double _borderOpacity = 1.0;
|
||||
public double BorderOpacity
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -57,13 +73,20 @@ public class Source : SceneElement
|
||||
Raise(nameof(IsLiveCapture));
|
||||
Raise(nameof(DisplaySource));
|
||||
Raise(nameof(IsWebSource));
|
||||
Raise(nameof(IsTextOverlay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
|
||||
|
||||
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
|
||||
private bool _isBackground;
|
||||
@@ -89,7 +112,7 @@ public class Source : SceneElement
|
||||
private string? _captureKey;
|
||||
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
|
||||
|
||||
public bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
||||
public override bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
||||
|
||||
// Image (asset stored in the layout database)
|
||||
private string? _assetId;
|
||||
@@ -113,7 +136,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;
|
||||
|
||||
@@ -169,7 +192,55 @@ public class Source : SceneElement
|
||||
set => Set(ref _mediaPlaybackState, value);
|
||||
}
|
||||
|
||||
public bool IsMediaSource => Type == SourceType.MediaSource;
|
||||
public override 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;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The vertical (9:16) tier's camera rules — TASK 57, the "vv" feature.
|
||||
///
|
||||
/// The vertical tier is a centred band crop of the SAME 1920×1080 master the
|
||||
/// 16:9 tiers use, so a camera placed for 16:9 lands outside the cropped
|
||||
/// window and vanishes from the vertical output. The vertical tier therefore
|
||||
/// keeps its OWN placement per scene: a small tile inside the band, snapped to
|
||||
/// one of six spots — the band splits into a top and a bottom half, each with
|
||||
/// a left, a centre and a right slot.
|
||||
///
|
||||
/// Geometry only: the preview and the compositor both read the element's own
|
||||
/// X/Y/W/H + ClipShape, so the placement is resolved by swapping those values
|
||||
/// when the tier changes. No render-path change.
|
||||
/// </summary>
|
||||
public static class VerticalCameraLayout
|
||||
{
|
||||
/// <summary>The vertical tier's camera tile over the master frame —
|
||||
/// 270×152 master px = 480×270 in the 1080×1920 output.</summary>
|
||||
public const double TileWidth = 270;
|
||||
public const double TileHeight = 152;
|
||||
|
||||
/// <summary>How far the tile keeps from the band's outer edge — the app's
|
||||
/// own placement margin (32 master px) used by the default webcam spot.</summary>
|
||||
public const double Inset = 32;
|
||||
|
||||
/// <summary>True for the portrait tier (the vertical 1080×1920 one).</summary>
|
||||
public static bool IsVerticalTier(QualityOption quality) => quality.Height > quality.Width;
|
||||
|
||||
/// <summary>The half of the band a tile lives in — top or bottom.</summary>
|
||||
public static Rect AreaFor(Rect band, bool bottom)
|
||||
=> new(band.Left, bottom ? band.Top + band.Height / 2 : band.Top, band.Width, band.Height / 2);
|
||||
|
||||
/// <summary>The largest a camera may be inside the band: one whole area.</summary>
|
||||
public static (double Width, double Height) MaxSizeFor(Rect band)
|
||||
=> (band.Width, band.Height / 2);
|
||||
|
||||
/// <summary>True when a camera is wide enough to fill one whole area of the
|
||||
/// band — the "enlarged" Chat layout (one 607×540 area).</summary>
|
||||
public static bool FillsArea(Rect band, double width) => width >= band.Width - 1;
|
||||
|
||||
/// <summary>The two sizes a camera may take in the vertical tier's Chat
|
||||
/// layout: the small tile (normal) or one whole area (enlarged). Returns
|
||||
/// the one nearer the requested width, so the resize handle snaps between
|
||||
/// them instead of taking any size in between.</summary>
|
||||
public static (double Width, double Height) SizeFor(Rect band, double requestedWidth)
|
||||
{
|
||||
var (areaWidth, areaHeight) = MaxSizeFor(band);
|
||||
return Math.Abs(requestedWidth - TileWidth) <= Math.Abs(requestedWidth - areaWidth)
|
||||
? (TileWidth, TileHeight)
|
||||
: (areaWidth, areaHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The spot a tile snaps to: the area (top/bottom) and the column
|
||||
/// (left/centre/right) its centre falls in, then the tile's top-left for
|
||||
/// that spot — inset from the band's outer edge, and never outside the band
|
||||
/// (a tile wider than the inset eats it).
|
||||
/// </summary>
|
||||
public static (double X, double Y) SpotFor(Rect band, double width, double height, double centreX, double centreY)
|
||||
{
|
||||
var bottom = centreY >= band.Top + band.Height / 2;
|
||||
var area = AreaFor(band, bottom);
|
||||
var third = area.Width / 3;
|
||||
|
||||
double x;
|
||||
if (centreX < area.Left + third) x = area.Left + Inset;
|
||||
else if (centreX < area.Left + 2 * third) x = area.Left + (area.Width - width) / 2;
|
||||
else x = area.Right - Inset - width;
|
||||
|
||||
var y = bottom ? area.Bottom - Inset - height : area.Top + Inset;
|
||||
|
||||
x = Math.Clamp(x, band.Left, Math.Max(band.Left, band.Right - width));
|
||||
y = Math.Clamp(y, band.Top, Math.Max(band.Top, band.Bottom - height));
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
/// <summary>The default tile spot: the bottom-right corner of the band —
|
||||
/// the same corner the 16:9 default webcam placement uses.</summary>
|
||||
public static (double X, double Y) DefaultSpot(Rect band, double width, double height)
|
||||
=> SpotFor(band, width, height, band.Right, band.Bottom);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ytLive.Models;
|
||||
@@ -13,5 +14,223 @@ 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;
|
||||
}
|
||||
|
||||
// ─── Vertical (9:16) tier placement (TASK 57, the "vv" feature) ────────
|
||||
// The vertical tier is a centred band crop of the same master frame, so the
|
||||
// 16:9 placement lands outside the cropped window and the camera vanishes
|
||||
// from the vertical output. The vertical tier therefore keeps its OWN
|
||||
// position, size and shape per scene, stored here and resolved when the
|
||||
// tier changes; switching back to 16:9 restores the desktop layout exactly.
|
||||
|
||||
/// <summary>The vertical placement's X (null until the scene has one).</summary>
|
||||
public double? VerticalX { get; set; }
|
||||
|
||||
/// <summary>The vertical placement's Y (null until the scene has one).</summary>
|
||||
public double? VerticalY { get; set; }
|
||||
|
||||
/// <summary>The vertical placement's width (null until the scene has one).</summary>
|
||||
public double? VerticalWidth { get; set; }
|
||||
|
||||
/// <summary>The vertical placement's height (null until the scene has one).</summary>
|
||||
public double? VerticalHeight { get; set; }
|
||||
|
||||
/// <summary>The vertical placement's clip shape (circle or rect) — the
|
||||
/// creator's own choice per scene, independent of the 16:9 shape.</summary>
|
||||
public ClipShape? VerticalClipShape { get; set; }
|
||||
|
||||
/// <summary>The vertical placement's pre-Round rect (so a Round tile
|
||||
/// restores its aspect on toggle-back, like the 16:9 one does).</summary>
|
||||
public double? VerticalRectWidth { get; set; }
|
||||
|
||||
/// <inheritdoc cref="VerticalRectWidth"/>
|
||||
public double? VerticalRectHeight { get; set; }
|
||||
|
||||
/// <summary>True once this scene has a vertical placement of its own.</summary>
|
||||
public bool HasVerticalLayout => VerticalX is not null;
|
||||
|
||||
/// <summary>True while the vertical placement is the LIVE geometry (the
|
||||
/// element's own X/Y/W/H). The 16:9 layout is parked in
|
||||
/// <see cref="LandscapeX"/> and friends until the tier switches back.</summary>
|
||||
public bool VerticalLayoutLive { get; private set; }
|
||||
|
||||
/// <summary>True while the vertical placement must stay RECT — the Chat
|
||||
/// scene's vertical layout (the creator's table: "vv · Chat = rect only, no
|
||||
/// circle option"). The circle is not offered there, so the shape cannot
|
||||
/// become Round by any writer.</summary>
|
||||
public bool VerticalShapeLocked { get; internal set; }
|
||||
|
||||
/// <summary>The vertical tier's band over the master frame (transient — the
|
||||
/// VM sets it when the tier changes). While the vertical placement is live
|
||||
/// the camera's geometry is clamped to it: outside the band is invisible.</summary>
|
||||
public Rect VerticalBand { get; set; } = Rect.Empty;
|
||||
|
||||
// The parked 16:9 layout — meaningful only while the vertical placement is
|
||||
// live (it is the 16:9 geometry the tier switch restores).
|
||||
public double LandscapeX { get; private set; }
|
||||
public double LandscapeY { get; private set; }
|
||||
public double LandscapeWidth { get; private set; }
|
||||
public double LandscapeHeight { get; private set; }
|
||||
public ClipShape LandscapeClipShape { get; private set; }
|
||||
public double? LandscapeRectWidth { get; private set; }
|
||||
public double? LandscapeRectHeight { get; private set; }
|
||||
|
||||
/// <summary>Parks the live (16:9) layout and brings a vertical placement
|
||||
/// in. The vertical placement becomes the live geometry from here on.</summary>
|
||||
public void BeginVerticalLayout(double x, double y, double width, double height, ClipShape clipShape)
|
||||
{
|
||||
LandscapeX = X;
|
||||
LandscapeY = Y;
|
||||
LandscapeWidth = Width;
|
||||
LandscapeHeight = Height;
|
||||
LandscapeClipShape = ClipShape;
|
||||
LandscapeRectWidth = RectWidth;
|
||||
LandscapeRectHeight = RectHeight;
|
||||
|
||||
VerticalLayoutLive = true;
|
||||
Width = width;
|
||||
Height = height;
|
||||
X = x;
|
||||
Y = y;
|
||||
ClipShape = clipShape;
|
||||
RectWidth = VerticalRectWidth;
|
||||
RectHeight = VerticalRectHeight;
|
||||
}
|
||||
|
||||
/// <summary>Restores the parked 16:9 layout — the tier switched back.</summary>
|
||||
public void EndVerticalLayout()
|
||||
{
|
||||
if (!VerticalLayoutLive) return;
|
||||
|
||||
// The vertical placement is the creator's own — keep it, so switching
|
||||
// back and forth does not lose where they put it.
|
||||
CommitLiveLayoutToVertical();
|
||||
|
||||
VerticalLayoutLive = false;
|
||||
Width = LandscapeWidth;
|
||||
Height = LandscapeHeight;
|
||||
X = LandscapeX;
|
||||
Y = LandscapeY;
|
||||
ClipShape = LandscapeClipShape;
|
||||
RectWidth = LandscapeRectWidth;
|
||||
RectHeight = LandscapeRectHeight;
|
||||
}
|
||||
|
||||
/// <summary>Copies the live geometry into the stored vertical placement, so
|
||||
/// the stored copy never lags the live one (the save path calls this).</summary>
|
||||
public void CommitLiveLayoutToVertical()
|
||||
{
|
||||
VerticalX = X;
|
||||
VerticalY = Y;
|
||||
VerticalWidth = Width;
|
||||
VerticalHeight = Height;
|
||||
VerticalClipShape = ClipShape;
|
||||
VerticalRectWidth = RectWidth;
|
||||
VerticalRectHeight = RectHeight;
|
||||
}
|
||||
|
||||
// The Chat scene's vertical layout is rect-only (the creator's table, the
|
||||
// circle option is not offered there). Locking the SHAPE property — not
|
||||
// just the toggle — means no writer can slip a Round camera in: the
|
||||
// toggle, the shape button and the placement swap all go through here.
|
||||
public override ClipShape ClipShape
|
||||
{
|
||||
get => base.ClipShape;
|
||||
set => base.ClipShape = VerticalShapeLocked ? ClipShape.Traditional : value;
|
||||
}
|
||||
|
||||
public override void ToggleClipShape()
|
||||
{
|
||||
if (VerticalShapeLocked)
|
||||
{
|
||||
ClipShape = ClipShape.Traditional;
|
||||
return;
|
||||
}
|
||||
base.ToggleClipShape();
|
||||
}
|
||||
|
||||
// The geometry is clamped into the band while the vertical placement is
|
||||
// live — a camera outside the cropped window is a camera the viewers never
|
||||
// see, and every writer (drag, resize handle, the properties fields) goes
|
||||
// through these setters.
|
||||
public override double X
|
||||
{
|
||||
get => base.X;
|
||||
set => base.X = ClampToBand(value, base.Width, vertical: true);
|
||||
}
|
||||
|
||||
public override double Y
|
||||
{
|
||||
get => base.Y;
|
||||
set => base.Y = ClampToBand(value, base.Height, vertical: false);
|
||||
}
|
||||
|
||||
public override double Width
|
||||
{
|
||||
get => base.Width;
|
||||
set => base.Width = VerticalLayoutLive && !VerticalBand.IsEmpty
|
||||
? Math.Min(value, VerticalBand.Width)
|
||||
: value;
|
||||
}
|
||||
|
||||
public override double Height
|
||||
{
|
||||
get => base.Height;
|
||||
set => base.Height = VerticalLayoutLive && !VerticalBand.IsEmpty
|
||||
? Math.Min(value, VerticalBand.Height / 2)
|
||||
: value;
|
||||
}
|
||||
|
||||
private double ClampToBand(double value, double size, bool vertical)
|
||||
{
|
||||
if (!VerticalLayoutLive || VerticalBand.IsEmpty) return value;
|
||||
var band = VerticalBand;
|
||||
var min = vertical ? band.Left : band.Top;
|
||||
var max = vertical ? band.Right - size : band.Bottom - size;
|
||||
if (max < min) return min;
|
||||
return Math.Clamp(value, min, max);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,14 +7,18 @@ 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`, 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); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only); **TASK 59:** `IsRectShape`/`IsRoundShape` — the shape as a CHOICE (the properties panel shows Rect | Circle with Rect selected by default), each a settable bool whose setter flips the shape through `ToggleClipShape()` and always re-raises, so the UI snaps back when a write is refused; **TASK 63:** `BorderColorEntry` (the palette entry for the current border colour — hex + NAME, a nameless entry for a custom hex; setting it writes the hex back) and `BorderColorOptions` (the palette + that entry), both raised when `BorderColor` changes — the drop-down binds to them |
|
||||
| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
|
||||
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||
| `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` |
|
||||
| `BorderColorPalette.cs` | **TASK 60:** the colours the webcam's border-colour drop-down offers — `NamedColor(Hex, Name)` + `BorderColorPalette.All` (the single source of truth; the drop-down is bound to it) and `NameOf(hex)` (case-insensitive; **empty** for a colour outside the palette — "no name" is the honest answer, TASK 62). The creator asked for a colour NAME next to each hex: `#e94560 Crimson`. **TASK 63:** `EntryFor(hex)` (the palette entry for a hex, case-insensitively; a nameless entry for a colour outside the palette) and `OptionsFor(hex)` (the palette + that entry) — the two the drop-down binds to; `NamedColor.ToString()` is `"Crimson (#e94560)"` |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||
| `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;
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
LlamaCasty — Microsoft Store MSIX manifest (TASK 48).
|
||||
|
||||
Full trust is the whole point of this file: the app records to the user's profile,
|
||||
launches the bundled ffmpeg as a CHILD PROCESS, opens an ordinary Win32 folder picker,
|
||||
and drives WebView2. None of that works in an AppContainer, so this is a
|
||||
packagedClassicApp at mediumIL with the runFullTrust restricted capability.
|
||||
|
||||
⛔ Do NOT change this to appContainer: recording silently starts writing to a
|
||||
per-package virtualised folder and the output "vanishes" (TASK 48 item 6).
|
||||
|
||||
⚠️ Before submission, replace Publisher with the exact Publisher value Partner Center
|
||||
shows for the account, and PublisherDisplayName with its display name — a mismatch is
|
||||
a certification rejection. The version is bumped per submission.
|
||||
-->
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
|
||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||
IgnorableNamespaces="uap uap10 rescap">
|
||||
|
||||
<Identity
|
||||
Name="LlamaCasty"
|
||||
Publisher="CN=LlamaCasty"
|
||||
Version="1.0.0.0"
|
||||
ProcessorArchitecture="x64" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>LlamaCasty</DisplayName>
|
||||
<PublisherDisplayName>LlamaCasty</PublisherDisplayName>
|
||||
<Description>Live streaming to YouTube: scenes, sources, a webcam with background effects, alerts, chat and recording.</Description>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily
|
||||
Name="Windows.Desktop"
|
||||
MinVersion="10.0.19041.0"
|
||||
MaxVersionTested="10.0.26100.0" />
|
||||
</Dependencies>
|
||||
|
||||
<!-- English only (Store policy 10.7): a second language here means the listing
|
||||
description must be localised into it too. -->
|
||||
<Resources>
|
||||
<Resource Language="en-US" />
|
||||
</Resources>
|
||||
|
||||
<Applications>
|
||||
<Application
|
||||
Id="LlamaCasty"
|
||||
Executable="ytLive.exe"
|
||||
EntryPoint="Windows.FullTrustApplication"
|
||||
uap10:TrustLevel="mediumIL"
|
||||
uap10:RuntimeBehavior="packagedClassicApp">
|
||||
<uap:VisualElements
|
||||
DisplayName="LlamaCasty"
|
||||
Description="Live streaming to YouTube: scenes, sources, a webcam with background effects, alerts, chat and recording."
|
||||
BackgroundColor="#171928"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<!-- The wide and 310 tiles live on DefaultTile, not on VisualElements — the
|
||||
Windows 10 uap:VisualElements schema has no such attributes. -->
|
||||
<uap:DefaultTile
|
||||
Wide310x150Logo="Assets\Wide310x150Logo.png"
|
||||
Square310x310Logo="Assets\Square310x310Logo.png" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<DeviceCapability Name="webcam" />
|
||||
<DeviceCapability Name="microphone" />
|
||||
</Capabilities>
|
||||
|
||||
</Package>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +383,19 @@ public sealed class AudioMixer : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 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;
|
||||
if (delay > TimeSpan.Zero)
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// Per-stage composite timing (webcam-cadence unit, 2026-10-16): wall time per render
|
||||
/// pass, split into named stages — the backdrop, every element (keyed by z-index +
|
||||
/// name), the global overlays, and the final output scale. The pump owns one instance
|
||||
/// for the whole session, passes it into <see cref="SceneCompositor.Render"/> /
|
||||
/// <c>CompositeLayers</c> / <c>BakeStaticBase</c>, and logs an averaged breakdown every
|
||||
/// stats window — the "which of the 6 elements eats ~140ms" reading the old stats line
|
||||
/// could not give (it reported render/acquire/resolve, never the culprit layer).
|
||||
/// <para>Stage names are stable and greppable: <c>backdrop</c>, <c>base</c>,
|
||||
/// <c>flash</c>, <c>social</c>, <c>ticker</c>, <c>zoom</c>, <c>scale</c>,
|
||||
/// <c>bake backdrop</c>, <c>bake overlays</c>, and <c>#<index> <name></c> per
|
||||
/// element (bake-pass elements are prefixed <c>bake</c> so a one-off rebake cannot
|
||||
/// masquerade as a per-composite cost). A <c>#<i> <name> raster</c> sub-stage NESTS inside its parent's total
|
||||
/// (it is the paste-cache rasterize miss — the prime suspect when one element costs a
|
||||
/// full resample every tick). Stage time INCLUDES that stage's frame resolve; resolve
|
||||
/// is separately totalled in the stats line.</para>
|
||||
/// <para>Single writer in production (the pump's render thread — every blit joins its
|
||||
/// <see cref="StripPool"/> workers before returning); the lock only makes a test able
|
||||
/// to share one across threads.</para>
|
||||
/// </summary>
|
||||
public sealed class CompositeTimings
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, long> _stages = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Accumulate one stage sample. Non-positive ticks are dropped — a
|
||||
/// skipped layer must not create a zero-cost entry that dilutes the reading.</summary>
|
||||
public void Add(string stage, long ticks)
|
||||
{
|
||||
if (ticks <= 0 || stage.Length == 0) return;
|
||||
lock (_gate)
|
||||
{
|
||||
_stages.TryGetValue(stage, out var cur);
|
||||
_stages[stage] = cur + ticks;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clear every stage — called once per stats window after the parts line
|
||||
/// is logged, so each window reports its own breakdown.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
lock (_gate) _stages.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Average milliseconds per render for every stage at or above
|
||||
/// <paramref name="minMs"/>, sorted slowest-first:
|
||||
/// <c>"#2 Webcam 88.1ms, backdrop 61.2ms, #2 Webcam raster 84.0ms"</c>.
|
||||
/// <paramref name="renders"/> is the divisor (the window's render count, so the
|
||||
/// numbers compare directly with the stats line's <c>avg render</c>). The floor
|
||||
/// keeps a healthy pipeline from burying the contender in zeroes; the empty string
|
||||
/// means nothing was composited (the fully-static shortcut and C4 cache hits do no
|
||||
/// per-element work — an empty parts line there is the correct reading).</summary>
|
||||
public string Describe(int renders, double minMs = 0.1)
|
||||
{
|
||||
List<KeyValuePair<string, long>> snap;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_stages.Count == 0) return string.Empty;
|
||||
snap = _stages.ToList();
|
||||
}
|
||||
|
||||
var freq = (double)Stopwatch.Frequency;
|
||||
var parts = snap
|
||||
.Select(kv => (Stage: kv.Key, Ms: kv.Value / freq * 1000.0 / System.Math.Max(1, renders)))
|
||||
.Where(p => p.Ms >= minMs)
|
||||
.OrderByDescending(p => p.Ms)
|
||||
.Select(p => $"{p.Stage} {p.Ms:F1}ms");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ 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,
|
||||
CompositeTimings? timings = null)
|
||||
{
|
||||
if (scene == null) throw new ArgumentNullException(nameof(scene));
|
||||
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
|
||||
@@ -39,14 +67,84 @@ 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];
|
||||
|
||||
// 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));
|
||||
var tBase = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
timings?.Add("base", System.Diagnostics.Stopwatch.GetTimestamp() - tBase);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (split < 0) split = scene.Elements.Count;
|
||||
|
||||
var tBackdrop = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
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);
|
||||
|
||||
@@ -55,40 +153,345 @@ public sealed class SceneCompositor
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
timings?.Add("backdrop", System.Diagnostics.Stopwatch.GetTimestamp() - tBackdrop);
|
||||
|
||||
for (var ei = 0; ei < elements.Count; ei++)
|
||||
{
|
||||
var element = elements[ei];
|
||||
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 fullFrame = frameFor(element);
|
||||
if (fullFrame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame, timings, $"#{ei} {element.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, frame, timings, $"#{i} {element.Name}");
|
||||
}
|
||||
|
||||
if (flashFrame != null)
|
||||
{
|
||||
var tFlash = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
|
||||
timings?.Add("flash", System.Diagnostics.Stopwatch.GetTimestamp() - tFlash);
|
||||
}
|
||||
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
{
|
||||
var tSocial = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
|
||||
timings?.Add("social", System.Diagnostics.Stopwatch.GetTimestamp() - tSocial);
|
||||
}
|
||||
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
{
|
||||
var tTicker = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
timings?.Add("ticker", System.Diagnostics.Stopwatch.GetTimestamp() - tTicker);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
var tZoom = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
timings?.Add("zoom", System.Diagnostics.Stopwatch.GetTimestamp() - tZoom);
|
||||
}
|
||||
|
||||
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var scaled = StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/// <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,
|
||||
CompositeTimings? timings = null)
|
||||
{
|
||||
if (split <= 0) return null;
|
||||
var opts = options();
|
||||
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
|
||||
|
||||
var tBake = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
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);
|
||||
}
|
||||
|
||||
timings?.Add("bake backdrop", System.Diagnostics.Stopwatch.GetTimestamp() - tBake);
|
||||
|
||||
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, timings, $"bake #{i} {element.Name}");
|
||||
}
|
||||
|
||||
var baked = new VideoFrame(cropW, cropH, buffer);
|
||||
if (flashFrame != null || (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0))
|
||||
{
|
||||
var tOverlays = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
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);
|
||||
timings?.Add("bake overlays", System.Diagnostics.Stopwatch.GetTimestamp() - tOverlays);
|
||||
}
|
||||
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,
|
||||
CompositeTimings? timings = 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];
|
||||
var tBase = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, baseFrame.BgraPixels.Length);
|
||||
timings?.Add("base", System.Diagnostics.Stopwatch.GetTimestamp() - tBase);
|
||||
|
||||
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, timings, $"#{i} {element.Name}");
|
||||
}
|
||||
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
{
|
||||
var tTicker = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
timings?.Add("ticker", System.Diagnostics.Stopwatch.GetTimestamp() - tTicker);
|
||||
}
|
||||
|
||||
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
|
||||
if (zoom != null)
|
||||
{
|
||||
var tZoom = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
timings?.Add("zoom", System.Diagnostics.Stopwatch.GetTimestamp() - tZoom);
|
||||
}
|
||||
|
||||
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var scaled = StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
|
||||
return scaled;
|
||||
}
|
||||
|
||||
private static void BlitBakedElement(
|
||||
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame,
|
||||
CompositeTimings? timings, string stage)
|
||||
{
|
||||
var tElement = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
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);
|
||||
// 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, timings, stage);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
timings?.Add(stage, System.Diagnostics.Stopwatch.GetTimestamp() - tElement);
|
||||
}
|
||||
|
||||
if (flashFrame != null)
|
||||
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
|
||||
/// <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,
|
||||
CompositeTimings? timings, string stage)
|
||||
{
|
||||
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;
|
||||
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
|
||||
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);
|
||||
}
|
||||
|
||||
return StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
if (raster == null)
|
||||
{
|
||||
var tRaster = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
// 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;
|
||||
}
|
||||
timings?.Add(stage + " raster", System.Diagnostics.Stopwatch.GetTimestamp() - tRaster);
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -131,26 +534,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 = 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 px = x - ex; // element space
|
||||
var py = y - ey;
|
||||
if (px < ox || px > ox + drawnW || py < oy || py > oy + drawnH) continue;
|
||||
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 +679,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 +886,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 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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
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,6 +16,10 @@ public static class StaticPixelCache
|
||||
public static VideoFrame? Get(string assetId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetId)) return null;
|
||||
// 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);
|
||||
@@ -25,6 +29,7 @@ public static class StaticPixelCache
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -29,15 +29,98 @@ 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();
|
||||
|
||||
/// <summary>Per-stage composite timing for the CURRENT stats window (webcam-cadence
|
||||
/// unit, 2026-10-16): the compositor records each stage into this collector and
|
||||
/// <see cref="ReportStats"/> logs the averaged breakdown, then clears it — the
|
||||
/// "which of the 6 elements eats ~140ms" reading (see the parts line).</summary>
|
||||
private readonly CompositeTimings _timings = 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 +137,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 +150,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 +199,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 +312,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 +321,136 @@ 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 : ""));
|
||||
|
||||
// Per-stage breakdown (webcam-cadence unit, 2026-10-16): the stats line above
|
||||
// says WHAT a composite cost; this says WHERE it went — one entry per stage,
|
||||
// slowest first, averaged per render so the numbers compare with `avg render`.
|
||||
// No entries (fully-static shortcut, or every render a C4 cache hit) means no
|
||||
// per-element work happened — that IS the reading. The sub-stage
|
||||
// `#i <name> raster` is the paste-cache rasterize miss and nests in its
|
||||
// element's total.
|
||||
var parts = _timings.Describe(statRenders);
|
||||
if (parts.Length > 0)
|
||||
_log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}");
|
||||
_timings.Reset();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Scene? lastDumpedScene = null;
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -222,6 +458,19 @@ public sealed class FramePump : IDisposable
|
||||
var scene = _sceneProvider();
|
||||
if (scene != null)
|
||||
{
|
||||
// Scene dump (webcam-cadence unit, 2026-10-16): the 141ms regression
|
||||
// could not be compared take-to-take because the log recorded no scene
|
||||
// shape at all. Dump it when the pump starts on a scene and whenever
|
||||
// the active scene changes — one header line plus one line per element
|
||||
// (z-index, name, kind, source type, visibility, rect, opacity, clip).
|
||||
if (!ReferenceEquals(scene, lastDumpedScene))
|
||||
{
|
||||
lastDumpedScene = scene;
|
||||
foreach (var line in FormatSceneDump(scene, _sceneGraph?.GetSplitPoint(scene) ?? -1))
|
||||
_log?.Invoke(line);
|
||||
}
|
||||
|
||||
var iterStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var compositorOptions = _compositorOptions();
|
||||
VideoFrame? socialBarFrame = null;
|
||||
var socialBarTop = 0;
|
||||
@@ -235,30 +484,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, _timings);
|
||||
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);
|
||||
// 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;
|
||||
|
||||
// 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 +625,343 @@ 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,
|
||||
CompositeTimings? timings = null)
|
||||
{
|
||||
resolver ??= _frameResolver;
|
||||
if (_sceneGraph == null)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver, timings);
|
||||
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, timings: timings);
|
||||
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 tOverlay = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
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;
|
||||
timings?.Add("overlay", System.Diagnostics.Stopwatch.GetTimestamp() - tOverlay);
|
||||
}
|
||||
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
|
||||
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
|
||||
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, timings: timings);
|
||||
if (baseFrame != null)
|
||||
{
|
||||
var frame = SceneCompositor.CompositeLayers(
|
||||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch, timings: timings);
|
||||
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, timings);
|
||||
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,
|
||||
CompositeTimings? timings = null)
|
||||
{
|
||||
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
|
||||
// at 1:1 they are the same buffer size. Off-size tiers (a vertical 1080×1920
|
||||
// tier, an H264-1080p tier) fall back to a per-tick full render; the deployed
|
||||
// config is master-sized and caching a per-tick fresh scale buffer is the known
|
||||
// vertical-tier follow-up (see ai.md), not silently baked here.
|
||||
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, timings: timings);
|
||||
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>The pump's scene dump (webcam-cadence unit, 2026-10-16): one header line
|
||||
/// plus one line per element, in z-order, carrying the shape two takes must be
|
||||
/// compared by — index, name, kind, source type, visibility, rect in master pixels,
|
||||
/// opacity, clip. <paramref name="split"/> is the SceneGraph split point (-1 when no
|
||||
/// graph is wired: the full-render path). The 10:03 vs 12:01 regression
|
||||
/// (29–34ms → 143–155ms at "the same scene shape") could not be compared because the
|
||||
/// log recorded no scene at all; this is that record.</summary>
|
||||
internal static string[] FormatSceneDump(Scene scene, int split)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"FramePump scene dump: '{scene.Name}' elements={scene.Elements.Count}"
|
||||
+ (split >= 0 ? $" split={split}" : "")
|
||||
};
|
||||
for (var i = 0; i < scene.Elements.Count; i++)
|
||||
{
|
||||
var e = scene.Elements[i];
|
||||
var source = e is Source s
|
||||
? $" type={s.Type}{(s.IsBackground ? " background" : "")}"
|
||||
: "";
|
||||
lines.Add($" [{i}] {e.Name} kind={e.Kind}{source}"
|
||||
+ $" vis={(e.IsVisible ? 1 : 0)} rect={e.X:F0},{e.Y:F0} {e.Width:F0}x{e.Height:F0}"
|
||||
+ $" op={e.Opacity:F2} clip={e.ClipShape}");
|
||||
}
|
||||
return lines.ToArray();
|
||||
}
|
||||
|
||||
/// <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}");
|
||||
|
||||