Files
LlamaCasty/ViewModels/MainViewModel.Webcam.cs
T
gramps 6be062c45f vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene
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.
2026-10-21 08:58:24 -07:00

581 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}