The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).
Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; 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; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
X/Y/W/H overrides clamp every write into the band - outside it the camera is
invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
constrains every writer (drag, resize handle, typed fields) in ONE choke point
instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
Live scene gets its small tile automatically on the first vertical switch (the
vertical stream would otherwise have no camera at all); the other scenes keep
their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
the vertical tier instead of free-floating over the master.
Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
(X/Y/W/H) for the manual entry the creator asked for, 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).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
live the save writes the parked values there and the live ones into Vertical*.
Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.
Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.
Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
581 lines
25 KiB
C#
581 lines
25 KiB
C#
using System.Collections.Generic;
|
||
using System.Windows;
|
||
using System.Windows.Input;
|
||
using System.Windows.Media.Imaging;
|
||
using System.Windows.Threading;
|
||
using ytLive;
|
||
using ytLive.Helpers;
|
||
using ytLive.Models;
|
||
using ytLive.Services;
|
||
using ytLive.Services.BackgroundEffects;
|
||
|
||
namespace ytLive.ViewModels;
|
||
|
||
public partial class MainViewModel
|
||
{
|
||
// Webcam: one camera input app-wide. The identity (DeviceId) lives on the
|
||
// Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
|
||
// (webcam.{scene}.config). CameraManager still owns the single session.
|
||
private readonly ICameraEnumerator _cameraEnumerator;
|
||
private readonly CameraManager _cameraManager;
|
||
private Webcam? _webcam;
|
||
private string? _webcamError;
|
||
private string? _webcamLockAlert;
|
||
private DispatcherTimer? _webcamLockPollTimer;
|
||
|
||
/// <summary>
|
||
/// Add → Webcam menu item. The row is offered iff the SCENE has no webcam
|
||
/// config yet AND a camera is attainable — an app default is selected and
|
||
/// its session is RUNNING right now (identity alone is not enough: the
|
||
/// device may have been unplugged, or the lock may still be failing, in
|
||
/// which case the row stays grey and the persistent alert says why).
|
||
/// Per-scene max is per-scene: another scene holding its own placement does
|
||
/// NOT grey this one.
|
||
/// </summary>
|
||
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && IsWebcamAttainable;
|
||
|
||
private bool IsWebcamAttainable => _webcam != null && _cameraManager.IsRunning(_webcam.DeviceId);
|
||
|
||
/// <summary>
|
||
/// Why the Webcam row is greyed, when it is — never a silent bool. The max-1
|
||
/// reason gets the gentle "graduated to OBS" line; no attainable camera gets
|
||
/// its own wording. Shown as the row's tooltip.
|
||
/// </summary>
|
||
public string WebcamAddToolTip
|
||
{
|
||
get
|
||
{
|
||
if (StagedScene?.WebcamConfig != null)
|
||
return "Already in this scene — one webcam per stream. A second camera means you've graduated to OBS.";
|
||
if (!IsWebcamAttainable)
|
||
return "No webcam is currently available — plug one in, or allow camera access in Windows.";
|
||
return "Adds the app default webcam to this scene.";
|
||
}
|
||
}
|
||
|
||
// Re-raise the rows that read live CameraManager session state: the offer
|
||
// flips the moment a session starts (lock acquired, first frame) or dies.
|
||
private void RaiseWebcamGateChanged()
|
||
{
|
||
OnPropertyChanged(nameof(CanAddWebcam));
|
||
OnPropertyChanged(nameof(WebcamAddToolTip));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
|
||
/// webcam config (add one) or hides it (unhide — keeps the config row).
|
||
/// </summary>
|
||
public bool CanShowWebcamInStagedScene
|
||
=> StagedScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
|
||
|
||
/// <summary>Swap-the-device item: enabled once a camera has been picked at all.</summary>
|
||
public bool CanChangeWebcam => _webcam != null;
|
||
|
||
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
|
||
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
|
||
|
||
/// <summary>
|
||
/// The reason the webcam feed is down (device in use, offline, locked, no frames),
|
||
/// or null when it's alive. Surfaced as a red chip so a dead camera is never a
|
||
/// silent empty box. Cleared the moment a real frame arrives.
|
||
/// </summary>
|
||
public string? WebcamError
|
||
{
|
||
get => _webcamError;
|
||
private set => SetProperty(ref _webcamError, value);
|
||
}
|
||
|
||
// Startup resource validation keeps its task for tests to await (the real
|
||
// app fires it fire-and-forget from the ctor).
|
||
internal Task? WebcamStartupValidationTask { get; private set; }
|
||
|
||
/// <summary>
|
||
/// Red alert shown at the bottom of the Layers panel when the one-and-only
|
||
/// detected webcam could NOT be locked at startup. Persistent: while set, a
|
||
/// light re-poll re-runs the lock check so the moment the camera frees up
|
||
/// (another app closed) the alert clears itself; a Retry button does it now.
|
||
/// </summary>
|
||
public string? WebcamLockAlert
|
||
{
|
||
get => _webcamLockAlert;
|
||
private set
|
||
{
|
||
if (!SetProperty(ref _webcamLockAlert, value)) return;
|
||
if (value == null)
|
||
{
|
||
_webcamLockPollTimer?.Stop();
|
||
}
|
||
else if (_webcamLockPollTimer == null)
|
||
{
|
||
_webcamLockPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||
_webcamLockPollTimer.Tick += (_, _) => _ = ValidateWebcamResourceStartupAsync();
|
||
_webcamLockPollTimer.Start();
|
||
}
|
||
}
|
||
}
|
||
|
||
public ICommand RetryWebcamLockCommand { get; }
|
||
|
||
// Webcam size safeguard: no more than half the frame in any dimension
|
||
// (960x540 over the 1920x1080 master), and no less than 10% of it
|
||
// (192x108). Enforced at resize and on layout load. The Chat scene is
|
||
// exempt from the per-dimension half — its webcam may take half the
|
||
// screen AREA (~1358x764 @16:9) so the viewer sees the creator better,
|
||
// matched by canonical name (SceneCatalog.IsChat).
|
||
public const double WebcamMaxWidth = 960;
|
||
public const double WebcamMaxHeight = 540;
|
||
public const double WebcamChatMaxWidth = 1358;
|
||
public const double WebcamChatMaxHeight = 764;
|
||
public const double WebcamMinWidth = MasterFrameWidth * 0.1;
|
||
public const double WebcamMinHeight = MasterFrameHeight * 0.1;
|
||
|
||
public static double MaxWebcamWidthFor(string? sceneName)
|
||
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth;
|
||
|
||
public static double MaxWebcamHeightFor(string? sceneName)
|
||
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight;
|
||
|
||
internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName)
|
||
{
|
||
var maxWidth = MaxWebcamWidthFor(sceneName);
|
||
var maxHeight = MaxWebcamHeightFor(sceneName);
|
||
var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height);
|
||
if (scale < 1)
|
||
{
|
||
config.Width = Math.Round(config.Width * scale);
|
||
config.Height = Math.Round(config.Height * scale);
|
||
return;
|
||
}
|
||
|
||
var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
|
||
if (minScale > 1)
|
||
{
|
||
config.Width = Math.Round(config.Width * minScale);
|
||
config.Height = Math.Round(config.Height * minScale);
|
||
}
|
||
}
|
||
|
||
// One-time heal for layouts saved before the rect dims were persisted (v3→v4):
|
||
// a Traditional webcam that ended up square (Round resize then reload lost the
|
||
// pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
|
||
// a square bounding box is correct there — and an explicit pre-Round rect wins.
|
||
internal static void HealLegacySquareRect(WebcamSceneConfig config)
|
||
{
|
||
if (config.ClipShape != ClipShape.Traditional) return;
|
||
if (config.RectWidth != null || config.RectHeight != null) return;
|
||
if (Math.Abs(config.Width - config.Height) >= 1) return;
|
||
config.Width = Math.Round(config.Height * 16.0 / 9.0);
|
||
}
|
||
|
||
public ICommand AddWebcamCommand { get; }
|
||
public ICommand ChangeWebcamCommand { get; }
|
||
public ICommand ShowWebcamCommand { get; }
|
||
|
||
// After a layout load / file open: re-point the webcam identity, stop the
|
||
// previous device if it changed, and acquire the current one once per scene
|
||
// that uses it (CameraManager refcounts by DeviceId — one camera session).
|
||
private void ReacquireWebcam()
|
||
{
|
||
var previousDevice = _webcam?.DeviceId;
|
||
_webcam = _layoutStore.Webcam;
|
||
var newDevice = _webcam?.DeviceId;
|
||
|
||
if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
|
||
_ = _cameraManager.ReleaseAllAsync(previousDevice);
|
||
|
||
OnPropertyChanged(nameof(CanChangeWebcam));
|
||
OnPropertyChanged(nameof(CanAddWebcam));
|
||
|
||
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
|
||
if (previousDevice != newDevice)
|
||
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
|
||
_ = _cameraManager.AcquireAsync(newDevice);
|
||
|
||
// A session already running (same device, or one that produced a first
|
||
// frame) has its shared bitmap; freshly loaded configs must adopt it here,
|
||
// because PreviewBitmapChanged never re-fires for an existing bitmap.
|
||
// SyncWebcamEffects also declares each config's background-effect variant.
|
||
SyncWebcamEffects();
|
||
}
|
||
|
||
// CameraManager creates the shared WriteableBitmap on the UI thread at the
|
||
// device's frame size; every scene's webcam config picks it up from here. A
|
||
// bitmap means a real frame arrived — the camera is provably alive.
|
||
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
|
||
{
|
||
if (_webcam?.DeviceId != deviceId) return;
|
||
WebcamError = null;
|
||
WebcamLockAlert = null; // the camera is provably alive — alert resolved.
|
||
RaiseWebcamGateChanged();
|
||
foreach (var scene in Scenes)
|
||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||
if (EffectKeyFor(config).Effect == BackgroundEffect.None)
|
||
config.VideoImageSource = bitmap;
|
||
}
|
||
|
||
// TASK 51: an effect variant's first processed frame created its bitmap — every config
|
||
// showing that variant adopts it (later frames write into it in place).
|
||
private void OnCameraEffectPreviewBitmapChanged(string deviceId, WebcamEffectKey key, WriteableBitmap bitmap)
|
||
{
|
||
if (_webcam?.DeviceId != deviceId) return;
|
||
foreach (var scene in Scenes)
|
||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||
if (EffectKeyFor(config) == key)
|
||
config.VideoImageSource = bitmap;
|
||
}
|
||
|
||
internal static WebcamEffectKey EffectKeyFor(WebcamSceneConfig config)
|
||
=> WebcamEffectKey.For(config.BackgroundEffect, config.BackgroundStrength);
|
||
|
||
private readonly HashSet<WebcamSceneConfig> _effectWatchedConfigs = new();
|
||
|
||
/// <summary>TASK 51: declares the union of every scene's webcam effect variant to the camera
|
||
/// (unused variants stop costing CPU) and points each config's preview at its variant's
|
||
/// bitmap. An effect variant with no processed frame yet shows nothing rather than the raw
|
||
/// room. Re-run whenever a config is added/removed/loaded or its effect changes.</summary>
|
||
internal void SyncWebcamEffects()
|
||
{
|
||
if (_webcam == null || string.IsNullOrWhiteSpace(_webcam.DeviceId)) return;
|
||
var configs = Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()).ToList();
|
||
_cameraManager.SetRequestedEffects(_webcam.DeviceId, configs.Select(EffectKeyFor));
|
||
foreach (var config in configs)
|
||
{
|
||
if (_effectWatchedConfigs.Add(config))
|
||
config.PropertyChanged += OnWebcamConfigEffectChanged;
|
||
var key = EffectKeyFor(config);
|
||
var bitmap = _cameraManager.GetPreviewBitmap(_webcam.DeviceId, key);
|
||
// A strength change within the same effect keeps showing the previous variant's
|
||
// last frame until the new one renders (no blank flicker while dragging the slider);
|
||
// switching INTO an effect shows nothing first, so Remove never flashes the room.
|
||
var sameEffect = _shownEffect.TryGetValue(config, out var shown) && shown.Effect == key.Effect;
|
||
_shownEffect[config] = key;
|
||
if (bitmap != null) config.VideoImageSource = bitmap;
|
||
else if (key.Effect != BackgroundEffect.None && !sameEffect) config.VideoImageSource = null;
|
||
}
|
||
}
|
||
|
||
private readonly Dictionary<WebcamSceneConfig, WebcamEffectKey> _shownEffect = new();
|
||
|
||
private void OnWebcamConfigEffectChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||
{
|
||
if (e.PropertyName is not (nameof(WebcamSceneConfig.BackgroundEffect) or nameof(WebcamSceneConfig.BackgroundStrength)))
|
||
return;
|
||
SyncWebcamEffects();
|
||
ScheduleSave();
|
||
}
|
||
|
||
// The camera failed to start or died asynchronously (in use, offline, locked,
|
||
// no frames within the proof timeout) — surface it instead of a silent box.
|
||
private void OnCameraFailed(string deviceId, string message)
|
||
{
|
||
if (_webcam?.DeviceId != deviceId) return;
|
||
WebcamError = $"Webcam offline: {message}";
|
||
RaiseWebcamGateChanged();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs exactly once at startup (and on Retry): enumerate the OS webcams,
|
||
/// then tri-state:
|
||
/// 0 → no alarm, layer inactive.
|
||
/// 1 → try to lock; failure → persistent red alert (re-poll every 5 s).
|
||
/// ≥2 → leave to the settings-dialog selector (next slice).
|
||
/// Re-running after a failure re-enumerates then retries the lock; a real
|
||
/// frame arriving (first-frame proof) also clears the alert.
|
||
/// </summary>
|
||
internal async Task ValidateWebcamResourceStartupAsync()
|
||
{
|
||
IReadOnlyList<CameraDeviceInfo> cameras;
|
||
try
|
||
{
|
||
cameras = await _cameraEnumerator.GetCamerasAsync();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppLog.Write($"WebcamResource: enumeration failed: {ex.Message}");
|
||
WebcamLockAlert = "Web Cam unavailable — couldn't enumerate cameras.";
|
||
return;
|
||
}
|
||
|
||
var count = cameras?.Count ?? 0;
|
||
if (count != 1)
|
||
{
|
||
// 0: no camera; ≥2: settings-dialog selector (next slice).
|
||
WebcamLockAlert = null;
|
||
return;
|
||
}
|
||
|
||
var device = cameras![0];
|
||
|
||
// Lay the app-wide base lock. AcquireAsync on an already-running session
|
||
// (loaded identity's configs) just bumps its refcount — that bump is the
|
||
// app's own hold, so removing EVERY scene's placement keeps the session
|
||
// alive and the Web Cam row offered (the app default outlives the scenes).
|
||
var started = await _cameraManager.AcquireAsync(device.Id);
|
||
if (started)
|
||
{
|
||
// The camera is now provably attainable: adopt the new identity when
|
||
// there wasn't one, then re-raise the offer either way
|
||
// (RaiseWebcamGateChanged) so an existing identity whose session was
|
||
// down un-greys the row the moment the lock is real.
|
||
if (_webcam == null)
|
||
{
|
||
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
||
OnPropertyChanged(nameof(CanChangeWebcam));
|
||
}
|
||
RaiseWebcamGateChanged();
|
||
WebcamLockAlert = null;
|
||
return;
|
||
}
|
||
|
||
var reason = WebcamError ?? "the camera could not be locked.";
|
||
WebcamLockAlert = $"Web Cam unavailable: {reason}";
|
||
}
|
||
|
||
// Adds the webcam to the active scene. The webcam is an app-level resource:
|
||
// once a camera is the app default, adding the layer just places it — the
|
||
// picker runs only for the initial selection. Each scene's placement config
|
||
// is independent (webcam.{scene}.config); the camera device is Windows' own
|
||
// singleton and stays where we put it.
|
||
private async Task AddWebcamToStagedSceneAsync()
|
||
{
|
||
var scene = StagedScene;
|
||
if (scene == null || scene.WebcamConfig != null) return;
|
||
|
||
if (_webcam == null)
|
||
{
|
||
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
||
{
|
||
Owner = Application.Current.MainWindow
|
||
};
|
||
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
||
var device = dialog.PickedDevice;
|
||
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
|
||
OnPropertyChanged(nameof(CanChangeWebcam));
|
||
OnPropertyChanged(nameof(CanAddWebcam));
|
||
OnPropertyChanged(nameof(WebcamAddToolTip));
|
||
}
|
||
|
||
var config = new WebcamSceneConfig
|
||
{
|
||
WebcamId = _webcam.Id,
|
||
Name = _webcam.Name,
|
||
Width = 480,
|
||
Height = 270,
|
||
X = MasterFrameWidth - 480 - 32,
|
||
Y = MasterFrameHeight - 270 - 32,
|
||
};
|
||
|
||
_sceneGraph.AddElement(scene, config);
|
||
SelectedElement = config;
|
||
|
||
// A camera added while the vertical tier is live starts inside the
|
||
// band (TASK 57) — outside it, the camera would not be in the stream.
|
||
if (IsVerticalTier)
|
||
ResolveVerticalCameraLayout(scene, config, vertical: true);
|
||
|
||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
||
UpdateActiveBackground();
|
||
|
||
// PreviewBitmapChanged fires only on the camera's first frame, so a config
|
||
// added while the session is already running must pick up the shared bitmap
|
||
// directly (it's written in place from then on) — SyncWebcamEffects does that,
|
||
// per the config's background-effect variant.
|
||
SyncWebcamEffects();
|
||
|
||
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
|
||
if (!started)
|
||
{
|
||
_notifications.Warning("Camera unavailable",
|
||
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
|
||
}
|
||
}
|
||
|
||
// Swaps the device on the app-wide webcam identity. The old device is stopped
|
||
// unconditionally; each scene that uses the webcam re-acquires the new one so
|
||
// the per-config refcount stays honest. Only called when the device differs.
|
||
private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device)
|
||
{
|
||
var oldDevice = _webcam!.DeviceId;
|
||
_webcam.DeviceId = device.Id;
|
||
_webcam.Name = device.DisplayName;
|
||
ScheduleSave();
|
||
|
||
if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
|
||
await _cameraManager.ReleaseAllAsync(oldDevice);
|
||
|
||
SyncWebcamEffects(); // the new device inherits the scenes' background effects
|
||
|
||
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
|
||
{
|
||
var started = await _cameraManager.AcquireAsync(device.Id);
|
||
if (!started)
|
||
{
|
||
_notifications.Warning("Camera unavailable",
|
||
WebcamError ?? "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.");
|
||
break;
|
||
}
|
||
}
|
||
|
||
RaiseWebcamGateChanged();
|
||
}
|
||
|
||
// "Change Webcam…" from the webcam's context menu: picker, then swap the
|
||
// app-wide identity if a different device was chosen.
|
||
private async Task ChangeWebcamAsync()
|
||
{
|
||
if (_webcam == null) return;
|
||
|
||
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
|
||
{
|
||
Owner = Application.Current.MainWindow
|
||
};
|
||
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
|
||
if (dialog.PickedDevice.Id == _webcam.DeviceId) return;
|
||
|
||
await SwapWebcamIdentityAsync(dialog.PickedDevice);
|
||
}
|
||
|
||
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's
|
||
// existing (hidden) config — the config row survives Hide in this scene — or
|
||
// adds the webcam here for the first time (which opens the camera picker).
|
||
private void ShowWebcamInStagedScene()
|
||
{
|
||
var scene = StagedScene;
|
||
if (scene == null) return;
|
||
|
||
var config = scene.WebcamConfig;
|
||
if (config != null)
|
||
{
|
||
config.IsVisible = true;
|
||
SelectedElement = config;
|
||
return;
|
||
}
|
||
|
||
_ = AddWebcamToStagedSceneAsync();
|
||
}
|
||
|
||
// ─── Vertical (9:16) tier camera placement (TASK 57, the "vv" feature) ──
|
||
// The vertical tier is a centred band crop of the same 1920×1080 master, so
|
||
// the 16:9 camera placement falls outside the cropped window and the camera
|
||
// is simply not in the vertical stream. Each scene therefore keeps its OWN
|
||
// vertical placement, and the tier switch swaps the live geometry (the
|
||
// preview and the compositor both read the element's own X/Y/W/H — no
|
||
// render-path change).
|
||
|
||
/// <summary>True while the portrait tier (1080×1920) is the active tier.</summary>
|
||
public bool IsVerticalTier => VerticalCameraLayout.IsVerticalTier(SelectedQuality);
|
||
|
||
/// <summary>The vertical tier's cropped window over the master frame.</summary>
|
||
public Rect VerticalBand => new(OutputRectX, OutputRectY, OutputRectWidth, OutputRectHeight);
|
||
|
||
/// <summary>
|
||
/// Resolves every scene's camera placement for the tier that was just
|
||
/// picked: the vertical tier brings each scene's own vertical placement in
|
||
/// (the Live scene gets its small tile on first use), and switching back to
|
||
/// 16:9 puts the desktop layout back exactly where it was.
|
||
/// </summary>
|
||
private bool ResolveVerticalCameraLayouts(bool vertical)
|
||
{
|
||
var changed = false;
|
||
foreach (var scene in Scenes)
|
||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||
changed |= ResolveVerticalCameraLayout(scene, config, vertical);
|
||
return changed;
|
||
}
|
||
|
||
private bool ResolveVerticalCameraLayout(Scene scene, WebcamSceneConfig config, bool vertical)
|
||
{
|
||
if (!vertical)
|
||
{
|
||
config.VerticalBand = Rect.Empty;
|
||
if (!config.VerticalLayoutLive) return false;
|
||
config.EndVerticalLayout();
|
||
return true;
|
||
}
|
||
|
||
config.VerticalBand = VerticalBand;
|
||
if (config.VerticalLayoutLive) return false;
|
||
|
||
// A scene with a vertical placement of its own gets it back.
|
||
if (config.HasVerticalLayout)
|
||
{
|
||
config.BeginVerticalLayout(
|
||
config.VerticalX!.Value,
|
||
config.VerticalY!.Value,
|
||
config.VerticalWidth ?? VerticalCameraLayout.TileWidth,
|
||
config.VerticalHeight ?? VerticalCameraLayout.TileHeight,
|
||
config.VerticalClipShape ?? config.ClipShape);
|
||
return true;
|
||
}
|
||
|
||
// The Live scene's camera must be INSIDE the band or the vertical
|
||
// output has no camera at all — it starts as a small tile in the
|
||
// bottom-right spot (the corner the 16:9 default uses). The other
|
||
// scenes keep their 16:9 placement until the creator drags them into
|
||
// the band (creator ruling 2026-10-17: Starting/BRB/Ending unchanged).
|
||
if (!SceneCatalog.Is(scene.Name, SceneCatalog.Live)) return false;
|
||
|
||
var spot = VerticalCameraLayout.DefaultSpot(
|
||
VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight);
|
||
config.BeginVerticalLayout(
|
||
spot.X, spot.Y,
|
||
VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight,
|
||
config.ClipShape);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Moves a camera inside the vertical band and snaps it to one of the six
|
||
/// spots (top/bottom × left/centre/right). The drop point is where the
|
||
/// camera's top-left would land; the spot is the one its centre falls in.
|
||
/// </summary>
|
||
public void MoveWebcamInVerticalTier(WebcamSceneConfig config, double x, double y)
|
||
{
|
||
if (!IsVerticalTier) return;
|
||
EnsureVerticalCameraLayout(config);
|
||
var spot = VerticalCameraLayout.SpotFor(
|
||
VerticalBand, config.Width, config.Height,
|
||
x + config.Width / 2, y + config.Height / 2);
|
||
config.X = spot.X;
|
||
config.Y = spot.Y;
|
||
}
|
||
|
||
/// <summary>Snaps a camera back onto its spot — the resize handle grew or
|
||
/// shrank it and the spot's anchor has to follow.</summary>
|
||
public void SnapWebcamInVerticalTier(WebcamSceneConfig config)
|
||
{
|
||
if (!IsVerticalTier) return;
|
||
EnsureVerticalCameraLayout(config);
|
||
var spot = VerticalCameraLayout.SpotFor(
|
||
VerticalBand, config.Width, config.Height,
|
||
config.X + config.Width / 2, config.Y + config.Height / 2);
|
||
config.X = spot.X;
|
||
config.Y = spot.Y;
|
||
}
|
||
|
||
/// <summary>Makes the scene's vertical placement live — creating it (a small
|
||
/// tile in the default spot) when the scene has none yet.</summary>
|
||
private void EnsureVerticalCameraLayout(WebcamSceneConfig config)
|
||
{
|
||
if (config.VerticalLayoutLive) return;
|
||
|
||
if (config.HasVerticalLayout)
|
||
{
|
||
config.BeginVerticalLayout(
|
||
config.VerticalX!.Value,
|
||
config.VerticalY!.Value,
|
||
config.VerticalWidth ?? VerticalCameraLayout.TileWidth,
|
||
config.VerticalHeight ?? VerticalCameraLayout.TileHeight,
|
||
config.VerticalClipShape ?? config.ClipShape);
|
||
return;
|
||
}
|
||
|
||
var spot = VerticalCameraLayout.DefaultSpot(
|
||
VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight);
|
||
config.BeginVerticalLayout(
|
||
spot.X, spot.Y,
|
||
VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight,
|
||
config.ClipShape);
|
||
}
|
||
} |