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).
201 lines
8.1 KiB
C#
201 lines
8.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using System.Windows.Media.Imaging;
|
|
using Microsoft.Win32;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Audio;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
// Media source (TASK 21): decode sessions are owned app-wide by
|
|
// MediaVideoSourceManager, refcounted by MediaPath. Modeled on the Webcam/Background
|
|
// partials: the readonly manager field is assigned in the core constructor, the
|
|
// shared WriteableBitmap published per file is adopted by every media Source whose
|
|
// MediaPath matches, and ResolveOutputFrame feeds the live compositor.
|
|
public partial class MainViewModel
|
|
{
|
|
// Decode resolution: the 1920x1080 master. Media frames are scaled to the
|
|
// element rect by the compositor (UniformToFill), so this is the capture size.
|
|
private const int MediaDecodeWidth = 1920;
|
|
private const int MediaDecodeHeight = 1080;
|
|
|
|
private const string VideoFileFilter =
|
|
"Video files (*.mp4;*.mov;*.avi;*.mkv;*.webm)|*.mp4;*.mov;*.avi;*.mkv;*.webm|All files (*.*)|*.*";
|
|
|
|
private readonly MediaVideoSourceManager _mediaManager;
|
|
|
|
// One subscription per media Source, so removing a layer stops the listener as
|
|
// well as the decoder (a scene element outlives its layer list row).
|
|
private readonly Dictionary<string, PropertyChangedEventHandler> _mediaHandlers = new();
|
|
|
|
// MediaVideoSourceManager publishes one shared WriteableBitmap per file; every
|
|
// media Source pointing at that path picks it up for the preview/canvas.
|
|
private void OnMediaPreviewBitmapChanged(string path, WriteableBitmap bitmap)
|
|
{
|
|
foreach (var source in Scenes.SelectMany(s => s.Elements.OfType<Source>())
|
|
.Where(s => s.IsMediaSource && s.MediaPath == path))
|
|
source.VideoImageSource = bitmap;
|
|
}
|
|
|
|
private void OnMediaFailed(string path, string message)
|
|
{
|
|
Helpers.AppLog.Write($"MediaSource: '{path}' failed: {message}");
|
|
_notifications.Warning("Couldn't play that video", System.IO.Path.GetFileName(path));
|
|
}
|
|
|
|
// ---------- add / change the file ----------
|
|
|
|
private void AddMedia()
|
|
{
|
|
if (PickVideoFile() is { } path)
|
|
AddMediaSource(path);
|
|
}
|
|
|
|
private string? PickVideoFile(string title = "Choose a video file")
|
|
{
|
|
var dialog = new OpenFileDialog
|
|
{
|
|
Title = title,
|
|
Filter = VideoFileFilter,
|
|
};
|
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
|
}
|
|
|
|
/// <summary>Adds a MediaSource layer for <paramref name="path"/> to the staged scene.
|
|
/// Internal so the Good Dog test drives the real add path without a file dialog.</summary>
|
|
internal void AddMediaSource(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return;
|
|
AddSource(SourceType.MediaSource, path);
|
|
}
|
|
|
|
private void ChangeMediaFile(Source? source)
|
|
{
|
|
if (source is not { IsMediaSource: true }) return;
|
|
if (PickVideoFile("Replace the video") is not { } path) return;
|
|
PushPreviewUndo();
|
|
source.MediaPath = path;
|
|
}
|
|
|
|
// ---------- session lifecycle (one session per distinct path) ----------
|
|
|
|
/// <summary>Starts decoding the file if nothing else already is, and subscribes to the
|
|
/// layer so a file swap or a loop flip reaches the session.</summary>
|
|
private void RegisterMediaSource(Source source)
|
|
{
|
|
if (_mediaHandlers.ContainsKey(source.Id)) return;
|
|
|
|
string? lastPath = source.MediaPath;
|
|
|
|
void OnSourceChanged(object? _, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName == nameof(Source.MediaPath))
|
|
{
|
|
var previous = lastPath;
|
|
lastPath = source.MediaPath;
|
|
if (string.Equals(previous, source.MediaPath, StringComparison.OrdinalIgnoreCase)) return;
|
|
// The layer now points elsewhere, so the old file may be unreferenced —
|
|
// release it before acquiring the replacement.
|
|
ReleaseMediaIfUnused(previous);
|
|
EnsureMediaSession(source.MediaPath);
|
|
}
|
|
else if (e.PropertyName == nameof(Source.MediaIsLooping) && source.MediaPath is { } path)
|
|
{
|
|
_mediaManager.SetLooping(path, AnyMediaReferenceLoops(path));
|
|
}
|
|
else if (e.PropertyName is nameof(Source.MediaVolume) or nameof(Source.MediaMuted)
|
|
&& source.MediaPath is { } audioPath)
|
|
{
|
|
ApplyMediaAudioSettings(audioPath);
|
|
}
|
|
}
|
|
|
|
_mediaHandlers[source.Id] = OnSourceChanged;
|
|
source.PropertyChanged += OnSourceChanged;
|
|
EnsureMediaSession(source.MediaPath);
|
|
}
|
|
|
|
private void UnregisterMediaSource(Source source)
|
|
{
|
|
if (_mediaHandlers.Remove(source.Id, out var handler))
|
|
source.PropertyChanged -= handler;
|
|
}
|
|
|
|
private void EnsureMediaSession(string? path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return;
|
|
if (_mediaManager.HasSession(path))
|
|
{
|
|
ApplyMediaAudioSettings(path);
|
|
return;
|
|
}
|
|
_ = AcquireMediaAsync(path);
|
|
}
|
|
|
|
private async System.Threading.Tasks.Task AcquireMediaAsync(string path)
|
|
{
|
|
if (!await _mediaManager.AcquireAsync(path)) return;
|
|
|
|
// A session can be running before this layer existed (shared path), in which
|
|
// case the bitmap is already published and will not be raised again for us.
|
|
if (_mediaManager.GetPreviewBitmap(path) is { } bitmap)
|
|
OnMediaPreviewBitmapChanged(path, bitmap);
|
|
|
|
ApplyMediaAudioSettings(path);
|
|
}
|
|
|
|
/// <summary>The file's sound controls: one decoder is shared per path, so the level and
|
|
/// mute are per session — the layer that was last touched is the one that wins.</summary>
|
|
private void ApplyMediaAudioSettings(string path)
|
|
{
|
|
var source = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.FirstOrDefault(s => s.IsMediaSource && s.MediaPath == path);
|
|
if (source == null) return;
|
|
_mediaManager.SetAudioSettings(path, source.MediaVolume, source.MediaMuted);
|
|
}
|
|
|
|
/// <summary>A clip's own soundtrack joins the desktop/game group, so the desktop fader and
|
|
/// the voice ducker treat it exactly like game audio (TASK 21 audio slice).</summary>
|
|
private void OnMediaAudioAvailable(string path, AudioSample sample)
|
|
=> _audioMixer.EnqueueDesktopMediaAudio(sample);
|
|
|
|
/// <summary>Shared sessions or not, the loop state is the path's: any live media
|
|
/// reference asking for a loop makes the one decoder loop (recorded rule, TASK 21).</summary>
|
|
private bool AnyMediaReferenceLoops(string path)
|
|
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Any(s => s.IsMediaSource && s.MediaPath == path && s.MediaIsLooping);
|
|
|
|
private bool HasMediaReference(string path)
|
|
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Any(s => s.IsMediaSource && s.MediaPath == path);
|
|
|
|
private void ReleaseMediaIfUnused(string? path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return;
|
|
if (!_mediaManager.HasSession(path)) return;
|
|
if (HasMediaReference(path)) return;
|
|
_ = _mediaManager.ReleaseAsync(path);
|
|
}
|
|
|
|
/// <summary>After a layout load: subscribe every persisted media layer and start one
|
|
/// session per distinct file; release sessions for files the layout no longer uses.</summary>
|
|
private void ReacquireMediaSources()
|
|
{
|
|
var sources = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.IsMediaSource && !string.IsNullOrWhiteSpace(s.MediaPath))
|
|
.ToList();
|
|
|
|
foreach (var source in sources)
|
|
RegisterMediaSource(source);
|
|
|
|
var wanted = sources.Select(s => s.MediaPath!).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
|
foreach (var stale in _mediaManager.GetActiveKeys()
|
|
.Where(key => !wanted.Contains(key, StringComparer.OrdinalIgnoreCase))
|
|
.ToList())
|
|
_ = _mediaManager.ReleaseAllAsync(stale);
|
|
}
|
|
}
|