feat(chroma): offer to remove a green or blue screen, and key it in the decode
TASK 64 slices D and E: the feature is usable end to end. - Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen — Remove it", with the measured colour as a swatch. - Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as `colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers (the shared preview bitmap and the compositor's frame) carry the same alpha. - Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two layers with the same settings still share one decoder, and changing the look releases the old session and starts the new one. Removal, load reconciliation, loop and audio settings all move to the same key. - Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer comes back keyed. - Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe. Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session rather than the old one; the setting survives a save. Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
This commit is contained in:
@@ -761,6 +761,47 @@
|
||||
</DockPanel>
|
||||
<TextBlock Text="Sound plays through the desktop audio controls, like game audio."
|
||||
Style="{StaticResource PropertyHint}"/>
|
||||
|
||||
<!-- Green/Blue Screen (TASK 64): the app offers, the creator
|
||||
accepts. No engineering words — Amount/Softness/Fringe. -->
|
||||
<Border Style="{StaticResource PropertyDivider}"/>
|
||||
<TextBlock Text="Green/Blue Screen" Style="{StaticResource PropertyHeader}"/>
|
||||
|
||||
<Border Margin="10,0,0,4" Padding="6,5" CornerRadius="4"
|
||||
Background="{StaticResource Brush.Outline}"
|
||||
Visibility="{Binding SelectedElement.HasChromaSuggestion, Converter={StaticResource BoolToVis}}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Left" Width="12" Height="12" CornerRadius="2"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Background="{Binding SelectedElement.ChromaSuggestionColor}"
|
||||
ToolTip="The screen colour this clip was shot against"/>
|
||||
<Button DockPanel.Dock="Right" Content="Remove it"
|
||||
Style="{StaticResource PropertyButton}" Padding="8,2" FontSize="11"
|
||||
Command="{Binding AcceptChromaSuggestionCommand}"
|
||||
CommandParameter="{Binding SelectedElement}"/>
|
||||
<TextBlock Text="{Binding SelectedElement.ChromaSuggestionText}"
|
||||
Style="{StaticResource PropertyFieldLabel}" VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,0,0,4"
|
||||
Content="Remove the background" IsChecked="{Binding SelectedElement.ChromaKeyEnabled, Mode=TwoWay}"
|
||||
ToolTip="Key out the green/blue backdrop so the layers beneath show through"/>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Amount" Style="{StaticResource PropertyFieldLabel}" Width="52"/>
|
||||
<Slider Minimum="0" Maximum="0.5" Width="110"
|
||||
Value="{Binding SelectedElement.ChromaKeySimilarity, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<DockPanel Margin="10,0,0,4">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Softness" Style="{StaticResource PropertyFieldLabel}" Width="52"/>
|
||||
<Slider Minimum="0" Maximum="0.5" Width="110"
|
||||
Value="{Binding SelectedElement.ChromaKeySoftness, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<CheckBox Style="{StaticResource PropertyPillCheck}" Margin="10,0,0,4"
|
||||
Content="Fringe" IsChecked="{Binding SelectedElement.ChromaKeySpill, Mode=TwoWay}"
|
||||
ToolTip="Clean the coloured rim the screen leaves on hair and edges"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Chat box config -->
|
||||
|
||||
+17
-15
@@ -1,21 +1,24 @@
|
||||
# HANDOFF — 2026-11-05 (TASK 21 — video file layer + its audio)
|
||||
# HANDOFF — 2026-11-05 (TASK 64 — green/blue screen for clips)
|
||||
|
||||
## State
|
||||
|
||||
- `main`; TASK 21 is complete. A video file added from the (+) menu decodes, composites into preview and
|
||||
stream, loops on toggle, and **plays its own soundtrack** (Mute + Level per layer, mixed into the
|
||||
desktop/game group so the desktop fader and the ducker apply). Removing it stops the decoder.
|
||||
- Decoding is now ONE pipe: `Services/VideoClipPipe.cs` behind both `MediaVideoSource` (TASK 21) and
|
||||
`AlertClipDecoder` (TASK 47) — "a clip is a clip". Alert behaviour is unchanged and its tests stay green.
|
||||
- Tests: `MediaSourceAcquisitionTests` (add → decoder starts, frames, loop, remove) and
|
||||
`MediaSourceAudioTests` (chunks, level scaling, mute, sound reaching the desktop meter). App build 0 warnings.
|
||||
- `main`. TASK 21 complete (video file layer + its soundtrack). TASK 64 shipped through slice E:
|
||||
a clip with a green or blue backdrop is **offered** a key on add ("Looks like a green screen —
|
||||
Remove it"), accepting it keys the background **inside the decode pipe** (bundled ffmpeg
|
||||
`colorkey`/`despill`), with Amount/Softness/Fringe in the panel and the settings persisted.
|
||||
Sessions are keyed by **path + look**, so identical looks share one decoder.
|
||||
- Detection stays silent on lawns/pitches (confidence = coverage × hue tightness) and ignores
|
||||
letterbox bars. It never acts on its own.
|
||||
- Tests: `ChromaKeyDetectorTests` (pure) + `ChromaKeyApplyTests` (pipe asks ffmpeg to key; offer →
|
||||
accept → keyed session feeds the resolver; survives save). App build 0 warnings; full suite green
|
||||
apart from the two documented pre-existing failures.
|
||||
|
||||
## Next action
|
||||
|
||||
- Next queued unit: TASK 49 (chat profanity filter). Day 3's TASK 32/33 resilience slices are still
|
||||
outstanding — 32·4 (fragmented MP4) is the smallest.
|
||||
- Agreed next feature work (designed, not started): green/blue screen detection + keying through the clip
|
||||
pipe — suggest-only by default, an opt-in "Auto Green/Blue Screen" setting, alert clips included.
|
||||
- TASK 64 slice F: the **"Auto Green/Blue Screen"** opt-in setting (App Settings, high confidence
|
||||
only) and wiring alert clips to the same settings block.
|
||||
- Then TASK 49 (chat profanity filter). Day 3's TASK 32/33 resilience slices remain outstanding —
|
||||
32·4 (fragmented MP4) is the smallest.
|
||||
- Do not push absent a checkpoint request.
|
||||
|
||||
## Landmines
|
||||
@@ -23,7 +26,6 @@
|
||||
- `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` injects physical
|
||||
mouse input. Do not run it while the desktop is in use.
|
||||
- Integration tests constructing `MainWindow` must set a temporary `LayoutPathOverride` and clear it in `finally`.
|
||||
- Media sessions are keyed per **path**, not per layer: acquire through `EnsureMediaSession`, release
|
||||
through `ReleaseMediaIfUnused`, never `AcquireAsync` twice for one path. Level/mute are therefore
|
||||
per session too.
|
||||
- Media sessions are keyed by **path + look** (`MainViewModel.MediaSessionKey`); never acquire by raw
|
||||
path, and release through `ReleaseMediaIfUnused`.
|
||||
- Bundled FFmpeg stays outside the single-file exe (`ExcludeFromSingleFile="true"`).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Windows.Media;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
@@ -282,6 +283,114 @@ public class Source : SceneElement
|
||||
|
||||
public override bool IsMediaSource => Type == SourceType.MediaSource;
|
||||
|
||||
// ── Green/Blue Screen keying (TASK 64) ────────────────────────────────────────────
|
||||
// Settings live on the LAYER. The decoder is keyed by (path + these settings), so two
|
||||
// layers with the same look share one decode and two different looks get two decoders.
|
||||
|
||||
private bool _chromaKeyEnabled;
|
||||
public bool ChromaKeyEnabled
|
||||
{
|
||||
get => _chromaKeyEnabled;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeyEnabled, value))
|
||||
{
|
||||
Raise(nameof(ChromaKey));
|
||||
Raise(nameof(HasChromaSuggestion));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _chromaKeyColor = "#00B140";
|
||||
/// <summary>The screen colour as it was shot (the detector's median), not a canonical green.</summary>
|
||||
public string ChromaKeyColor
|
||||
{
|
||||
get => _chromaKeyColor;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeyColor, value)) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private double _chromaKeySimilarity = 0.12;
|
||||
/// <summary>Creator-facing "Amount": how far from the key colour still counts as screen.</summary>
|
||||
public double ChromaKeySimilarity
|
||||
{
|
||||
get => _chromaKeySimilarity;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeySimilarity, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private double _chromaKeySoftness = 0.08;
|
||||
/// <summary>Creator-facing "Softness": the width of the fade at the keyed edge.</summary>
|
||||
public double ChromaKeySoftness
|
||||
{
|
||||
get => _chromaKeySoftness;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeySoftness, Math.Clamp(value, 0, 0.5))) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private bool _chromaKeySpill = true;
|
||||
/// <summary>Creator-facing "Fringe": clean the coloured rim the screen leaves on hair and edges.</summary>
|
||||
public bool ChromaKeySpill
|
||||
{
|
||||
get => _chromaKeySpill;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaKeySpill, value)) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
private ChromaScreen _chromaScreen = ChromaScreen.Green;
|
||||
public ChromaScreen ChromaScreen
|
||||
{
|
||||
get => _chromaScreen;
|
||||
set
|
||||
{
|
||||
if (Set(ref _chromaScreen, value)) Raise(nameof(ChromaKey));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The look this layer asks its decoder for — the one place the settings become an
|
||||
/// ffmpeg chain and a session signature.</summary>
|
||||
public ChromaKeyOptions ChromaKey
|
||||
=> new(ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness, ChromaKeySpill, ChromaScreen);
|
||||
|
||||
private ChromaKeySuggestion _chromaSuggestion;
|
||||
/// <summary>The detector's offer for this clip. Transient by design — never persisted, because
|
||||
/// it is a hint about a file, not a setting the creator made.</summary>
|
||||
public ChromaKeySuggestion ChromaSuggestion
|
||||
{
|
||||
get => _chromaSuggestion;
|
||||
set
|
||||
{
|
||||
_chromaSuggestion = value;
|
||||
Raise(nameof(ChromaSuggestion));
|
||||
Raise(nameof(HasChromaSuggestion));
|
||||
Raise(nameof(ChromaSuggestionText));
|
||||
Raise(nameof(HasChromaSuggestionColor));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while there is an offer the creator has not accepted.</summary>
|
||||
public bool HasChromaSuggestion => !ChromaKeyEnabled && ChromaSuggestion.Screen != ChromaScreen.None;
|
||||
|
||||
public string ChromaSuggestionText => ChromaSuggestion.Screen switch
|
||||
{
|
||||
ChromaScreen.Green => "Looks like a green screen.",
|
||||
ChromaScreen.Blue => "Looks like a blue screen.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>The detected colour, for the swatch next to the offer.</summary>
|
||||
public string ChromaSuggestionColor => ChromaSuggestion.ColorHex ?? "#00B140";
|
||||
|
||||
public bool HasChromaSuggestionColor => ChromaSuggestion.ColorHex != null;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64: reads ONE frame out of a video file so the app can look at its background before
|
||||
/// the creator says anything. Kept behind an interface so tests never spawn ffmpeg, and separate
|
||||
/// from the decode pipe because this is a read, not a playback session.
|
||||
/// </summary>
|
||||
public interface IChromaProbe
|
||||
{
|
||||
/// <summary>The file's first usable frame at a small size, or null when it cannot be read
|
||||
/// (bad path, unsupported file, ffmpeg gave nothing). Null is a normal answer: the layer
|
||||
/// simply gets no suggestion.</summary>
|
||||
Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IChromaProbe"/>: one ffmpeg pass, scaled to 160×90 so the look is
|
||||
/// cheap and the detector sees plenty of the backdrop.</summary>
|
||||
public sealed class FfmpegChromaProbe : IChromaProbe
|
||||
{
|
||||
private const int ProbeWidth = 160;
|
||||
private const int ProbeHeight = 90;
|
||||
|
||||
private readonly IFfmpegLocator _locator;
|
||||
private readonly Func<IDecodeProcess> _processFactory;
|
||||
|
||||
public FfmpegChromaProbe(IFfmpegLocator locator, Func<IDecodeProcess> processFactory)
|
||||
{
|
||||
_locator = locator;
|
||||
_processFactory = processFactory;
|
||||
}
|
||||
|
||||
public async Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
try
|
||||
{
|
||||
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{path}\" -frames:v 1 -an " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf scale={ProbeWidth}:{ProbeHeight} -";
|
||||
|
||||
using var process = _processFactory();
|
||||
process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
|
||||
var frames = await Task.Run(() =>
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
process.StandardOutput.CopyTo(buffer);
|
||||
var reader = new RawVideoFrameReader(ProbeWidth, ProbeHeight);
|
||||
var fed = reader.Feed(buffer.ToArray());
|
||||
return fed;
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return frames.Count > 0 ? frames[0] : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Helpers.AppLog.Write($"ChromaProbe: '{Path.GetFileName(path)}' unreadable: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,9 @@ public partial class LayoutStore : IDisposable
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume, SortOrder,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
|
||||
ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity, ChromaKeySoftness,
|
||||
ChromaKeySpill, ChromaScreen
|
||||
FROM Source ORDER BY SortOrder
|
||||
""";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
@@ -184,6 +186,16 @@ public partial class LayoutStore : IDisposable
|
||||
? TextLayerVerticalAlignment.Top
|
||||
: (TextLayerVerticalAlignment)reader.GetInt32(49),
|
||||
MediaMuted = reader.GetInt32(reader.GetOrdinal("MediaMuted")) != 0,
|
||||
ChromaKeyEnabled = reader.GetInt32(reader.GetOrdinal("ChromaKeyEnabled")) != 0,
|
||||
ChromaKeyColor = reader.IsDBNull(reader.GetOrdinal("ChromaKeyColor"))
|
||||
? "#00B140"
|
||||
: reader.GetString(reader.GetOrdinal("ChromaKeyColor")),
|
||||
ChromaKeySimilarity = reader.GetDouble(reader.GetOrdinal("ChromaKeySimilarity")),
|
||||
ChromaKeySoftness = reader.GetDouble(reader.GetOrdinal("ChromaKeySoftness")),
|
||||
ChromaKeySpill = reader.GetInt32(reader.GetOrdinal("ChromaKeySpill")) != 0,
|
||||
ChromaScreen = reader.IsDBNull(reader.GetOrdinal("ChromaScreen"))
|
||||
? ChromaScreen.Green
|
||||
: (ChromaScreen)reader.GetInt32(reader.GetOrdinal("ChromaScreen")),
|
||||
};
|
||||
if (!sourcesByScene.TryGetValue(sceneId, out var list))
|
||||
sourcesByScene[sceneId] = list = new List<(Source, int)>();
|
||||
|
||||
@@ -385,6 +385,44 @@ public partial class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 64: green/blue screen settings per layer (applied in the decode pipe).
|
||||
if (!columns.Contains("ChromaKeyEnabled"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyEnabled INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeyColor"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeyColor TEXT NOT NULL DEFAULT '#00B140';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeySimilarity"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySimilarity REAL NOT NULL DEFAULT 0.12;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeySoftness"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySoftness REAL NOT NULL DEFAULT 0.08;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaKeySpill"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaKeySpill INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
if (!columns.Contains("ChromaScreen"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChromaScreen INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// TASK 47: alert video config. The default clip asset id + the creator's
|
||||
// replacement path (a FILE reference, never a stored BLOB) + the pill,
|
||||
// ticker toggle and volume live on the row.
|
||||
|
||||
@@ -94,7 +94,9 @@ public partial class LayoutStore : IDisposable
|
||||
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
|
||||
AlertDisplayMethod, IsBrandLogo, BlurAmount, AlertAudioPath, AlertAudioLoop,
|
||||
TextContent, TextFontFamily, TextFontSize, TextColor, TextBold, TextItalic,
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted)
|
||||
TextUnderline, TextAlignment, TextVerticalAlignment, MediaMuted,
|
||||
ChromaKeyEnabled, ChromaKeyColor, ChromaKeySimilarity,
|
||||
ChromaKeySoftness, ChromaKeySpill, ChromaScreen)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
@@ -104,7 +106,9 @@ public partial class LayoutStore : IDisposable
|
||||
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
|
||||
$alertDisplayMethod, $isBrandLogo, $blurAmount, $alertAudioPath, $alertAudioLoop,
|
||||
$textContent, $textFontFamily, $textFontSize, $textColor, $textBold, $textItalic,
|
||||
$textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted)
|
||||
$textUnderline, $textAlignment, $textVerticalAlignment, $mediaMuted,
|
||||
$chromaEnabled, $chromaColor, $chromaSimilarity, $chromaSoftness,
|
||||
$chromaSpill, $chromaScreen)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
@@ -138,6 +142,12 @@ public partial class LayoutStore : IDisposable
|
||||
var mediaIsLoopingP = cmd.Parameters.Add("$mediaIsLooping", SqliteType.Integer);
|
||||
var mediaVolumeP = cmd.Parameters.Add("$mediaVolume", SqliteType.Real);
|
||||
var mediaMutedP = cmd.Parameters.Add("$mediaMuted", SqliteType.Integer);
|
||||
var chromaEnabledP = cmd.Parameters.Add("$chromaEnabled", SqliteType.Integer);
|
||||
var chromaColorP = cmd.Parameters.Add("$chromaColor", SqliteType.Text);
|
||||
var chromaSimilarityP = cmd.Parameters.Add("$chromaSimilarity", SqliteType.Real);
|
||||
var chromaSoftnessP = cmd.Parameters.Add("$chromaSoftness", SqliteType.Real);
|
||||
var chromaSpillP = cmd.Parameters.Add("$chromaSpill", SqliteType.Integer);
|
||||
var chromaScreenP = cmd.Parameters.Add("$chromaScreen", SqliteType.Integer);
|
||||
var mediaPlaybackStateP = cmd.Parameters.Add("$mediaPlaybackState", SqliteType.Text);
|
||||
var alertVideoAssetIdP = cmd.Parameters.Add("$alertVideoAssetId", SqliteType.Text);
|
||||
var alertVideoPathP = cmd.Parameters.Add("$alertVideoPath", SqliteType.Text);
|
||||
@@ -200,6 +210,12 @@ public partial class LayoutStore : IDisposable
|
||||
mediaIsLoopingP.Value = source.MediaIsLooping ? 1 : 0;
|
||||
mediaVolumeP.Value = source.MediaVolume;
|
||||
mediaMutedP.Value = source.MediaMuted ? 1 : 0;
|
||||
chromaEnabledP.Value = source.ChromaKeyEnabled ? 1 : 0;
|
||||
chromaColorP.Value = source.ChromaKeyColor;
|
||||
chromaSimilarityP.Value = source.ChromaKeySimilarity;
|
||||
chromaSoftnessP.Value = source.ChromaKeySoftness;
|
||||
chromaSpillP.Value = source.ChromaKeySpill ? 1 : 0;
|
||||
chromaScreenP.Value = (int)source.ChromaScreen;
|
||||
mediaPlaybackStateP.Value = source.MediaPlaybackState.ToString();
|
||||
alertVideoAssetIdP.Value = (object?)source.AlertVideoAssetId ?? DBNull.Value;
|
||||
alertVideoPathP.Value = (object?)source.AlertVideoPath ?? DBNull.Value;
|
||||
|
||||
@@ -10,6 +10,9 @@ namespace ytLive.Services;
|
||||
/// (TASK 47), so the two features cannot drift apart again in how they decode.
|
||||
/// <para>Video AND the file's own soundtrack are decoded. The manager scales the audio by
|
||||
/// the layer's level/mute and hands it to the mixer's desktop group.</para>
|
||||
/// <para><b>Keying (TASK 64) is a decode option</b>: pass <see cref="ChromaKeyOptions"/> and the
|
||||
/// green/blue screen is removed inside ffmpeg's pipe, so the preview bitmap and the composited
|
||||
/// frame both carry the keyed alpha. Two looks = two sources; identical looks share one.</para>
|
||||
/// </summary>
|
||||
public sealed class MediaVideoSource : IMediaFrameSource
|
||||
{
|
||||
@@ -35,11 +38,13 @@ public sealed class MediaVideoSource : IMediaFrameSource
|
||||
IFfmpegLocator locator,
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null,
|
||||
ChromaKeyOptions? chromaKey = null)
|
||||
{
|
||||
_path = path;
|
||||
_clip = new VideoClipPipe(
|
||||
path, width, height, locator, processFactory, frameRateProbe, delay, audio: true);
|
||||
path, width, height, locator, processFactory, frameRateProbe, delay,
|
||||
audio: true, chromaKey: chromaKey);
|
||||
_clip.FrameAvailable += frame => FrameAvailable?.Invoke(frame);
|
||||
_clip.AudioAvailable += sample => AudioAvailable?.Invoke(sample);
|
||||
_clip.Completed += () => Completed?.Invoke();
|
||||
|
||||
@@ -41,7 +41,8 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Func<string, IMediaFrameSource?> _sourceFactory;
|
||||
// (file path, the layer's look) → decoder. The SESSION key is separate: path + look.
|
||||
private readonly Func<string, ChromaKeyOptions?, IMediaFrameSource?> _sourceFactory;
|
||||
private readonly Dispatcher? _uiDispatcher;
|
||||
private readonly Func<string, bool>? _loopProvider;
|
||||
private readonly Dictionary<string, MediaSession> _sessions = new();
|
||||
@@ -58,7 +59,7 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
public event Action<string, AudioSample>? AudioAvailable;
|
||||
|
||||
public MediaVideoSourceManager(
|
||||
Func<string, IMediaFrameSource?> sourceFactory,
|
||||
Func<string, ChromaKeyOptions?, IMediaFrameSource?> sourceFactory,
|
||||
Dispatcher? uiDispatcher = null,
|
||||
Func<string, bool>? loopProvider = null)
|
||||
{
|
||||
@@ -67,8 +68,14 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
_loopProvider = loopProvider;
|
||||
}
|
||||
|
||||
/// <summary>Increments the refcount for a file, starting the decoder the first time.</summary>
|
||||
public async Task<bool> AcquireAsync(string key)
|
||||
/// <summary>Increments the refcount for a file, starting the decoder the first time.
|
||||
/// <paramref name="keyOptions"/> is the layer's look (TASK 64): the caller keys sessions by
|
||||
/// path + look, so two looks on one file are two decoders and identical looks share one.</summary>
|
||||
public async Task<bool> AcquireAsync(string key, ChromaKeyOptions? keyOptions = null)
|
||||
=> await AcquireAsync(key, key, keyOptions).ConfigureAwait(false);
|
||||
|
||||
/// <summary>Acquires by session key, decoding <paramref name="path"/> with the given look.</summary>
|
||||
public async Task<bool> AcquireAsync(string key, string path, ChromaKeyOptions? keyOptions = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return false;
|
||||
|
||||
@@ -87,7 +94,7 @@ public sealed class MediaVideoSourceManager : IDisposable
|
||||
IMediaFrameSource? source;
|
||||
try
|
||||
{
|
||||
source = _sourceFactory(key);
|
||||
source = _sourceFactory(path, keyOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class VideoClipPipe : IDisposable
|
||||
private readonly IFrameRateProbe? _probe;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
||||
private readonly bool _audio;
|
||||
private readonly ChromaKeyOptions? _key;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private IDisposable? _run;
|
||||
private IDecodeProcess? _activeVideo;
|
||||
@@ -57,7 +58,8 @@ public sealed class VideoClipPipe : IDisposable
|
||||
Func<IDecodeProcess> processFactory,
|
||||
IFrameRateProbe? frameRateProbe = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? delay = null,
|
||||
bool audio = true)
|
||||
bool audio = true,
|
||||
ChromaKeyOptions? chromaKey = null)
|
||||
{
|
||||
_path = path;
|
||||
_width = width;
|
||||
@@ -67,6 +69,7 @@ public sealed class VideoClipPipe : IDisposable
|
||||
_probe = frameRateProbe;
|
||||
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
||||
_audio = audio;
|
||||
_key = chromaKey;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken = default)
|
||||
@@ -147,7 +150,7 @@ public sealed class VideoClipPipe : IDisposable
|
||||
{
|
||||
var args =
|
||||
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
||||
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
|
||||
$"-f rawvideo -pix_fmt bgra -vf {VideoFilterChain.BuildVideo(_width, _height, _key)} -an -";
|
||||
using var process = _processFactory();
|
||||
_activeVideo = process;
|
||||
process.Start(new ProcessStartInfo
|
||||
|
||||
@@ -43,7 +43,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block).
|
||||
| — | ✅ | Output entitlement code: free at 30 fps / paid at 60 fps regardless of resolution; Steam App ID is still pending | [`commercial`](docs/ai/commercial.md) |
|
||||
| 3·17 | ✅ | **Text layer** — content, font family/size/weight/style, colour, alignment, drag/resize, persisted (done 2026-11-04) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
|
||||
| 21 | ✅ | Media source: video file added from the (+) menu decodes, composites, plays its soundtrack, loops, and stops with its last reference (done 2026-11-05) | [`task-21`](TASKS/task-21-media-source.md) |
|
||||
| 64 | 🔶 **NEXT** | **Green/Blue Screen** — detect a green/blue backdrop in a clip and key it out through the decode pipe; suggest-only, opt-in auto-apply, alert clips included | [`task-64`](TASKS/task-64-green-blue-screen.md) |
|
||||
| 64 | 🔶 | **Green/Blue Screen** — detect + key shipped 2026-11-05 (offer on add, Amount/Softness/Fringe, keyed in the decode pipe, persisted); **remaining: opt-in auto-apply setting + alert clips** | [`task-64`](TASKS/task-64-green-blue-screen.md) |
|
||||
| — | ☐ | Transitions **Move**: per-layer carry-across-scenes flag, per-scene placement, fixed z band | [`ui-surface`](docs/ai/ui-surface.md) |
|
||||
| 55·2–3 | ☐ | Disk hygiene: delete `%TEMP%` alert clips on close + sweep at startup; low-disk guard before REC (slice 1 shipped) | [`task-55`](TASKS/task-55-disk-hygiene.md) |
|
||||
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
|
||||
|
||||
@@ -46,9 +46,9 @@ lighting to blame. The reversal is recorded there.
|
||||
| A | ✅ media audio + shared clip pipe (TASK 21) | shipped `2670ac0` |
|
||||
| B | ✅ Pure `Services/ChromaKeyDetector.cs`: sample a `VideoFrame` → green / blue / none + suggested colour + confidence | synthetic frames: green, blue, gradient, letterboxed, lawn (must say none) |
|
||||
| C | ✅ Pure `Services/VideoFilterChain.cs`: options → `-vf` string (`scale → colorkey → despill → format=bgra`) | exact string assertions (encoder `FfmpegArgs` style) |
|
||||
| D | Detect on add: one `-frames:v 1` probe (mirror `IFrameRateProbe`) → suggestion in the panel; no rendering change | add a green-screen file → suggestion appears with its colour |
|
||||
| E | Apply: session key `path|key-settings`, filter chain in the pipe, level/mute untouched | keyed layer over art shows the art through keyed pixels **and** the preview bitmap carries alpha |
|
||||
| F | Alert clips (falls out of E), tuning sliders, "Auto Green/Blue Screen" setting (App Settings), spill | slider reaches the chain and the restart preserves loop state |
|
||||
| D | ✅ Detect on add: one `-frames:v 1` probe (mirror `IFrameRateProbe`) → suggestion in the panel; no rendering change | add a green-screen file → suggestion appears with its colour |
|
||||
| E | ✅ Apply: session key `path|key-settings`, filter chain in the pipe, level/mute untouched | keyed layer over art shows the art through keyed pixels **and** the preview bitmap carries alpha |
|
||||
| F | ☐ **Remaining:** "Auto Green/Blue Screen" opt-in in App Settings (high confidence only), alert clips wired to the same settings block, spill quality pass | slider reaches the chain and the restart preserves loop state |
|
||||
|
||||
## 3. Landmines for whoever builds this
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ namespace ytLive.ViewModels;
|
||||
// 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.
|
||||
//
|
||||
// TASK 64 changed what a "session" is: it is now keyed by file path + the layer's LOOK
|
||||
// (green/blue screen settings), because the key is applied inside the decode pipe. Two
|
||||
// layers with the same look share one decoder; two different looks get two.
|
||||
public partial class MainViewModel
|
||||
{
|
||||
// Decode resolution: the 1920x1080 master. Media frames are scaled to the
|
||||
@@ -27,23 +31,37 @@ public partial class MainViewModel
|
||||
|
||||
private readonly MediaVideoSourceManager _mediaManager;
|
||||
|
||||
// Reads one frame of a clip so the layer can offer "looks like a green screen" (TASK 64).
|
||||
private readonly IChromaProbe? _chromaProbe;
|
||||
|
||||
// One probe per file per launch — the answer belongs to the file, not to the layer.
|
||||
private readonly HashSet<string> _chromaProbedPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 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)
|
||||
/// <summary>What a decode session is: the file plus the look applied in the pipe (TASK 64).
|
||||
/// A path cannot contain '|', so the key is unambiguous and human-readable in logs.</summary>
|
||||
internal static string MediaSessionKey(string path, ChromaKeyOptions key)
|
||||
=> $"{path}|{key.Signature}";
|
||||
|
||||
internal static string MediaSessionKey(Source source)
|
||||
=> source.MediaPath is { } path ? MediaSessionKey(path, source.ChromaKey) : string.Empty;
|
||||
|
||||
// MediaVideoSourceManager publishes one shared WriteableBitmap per session; every
|
||||
// media Source pointing at that path+look picks it up for the preview/canvas.
|
||||
private void OnMediaPreviewBitmapChanged(string sessionKey, WriteableBitmap bitmap)
|
||||
{
|
||||
foreach (var source in Scenes.SelectMany(s => s.Elements.OfType<Source>())
|
||||
.Where(s => s.IsMediaSource && s.MediaPath == path))
|
||||
source.VideoImageSource = bitmap;
|
||||
foreach (var source in Scenes.SelectMany(s => s.Elements).OfType<Source>())
|
||||
if (source.IsMediaSource && MediaSessionKey(source) == sessionKey)
|
||||
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));
|
||||
_notifications.Warning("Couldn't play that video", System.IO.Path.GetFileName(SessionPath(path)));
|
||||
}
|
||||
|
||||
// ---------- add / change the file ----------
|
||||
@@ -77,45 +95,93 @@ public partial class MainViewModel
|
||||
if (source is not { IsMediaSource: true }) return;
|
||||
if (PickVideoFile("Replace the video") is not { } path) return;
|
||||
PushPreviewUndo();
|
||||
source.ChromaSuggestion = default;
|
||||
source.MediaPath = path;
|
||||
}
|
||||
|
||||
// ---------- session lifecycle (one session per distinct path) ----------
|
||||
// ---------- green / blue screen detection (TASK 64) ----------
|
||||
|
||||
/// <summary>Looks at one frame of the clip and stores the offer on the layer. Runs off the
|
||||
/// UI thread through the probe seam; a file that cannot be read simply gets no offer.</summary>
|
||||
private async System.Threading.Tasks.Task SuggestChromaKeyAsync(Source source)
|
||||
{
|
||||
if (!source.IsMediaSource || source.MediaPath is not { } path) return;
|
||||
if (_chromaProbe == null) return;
|
||||
|
||||
var frame = await _chromaProbe.ProbeAsync(path).ConfigureAwait(true);
|
||||
if (frame == null) return;
|
||||
if (!string.Equals(source.MediaPath, path, StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
source.ChromaSuggestion = ChromaKeyDetector.Detect(frame);
|
||||
}
|
||||
|
||||
/// <summary>Accepts the detector's offer: the layer keys the colour the camera actually saw.</summary>
|
||||
private void AcceptChromaSuggestion(Source? source)
|
||||
{
|
||||
if (source is not { IsMediaSource: true } || source.ChromaSuggestion.Screen == ChromaScreen.None) return;
|
||||
PushPreviewUndo();
|
||||
var key = ChromaKeyOptions.For(source.ChromaSuggestion);
|
||||
source.ChromaKeyColor = key.Color;
|
||||
source.ChromaKeySimilarity = key.Similarity;
|
||||
source.ChromaKeySoftness = key.Softness;
|
||||
source.ChromaKeySpill = key.Spill;
|
||||
source.ChromaScreen = key.Screen;
|
||||
source.ChromaKeyEnabled = true;
|
||||
}
|
||||
|
||||
// ---------- session lifecycle (one session per path + look) ----------
|
||||
|
||||
/// <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>
|
||||
/// layer so a file swap, a loop flip or a look change reaches the right session.</summary>
|
||||
private void RegisterMediaSource(Source source)
|
||||
{
|
||||
if (_mediaHandlers.ContainsKey(source.Id)) return;
|
||||
|
||||
string? lastPath = source.MediaPath;
|
||||
var lastKey = MediaSessionKey(source);
|
||||
|
||||
void OnSourceChanged(object? _, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(Source.MediaPath))
|
||||
if (e.PropertyName is nameof(Source.MediaPath) or nameof(Source.ChromaKeyEnabled)
|
||||
or nameof(Source.ChromaKeyColor) or nameof(Source.ChromaKeySimilarity)
|
||||
or nameof(Source.ChromaKeySoftness) or nameof(Source.ChromaKeySpill)
|
||||
or nameof(Source.ChromaScreen))
|
||||
{
|
||||
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.
|
||||
var next = MediaSessionKey(source);
|
||||
if (string.Equals(next, lastKey, StringComparison.OrdinalIgnoreCase)) return;
|
||||
var previous = lastKey;
|
||||
lastKey = next;
|
||||
// A look change is a different decode: let the old session go (if nothing
|
||||
// else uses it) and start the new one.
|
||||
ReleaseMediaIfUnused(previous);
|
||||
EnsureMediaSession(source.MediaPath);
|
||||
EnsureMediaSession(next);
|
||||
return;
|
||||
}
|
||||
else if (e.PropertyName == nameof(Source.MediaIsLooping) && source.MediaPath is { } path)
|
||||
|
||||
if (e.PropertyName == nameof(Source.MediaIsLooping) && MediaSessionKey(source) is { Length: > 0 } key)
|
||||
{
|
||||
_mediaManager.SetLooping(path, AnyMediaReferenceLoops(path));
|
||||
_mediaManager.SetLooping(key, AnyMediaReferenceLoops(key));
|
||||
}
|
||||
else if (e.PropertyName is nameof(Source.MediaVolume) or nameof(Source.MediaMuted)
|
||||
&& source.MediaPath is { } audioPath)
|
||||
&& MediaSessionKey(source) is { Length: > 0 } audioKey)
|
||||
{
|
||||
ApplyMediaAudioSettings(audioPath);
|
||||
ApplyMediaAudioSettings(audioKey);
|
||||
}
|
||||
}
|
||||
|
||||
_mediaHandlers[source.Id] = OnSourceChanged;
|
||||
source.PropertyChanged += OnSourceChanged;
|
||||
EnsureMediaSession(source.MediaPath);
|
||||
|
||||
if (source.MediaPath is { Length: > 0 } path)
|
||||
{
|
||||
EnsureMediaSession(MediaSessionKey(path, source.ChromaKey));
|
||||
|
||||
// One probe per file per launch: the answer depends on the file, not the layer.
|
||||
if (source.ChromaSuggestion.Screen == ChromaScreen.None && !_chromaProbedPaths.Contains(path))
|
||||
{
|
||||
_chromaProbedPaths.Add(path);
|
||||
_ = SuggestChromaKeyAsync(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UnregisterMediaSource(Source source)
|
||||
@@ -124,64 +190,68 @@ public partial class MainViewModel
|
||||
source.PropertyChanged -= handler;
|
||||
}
|
||||
|
||||
private void EnsureMediaSession(string? path)
|
||||
private void EnsureMediaSession(string? sessionKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
if (_mediaManager.HasSession(path))
|
||||
if (string.IsNullOrWhiteSpace(sessionKey)) return;
|
||||
if (_mediaManager.HasSession(sessionKey))
|
||||
{
|
||||
ApplyMediaAudioSettings(path);
|
||||
ApplyMediaAudioSettings(sessionKey);
|
||||
return;
|
||||
}
|
||||
_ = AcquireMediaAsync(path);
|
||||
_ = AcquireMediaSessionAsync(sessionKey);
|
||||
}
|
||||
|
||||
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)
|
||||
private async System.Threading.Tasks.Task AcquireMediaSessionAsync(string sessionKey)
|
||||
{
|
||||
var source = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.FirstOrDefault(s => s.IsMediaSource && s.MediaPath == path);
|
||||
.FirstOrDefault(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey);
|
||||
if (source?.MediaPath is not { } path) return;
|
||||
|
||||
if (!await _mediaManager.AcquireAsync(sessionKey, path, source.ChromaKey)) return;
|
||||
|
||||
// A session can be running before this layer existed (shared look), in which
|
||||
// case the bitmap is already published and will not be raised again for us.
|
||||
if (_mediaManager.GetPreviewBitmap(sessionKey) is { } bitmap)
|
||||
OnMediaPreviewBitmapChanged(sessionKey, bitmap);
|
||||
|
||||
ApplyMediaAudioSettings(sessionKey);
|
||||
}
|
||||
|
||||
/// <summary>The session's sound controls: one decoder is shared per path+look, so the level
|
||||
/// and mute are per session — the layer that was last touched is the one that wins.</summary>
|
||||
private void ApplyMediaAudioSettings(string sessionKey)
|
||||
{
|
||||
var source = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.FirstOrDefault(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey);
|
||||
if (source == null) return;
|
||||
_mediaManager.SetAudioSettings(path, source.MediaVolume, source.MediaMuted);
|
||||
_mediaManager.SetAudioSettings(sessionKey, 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)
|
||||
private void OnMediaAudioAvailable(string sessionKey, 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)
|
||||
/// <summary>Shared sessions or not, the loop state is the session's: any live reference
|
||||
/// asking for a loop makes that one decoder loop (recorded rule, TASK 21).</summary>
|
||||
private bool AnyMediaReferenceLoops(string sessionKey)
|
||||
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Any(s => s.IsMediaSource && s.MediaPath == path && s.MediaIsLooping);
|
||||
.Any(s => s.IsMediaSource && MediaSessionKey(s) == sessionKey && s.MediaIsLooping);
|
||||
|
||||
private bool HasMediaReference(string path)
|
||||
private bool HasMediaReference(string sessionKey)
|
||||
=> Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
.Any(s => s.IsMediaSource && s.MediaPath == path);
|
||||
.Any(s => s.IsMediaSource && string.Equals(MediaSessionKey(s), sessionKey, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void ReleaseMediaIfUnused(string? path)
|
||||
private void ReleaseMediaIfUnused(string? sessionKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return;
|
||||
if (!_mediaManager.HasSession(path)) return;
|
||||
if (HasMediaReference(path)) return;
|
||||
_ = _mediaManager.ReleaseAsync(path);
|
||||
if (string.IsNullOrWhiteSpace(sessionKey)) return;
|
||||
if (!_mediaManager.HasSession(sessionKey)) return;
|
||||
if (HasMediaReference(sessionKey)) return;
|
||||
_ = _mediaManager.ReleaseAsync(sessionKey);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// session per distinct path+look; release sessions the layout no longer uses.</summary>
|
||||
private void ReacquireMediaSources()
|
||||
{
|
||||
var sources = Scenes.SelectMany(s => s.Elements).OfType<Source>()
|
||||
@@ -191,10 +261,17 @@ public partial class MainViewModel
|
||||
foreach (var source in sources)
|
||||
RegisterMediaSource(source);
|
||||
|
||||
var wanted = sources.Select(s => s.MediaPath!).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var wanted = sources.Select(MediaSessionKey)
|
||||
.Where(key => key.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
foreach (var stale in _mediaManager.GetActiveKeys()
|
||||
.Where(key => !wanted.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList())
|
||||
_ = _mediaManager.ReleaseAllAsync(stale);
|
||||
}
|
||||
|
||||
/// <summary>A session key carries a file path and a look; the toast wants the file name.</summary>
|
||||
private static string SessionPath(string sessionKey)
|
||||
=> sessionKey.Split('|')[0];
|
||||
}
|
||||
|
||||
@@ -300,16 +300,16 @@ public partial class MainViewModel
|
||||
_webView2Manager?.Unregister(webSource.Id);
|
||||
|
||||
// Media: the layer stops listening, and its decode session stops once no scene
|
||||
// references the file any more (sessions are shared per path).
|
||||
string? mediaPath = null;
|
||||
// references the same file + look any more (TASK 64: sessions are per path + look).
|
||||
string? mediaSessionKey = null;
|
||||
if (element is Source mediaSource && mediaSource.IsMediaSource)
|
||||
{
|
||||
mediaSessionKey = MediaSessionKey(mediaSource);
|
||||
UnregisterMediaSource(mediaSource);
|
||||
mediaPath = mediaSource.MediaPath;
|
||||
}
|
||||
|
||||
_sceneGraph.RemoveElement(scene, element);
|
||||
ReleaseMediaIfUnused(mediaPath);
|
||||
ReleaseMediaIfUnused(mediaSessionKey);
|
||||
|
||||
if (element is WebcamSceneConfig)
|
||||
{
|
||||
|
||||
@@ -190,6 +190,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
public ICommand AddImageCommand { get; }
|
||||
public ICommand AddMediaCommand { get; }
|
||||
public ICommand ChangeMediaFileCommand { get; }
|
||||
/// <summary>Accepts the detector's green/blue-screen offer on a clip (TASK 64).</summary>
|
||||
public ICommand AcceptChromaSuggestionCommand { get; }
|
||||
public ICommand RemoveSourceCommand { get; }
|
||||
public ICommand EditElementCommand { get; }
|
||||
public ICommand ToggleElementVisibilityCommand { get; }
|
||||
@@ -266,6 +268,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
ChangeMediaFileCommand = new RelayCommand(
|
||||
element => ChangeMediaFile(element as Source),
|
||||
element => element is Source { IsMediaSource: true });
|
||||
AcceptChromaSuggestionCommand = new RelayCommand(
|
||||
element => AcceptChromaSuggestion(element as Source),
|
||||
element => element is Source { HasChromaSuggestion: true });
|
||||
RemoveSourceCommand = new RelayCommand(element => { PushPreviewUndo(); RemoveElement(element as SceneElement); });
|
||||
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
|
||||
ToggleElementVisibilityCommand = new RelayCommand(element => { PushPreviewUndo(); ToggleElementVisibility(element as SceneElement); });
|
||||
@@ -401,12 +406,18 @@ public partial class MainViewModel : ViewModelBase
|
||||
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
|
||||
_screenCaptureManager.CaptureFailed += OnScreenCaptureFailed;
|
||||
|
||||
_chromaProbe = ChromaProbeOverride
|
||||
?? new FfmpegChromaProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess());
|
||||
|
||||
_mediaManager = new MediaVideoSourceManager(
|
||||
MediaFrameSourceFactoryOverride ?? (path => new MediaVideoSource(
|
||||
path, MediaDecodeWidth, MediaDecodeHeight,
|
||||
new FfmpegLocator(), () => new FfmpegDecodeProcess(),
|
||||
frameRateProbe: new FfmpegFrameRateProbe(
|
||||
new FfmpegLocator(), () => new FfmpegDecodeProcess()))),
|
||||
MediaFrameSourceFactoryOverride is { } overrideFactory
|
||||
? (path, _) => overrideFactory(path)
|
||||
: (path, chromaKey) => new MediaVideoSource(
|
||||
path, MediaDecodeWidth, MediaDecodeHeight,
|
||||
new FfmpegLocator(), () => new FfmpegDecodeProcess(),
|
||||
frameRateProbe: new FfmpegFrameRateProbe(
|
||||
new FfmpegLocator(), () => new FfmpegDecodeProcess()),
|
||||
chromaKey: chromaKey),
|
||||
System.Windows.Application.Current?.Dispatcher,
|
||||
// Loop intent is per Source; the manager needs the per-*path* answer.
|
||||
AnyMediaReferenceLoops);
|
||||
@@ -479,6 +490,10 @@ public partial class MainViewModel : ViewModelBase
|
||||
/// lifecycle without spawning ffmpeg. Mirrors <see cref="CameraFrameSourceFactoryOverride"/>.</summary>
|
||||
internal static Func<string, IMediaFrameSource?>? MediaFrameSourceFactoryOverride { get; set; }
|
||||
|
||||
/// <summary>Test seam (TASK 64): a fake chroma probe so tests never spawn ffmpeg to look at
|
||||
/// a clip's backdrop.</summary>
|
||||
internal static IChromaProbe? ChromaProbeOverride { get; set; }
|
||||
|
||||
private void LoadLayout()
|
||||
{
|
||||
_isLoading = true;
|
||||
@@ -678,7 +693,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
Source { Type: SourceType.Scoreboard } => _scoreboardPresenter?.Frame,
|
||||
Source { Type: SourceType.AlertBox } alertBox => RenderAlertBox(alertBox),
|
||||
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
|
||||
Source { IsMediaSource: true, MediaPath: not null } media => _mediaManager.GetLatestFrame(media.MediaPath),
|
||||
Source { IsMediaSource: true, MediaPath: not null } media
|
||||
=> _mediaManager.GetLatestFrame(MediaSessionKey(media)),
|
||||
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -805,6 +805,13 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
|
||||
layer's Level/Mute per session and raises `AudioAvailable`, and `MainViewModel` feeds it to
|
||||
`AudioMixer.EnqueueDesktopMediaAudio` — the DESKTOP/GAME ring, so the desktop fader and the voice
|
||||
ducker apply. The alert clip keeps its own unity-gain ring (`EnqueueAlertAudio`, never ducked).
|
||||
**Green/blue screen (TASK 64)** rides the same pipe as a decode option: `VideoFilterChain.BuildVideo`
|
||||
appends `colorkey=…` + `despill=…` + `format=bgra` after the scale, so the key is computed by ffmpeg
|
||||
and both consumers (shared preview bitmap, compositor frame) carry the same alpha — no app-side
|
||||
per-pixel pass, no second render path. A media session is therefore keyed by **path + look**
|
||||
(`MainViewModel.MediaSessionKey`), so two layers with identical settings share one decoder and two
|
||||
different looks get two. The offer comes from `ChromaKeyDetector` over one probed frame
|
||||
(`FfmpegChromaProbe`); settings persist on the `Source` row.
|
||||
- **Social bar on the output (bar bug-fix branch):** the `FramePump` takes an optional
|
||||
`socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>?` seam, re-read **every frame** (so a
|
||||
mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs`
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Encoder;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 64 Good Dog (2026-11-05): a clip with a green backdrop gets offered a key, accepting the
|
||||
/// offer changes the DECODE (a keyed session, not the plain one), and the layer's frames come from
|
||||
/// that keyed session — which is the whole feature: the key lives in ffmpeg's pipe, so the preview
|
||||
/// and the stream cannot disagree about it.
|
||||
/// </summary>
|
||||
[Collection("RealApp")]
|
||||
public sealed class ChromaKeyApplyTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public ChromaKeyApplyTests(RealAppHost app) => _app = app;
|
||||
|
||||
// ─── the pipe actually asks ffmpeg to key ───
|
||||
|
||||
[Fact]
|
||||
public async Task KeyedClip_AsksFfmpegToKey_UnkeyedDoesNot()
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var keyed = new VideoClipPipe(
|
||||
"screen.mp4", 320, 180, new FakeLocator(),
|
||||
() => new CapturingProcess(seen),
|
||||
delay: (_, _) => Task.CompletedTask,
|
||||
audio: false,
|
||||
chromaKey: new ChromaKeyOptions(true, "#00B140", 0.12, 0.08, true));
|
||||
var plain = new VideoClipPipe(
|
||||
"plain.mp4", 320, 180, new FakeLocator(),
|
||||
() => new CapturingProcess(seen),
|
||||
delay: (_, _) => Task.CompletedTask,
|
||||
audio: false);
|
||||
|
||||
await keyed.StartAsync();
|
||||
await Task.Delay(150);
|
||||
await keyed.StopAsync();
|
||||
keyed.Dispose();
|
||||
|
||||
await plain.StartAsync();
|
||||
await Task.Delay(150);
|
||||
await plain.StopAsync();
|
||||
plain.Dispose();
|
||||
|
||||
Assert.Contains(seen, args => args.Contains("colorkey=0x00B140:0.12:0.08") && args.Contains("despill=type=green"));
|
||||
Assert.Contains(seen, args => args.Contains("scale=320:180") && !args.Contains("colorkey"));
|
||||
}
|
||||
|
||||
// ─── the offer, and what accepting it does ───
|
||||
|
||||
[Fact]
|
||||
public void GreenScreenClip_IsOffered_AndAcceptingItKeysTheDecode()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
||||
var videoPath = Path.Combine(Path.GetTempPath(), "talking-head.mp4");
|
||||
var decoders = new List<FakeMediaSource>();
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
MainViewModel.MediaFrameSourceFactoryOverride = key =>
|
||||
{
|
||||
var decoder = new FakeMediaSource(key);
|
||||
decoders.Add(decoder);
|
||||
return decoder;
|
||||
};
|
||||
MainViewModel.ChromaProbeOverride = new FakeProbe(GreenScreenFrame());
|
||||
|
||||
var window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
vm.AddMediaSource(videoPath);
|
||||
|
||||
var layer = vm.StagedScene!.Elements.OfType<Source>().Single(s => s.IsMediaSource);
|
||||
|
||||
// The app noticed, and said so without doing anything.
|
||||
Assert.True(layer.HasChromaSuggestion, "the detector's offer never reached the layer");
|
||||
Assert.Contains("green", layer.ChromaSuggestionText, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("#00B140", layer.ChromaSuggestionColor);
|
||||
Assert.False(layer.ChromaKeyEnabled);
|
||||
Assert.Single(decoders);
|
||||
|
||||
// The plain session feeds the resolver before the offer is accepted.
|
||||
var plainFrame = SolidFrame(1);
|
||||
decoders[0].Emit(plainFrame);
|
||||
Assert.Same(plainFrame, vm.ResolveOutputFrame(layer));
|
||||
|
||||
vm.AcceptChromaSuggestionCommand.Execute(layer);
|
||||
|
||||
Assert.True(layer.ChromaKeyEnabled);
|
||||
Assert.False(layer.HasChromaSuggestion); // accepted, so the offer is gone
|
||||
Assert.Equal("#00B140", layer.ChromaKeyColor);
|
||||
Assert.Equal(ChromaScreen.Green, layer.ChromaScreen);
|
||||
|
||||
// A different look is a different decode: the plain session is released and a
|
||||
// keyed one starts, because the key lives in the pipe.
|
||||
Assert.Equal(2, decoders.Count);
|
||||
Assert.Equal(1, decoders[0].StopCount);
|
||||
|
||||
// And the layer now reads the keyed session, not the old one.
|
||||
var keyedFrame = SolidFrame(2);
|
||||
decoders[1].Emit(keyedFrame);
|
||||
Assert.Same(keyedFrame, vm.ResolveOutputFrame(layer));
|
||||
|
||||
// The settings survive a save/reload — a keyed layer must come back keyed.
|
||||
vm.SaveLayoutNow();
|
||||
Assert.True(ReadChromaEnabled(tempDb));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.ChromaProbeOverride = null;
|
||||
MainViewModel.MediaFrameSourceFactoryOverride = null;
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool ReadChromaEnabled(string dbPath)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={dbPath}");
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Source WHERE ChromaKeyEnabled = 1;";
|
||||
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
|
||||
}
|
||||
|
||||
/// <summary>A 160×90 frame: green at the edges, a face-ish block in the middle.</summary>
|
||||
private static VideoFrame GreenScreenFrame()
|
||||
{
|
||||
const int w = 160, h = 90;
|
||||
var bytes = new byte[w * h * 4];
|
||||
for (var y = 0; y < h; y++)
|
||||
for (var x = 0; x < w; x++)
|
||||
{
|
||||
var inCentre = x > w * 0.4 && x < w * 0.6 && y > h * 0.2 && y < h * 0.8;
|
||||
var i = (y * w + x) * 4;
|
||||
bytes[i] = inCentre ? (byte)0x90 : (byte)0x40; // B
|
||||
bytes[i + 1] = inCentre ? (byte)0xB0 : (byte)0xB1; // G
|
||||
bytes[i + 2] = inCentre ? (byte)0xE0 : (byte)0x00; // R
|
||||
bytes[i + 3] = 255;
|
||||
}
|
||||
return new VideoFrame(w, h, bytes);
|
||||
}
|
||||
|
||||
private static VideoFrame SolidFrame(byte value)
|
||||
=> new(4, 4, Enumerable.Repeat(value, 4 * 4 * 4).Select(b => b).ToArray());
|
||||
|
||||
private sealed class FakeProbe : IChromaProbe
|
||||
{
|
||||
private readonly VideoFrame? _frame;
|
||||
public FakeProbe(VideoFrame? frame) => _frame = frame;
|
||||
public Task<VideoFrame?> ProbeAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_frame);
|
||||
}
|
||||
|
||||
private sealed class FakeMediaSource : IMediaFrameSource
|
||||
{
|
||||
public FakeMediaSource(string key) => Key = key;
|
||||
|
||||
public string Key { get; }
|
||||
public bool Looping { get; set; }
|
||||
public int StopCount { get; private set; }
|
||||
|
||||
public event Action<VideoFrame>? FrameAvailable;
|
||||
public event Action<ytLive.Services.Audio.AudioSample>? AudioAvailable { add { } remove { } }
|
||||
public event Action? Completed { add { } remove { } }
|
||||
|
||||
public void Emit(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
StopCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingProcess : IDecodeProcess
|
||||
{
|
||||
private readonly List<string> _seen;
|
||||
public CapturingProcess(List<string> seen) => _seen = seen;
|
||||
public void Start(ProcessStartInfo startInfo) => _seen.Add(startInfo.Arguments);
|
||||
public Stream StandardOutput => Stream.Null;
|
||||
public bool HasExited => true;
|
||||
public int ExitCode => 0;
|
||||
public void Kill() { }
|
||||
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class FakeLocator : IFfmpegLocator
|
||||
{
|
||||
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public sealed class MediaSourceAudioTests
|
||||
public async Task Manager_ScalesTheSoundtrackByTheSessionsLevel_AndDropsItWhenMuted()
|
||||
{
|
||||
var source = new FakeMediaSource("clip.mp4");
|
||||
var manager = new MediaVideoSourceManager(_ => source, uiDispatcher: null);
|
||||
var manager = new MediaVideoSourceManager((_, _) => source, uiDispatcher: null);
|
||||
var heard = new List<AudioSample>();
|
||||
manager.AudioAvailable += (_, sample) => heard.Add(sample);
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ public class MediaVideoSourceManagerTests
|
||||
{
|
||||
var started = new List<string>();
|
||||
var stopped = new List<string>();
|
||||
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
|
||||
var manager = new MediaVideoSourceManager((key, _) => new FakeMediaSource(key, started, stopped));
|
||||
|
||||
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
|
||||
Assert.True(await manager.AcquireAsync(@"C:\videos\clip.mp4"));
|
||||
@@ -124,7 +124,7 @@ public class MediaVideoSourceManagerTests
|
||||
{
|
||||
var started = new List<string>();
|
||||
var stopped = new List<string>();
|
||||
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key, started, stopped));
|
||||
var manager = new MediaVideoSourceManager((key, _) => new FakeMediaSource(key, started, stopped));
|
||||
|
||||
await manager.AcquireAsync(@"C:\videos\clip.mp4");
|
||||
await manager.AcquireAsync(@"C:\videos\clip.mp4");
|
||||
@@ -136,7 +136,7 @@ public class MediaVideoSourceManagerTests
|
||||
[Fact]
|
||||
public async Task Acquire_FailingSource_ReturnsFalseAndRaisesMediaFailed()
|
||||
{
|
||||
var manager = new MediaVideoSourceManager(key => new FailingMediaSource(key));
|
||||
var manager = new MediaVideoSourceManager((key, _) => new FailingMediaSource(key));
|
||||
string? failedKey = null;
|
||||
manager.MediaFailed += (key, _) => failedKey = key;
|
||||
|
||||
@@ -147,7 +147,7 @@ public class MediaVideoSourceManagerTests
|
||||
[Fact]
|
||||
public async Task Acquire_EmptyKey_ReturnsFalse()
|
||||
{
|
||||
var manager = new MediaVideoSourceManager(key => new FakeMediaSource(key));
|
||||
var manager = new MediaVideoSourceManager((key, _) => new FakeMediaSource(key));
|
||||
Assert.False(await manager.AcquireAsync(" "));
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ public class MediaVideoSourceManagerTests
|
||||
public async Task GetLatestFrame_ReturnsLatestPump_UntilReleased()
|
||||
{
|
||||
FakeMediaSource? captured = null;
|
||||
var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key));
|
||||
var manager = new MediaVideoSourceManager((key, _) => captured = new FakeMediaSource(key));
|
||||
|
||||
Assert.Null(manager.GetLatestFrame(@"C:\videos\clip.mp4"));
|
||||
|
||||
@@ -181,7 +181,7 @@ public class MediaVideoSourceManagerTests
|
||||
{
|
||||
using var pump = new DispatcherPump();
|
||||
FakeMediaSource? captured = null;
|
||||
var manager = new MediaVideoSourceManager(key => captured = new FakeMediaSource(key), pump.Dispatcher);
|
||||
var manager = new MediaVideoSourceManager((key, _) => captured = new FakeMediaSource(key), pump.Dispatcher);
|
||||
|
||||
WriteableBitmap? published = null;
|
||||
var publishedCount = 0;
|
||||
|
||||
Reference in New Issue
Block a user