diff --git a/Services/Encoder/FramePump.cs b/Services/Encoder/FramePump.cs index 36772ad..bda8227 100644 --- a/Services/Encoder/FramePump.cs +++ b/Services/Encoder/FramePump.cs @@ -32,6 +32,7 @@ public sealed class FramePump : IDisposable private readonly Func? _alertTicker; private readonly Func? _brandFlash; private readonly TransitionService? _transition; + private VideoFrame? _lastOutput; private readonly SceneGraph? _sceneGraph; private readonly SceneCompositor _compositor = new(); @@ -497,6 +498,9 @@ public sealed class FramePump : IDisposable socialBarTicks += sw.ElapsedTicks; VideoFrame frame; + if (_transition is { CaptureRequested: true } capture && _lastOutput != null) + capture.SupplyFromFrame(new VideoFrame(_lastOutput.Width, _lastOutput.Height, + (byte[])_lastOutput.BgraPixels.Clone())); var scratchSize = compositorOptions.SourceRectWidth * compositorOptions.SourceRectHeight * 4; renderSw.Restart(); @@ -567,6 +571,7 @@ public sealed class FramePump : IDisposable // tick's buffers are recyclable once the enqueue snapshot them. // The free-list Contains guard keeps the Cut path (BlendFrame // returns toFrame itself, aliasing scratch) safe. + _lastOutput = frame; ReleaseScratch(frame.BgraPixels); ReleaseScratch(scratch); diff --git a/Services/TransitionService.cs b/Services/TransitionService.cs index 8065b78..df2e0d7 100644 --- a/Services/TransitionService.cs +++ b/Services/TransitionService.cs @@ -45,12 +45,23 @@ public sealed class TransitionService /// public VideoFrame? FromFrame { get; private set; } + /// True when a transition started without its "from" picture; the frame pump + /// supplies the outgoing program frame on its next tick via . + public bool CaptureRequested { get; private set; } + + public void SupplyFromFrame(VideoFrame frame) + { + FromFrame = frame; + CaptureRequested = false; + } + /// Begin a transition. Caller should capture FromFrame from the current LiveScene render. public void Start(TransitionType type, double durationMs, VideoFrame? fromFrame) { CurrentType = type; DurationMs = durationMs > 0 ? durationMs : DefaultDurationMs; FromFrame = fromFrame; + CaptureRequested = fromFrame == null && type != TransitionType.Cut; ElapsedMs = 0; Active = true; diff --git a/TASKS.md b/TASKS.md index ab787bb..27c5bcf 100644 --- a/TASKS.md +++ b/TASKS.md @@ -451,3 +451,14 @@ These were explicitly ruled out: - **OBS-style source expansion** (game capture, browser source, media playlist, VLC, color-key, MIDI — the minimal source set is deliberate; creators who need more have graduated to OBS) - **Multi-layout** (auto-save, single layout; multi-layout = OBS territory) - **Cross-process stream resume** (API makes it impossible; creator ruling 2026-09-01) + +## Transitions & brand logo (queued 2026-10-27) + +- ✅ **Fade fix** — swaps fade from the outgoing picture (`TransitionFadeTests`). On-screen check pending. +- ☐ **Transition picker** in App Settings (type + duration). +- ☐ **Wipe** — CPU blend, direction setting. +- ☐ **Move** — persistent layers (per-layer "carry across scenes" flag), per-scene placement, fixed z band. +- ☐ **Brand logo** — permanent "Brand logo" layer above background in every scene; file in App Settings + (imported to the asset table); per-scene eye toggle; top-left default. +- ☐ **Cube** — GPU path gated on capability; CPU path only if it holds 60fps. +- Gear: right-click opens App Settings (built, on-screen check pending). Left-click menu stays for now. diff --git a/ai.md b/ai.md index adf6337..23a5f5b 100644 --- a/ai.md +++ b/ai.md @@ -1287,3 +1287,12 @@ nothing. COPPA is very unlikely to bind (not child-directed), but note the enfor (FTC Sept 2025 *Apitor*: operators are responsible for what third-party components collect) — so the answer is a documented inquiry, and ours is "no third-party analytics or ad SDKs, no persisted chat or reward payloads." + +## Transitions (2026-10-27) + +- **Fade starts from the outgoing picture.** `TransitionService.Start` with no "from" frame sets + `CaptureRequested`; the frame pump copies its last output at the top of its next tick and calls + `SupplyFromFrame`. Before that fix the blend had no starting frame and a scene swap was instant. + Guard: `ytLive.Tests/TransitionFadeTests.cs`. +- **Planned next (see TASKS.md):** transition picker in App Settings, Wipe, Move (persistent layers), + brand logo layer, GPU cube (capability-gated). diff --git a/ytLive.Tests/TransitionFadeTests.cs b/ytLive.Tests/TransitionFadeTests.cs new file mode 100644 index 0000000..9a25132 --- /dev/null +++ b/ytLive.Tests/TransitionFadeTests.cs @@ -0,0 +1,26 @@ +using ytLive.Services; +using Xunit; + +namespace ytLive.Tests; + +public class TransitionFadeTests +{ + [Fact] + public void Fade_Without_A_From_Frame_Requests_Capture_And_Blends_Once_Supplied() + { + var transition = new TransitionService(); + var to = new VideoFrame(2, 2, Enumerable.Repeat((byte)255, 16).ToArray()); + var from = new VideoFrame(2, 2, new byte[16]); + + transition.Start(TransitionType.Fade, 300, null); + Assert.True(transition.CaptureRequested); + + transition.SupplyFromFrame(from); + Assert.False(transition.CaptureRequested); + + transition.Tick(150); + var blended = transition.BlendFrame(to); + Assert.NotSame(to, blended); + Assert.NotEqual((byte)255, blended.BgraPixels[0]); + } +}