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:
2026-08-22 18:10:59 -07:00
parent e06bfa42a8
commit 4b9a8e515f
35 changed files with 1406 additions and 717 deletions
+36 -8
View File
@@ -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
+28 -4
View File
@@ -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
View File
@@ -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();
+5 -5
View File
@@ -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));
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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;
}
+154
View File
@@ -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);
}
}
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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",