feat(postit): add live capture previews on Android and Desktop

Reuse oriented Android EGL frames and duplicate Desktop video output before encoding without microphone monitoring. Cover preview lifecycle, rendering and recording with targeted tests and document the feature.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-25 01:03:36 +00:00
commit c2068f1476
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
14 changed files with 469 additions and 27 deletions

View file

@ -27,7 +27,7 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
{
foreach (var rotation in new[] { 0, 90, 180, 270 })
await VerifyRotationAsync(rotation);
File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\nsource=synthetic");
File.WriteAllText(resultPath, "PASS\nrotations=0,90,180,270\nformat=MPEG-TS\npreview=RGBA,detach,reattach,letterbox\nsource=synthetic");
}
catch (Exception ex)
{
@ -60,6 +60,10 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
recorder.Prepare();
using var encoderSurface = recorder.Surface
?? throw new InvalidOperationException("Encoder surface unavailable.");
using var preview = ImageReader.NewInstance(outputWidth + 160, outputHeight + 80,
(ImageFormatType)Format.Rgba8888, 3)
?? throw new InvalidOperationException("Preview reader unavailable.");
var previewFrames = 0;
Exception? failure = null;
var stopping = 0;
Stage("initialize EGL");
@ -71,6 +75,8 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
File.WriteAllText(Path.Combine(FilesDir.AbsolutePath, "camera-rotation-test-result.txt"), $"FAIL\n{error}");
}))
{
await rotator.SetPreviewAsync(preview.Surface, preview.Width, preview.Height,
error => failure = error);
Stage("start recorder");
recorder.Start();
Task? stop = null;
@ -83,6 +89,10 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
for (var frame = 0; frame < 90; frame++)
{
if (failure is not null) throw failure;
if (frame == 10) await rotator.SetPreviewAsync(null);
if (frame == 15)
await rotator.SetPreviewAsync(preview.Surface, preview.Width, preview.Height,
error => failure = error);
if (frame == 45)
{
Volatile.Write(ref stopping, 1);
@ -107,11 +117,24 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
canvas.Dispose();
}
await Task.Delay(34);
using var image = preview.AcquireLatestImage();
if (image is not null)
{
try
{
VerifyPreview(image, rotation, outputWidth, outputHeight);
previewFrames++;
}
finally { image.Close(); }
}
}
if (failure is not null) throw failure;
Stage("wait for recorder stop");
await (stop ?? throw new InvalidOperationException("Recorder stop was not requested."))
.WaitAsync(TimeSpan.FromSeconds(10));
if (previewFrames < 8)
throw new InvalidOperationException($"Too few preview frames: {previewFrames}.");
await rotator.SetPreviewAsync(null);
}
finally
{
@ -143,5 +166,33 @@ public sealed class AndroidCameraFrameRotationTestActivity : Activity
}
File.Delete(path);
}
private static void VerifyPreview(Image image, int rotation, int outputWidth, int outputHeight)
{
var plane = image.GetPlanes()![0];
var buffer = plane.Buffer!;
var scale = Math.Min((double)image.Width / outputWidth, (double)image.Height / outputHeight);
var width = (int)(outputWidth * scale);
var height = (int)(outputHeight * scale);
Color[] expected = rotation switch
{
90 => [Color.Blue, Color.Red, Color.Yellow, Color.Green],
180 => [Color.Yellow, Color.Blue, Color.Green, Color.Red],
270 => [Color.Green, Color.Yellow, Color.Red, Color.Blue],
_ => [Color.Red, Color.Green, Color.Blue, Color.Yellow]
};
for (var i = 0; i < expected.Length; i++)
{
var x = (image.Width - width) / 2 + (i % 2 * 2 + 1) * width / 4;
var y = (image.Height - height) / 2 + (i / 2 * 2 + 1) * height / 4;
var offset = y * plane.RowStride + x * plane.PixelStride;
var actual = new Color(buffer.Get(offset) & 255, buffer.Get(offset + 1) & 255, buffer.Get(offset + 2) & 255);
var target = expected[i];
if (Math.Abs(actual.R - target.R) > 5 || Math.Abs(actual.G - target.G) > 5 || Math.Abs(actual.B - target.B) > 5)
throw new InvalidOperationException($"Preview rotation {rotation}, quadrant {i}: {actual}, expected {target}.");
}
if (buffer.Get(0) != 0 || buffer.Get(1) != 0 || buffer.Get(2) != 0)
throw new InvalidOperationException("Preview letterbox is not black.");
}
}
#endif

