A video file was picture-only: the decode pipe passed `-an`, so a starting-soon or BRB clip with music played silent everywhere. Adds sound to every clip and collapses the two decoders into one. - `Services/VideoClipPipe.cs`: the shared pipe behind `MediaVideoSource` (TASK 21) and `AlertClipDecoder` (TASK 47) — video (`rawvideo bgra`, scaled) + audio (`f32le -ac 2 -ar 48000`, 50 ms chunks, byte-carry across reads), both paced to real time, loop restarts both so picture and sound stay together. Alert behaviour is unchanged. - `MediaVideoSourceManager` applies the layer's Level/Mute per session and raises `AudioAvailable`; `MainViewModel` feeds it to the new `AudioMixer.EnqueueDesktopMediaAudio`, which joins the DESKTOP/GAME ring so the desktop fader and the mic auto-ducker apply exactly as they do for game audio and TRAX (creator ruling 2026-11-05: audio joins the group). - Per-layer `MediaMuted` + existing `MediaVolume` surfaced as Mute/Level in the properties panel; `MediaMuted` persisted (ALTER TABLE guard + SELECT/INSERT). - Level/mute are per SESSION because one decoder is shared per file path — the last layer touched wins; one decoder per layer would play the file twice. Test: `MediaSourceAudioTests` — the pipe emits 50 ms chunks at 48k stereo, the manager scales by level and drops when muted, and a media layer's sound moves the desktop meter. Alert/decode suites unchanged and green. Docs: task-21 closed on item 10, the "silent soundtrack" facts corrected in frame-pipeline/output/overlays, HANDOFF rewritten. verify.sh green (529 passed; the two documented pre-existing failures only).
507 lines
17 KiB
C#
507 lines
17 KiB
C#
using System.Windows.Media;
|
||
using ytLive.Helpers;
|
||
|
||
namespace ytLive.Models;
|
||
|
||
public enum SourceType
|
||
{
|
||
DisplayCapture,
|
||
WindowCapture,
|
||
Background,
|
||
Image,
|
||
TextOverlay,
|
||
Countdown,
|
||
WebSource,
|
||
ChatBox,
|
||
/// <summary>The session scoreboard strip: full-width, state not events.</summary>
|
||
Scoreboard,
|
||
MediaSource,
|
||
/// <summary>The designated alert zone: the six monetization events play their
|
||
/// unique celebration animations here (OBS alert-box equivalent, native).</summary>
|
||
AlertBox,
|
||
/// <summary>A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
|
||
/// only, always below the brand logo, several allowed (strongest wins on overlap).</summary>
|
||
Blur,
|
||
/// <summary>A scene-local feed of recent alert events.</summary>
|
||
Ticker
|
||
}
|
||
|
||
/// <summary>Distinguishes how a media source presents its content (TASK 21).</summary>
|
||
public enum MediaSourceType
|
||
{
|
||
Video,
|
||
Audio
|
||
}
|
||
|
||
/// <summary>Playback state of a media source (TASK 21).</summary>
|
||
public enum MediaPlaybackState
|
||
{
|
||
Stopped,
|
||
Playing,
|
||
Paused
|
||
}
|
||
|
||
public enum TextLayerAlignment
|
||
{
|
||
Left,
|
||
Center,
|
||
Right
|
||
}
|
||
|
||
public enum TextLayerVerticalAlignment
|
||
{
|
||
Top,
|
||
Center,
|
||
Bottom
|
||
}
|
||
|
||
public enum ClipShape
|
||
{
|
||
Traditional,
|
||
Round
|
||
}
|
||
|
||
/// <summary>How an alert box presents its announcement strip (TASK 47). The strip is
|
||
/// the full-width pill naming the event ("Viewer — Super Chat · $12.34").</summary>
|
||
public enum AlertDisplayMethod
|
||
{
|
||
/// <summary>Marquee: the pill scrolls across the top edge, paced so the text is
|
||
/// read <see cref="AlertOverlayLayer.TickerReadsPerAlert"/> times per alert.</summary>
|
||
TickerScroll,
|
||
/// <summary>Pulse: the static pill blinks on and off for the whole alert.</summary>
|
||
Flash,
|
||
/// <summary>Static: the pill sits centred and still for the whole alert.</summary>
|
||
Solid
|
||
}
|
||
|
||
/// <summary>
|
||
/// A multi-instance scene object: image/background/text (screen/window later).
|
||
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||
/// is a <see cref="WebcamSceneConfig"/>. See the singleton-vs-multi-instance
|
||
/// discriminator in ai.md.
|
||
/// </summary>
|
||
public class Source : SceneElement
|
||
{
|
||
private readonly Ticker _ticker = new();
|
||
|
||
public Source()
|
||
{
|
||
_ticker.PropertyChanged += (_, e) =>
|
||
{
|
||
if (e.PropertyName == nameof(Ticker.IsEnabled)) Raise(nameof(AlertShowTicker));
|
||
if (e.PropertyName == nameof(Ticker.DisplayMethod)) Raise(nameof(AlertDisplayMethod));
|
||
};
|
||
}
|
||
|
||
public Ticker Ticker => _ticker;
|
||
|
||
private SourceType _type;
|
||
public SourceType Type
|
||
{
|
||
get => _type;
|
||
set
|
||
{
|
||
if (Set(ref _type, value))
|
||
{
|
||
IsBackground = value == SourceType.Background;
|
||
Raise(nameof(IsLiveCapture));
|
||
Raise(nameof(DisplaySource));
|
||
Raise(nameof(IsWebSource));
|
||
Raise(nameof(IsTextOverlay));
|
||
Raise(nameof(IsScoreboard));
|
||
Raise(nameof(IsTicker));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Blur is draggable like a layer: it has no pixels of its own, but the creator places
|
||
/// and sizes it in the preview, so it needs the same selection box, grab and resize handle
|
||
/// (creator ruling 2026-11-01).</summary>
|
||
public override bool IsDraggable => Type is SourceType.Image or SourceType.WebSource or SourceType.ChatBox
|
||
or SourceType.TextOverlay or SourceType.AlertBox or SourceType.Blur or SourceType.Ticker
|
||
or SourceType.MediaSource;
|
||
|
||
/// <summary>Live captures, chat, web sources and media are dynamic; static art is fixed.</summary>
|
||
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
|
||
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
|
||
or SourceType.Blur or SourceType.Ticker
|
||
? ElementKind.Dynamic : ElementKind.Static;
|
||
|
||
public override bool IsWebSource => Type == SourceType.WebSource;
|
||
public override bool IsTextOverlay => Type == SourceType.TextOverlay;
|
||
|
||
public override bool IsScoreboard => Type == SourceType.Scoreboard;
|
||
public bool IsTicker => Type == SourceType.Ticker;
|
||
|
||
/// <summary>The permanent brand logo layer (one per scene, directly above the background).
|
||
/// Locked against removal; its image comes from the App Settings brand logo.</summary>
|
||
public bool IsBrandLogo { get; set; }
|
||
|
||
/// <summary>The permanent scene background (live desktop/game capture). Never removable/reorderable.</summary>
|
||
private bool _isBackground;
|
||
public override bool IsBackground { get => _isBackground; set => Set(ref _isBackground, value); }
|
||
|
||
private bool _showDesktop;
|
||
public bool ShowDesktop { get => _showDesktop; set => Set(ref _showDesktop, value); }
|
||
|
||
private bool _isEnabled = true;
|
||
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
|
||
|
||
// Display/Window capture
|
||
private int? _monitorIndex;
|
||
public int? MonitorIndex { get => _monitorIndex; set => Set(ref _monitorIndex, value); }
|
||
|
||
private IntPtr? _windowHandle;
|
||
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
||
|
||
/// <summary>
|
||
/// Identifies what this live-capture source points at: "monitor:<index>" or
|
||
/// "window:<hwnd>". Persisted so a re-designated background survives a reload.
|
||
/// </summary>
|
||
private string? _captureKey;
|
||
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
|
||
|
||
public override bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
|
||
|
||
// Image (asset stored in the layout database)
|
||
private string? _assetId;
|
||
private ImageSource? _imageSource;
|
||
|
||
public string? AssetId
|
||
{
|
||
get => _assetId;
|
||
set
|
||
{
|
||
if (Set(ref _assetId, value))
|
||
{
|
||
_imageSource = ImageCache.Get(value ?? string.Empty);
|
||
Raise(nameof(ImageSource));
|
||
Raise(nameof(DisplaySource));
|
||
}
|
||
}
|
||
}
|
||
|
||
public ImageSource? ImageSource => _imageSource;
|
||
|
||
/// <summary>Types whose frame is produced at runtime rather than stored as an asset. The
|
||
/// scoreboard joined this list 2026-10-30 — without it the strip was rendered, published to
|
||
/// VideoImageSource, and never displayed (the same shape as the invisible-alert defect).</summary>
|
||
private bool IsFrameSource =>
|
||
Type is SourceType.TextOverlay or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource
|
||
or SourceType.AlertBox or SourceType.Scoreboard or SourceType.Ticker;
|
||
|
||
public override ImageSource? DisplaySource => IsLiveCapture
|
||
? (VideoImageSource ?? _imageSource)
|
||
: (IsFrameSource ? VideoImageSource : _imageSource);
|
||
|
||
public override bool IsImageSource => Type == SourceType.Image;
|
||
|
||
// Countdown config
|
||
private int _timerMinutes = 15;
|
||
|
||
// Privacy blur (Live scene only): 0–1, the same scale and default as the webcam's background
|
||
// blur so "half strength" means the same blur in both places.
|
||
private double _blurAmount = 0.5;
|
||
public double BlurAmount
|
||
{
|
||
get => _blurAmount;
|
||
set => Set(ref _blurAmount, Math.Clamp(value, 0, 1));
|
||
}
|
||
|
||
public bool IsBlur => Type == SourceType.Blur;
|
||
|
||
/// <summary>True for a round blur (circle) — the shape rides the existing clip surface, so the
|
||
/// box/circle choice is the same switch every other layer uses rather than a second concept.</summary>
|
||
public bool IsRoundBlur => IsBlur && ClipShape == ClipShape.Round;
|
||
|
||
public int TimerMinutes
|
||
{
|
||
get => _timerMinutes;
|
||
set => Set(ref _timerMinutes, Math.Clamp(value, 1, 60));
|
||
}
|
||
|
||
// Web source config
|
||
private string? _webUri;
|
||
public string? WebUri { get => _webUri; set => Set(ref _webUri, value); }
|
||
|
||
// Media source config (TASK 21: video/audio file playback)
|
||
private MediaSourceType _mediaType = MediaSourceType.Video;
|
||
public MediaSourceType MediaType
|
||
{
|
||
get => _mediaType;
|
||
set => Set(ref _mediaType, value);
|
||
}
|
||
|
||
private string? _mediaPath;
|
||
public string? MediaPath
|
||
{
|
||
get => _mediaPath;
|
||
set
|
||
{
|
||
if (Set(ref _mediaPath, value))
|
||
{
|
||
Raise(nameof(IsMediaSource));
|
||
Raise(nameof(MediaFileDisplay));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>The chosen file's name for the properties panel (path stays in the model).</summary>
|
||
public string MediaFileDisplay =>
|
||
string.IsNullOrWhiteSpace(_mediaPath) ? "No video chosen" : System.IO.Path.GetFileName(_mediaPath);
|
||
|
||
private bool _mediaIsLooping;
|
||
public bool MediaIsLooping
|
||
{
|
||
get => _mediaIsLooping;
|
||
set => Set(ref _mediaIsLooping, value);
|
||
}
|
||
|
||
private double _mediaVolume = 1.0;
|
||
public double MediaVolume
|
||
{
|
||
get => _mediaVolume;
|
||
set => Set(ref _mediaVolume, Math.Clamp(value, 0.0, 1.0));
|
||
}
|
||
|
||
private bool _mediaMuted;
|
||
/// <summary>Silences this layer's soundtrack (the picture keeps playing). One decoder is
|
||
/// shared per file, so the level/mute are the file's — the layer is the control.</summary>
|
||
public bool MediaMuted
|
||
{
|
||
get => _mediaMuted;
|
||
set => Set(ref _mediaMuted, value);
|
||
}
|
||
|
||
private MediaPlaybackState _mediaPlaybackState = MediaPlaybackState.Stopped;
|
||
public MediaPlaybackState MediaPlaybackState
|
||
{
|
||
get => _mediaPlaybackState;
|
||
set => Set(ref _mediaPlaybackState, value);
|
||
}
|
||
|
||
public override bool IsMediaSource => Type == SourceType.MediaSource;
|
||
|
||
// Alert video config (TASK 47): the shipped default clip lives in the Asset
|
||
// table (seeded like image assets); the creator's replacement is a FILE PATH
|
||
// reference only — never into the DB (creator ruling, 2026-09-26). Runtime
|
||
// order: replacement path exists → DB default → six celebration animations.
|
||
private string? _alertVideoAssetId;
|
||
public string? AlertVideoAssetId
|
||
{
|
||
get => _alertVideoAssetId;
|
||
set => Set(ref _alertVideoAssetId, value);
|
||
}
|
||
|
||
private string? _alertVideoPath;
|
||
public string? AlertVideoPath
|
||
{
|
||
get => _alertVideoPath;
|
||
set => Set(ref _alertVideoPath, value);
|
||
}
|
||
|
||
private bool _alertUseDefaultVideo = true;
|
||
public bool AlertUseDefaultVideo
|
||
{
|
||
get => _alertUseDefaultVideo;
|
||
set => Set(ref _alertUseDefaultVideo, value);
|
||
}
|
||
|
||
public bool AlertShowTicker
|
||
{
|
||
get => _ticker.IsEnabled;
|
||
set => _ticker.IsEnabled = value;
|
||
}
|
||
|
||
private double _alertVideoVolume = 1.0;
|
||
public double AlertVideoVolume
|
||
{
|
||
get => _alertVideoVolume;
|
||
set => Set(ref _alertVideoVolume, Math.Clamp(value, 0.0, 1.0));
|
||
}
|
||
|
||
// Sound byte that plays with the alert clip (creator ruling 2026-11-01). Referenced from disk
|
||
// like the clip; the video always plays once to completion, the sound may loop under it.
|
||
private string? _alertAudioPath;
|
||
public string? AlertAudioPath
|
||
{
|
||
get => _alertAudioPath;
|
||
set => Set(ref _alertAudioPath, value);
|
||
}
|
||
|
||
private bool _alertAudioLoop;
|
||
public bool AlertAudioLoop
|
||
{
|
||
get => _alertAudioLoop;
|
||
set => Set(ref _alertAudioLoop, value);
|
||
}
|
||
|
||
public string AlertAudioFileDisplay =>
|
||
string.IsNullOrWhiteSpace(_alertAudioPath) ? "No sound byte" : System.IO.Path.GetFileName(_alertAudioPath);
|
||
|
||
/// <summary>True once a file is chosen — gates the loop switch and the clear button.</summary>
|
||
public bool HasAlertAudio => !string.IsNullOrWhiteSpace(_alertAudioPath);
|
||
|
||
public AlertDisplayMethod AlertDisplayMethod
|
||
{
|
||
get => _ticker.DisplayMethod;
|
||
set => _ticker.DisplayMethod = value;
|
||
}
|
||
|
||
public override bool IsAlertBox => Type == SourceType.AlertBox;
|
||
|
||
// Chat box config (TASK 3.18)
|
||
private double _chatFontSize = 16;
|
||
public double ChatFontSize
|
||
{
|
||
get => _chatFontSize;
|
||
set => Set(ref _chatFontSize, Math.Clamp(value, 8, 72));
|
||
}
|
||
|
||
private string _chatFontColor = "#FFFFFF";
|
||
public string ChatFontColor { get => _chatFontColor; set => Set(ref _chatFontColor, value); }
|
||
|
||
private string _chatBackgroundColor = "#80000000";
|
||
public string ChatBackgroundColor
|
||
{
|
||
get => _chatBackgroundColor;
|
||
set
|
||
{
|
||
if (Set(ref _chatBackgroundColor, value))
|
||
Raise(nameof(ChatBackgroundOpacity));
|
||
}
|
||
}
|
||
|
||
/// <summary>Alpha channel of ChatBackgroundColor as 0.0–1.0. Slider writes back the full hex string.</summary>
|
||
public double ChatBackgroundOpacity
|
||
{
|
||
get
|
||
{
|
||
var hex = _chatBackgroundColor.Trim().TrimStart('#');
|
||
return hex.Length == 8
|
||
? byte.Parse(hex.AsSpan(0, 2), System.Globalization.NumberStyles.HexNumber) / 255.0
|
||
: 1.0;
|
||
}
|
||
set
|
||
{
|
||
var alpha = (byte)Math.Clamp(value * 255, 0, 255);
|
||
var hex = _chatBackgroundColor.Trim().TrimStart('#');
|
||
if (hex.Length == 8) hex = hex[2..];
|
||
if (hex.Length == 6) ChatBackgroundColor = $"#{alpha:X2}{hex}";
|
||
}
|
||
}
|
||
|
||
private int _chatMaxMessages = 5;
|
||
public int ChatMaxMessages
|
||
{
|
||
get => _chatMaxMessages;
|
||
set => Set(ref _chatMaxMessages, Math.Clamp(value, 1, 20));
|
||
}
|
||
|
||
private bool _chatShowTimestamps = true;
|
||
public bool ChatShowTimestamps { get => _chatShowTimestamps; set => Set(ref _chatShowTimestamps, value); }
|
||
|
||
private bool _chatShowBadges = true;
|
||
public bool ChatShowBadges { get => _chatShowBadges; set => Set(ref _chatShowBadges, value); }
|
||
|
||
private string _chatMemberColor = "#e94560";
|
||
public string ChatMemberColor { get => _chatMemberColor; set => Set(ref _chatMemberColor, value); }
|
||
|
||
private string _chatSuperChatColor = "#eab308";
|
||
public string ChatSuperChatColor { get => _chatSuperChatColor; set => Set(ref _chatSuperChatColor, value); }
|
||
|
||
private string _chatFontFamily = "Segoe UI";
|
||
public string ChatFontFamily { get => _chatFontFamily; set => Set(ref _chatFontFamily, value); }
|
||
|
||
private int _chatFadeSeconds;
|
||
public int ChatFadeSeconds
|
||
{
|
||
get => _chatFadeSeconds;
|
||
set => Set(ref _chatFadeSeconds, Math.Clamp(value, 0, 60));
|
||
}
|
||
|
||
public override bool IsChatBox => Type == SourceType.ChatBox;
|
||
|
||
private string _textContent = "Text";
|
||
public string TextContent
|
||
{
|
||
get => _textContent;
|
||
set => Set(ref _textContent, value ?? string.Empty);
|
||
}
|
||
|
||
private string _textFontFamily = "Segoe UI";
|
||
public string TextFontFamily
|
||
{
|
||
get => _textFontFamily;
|
||
set => Set(ref _textFontFamily, string.IsNullOrWhiteSpace(value) ? "Segoe UI" : value.Trim());
|
||
}
|
||
|
||
private double _textFontSize = 48;
|
||
public double TextFontSize
|
||
{
|
||
get => _textFontSize;
|
||
set => Set(ref _textFontSize, double.IsFinite(value) ? Math.Clamp(value, 8, 300) : 48);
|
||
}
|
||
|
||
private string _textColor = "#FFFFFF";
|
||
public string TextColor
|
||
{
|
||
get => _textColor;
|
||
set
|
||
{
|
||
if (!Set(ref _textColor, IsValidTextColor(value) ? value.ToUpperInvariant() : "#FFFFFF")) return;
|
||
Raise(nameof(TextColorEntry));
|
||
Raise(nameof(TextColorOptions));
|
||
}
|
||
}
|
||
|
||
public NamedColor TextColorEntry
|
||
{
|
||
get => BorderColorPalette.EntryFor(TextColor);
|
||
set => TextColor = value.Hex;
|
||
}
|
||
|
||
public IReadOnlyList<NamedColor> TextColorOptions => BorderColorPalette.OptionsFor(TextColor);
|
||
|
||
private bool _textBold;
|
||
public bool TextBold { get => _textBold; set => Set(ref _textBold, value); }
|
||
|
||
private bool _textItalic;
|
||
public bool TextItalic { get => _textItalic; set => Set(ref _textItalic, value); }
|
||
|
||
private bool _textUnderline;
|
||
public bool TextUnderline { get => _textUnderline; set => Set(ref _textUnderline, value); }
|
||
|
||
private TextLayerAlignment _textAlignment;
|
||
public TextLayerAlignment TextAlignment
|
||
{
|
||
get => _textAlignment;
|
||
set => Set(ref _textAlignment, Enum.IsDefined(value) ? value : TextLayerAlignment.Left);
|
||
}
|
||
|
||
private TextLayerVerticalAlignment _textVerticalAlignment;
|
||
public TextLayerVerticalAlignment TextVerticalAlignment
|
||
{
|
||
get => _textVerticalAlignment;
|
||
set => Set(ref _textVerticalAlignment,
|
||
Enum.IsDefined(value) ? value : TextLayerVerticalAlignment.Top);
|
||
}
|
||
|
||
private static bool IsValidTextColor(string? value)
|
||
{
|
||
if (value is not { Length: 7 or 9 } || value[0] != '#') return false;
|
||
for (var i = 1; i < value.Length; i++)
|
||
if (!Uri.IsHexDigit(value[i])) return false;
|
||
return true;
|
||
}
|
||
|
||
public override bool IsCountdown => Type == SourceType.Countdown;
|
||
|
||
private bool _chatPreviewEnabled = true;
|
||
public bool ChatPreviewEnabled
|
||
{
|
||
get => _chatPreviewEnabled;
|
||
set => Set(ref _chatPreviewEnabled, value);
|
||
}
|
||
}
|