Transitions in edit mode and on output: program transitions run while recording or streaming; the preview animates the outgoing picture (fade/move fade, wipe shrinks) in every mode

Test: PreviewTransitionTests (with the transition, wipe, and brand logo tests).
This commit is contained in:
2026-10-27 10:20:53 -07:00
parent 841c905c06
commit 7da287244b
4 changed files with 121 additions and 1 deletions
+2
View File
@@ -71,6 +71,8 @@
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/> Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/> <Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
</Grid> </Grid>
<Image x:Name="TransitionOverlay" Width="1920" Height="1080" Stretch="Fill"
IsHitTestVisible="False" Visibility="Collapsed"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080"> <Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding StagedScene.Elements}" <ItemsControl ItemsSource="{Binding StagedScene.Elements}"
Width="1920" Height="1080"> Width="1920" Height="1080">
+46
View File
@@ -36,14 +36,60 @@ public partial class PreviewPane : UserControl
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e) private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{ {
if (e.OldValue is MainViewModel oldVm) if (e.OldValue is MainViewModel oldVm)
{
oldVm.PropertyChanged -= ViewModel_PropertyChanged; oldVm.PropertyChanged -= ViewModel_PropertyChanged;
oldVm.PreviewTransition -= PlayPreviewTransition;
}
if (e.NewValue is MainViewModel newVm) if (e.NewValue is MainViewModel newVm)
{ {
newVm.PropertyChanged += ViewModel_PropertyChanged; newVm.PropertyChanged += ViewModel_PropertyChanged;
newVm.PreviewTransition += PlayPreviewTransition;
UpdateZoomBox(); UpdateZoomBox();
} }
} }
/// <summary>Animates the outgoing picture away over the new scene: a fade for Fade and Move,
/// a right-to-left shrink for Wipe. Cut plays nothing.</summary>
private void PlayPreviewTransition(ytLive.Services.TransitionType type, double milliseconds)
{
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, null);
TransitionOverlay.Clip = null;
if (type == ytLive.Services.TransitionType.Cut || milliseconds <= 0)
{
TransitionOverlay.Visibility = Visibility.Collapsed;
return;
}
var snapshot = new System.Windows.Media.Imaging.RenderTargetBitmap(1920, 1080, 96, 96,
System.Windows.Media.PixelFormats.Pbgra32);
snapshot.Render(PreviewCanvas);
TransitionOverlay.Source = snapshot;
TransitionOverlay.Opacity = 1;
TransitionOverlay.Visibility = Visibility.Visible;
var duration = TimeSpan.FromMilliseconds(milliseconds);
if (type == ytLive.Services.TransitionType.Wipe)
{
var clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 1920, 1080));
TransitionOverlay.Clip = clip;
var reveal = new System.Windows.Media.Animation.RectAnimation(new Rect(0, 0, 0, 1080), duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
reveal.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
clip.BeginAnimation(System.Windows.Media.RectangleGeometry.RectProperty, reveal);
}
else
{
var fade = new System.Windows.Media.Animation.DoubleAnimation(1, 0, duration)
{
FillBehavior = System.Windows.Media.Animation.FillBehavior.Stop,
};
fade.Completed += (_, _) => TransitionOverlay.Visibility = Visibility.Collapsed;
TransitionOverlay.BeginAnimation(System.Windows.UIElement.OpacityProperty, fade);
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{ {
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape)) if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
+7 -1
View File
@@ -115,14 +115,20 @@ public partial class MainViewModel
public void TransitionToScene(Scene scene) public void TransitionToScene(Scene scene)
{ {
if (scene.IsHidden || scene == StagedScene) return; if (scene.IsHidden || scene == StagedScene) return;
// The preview animates the outgoing picture before the switch, in every mode.
PreviewTransition?.Invoke(SelectedTransitionType, TransitionDurationMs);
StagedScene = scene; StagedScene = scene;
if (IsLive) // The program output transitions whenever it is running: recording or streaming.
if (IsLive || IsRecording)
{ {
LiveScene = scene; LiveScene = scene;
_transition.Start(SelectedTransitionType, TransitionDurationMs, null); _transition.Start(SelectedTransitionType, TransitionDurationMs, null);
} }
} }
/// <summary>Raised before a scene switch so the preview can animate the old picture away.</summary>
public event Action<TransitionType, double>? PreviewTransition;
/// <summary> /// <summary>
/// Apply the current transition type to the next StagedScene change while live. /// Apply the current transition type to the next StagedScene change while live.
/// Called from XAML when user picks a transition from the dropdown. /// Called from XAML when user picks a transition from the dropdown.
+66
View File
@@ -0,0 +1,66 @@
using System;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// A scene switch in edit mode raises the preview transition with the chosen type and
/// length, before the staged scene changes, so the preview can animate the old picture.
/// </summary>
[Collection("RealApp")]
public sealed class PreviewTransitionTests
{
private readonly RealAppHost _app;
public PreviewTransitionTests(RealAppHost app) => _app = app;
[Fact]
public void Scene_Switch_In_Edit_Mode_Raises_The_Preview_Transition_Before_The_Switch()
{
_app.Run(Run);
}
private static void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-preview-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
var from = vm.StagedScene!;
var to = vm.Scenes.First(s => s != from && !s.IsHidden);
vm.SelectedTransitionType = TransitionType.Fade;
vm.TransitionDurationMs = 800;
Scene? stagedWhenRaised = null;
TransitionType? typeRaised = null;
double? msRaised = null;
vm.PreviewTransition += (type, ms) =>
{
stagedWhenRaised = vm.StagedScene;
typeRaised = type;
msRaised = ms;
};
vm.TransitionToScene(to);
Assert.Same(from, stagedWhenRaised);
Assert.Equal(TransitionType.Fade, typeRaised);
Assert.Equal(800, msRaised);
Assert.Same(to, vm.StagedScene);
}
finally
{
MainViewModel.LayoutPathOverride = null;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
}