View file

@ -1,7 +1,10 @@
using System.Net;
using Avalonia.Headless.XUnit;
using Avalonia.Headless;
using Avalonia.Controls;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
@ -9,6 +12,69 @@ namespace PostIt.Tests;
public sealed class PodcastBroadcasterTests
{
[Fact]
public Task Shared_page_exposes_running_capture_preview_and_clears_it_before_disposal()
=> HeadlessUnitTestSession.GetOrStartForAssembly(typeof(PodcastBroadcasterTests).Assembly).Dispatch(async () =>
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-preview-{Guid.NewGuid():N}.json");
var devices = Platform.GetPodcastCaptureDevices;
var permissions = Platform.RequestPodcastPermissionsAsync;
var factory = Platform.CreatePodcastCaptureAsync;
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(new Settings(), store);
await using var transport = new Transport();
FakeCapture? capture = null;
PodcastsPageViewModel? vm = null;
try
{
var preview = new Border();
var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Platform.GetPodcastCaptureDevices = () => new([new("Camera", "camera")], [new("None", "")]);
Platform.RequestPodcastPermissionsAsync = (_, _) => Task.CompletedTask;
Platform.CreatePodcastCaptureAsync = (_, _, directory, _) =>
{
capture = new FakeCapture(directory, preview)
{
OnDispose = () =>
{
Assert.Null(vm!.CapturePreview);
disposed.TrySetResult();
}
};
return Task.FromResult<IPodcastCapture>(capture);
};
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
vm = new PodcastsPageViewModel(new PodcastApiClient(transport, () => "https://api.example/"), api, session)
{
Title = "Preview"
};
vm.DetectDevicesCommand.Execute(null);
await vm.StartCommand.ExecuteAsync(null);
Assert.Same(preview, vm.CapturePreview);
Assert.True(vm.IsBroadcasting);
var page = new PodcastsPage { DataContext = vm };
var host = page.FindControl<ContentControl>("CapturePreviewHost");
Assert.NotNull(host);
Assert.Same(preview, host.Content);
await vm.DeactivateAsync();
await disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Assert.Null(vm.CapturePreview);
Assert.Null(host.Content);
Assert.True(capture!.Stopped);
return true;
}
finally
{
if (vm?.IsBroadcasting == true) await vm.StopAsync();
Platform.GetPodcastCaptureDevices = devices;
Platform.RequestPodcastPermissionsAsync = permissions;
Platform.CreatePodcastCaptureAsync = factory;
File.Delete(tokenPath);
}
}, TestContext.Current.CancellationToken);
[AvaloniaFact]
public async Task Shared_page_uses_platform_devices_and_requests_permissions_before_creating_server_broadcast()
{
@ -120,9 +186,12 @@ public sealed class PodcastBroadcasterTests
public string Directory { get; }
public bool Stopped { get; private set; }
public bool Disposed { get; private set; }
public Control? Preview { get; }
public Action? OnDispose { get; init; }
public Task Completion => _completion.Task;
public FakeCapture(string directory)
public FakeCapture(string directory, Control? preview = null)
{
Preview = preview;
Directory = directory;
File.WriteAllBytes(Path.Combine(directory, "segment-00000000.ts"), [0x47, 1]);
File.WriteAllText(Path.Combine(directory, "index.m3u8"), "#EXTM3U\n#EXTINF:2,\nsegment-00000000.ts\n");
@ -136,7 +205,7 @@ public sealed class PodcastBroadcasterTests
_completion.TrySetResult();
return Task.CompletedTask;
}
public ValueTask DisposeAsync() { Disposed = true; return ValueTask.CompletedTask; }
public ValueTask DisposeAsync() { Disposed = true; OnDispose?.Invoke(); return ValueTask.CompletedTask; }
}
private sealed class Transport : IYavscApiClient

