Resolution dropdown drives the output frame: vertical 9:16 tier with semi-crop preview over the fixed 1920x1080 master
This commit is contained in:
@@ -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}}"/>
|
||||||
|
|||||||
@@ -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
@@ -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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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@6 → 480p30@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@6 → 720p30@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
|
||||||
|
|||||||
Reference in New Issue
Block a user