Files
LlamaCasty/ViewModels/MainViewModel.Webcam.cs
T
gramps 3a77662a95 vv webcam Unit 2: the Chat scene in the vertical tier - rect only, tile or one whole 607x540 area
The creator's locked table (2026-10-17) gives the Chat scene its own rules in
the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that
is either the normal small tile or ENLARGED to fill one whole 607x540 area
(top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat
scene's own variant of it.

The rule lives on the MODEL, not on the button:
- Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual.
- Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM)
  OVERRIDES the ClipShape property, so no writer can slip a circle in - the
  toggle, the Shape button and the placement swap all go through it. The lock
  is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns.
- ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT
  offered in the vertical Chat layout - the Shape button disappears, it is not
  merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier.
- Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived -
  no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the
  whole area, whichever is nearer the requested width, so the resize handle
  has two detents instead of any size in between.
- An enlarged camera FILLS the area its centre is in (top or bottom), so
  dragging it up/down swaps the areas.
- 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.
- Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag
  assigns, lets the model clamp into the band, then snaps (so the enlarged
  camera can be dragged from the top area to the bottom one).

Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for
the unit: the vertical tier is picked -> the Chat camera comes into the band as
a small RECT tile -> the circle option is not offered and cannot be set even on
the model -> dragged up and enlarged it fills the TOP area exactly -> back to
16:9 the desktop layout is restored exactly and the circle option is offered
again. 430/430 tests pass, 0 warnings.

Research (the creator's table is the established practice, not our invention):
- Streamlabs' vertical guide: "splitting the screen in half horizontally,
  placing the camera feed in the top half and the main content on the bottom
  (or vice versa) may be the bet", and a just-chatting vertical wants the
  camera "to take up most of the screen"
  (streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming)
- Bloopbot's vertical overlay docs describe the same per-layer model we use at
  scene granularity: "move or resize a layer on Vertical and it stops being
  Auto-placed. It keeps the place you gave it, and the horizontal layout is not
  touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format) - our
  swap-at-tier-change, stored per scene.
- Landmine recorded: "about the bottom 560 px of the vertical video can be
  covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat
  camera's enlarged BOTTOM area may be half-hidden by the platform chrome;
  TASK 53's safe-zone work decides that.

Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests),
TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md,
MyMistakes.md (3 recipes: lock the model property not the UI button; two detents
for a discrete size set; the edit tool cannot match an oldString with an em
dash), Models/ViewModels/Controls index rows.
2026-10-21 19:23:29 -07:00

636 lines
27 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)
{
// The Chat scene's vertical layout is RECT-only (the creator's table:
// "vv · Chat = rect only — no circle option"). The lock is cleared
// BEFORE the 16:9 layout comes back, so the desktop shape is restored.
config.VerticalShapeLocked = vertical && SceneCatalog.IsChat(scene.Name);
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 and Chat scenes are the camera scenes in the vertical tier:
// without a placement INSIDE the band the vertical output has no camera
// at all. They start as a small tile in the bottom-right spot (the
// corner the 16:9 default webcam uses). Starting/BRB/Ending keep their
// 16:9 placement (creator ruling 2026-10-17: unchanged) until the
// creator drags them into the band.
if (!SceneCatalog.Is(scene.Name, SceneCatalog.Live) && !SceneCatalog.IsChat(scene.Name))
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);
config.X = x;
config.Y = y;
SnapWebcamInVerticalTier(config);
}
/// <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 centreX = config.X + config.Width / 2;
var centreY = config.Y + config.Height / 2;
// The enlarged Chat camera fills one whole area — the top one or the
// bottom one, by where its centre is.
if (VerticalCameraLayout.FillsArea(VerticalBand, config.Width))
{
var area = VerticalCameraLayout.AreaFor(
VerticalBand, centreY >= VerticalBand.Top + VerticalBand.Height / 2);
config.Width = area.Width;
config.Height = area.Height;
config.X = area.Left;
config.Y = area.Top;
return;
}
var spot = VerticalCameraLayout.SpotFor(
VerticalBand, config.Width, config.Height, centreX, centreY);
config.X = spot.X;
config.Y = spot.Y;
}
/// <summary>True when this camera belongs to the Chat scene — the vertical
/// tier's Chat layout has its own rules (rect only, two sizes).</summary>
public bool IsChatSceneCamera(WebcamSceneConfig config)
=> Scenes.Any(s => SceneCatalog.IsChat(s.Name) && s.Elements.Contains(config));
/// <summary>False when the circle option is not offered — the Chat scene's
/// vertical layout is rect-only (the creator's table, 2026-10-17).</summary>
public bool CanToggleClipShape => !(IsVerticalTier
&& SelectedElement is WebcamSceneConfig selected
&& IsChatSceneCamera(selected));
/// <summary>Applies a resize inside the vertical tier: the Chat camera's
/// size is discrete — the small tile (normal) or one whole 607×540 area
/// (enlarged) — and every camera ends up snapped to its spot.</summary>
public void ResizeWebcamInVerticalTier(WebcamSceneConfig config, double requestedWidth, double requestedHeight)
{
if (!IsVerticalTier) return;
EnsureVerticalCameraLayout(config);
if (IsChatSceneCamera(config))
{
var (w, h) = VerticalCameraLayout.SizeFor(VerticalBand, requestedWidth);
config.Width = w;
config.Height = h;
}
else
{
config.Width = requestedWidth;
config.Height = requestedHeight;
}
SnapWebcamInVerticalTier(config);
}
/// <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);
}
}