View file

@ -1,5 +1,8 @@
using System.Net;
using System.Net.Http;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using LibVLCSharp.Shared;
using PostIt.Services;
using Yavsc.Abstract.Streaming;
@ -7,28 +10,47 @@ using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastNativeCaptureTests
public sealed class PodcastNativeCaptureTests(ITestOutputHelper output)
{
public static bool HasFixture => File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder()
[Theory(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]
[InlineData(false)]
[InlineData(true)]
public async Task LibVLC_produces_complete_H264_AAC_HLS_segments_without_external_encoder(bool preview)
{
var root = Path.Combine(Path.GetTempPath(), $"postit native's hls {Guid.NewGuid():N}");
Directory.CreateDirectory(root);
var pixels = preview ? Marshal.AllocHGlobal(640 * 480 * 4) : IntPtr.Zero;
try
{
Core.Initialize();
using var vlc = new LibVLC();
using var vlc = new LibVLC("--vout=dummy", "--aout=dummy", "--no-xlib",
"--avcodec-hw=none", "--avcodec-threads=2");
using var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
using var player = new MediaPlayer(vlc);
PodcastBroadcaster.ConfigureOutput(media, root);
var displayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
if (preview)
{
player.SetVideoFormat("RV32", 640, 480, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, pixels);
return IntPtr.Zero;
}, null, (_, _) => displayed.TrySetResult());
}
PodcastBroadcaster.ConfigureOutput(media, root, preview);
var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
player.EndReached += (_, _) => ended.TrySetResult();
player.EncounteredError += (_, _) => ended.TrySetException(new InvalidOperationException("LibVLC encoding failed."));
Assert.True(player.Play(media));
output.WriteLine($"Encoding started (preview={preview}).");
if (preview)
await displayed.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
await ended.Task.WaitAsync(TimeSpan.FromSeconds(45), TestContext.Current.CancellationToken);
output.WriteLine("End reached; stopping player.");
player.Stop();
output.WriteLine("Player stopped; checking segments.");
var playlist = await File.ReadAllTextAsync(Path.Combine(root, "index.m3u8"), TestContext.Current.CancellationToken);
var segments = HlsCapturePlaylist.Parse(playlist);
Assert.True(segments.Count >= 2, playlist);
@ -41,8 +63,34 @@ public sealed class PodcastNativeCaptureTests
Assert.InRange(segment.DurationSeconds, 0.001, 15);
for (var i = 0; i < bytes.Length; i += 188) Assert.Equal(0x47, bytes[i]);
}
var start = new ProcessStartInfo("ffprobe") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var argument in new[] { "-v", "error", "-show_streams", "-of", "json", Path.Combine(root, segments[0].FileName) })
start.ArgumentList.Add(argument);
using var probe = Process.Start(start) ?? throw new IOException("Cannot start ffprobe.");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
var stdout = probe.StandardOutput.ReadToEndAsync(timeout.Token);
var stderr = probe.StandardError.ReadToEndAsync(timeout.Token);
try
{
await probe.WaitForExitAsync(timeout.Token);
Assert.True(probe.ExitCode == 0, await stderr);
using var json = JsonDocument.Parse(await stdout);
var streams = json.RootElement.GetProperty("streams").EnumerateArray().ToArray();
Assert.Contains(streams, track => track.GetProperty("codec_name").GetString() == "h264");
Assert.Contains(streams, track => track.GetProperty("codec_name").GetString() == "aac");
}
finally
{
if (!probe.HasExited) probe.Kill(entireProcessTree: true);
await probe.WaitForExitAsync(CancellationToken.None);
}
}
finally
{
if (pixels != IntPtr.Zero) Marshal.FreeHGlobal(pixels);
Directory.Delete(root, recursive: true);
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact(Skip = "Set POSTIT_TEST_MEDIA to a local video with audio and install LibVLC with H.264/AAC encoders.", SkipUnless = nameof(HasFixture))]