Resolution dropdown drives the output frame: vertical 9:16 tier with semi-crop preview over the fixed 1920x1080 master

This commit is contained in:
2026-08-06 09:20:32 -07:00
parent 4bf4a13299
commit 8f0ecd3796
7 changed files with 92 additions and 9 deletions
+29
View File
@@ -377,6 +377,31 @@
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/> HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding DimRects}" IsHitTestVisible="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Rectangle Width="{Binding Width}" Height="{Binding Height}"
Fill="#000000" Opacity="0.55"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Rectangle IsHitTestVisible="False" Stroke="#e94560" StrokeThickness="3"
Canvas.Left="{Binding OutputRectX}" Canvas.Top="{Binding OutputRectY}"
Width="{Binding OutputRectWidth}" Height="{Binding OutputRectHeight}"
Visibility="{Binding IsOutputCropped, Converter={StaticResource BoolToVis}}"/>
<Grid x:Name="BrandFlashLayer" Width="1920" Height="1080" IsHitTestVisible="False" Opacity="0"> <Grid x:Name="BrandFlashLayer" Width="1920" Height="1080" IsHitTestVisible="False" Opacity="0">
<Grid.Style> <Grid.Style>
<Style TargetType="Grid"> <Style TargetType="Grid">
@@ -412,6 +437,10 @@
</Canvas> </Canvas>
</Grid> </Grid>
</Viewbox> </Viewbox>
<Border HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,8,8,0"
Background="#CC16213e" CornerRadius="4" Padding="8,3" IsHitTestVisible="False">
<TextBlock Text="{Binding ResolutionBadgeText}" Foreground="#e0e0e0" FontSize="12"/>
</Border>
<TextBlock Text="Preview" Foreground="#333" FontSize="24" <TextBlock Text="Preview" Foreground="#333" FontSize="24"
HorizontalAlignment="Center" VerticalAlignment="Center" HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding ShowPreviewPlaceholder, Converter={StaticResource BoolToVis}}"/>
+3 -1
View File
@@ -2,8 +2,10 @@ namespace ytLive.Models;
/// <summary> /// <summary>
/// A resolution tier offered by the bottom-bar dropdown. Bitrate is Mbps. /// A resolution tier offered by the bottom-bar dropdown. Bitrate is Mbps.
/// Width/Height is the output frame; the composition master is always
/// 1920x1080 and a non-16:9 tier renders as a centered crop of it.
/// </summary> /// </summary>
public sealed record QualityOption(string Display, int Fps, double Bitrate) public sealed record QualityOption(string Display, int Fps, double Bitrate, int Width, int Height)
{ {
/// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary> /// <summary>Self-documenting label, e.g. "1080p60 (8Mbps)".</summary>
public string Label => $"{Display} ({Bitrate:0.#}Mbps)"; public string Label => $"{Display} ({Bitrate:0.#}Mbps)";
+1 -1
View File
@@ -7,7 +7,7 @@ Plain data types. No logic beyond what a property can carry. See
|------|---------| |------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Sources` collection | | `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Sources` collection |
| `Source.cs` | A source: `SourceType` enum (Image/Webcam/Screen/Background/Text), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource`, asset identity | | `Source.cs` | A source: `SourceType` enum (Image/Webcam/Screen/Background/Text), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource`, asset identity |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown | | `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 | | `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) | | `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
+5 -1
View File
@@ -162,12 +162,16 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only 1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only
2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL 2. **RTMP push** — FFmpeg subprocess or native RTMP library, to the cached reusable stream's ingestion URL
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**: 3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
- Phone 480p @ 2.5 Mbps - 720p30 @ 6 Mbps
- 720p60 @ 6 Mbps - 720p60 @ 6 Mbps
- 1080p30 @ 8 Mbps - 1080p30 @ 8 Mbps
- **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming - **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
machine never notices; upload headroom stays comfortable) machine never notices; upload headroom stays comfortable)
- Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier)
- 1440p/4K = the paid unlock tiers (monetization), not the standard offering - 1440p/4K = the paid unlock tiers (monetization), not the standard offering
The composition master is always 1920×1080; a tier is an output rect + target resolution
(see `ai.md` "Resolution tiers"). Vertical output = the centered 607×1080 crop of the master
scaled to 1080×1920 (semi-crop preview is already implemented; the encoder applies the same rect).
Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required). Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
Quality is greyed out while live because the declared resolution can't change mid-stream — but Quality is greyed out while live because the declared resolution can't change mid-stream — but
with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
+52 -4
View File
@@ -295,12 +295,20 @@ public class MainViewModel : ViewModelBase
// dropdown is disabled while live because YouTube stream resolution is // dropdown is disabled while live because YouTube stream resolution is
// immutable after stream creation (see TASKS.md compliance notes). // immutable after stream creation (see TASKS.md compliance notes).
// The composition master frame. Every tier is an output rect + target
// resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
// use the full frame; the vertical tier uses a centered 9:16 window and
// the preview dims the cropped strips (semi-crop).
private const double MasterFrameWidth = 1920;
private const double MasterFrameHeight = 1080;
public QualityOption[] QualityOptions { get; } = public QualityOption[] QualityOptions { get; } =
{ {
new("1080p60", 60, 8.0), new("1080p60", 60, 8.0, 1920, 1080),
new("1080p30", 30, 8.0), new("1080p30", 30, 8.0, 1920, 1080),
new("720p60", 60, 6.0), new("720p60", 60, 6.0, 1280, 720),
new("480p30", 30, 2.5), new("720p30", 30, 6.0, 1280, 720),
new("Vertical 1080p60", 60, 8.0, 1080, 1920),
}; };
public string ResolutionHelp { get; } = public string ResolutionHelp { get; } =
@@ -319,12 +327,51 @@ public class MainViewModel : ViewModelBase
} }
} }
// Output frame of the selected tier over the 1920x1080 master. The strips
// outside it are dimmed in the preview so the cropped area stays visible.
public double OutputRectX { get; private set; }
public double OutputRectY { get; private set; }
public double OutputRectWidth { get; private set; }
public double OutputRectHeight { get; private set; }
public bool IsOutputCropped { get; private set; }
public string ResolutionBadgeText { get; private set; } = string.Empty;
public ObservableCollection<Rect> DimRects { get; } = new();
private void ApplyStreamQuality(QualityOption quality) private void ApplyStreamQuality(QualityOption quality)
{ {
var health = CurrentHealth; var health = CurrentHealth;
health.CurrentBitrate = quality.Bitrate; health.CurrentBitrate = quality.Bitrate;
health.FPS = quality.Fps; health.FPS = quality.Fps;
OnPropertyChanged(nameof(CurrentHealth)); OnPropertyChanged(nameof(CurrentHealth));
var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
var rectW = quality.Width * scale;
var rectH = quality.Height * scale;
var rectX = (MasterFrameWidth - rectW) / 2;
var rectY = (MasterFrameHeight - rectH) / 2;
OutputRectX = rectX;
OutputRectY = rectY;
OutputRectWidth = rectW;
OutputRectHeight = rectH;
DimRects.Clear();
if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
if (rectX + rectW < MasterFrameWidth)
DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
if (rectY + rectH < MasterFrameHeight)
DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
IsOutputCropped = DimRects.Count > 0;
ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
OnPropertyChanged(nameof(OutputRectX));
OnPropertyChanged(nameof(OutputRectY));
OnPropertyChanged(nameof(OutputRectWidth));
OnPropertyChanged(nameof(OutputRectHeight));
OnPropertyChanged(nameof(IsOutputCropped));
OnPropertyChanged(nameof(ResolutionBadgeText));
} }
public string BugReportText public string BugReportText
@@ -383,6 +430,7 @@ public class MainViewModel : ViewModelBase
AppLog.Write("MainViewModel ctor: services created"); AppLog.Write("MainViewModel ctor: services created");
_selectedQuality = QualityOptions[0]; _selectedQuality = QualityOptions[0];
ApplyStreamQuality(_selectedQuality);
_youtubeChat.MessageReceived += OnChatMessageReceived; _youtubeChat.MessageReceived += OnChatMessageReceived;
+1 -1
View File
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) | | `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`) |
| `GoLiveViewModel.cs` | Start Stream dialog: title/description/visibility, start/cancel requests | | `GoLiveViewModel.cs` | Start Stream dialog: title/description/visibility, start/cancel requests |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel | | `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
+1 -1
View File
@@ -59,7 +59,7 @@ C# / WPF (.NET 8) following MVVM:
- Services are currently instantiated in MainViewModel's constructor — no DI container yet - Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available) - Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary) - Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary)
- Resolution tiers (bottom bar): 1080p60@8 → 1080p30@8 → 720p60@6480p30@2.5 Mbps; default = first. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable) - Resolution tiers (bottom bar): 1080p60@8 (default) → 1080p30@8 → 720p60@6720p30@6**Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier; the bottom bar shows bitrate/FPS. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug) - Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug)
### Current limitations / TODOs ### Current limitations / TODOs