Control Surface UX: pill toggle, background rename, per-scene properties
- Sliding pill toggle (green ON / red OFF) replaces checkbox - Geometry mismatch error for custom backgrounds - Backdrop -> Background: C# rename, SQL column rename with migration - Source.IsBackground: new -> override (fixes WPF binding) - Legacy 'Backdrop' display name migrated to 'Background' - Per-scene UseDefaultBackground + CustomBackgroundPath - Asset files renamed backdrop -> background - StagedScene/LiveScene split, thumbnail strip, edit gating - TransitionService (Cut/Fade/Move) - Context menu: Capture Desktop, Refresh Desktop - Resolution badge removed, ON-AIR status light added
This commit is contained in:
@@ -9,14 +9,14 @@ namespace ytLive.Services.Compositor;
|
||||
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
|
||||
///
|
||||
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
|
||||
/// (the caller maps webcam → DeviceId, images → AssetId, backdrop → CaptureKey), keeping
|
||||
/// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping
|
||||
/// the compositor pure and free of WPF and of the capture managers.
|
||||
/// </summary>
|
||||
public sealed class SceneCompositor
|
||||
{
|
||||
/// <summary>
|
||||
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||
/// front): live backdrop (the scene's <c>IsBackdrop</c> source) → background image →
|
||||
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
|
||||
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
|
||||
/// branding flash → social bar (a global overlay; spans the master width at
|
||||
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
|
||||
@@ -45,10 +45,10 @@ public sealed class SceneCompositor
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var backdrop = elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
|
||||
var backdropFrame = backdrop != null && backdrop.IsVisible ? frameFor(backdrop) : null;
|
||||
if (backdropFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backdropFrame, 1f, false, false);
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
@@ -60,10 +60,10 @@ public sealed class SceneCompositor
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackdrop: true }:
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue; // backdrop/background are their own layers; Text isn't shipped
|
||||
continue; // background/background are their own layers; Text isn't shipped
|
||||
}
|
||||
|
||||
var frame = frameFor(element);
|
||||
@@ -91,6 +91,34 @@ public sealed class SceneCompositor
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a cross-fade blend between two scenes. Used by <see cref="TransitionService"/>
|
||||
/// during fade/move transitions: the caller renders each scene individually, then blends
|
||||
/// the two results at the given alpha (0 = fully from, 1 = fully to).
|
||||
/// </summary>
|
||||
public static VideoFrame BlendFrames(VideoFrame from, VideoFrame to, float alpha)
|
||||
{
|
||||
if (from.Width != to.Width || from.Height != to.Height)
|
||||
{
|
||||
if (alpha <= 0.5f) return from;
|
||||
return to;
|
||||
}
|
||||
|
||||
var count = from.BgraPixels.Length;
|
||||
var result = new byte[count];
|
||||
var inv = 1f - alpha;
|
||||
|
||||
for (var i = 0; i < count; i += 4)
|
||||
{
|
||||
result[i] = (byte)(from.BgraPixels[i] * inv + to.BgraPixels[i] * alpha);
|
||||
result[i + 1] = (byte)(from.BgraPixels[i + 1] * inv + to.BgraPixels[i + 1] * alpha);
|
||||
result[i + 2] = (byte)(from.BgraPixels[i + 2] * inv + to.BgraPixels[i + 2] * alpha);
|
||||
result[i + 3] = 255;
|
||||
}
|
||||
|
||||
return new VideoFrame(from.Width, from.Height, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UniformToFill blit of a source frame into element rect (ex, ey, ew, eh), with
|
||||
/// straight-alpha source-over, optional round clip (true circle, hard edge) and
|
||||
|
||||
@@ -29,6 +29,8 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly TransitionService? _transition;
|
||||
private readonly Func<Scene?>? _fromSceneProvider;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
private readonly object _gate = new();
|
||||
@@ -51,7 +53,9 @@ public sealed class FramePump : IDisposable
|
||||
Func<IFfmpegEncoder> encoderFactory,
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
TransitionService? transition = null,
|
||||
Func<Scene?>? fromSceneProvider = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||
@@ -61,6 +65,8 @@ public sealed class FramePump : IDisposable
|
||||
_log = log;
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
_transition = transition;
|
||||
_fromSceneProvider = fromSceneProvider;
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
@@ -207,6 +213,7 @@ public sealed class FramePump : IDisposable
|
||||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||||
var lastTick = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -227,11 +234,28 @@ public sealed class FramePump : IDisposable
|
||||
? 0
|
||||
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||||
}
|
||||
var frame = _compositor.Render(
|
||||
scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
|
||||
VideoFrame frame;
|
||||
if (_transition is { Active: true } transition && _fromSceneProvider != null)
|
||||
{
|
||||
var fromScene = _fromSceneProvider();
|
||||
var fromFrame = fromScene != null
|
||||
? _compositor.Render(fromScene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop)
|
||||
: _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
var toFrame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
frame = transition.BlendFrame(toFrame);
|
||||
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
|
||||
lastTick.Restart();
|
||||
}
|
||||
else
|
||||
{
|
||||
frame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
lastTick.Restart();
|
||||
}
|
||||
|
||||
IFfmpegEncoder? encoder;
|
||||
lock (_gate) encoder = _encoder;
|
||||
if (encoder == null) break; // _encoder is only cleared after the loop ends; defensive
|
||||
if (encoder == null) break;
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
}
|
||||
await _pacingDelay(interval, ct);
|
||||
|
||||
+117
-27
@@ -47,7 +47,7 @@ public class LayoutStore : IDisposable
|
||||
Name TEXT NOT NULL,
|
||||
IsHidden INTEGER NOT NULL DEFAULT 0,
|
||||
IsChatScene INTEGER NOT NULL DEFAULT 0,
|
||||
HasBackdrop INTEGER NOT NULL DEFAULT 1,
|
||||
HasBackground INTEGER NOT NULL DEFAULT 1,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""",
|
||||
@@ -77,7 +77,7 @@ public class LayoutStore : IDisposable
|
||||
DeviceId TEXT,
|
||||
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
|
||||
IsMirrored INTEGER NOT NULL DEFAULT 0,
|
||||
IsBackdrop INTEGER NOT NULL DEFAULT 0,
|
||||
IsBackground INTEGER NOT NULL DEFAULT 0,
|
||||
CaptureKey TEXT,
|
||||
SortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -198,10 +198,16 @@ public class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!columns.Contains("IsBackdrop"))
|
||||
if (columns.Contains("IsBackdrop"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackdrop INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.CommandText = "ALTER TABLE Source RENAME COLUMN IsBackdrop TO IsBackground;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
else if (!columns.Contains("IsBackground"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackground INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
@@ -267,6 +273,13 @@ public class LayoutStore : IDisposable
|
||||
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ShowDesktop INTEGER NOT NULL DEFAULT 0;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Rename legacy "Backdrop" display name to "Background" in existing rows
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "UPDATE Source SET Name = 'Background' WHERE Name = 'Backdrop';";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
|
||||
@@ -374,10 +387,10 @@ public class LayoutStore : IDisposable
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
// v5 → v6: Scene gains HasBackdrop. Added columns default to true; the
|
||||
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
|
||||
// ONE-TIME backfill (runs only when the column is first added) turns every
|
||||
// non-Live canonical scene off and drops their backdrop sources — the fix for
|
||||
// DBs saved before the Live-only policy. MainViewModel.EnforceBackdropPolicy
|
||||
// non-Live canonical scene off and drops their background sources — the fix for
|
||||
// DBs saved before the Live-only policy. MainViewModel.EnforceBackgroundPolicy
|
||||
// re-runs the same normalization on every load for DBs that miss the backfill.
|
||||
private void MigrateSceneTable()
|
||||
{
|
||||
@@ -390,26 +403,36 @@ public class LayoutStore : IDisposable
|
||||
columns.Add(reader.GetString(1));
|
||||
}
|
||||
|
||||
if (columns.Contains("HasBackdrop")) return;
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
if (columns.Contains("HasBackdrop"))
|
||||
{
|
||||
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackdrop INTEGER NOT NULL DEFAULT 1;";
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene RENAME COLUMN HasBackdrop TO HasBackground;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
else if (columns.Contains("HasBackground"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!columns.Contains("HasBackground"))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackground INTEGER NOT NULL DEFAULT 1;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
const string noBackdrop = "('starting', 'brb', 'chat', 'ending')";
|
||||
const string noBackground = "('starting', 'brb', 'chat', 'ending')";
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"UPDATE Scene SET HasBackdrop = 0 WHERE LOWER(TRIM(Name)) IN {noBackdrop};";
|
||||
cmd.CommandText = $"UPDATE Scene SET HasBackground = 0 WHERE LOWER(TRIM(Name)) IN {noBackground};";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"""
|
||||
DELETE FROM Source
|
||||
WHERE IsBackdrop = 1 AND SceneId IN
|
||||
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackdrop});
|
||||
WHERE IsBackground = 1 AND SceneId IN
|
||||
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackground});
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
@@ -486,7 +509,7 @@ public class LayoutStore : IDisposable
|
||||
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
@@ -496,7 +519,7 @@ public class LayoutStore : IDisposable
|
||||
Name = reader.GetString(1),
|
||||
IsHidden = reader.GetInt32(2) != 0,
|
||||
IsChatScene = reader.GetInt32(3) != 0,
|
||||
HasBackdrop = reader.GetInt32(4) != 0,
|
||||
HasBackground = reader.GetInt32(4) != 0,
|
||||
HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0,
|
||||
});
|
||||
}
|
||||
@@ -566,7 +589,7 @@ public class LayoutStore : IDisposable
|
||||
cmd.CommandText = """
|
||||
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
|
||||
IsBackdrop, CaptureKey, ShowDesktop,
|
||||
IsBackground, CaptureKey, ShowDesktop,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor
|
||||
FROM Source ORDER BY SortOrder
|
||||
@@ -590,7 +613,7 @@ public class LayoutStore : IDisposable
|
||||
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
||||
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
|
||||
IsMirrored = reader.GetInt32(13) != 0,
|
||||
IsBackdrop = reader.GetInt32(14) != 0,
|
||||
IsBackground = reader.GetInt32(14) != 0,
|
||||
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
ShowDesktop = reader.GetInt32(16) != 0,
|
||||
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
|
||||
@@ -653,6 +676,14 @@ public class LayoutStore : IDisposable
|
||||
if (configsByScene.TryGetValue(scene.Id, out var configs))
|
||||
foreach (var config in configs)
|
||||
scene.Elements.Add(config);
|
||||
|
||||
var path = GetSetting($"BackgroundPath_{scene.Id}");
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
scene.CustomBackgroundPath = path;
|
||||
|
||||
var useDefault = GetSetting($"BackgroundUseDefault_{scene.Id}");
|
||||
if (useDefault == "0")
|
||||
scene.UseDefaultBackground = false;
|
||||
}
|
||||
|
||||
return scenes;
|
||||
@@ -760,15 +791,15 @@ public class LayoutStore : IDisposable
|
||||
using (var cmd = _connection.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, HasSocialBar, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $hasSocialBar, $sort)
|
||||
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder)
|
||||
VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort)
|
||||
""";
|
||||
cmd.Transaction = tx;
|
||||
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
|
||||
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
|
||||
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
|
||||
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
|
||||
var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
|
||||
var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer);
|
||||
var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
|
||||
@@ -779,7 +810,7 @@ public class LayoutStore : IDisposable
|
||||
nameP.Value = scene.Name;
|
||||
hiddenP.Value = scene.IsHidden ? 1 : 0;
|
||||
chatP.Value = scene.IsChatScene ? 1 : 0;
|
||||
backdropP.Value = scene.HasBackdrop ? 1 : 0;
|
||||
backgroundP.Value = scene.HasBackground ? 1 : 0;
|
||||
socialBarP.Value = scene.HasSocialBar ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
cmd.ExecuteNonQuery();
|
||||
@@ -791,12 +822,12 @@ public class LayoutStore : IDisposable
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
|
||||
X, Y, Width, Height, Opacity, MonitorIndex,
|
||||
ClipShape, IsMirrored, IsBackdrop, CaptureKey, ShowDesktop, SortOrder,
|
||||
ClipShape, IsMirrored, IsBackground, CaptureKey, ShowDesktop, SortOrder,
|
||||
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
|
||||
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor)
|
||||
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
|
||||
$x, $y, $w, $h, $opacity, $monitor,
|
||||
$clip, $mirrored, $isBackdrop, $captureKey, $showDesktop, $sort,
|
||||
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
|
||||
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
|
||||
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor)
|
||||
""";
|
||||
@@ -815,7 +846,7 @@ public class LayoutStore : IDisposable
|
||||
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
|
||||
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
|
||||
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
|
||||
var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
|
||||
var isBackgroundP = cmd.Parameters.Add("$isBackground", SqliteType.Integer);
|
||||
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
|
||||
var showDesktopP = cmd.Parameters.Add("$showDesktop", SqliteType.Integer);
|
||||
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
|
||||
@@ -848,7 +879,7 @@ public class LayoutStore : IDisposable
|
||||
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
|
||||
clipP.Value = source.ClipShape.ToString();
|
||||
mirroredP.Value = source.IsMirrored ? 1 : 0;
|
||||
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
|
||||
isBackgroundP.Value = source.IsBackground ? 1 : 0;
|
||||
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
|
||||
showDesktopP.Value = source.ShowDesktop ? 1 : 0;
|
||||
sortP.Value = sort++;
|
||||
@@ -994,6 +1025,24 @@ public class LayoutStore : IDisposable
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Save custom background paths and use-default flags
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
var pathKey = $"BackgroundPath_{scene.Id}";
|
||||
if (!string.IsNullOrWhiteSpace(scene.CustomBackgroundPath))
|
||||
UpsertSetting(pathKey, scene.CustomBackgroundPath);
|
||||
else
|
||||
{
|
||||
using var del = _connection.CreateCommand();
|
||||
del.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
del.Parameters.AddWithValue("$key", pathKey);
|
||||
del.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var defaultKey = $"BackgroundUseDefault_{scene.Id}";
|
||||
UpsertSetting(defaultKey, scene.UseDefaultBackground ? "1" : "0");
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
@@ -1097,6 +1146,47 @@ public class LayoutStore : IDisposable
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public (string TransitionType, double DurationMs) LoadTransitionSettings()
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionType';";
|
||||
var type = cmd.ExecuteScalar() as string;
|
||||
|
||||
using var cmd2 = _connection.CreateCommand();
|
||||
cmd2.CommandText = "SELECT Value FROM Settings WHERE Key = 'TransitionDurationMs';";
|
||||
var dur = cmd2.ExecuteScalar() as string;
|
||||
|
||||
return (type ?? "Fade", dur != null && double.TryParse(dur, out var d) ? d : 300);
|
||||
}
|
||||
|
||||
public void SaveTransitionSettings(string transitionType, double durationMs)
|
||||
{
|
||||
UpsertSetting("TransitionType", transitionType);
|
||||
UpsertSetting("TransitionDurationMs", durationMs.ToString("F0"));
|
||||
}
|
||||
|
||||
private string? GetSetting(string key)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
return cmd.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
private void UpsertSetting(string key, string value)
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
using var insert = _connection.CreateCommand();
|
||||
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ($key, $value);";
|
||||
insert.Parameters.AddWithValue("$key", key);
|
||||
insert.Parameters.AddWithValue("$value", value);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
|
||||
@@ -30,10 +30,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
private bool _framePending;
|
||||
private DateTime _lastErrorLog = DateTime.MinValue;
|
||||
|
||||
// The composition master frame (see ai.md "Resolution tiers"): the backdrop
|
||||
// The composition master frame (see ai.md "Resolution tiers"): the background
|
||||
// is an input layer, so we never hold a CPU frame bigger than the master.
|
||||
private const int MaxBackdropWidth = 1920;
|
||||
private const int MaxBackdropHeight = 1080;
|
||||
private const int MaxBackgroundWidth = 1920;
|
||||
private const int MaxBackgroundHeight = 1080;
|
||||
|
||||
// A failing conversion must not re-flood the log at frame rate.
|
||||
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
|
||||
@@ -172,9 +172,9 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
|
||||
// The master frame is 1920×1080; a larger monitor is scaled down here so
|
||||
// the CPU never holds a frame above the master (the rendered layer fills
|
||||
// the frame with UniformToFill regardless).
|
||||
if (sw > MaxBackdropWidth || sh > MaxBackdropHeight)
|
||||
if (sw > MaxBackgroundWidth || sh > MaxBackgroundHeight)
|
||||
{
|
||||
var scale = Math.Min(MaxBackdropWidth / (double)sw, MaxBackdropHeight / (double)sh);
|
||||
var scale = Math.Min(MaxBackgroundWidth / (double)sw, MaxBackgroundHeight / (double)sh);
|
||||
var dw = Math.Max(1, (int)(sw * scale));
|
||||
var dh = Math.Max(1, (int)(sh * scale));
|
||||
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh));
|
||||
|
||||
@@ -158,7 +158,7 @@ public sealed class ScreenCaptureManager : IDisposable
|
||||
|
||||
/// <summary>The most recent frame for a capture key, or null before the first
|
||||
/// frame arrives (or if the key has no session). The live compositor reads
|
||||
/// the backdrop from here.</summary>
|
||||
/// the background from here.</summary>
|
||||
public VideoFrame? GetLatestFrame(string key)
|
||||
{
|
||||
lock (_gate)
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class HttpSocialValidator : ISocialValidator
|
||||
{
|
||||
_client = client ?? new HttpClient();
|
||||
_client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"Mozilla/5.0 (compatible; ytLlive social validator)");
|
||||
"Mozilla/5.0 (compatible; LlamaCasty social validator)");
|
||||
_log = log;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The three v1 transition types. Clicking a scene thumbnail triggers the selected
|
||||
/// transition; Go Live always uses Cut for a clean start.
|
||||
/// </summary>
|
||||
public enum TransitionType
|
||||
{
|
||||
Cut,
|
||||
Fade,
|
||||
Move
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages the transition state between StagedScene and LiveScene. When a
|
||||
/// transition is active, FramePump calls <see cref="BlendFrame"/> each tick
|
||||
/// to get the blended output. When the transition completes, <see cref="Active"/>
|
||||
/// becomes false and FramePump reverts to a direct LiveScene render.
|
||||
/// </summary>
|
||||
public sealed class TransitionService
|
||||
{
|
||||
private const double DefaultDurationMs = 300;
|
||||
|
||||
/// <summary>True while a transition is in progress.</summary>
|
||||
public bool Active { get; private set; }
|
||||
|
||||
/// <summary>The transition type for the current in-progress transition.</summary>
|
||||
public TransitionType CurrentType { get; private set; } = TransitionType.Cut;
|
||||
|
||||
/// <summary>Duration of the current transition in milliseconds.</summary>
|
||||
public double DurationMs { get; private set; } = DefaultDurationMs;
|
||||
|
||||
/// <summary>Elapsed time in milliseconds (updated by caller).</summary>
|
||||
public double ElapsedMs { get; private set; }
|
||||
|
||||
/// <summary>0.0 = fully "from" scene, 1.0 = fully "to" scene.</summary>
|
||||
public float Progress => DurationMs <= 0 ? 1f
|
||||
: Math.Clamp((float)(ElapsedMs / DurationMs), 0f, 1f);
|
||||
|
||||
/// <summary>
|
||||
/// The "from" frame (captured at transition start). Populated by the caller
|
||||
/// before starting the transition.
|
||||
/// </summary>
|
||||
public VideoFrame? FromFrame { get; private set; }
|
||||
|
||||
/// <summary>Begin a transition. Caller should capture FromFrame from the current LiveScene render.</summary>
|
||||
public void Start(TransitionType type, double durationMs, VideoFrame? fromFrame)
|
||||
{
|
||||
CurrentType = type;
|
||||
DurationMs = durationMs > 0 ? durationMs : DefaultDurationMs;
|
||||
FromFrame = fromFrame;
|
||||
ElapsedMs = 0;
|
||||
Active = true;
|
||||
|
||||
if (type == TransitionType.Cut)
|
||||
{
|
||||
Active = false;
|
||||
ElapsedMs = DurationMs;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the transition clock. Returns true while the transition is still active.
|
||||
/// </summary>
|
||||
public bool Tick(double elapsedMs)
|
||||
{
|
||||
if (!Active) return false;
|
||||
ElapsedMs += elapsedMs;
|
||||
if (ElapsedMs >= DurationMs)
|
||||
{
|
||||
ElapsedMs = DurationMs;
|
||||
Active = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blend the from-frame and the to-frame based on the current progress.
|
||||
/// Caller passes the freshly-rendered "to" scene; we return the blended output.
|
||||
/// </summary>
|
||||
public VideoFrame BlendFrame(VideoFrame toFrame)
|
||||
{
|
||||
if (FromFrame == null || CurrentType == TransitionType.Cut)
|
||||
return toFrame;
|
||||
|
||||
var t = Progress;
|
||||
return CurrentType switch
|
||||
{
|
||||
TransitionType.Fade => BlendFade(FromFrame, toFrame, t),
|
||||
TransitionType.Move => BlendMove(FromFrame, toFrame, t),
|
||||
_ => toFrame
|
||||
};
|
||||
}
|
||||
|
||||
private static VideoFrame BlendFade(VideoFrame from, VideoFrame to, float t)
|
||||
{
|
||||
var w = from.Width;
|
||||
var h = from.Height;
|
||||
var src = from.BgraPixels;
|
||||
var dst = to.BgraPixels;
|
||||
var outPixels = new byte[src.Length];
|
||||
|
||||
for (var i = 0; i < outPixels.Length; i += 4)
|
||||
{
|
||||
outPixels[i] = (byte)(src[i] * (1 - t) + dst[i] * t);
|
||||
outPixels[i + 1] = (byte)(src[i + 1] * (1 - t) + dst[i + 1] * t);
|
||||
outPixels[i + 2] = (byte)(src[i + 2] * (1 - t) + dst[i + 2] * t);
|
||||
outPixels[i + 3] = 255;
|
||||
}
|
||||
|
||||
return new VideoFrame(w, h, outPixels);
|
||||
}
|
||||
|
||||
private static VideoFrame BlendMove(VideoFrame from, VideoFrame to, float t)
|
||||
{
|
||||
var w = from.Width;
|
||||
var h = from.Height;
|
||||
var src = from.BgraPixels;
|
||||
var dst = to.BgraPixels;
|
||||
var outPixels = new byte[src.Length];
|
||||
var offset = (int)(w * (1 - t));
|
||||
|
||||
for (var y = 0; y < h; y++)
|
||||
{
|
||||
for (var x = 0; x < w; x++)
|
||||
{
|
||||
var di = (y * w + x) * 4;
|
||||
var fromX = x - offset;
|
||||
var toX = x + offset;
|
||||
|
||||
var srcPixel = fromX >= 0 && fromX < w
|
||||
? (src[(y * w + fromX) * 4], src[(y * w + fromX) * 4 + 1], src[(y * w + fromX) * 4 + 2])
|
||||
: ((byte)0, (byte)0, (byte)0);
|
||||
|
||||
var dstPixel = toX >= 0 && toX < w
|
||||
? (dst[(y * w + toX) * 4], dst[(y * w + toX) * 4 + 1], dst[(y * w + toX) * 4 + 2])
|
||||
: ((byte)0, (byte)0, (byte)0);
|
||||
|
||||
var edgeFalloff = Math.Clamp((float)(offset) / (w * 0.3f), 0, 1);
|
||||
var lerpF = Math.Clamp(1f - edgeFalloff, 0, 1);
|
||||
|
||||
outPixels[di] = (byte)(srcPixel.Item1 * (1 - lerpF) + dstPixel.Item1 * lerpF);
|
||||
outPixels[di + 1] = (byte)(srcPixel.Item2 * (1 - lerpF) + dstPixel.Item2 * lerpF);
|
||||
outPixels[di + 2] = (byte)(srcPixel.Item3 * (1 - lerpF) + dstPixel.Item3 * lerpF);
|
||||
outPixels[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return new VideoFrame(w, h, outPixels);
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,8 @@ public class YouTubeAuthService
|
||||
var error = context.Request.QueryString["error"];
|
||||
|
||||
var html = error != null
|
||||
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to ytLlive.</p></body></html>"
|
||||
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to ytLlive.</p></body></html>";
|
||||
? "<html><body style=\"font-family:sans-serif\"><h2>Sign-in failed</h2><p>You can close this window and return to LlamaCasty.</p></body></html>"
|
||||
: "<html><body style=\"font-family:sans-serif\"><h2>Sign-in successful!</h2><p>You can close this window and return to LlamaCasty.</p></body></html>";
|
||||
var buffer = Encoding.UTF8.GetBytes(html);
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
context.Response.ContentLength64 = buffer.Length;
|
||||
|
||||
@@ -111,7 +111,7 @@ public class YouTubeStreamService
|
||||
|
||||
var stream = new
|
||||
{
|
||||
snippet = new { title = "ytLlive Reusable Stream" },
|
||||
snippet = new { title = "LlamaCasty Reusable Stream" },
|
||||
cdn = new
|
||||
{
|
||||
ingestionType = "rtmp",
|
||||
|
||||
Reference in New Issue
Block a user