using System.Runtime.InteropServices;
using Sdcb.FFmpeg.Codecs;
using Sdcb.FFmpeg.Formats;
using Sdcb.FFmpeg.Raw;
using T3.Core.Video;
using Xunit;
namespace T3.VideoServices.Tests;
///
/// Exercises the real FFmpeg encode path: encode synthetic RGBA frames to a file, then decode it back with
/// and assert the round-trip metadata + frame count. Uses MPEG-4 Part 2
/// (mpeg4) — a software, always-present LGPL encoder — so the test needs no GPU and proves the
/// licence-clean path (software H.264 is libx264 = GPL and absent from the shipped build).
///
public class VideoFileEncoderTests
{
[Fact]
public void Encode_Mpeg4_RoundTripsThroughDecoder()
{
const int width = 320;
const int height = 240;
const int frameCount = 30;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-{Guid.NewGuid():N}.mp4");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(30, 1),
BitRate = 2_000_000,
VideoCodecId = AVCodecID.Mpeg4,
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
};
var frame = new byte[width * height * 4];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < frameCount; i++)
{
FillGradient(frame, width, height, i);
encoder.WriteVideoFrame(frame, width * 4);
}
} // Dispose → Finish: drains the encoder and writes the container trailer.
Assert.True(File.Exists(path));
Assert.True(new FileInfo(path).Length > 0, "encoded file is empty");
using var session = VideoDecoderSession.TryOpen(path, VideoPlaybackOptimization.FastSeeking, out var error);
Assert.Null(error);
Assert.NotNull(session);
Assert.Equal(width, session!.Width);
Assert.Equal(height, session.Height);
var decoded = 0;
while (session.TryReadNextFrame(out _))
decoded++;
// All frames must survive the round-trip (allow a 1-frame slack for encoder/muxer flush quirks).
Assert.True(decoded >= frameCount - 1, $"decoded {decoded} frames, expected ~{frameCount}");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Fact]
public void Encode_ProRes_RoundTripsThroughDecoder()
{
const int width = 320;
const int height = 240;
const int frameCount = 30;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-prores-{Guid.NewGuid():N}.mov");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(30, 1),
BitRate = 0, // ProRes derives its own rate from the profile
VideoCodecId = AVCodecID.Prores,
EncoderPixelFormat = AVPixelFormat.Yuv422p10le, // ProRes rejects 4:2:0
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
};
var frame = new byte[width * height * 4];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < frameCount; i++)
{
FillGradient(frame, width, height, i);
encoder.WriteVideoFrame(frame, width * 4);
}
}
Assert.True(File.Exists(path));
Assert.True(new FileInfo(path).Length > 0, "encoded file is empty");
using var session = VideoDecoderSession.TryOpen(path, VideoPlaybackOptimization.FastSeeking, out var error);
Assert.Null(error);
Assert.NotNull(session);
Assert.Equal(width, session!.Width);
Assert.Equal(height, session.Height);
var decoded = 0;
while (session.TryReadNextFrame(out _))
decoded++;
Assert.True(decoded >= frameCount - 1, $"decoded {decoded} ProRes frames, expected ~{frameCount}");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Theory]
[InlineData("libvpx-vp9", "mp4")] // VP9
[InlineData("libsvtav1", "mp4")] // AV1 (SVT)
[InlineData("ffv1", "mkv")] // lossless
[InlineData("libkvazaar", "mp4")] // HEVC software (BSD, not GPL libx265)
public void Encode_SoftwareCodec_RoundTripsThroughDecoder(string encoderName, string ext)
{
const int width = 192;
const int height = 144;
const int frameCount = 8;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-{encoderName}-{Guid.NewGuid():N}.{ext}");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(30, 1),
BitRate = 4_000_000,
VideoEncoderName = encoderName,
EncoderPixelFormat = AVPixelFormat.Yuv420p,
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
};
var frame = new byte[width * height * 4];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < frameCount; i++)
{
FillGradient(frame, width, height, i);
encoder.WriteVideoFrame(frame, width * 4);
}
}
Assert.True(File.Exists(path));
Assert.True(new FileInfo(path).Length > 0, "encoded file is empty");
using var session = VideoDecoderSession.TryOpen(path, VideoPlaybackOptimization.FastSeeking, out var error);
Assert.Null(error);
Assert.NotNull(session);
var decoded = 0;
while (session!.TryReadNextFrame(out _))
decoded++;
Assert.True(decoded >= frameCount - 1, $"decoded {decoded} {encoderName} frames, expected ~{frameCount}");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Fact]
public void HardwareEncoder_WhenAvailable_RoundTripsThroughDecoder()
{
var encoderName = HardwareEncoderProbe.H264HardwareEncoder;
if (encoderName == null)
return; // No usable hardware encoder on this machine (e.g. GPU-less CI) — nothing to verify here.
const int width = 320;
const int height = 240;
const int frameCount = 30;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-hw-{Guid.NewGuid():N}.mp4");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(30, 1),
BitRate = 4_000_000,
VideoEncoderName = encoderName,
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
};
var frame = new byte[width * height * 4];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < frameCount; i++)
{
FillGradient(frame, width, height, i);
encoder.WriteVideoFrame(frame, width * 4);
}
}
using var session = VideoDecoderSession.TryOpen(path, VideoPlaybackOptimization.FastSeeking, out var error);
Assert.Null(error);
Assert.NotNull(session);
Assert.Equal(width, session!.Width);
Assert.Equal(height, session.Height);
var decoded = 0;
while (session.TryReadNextFrame(out _))
decoded++;
Assert.True(decoded >= frameCount - 1, $"decoded {decoded} frames via {encoderName}, expected ~{frameCount}");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Theory]
[InlineData("hap")] // DXT1, RGB
[InlineData("hap_alpha")] // DXT5, with alpha
[InlineData("hap_q")] // scaled DXT5-YCoCg, higher quality
public void Encode_Hap_RoundTripsThroughDecoder(string hapFormat)
{
// The bundled LGPL build currently ships the HAP decoder but no HAP encoder, so this is skipped there;
// it validates the encode path on any build that does include it (e.g. a tier-2 ffmpeg).
if (Codec.FindEncoderByName("hap") == null)
return;
const int width = 320; // HAP is a DXT (4×4 block) codec, so both dimensions must be multiples of 4.
const int height = 240;
const int frameCount = 12;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-{hapFormat}-{Guid.NewGuid():N}.mov");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(30, 1),
VideoEncoderName = "hap",
EncoderPixelFormat = AVPixelFormat.Rgba, // HAP compresses RGBA directly
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
VideoCodecOptions = new[] { new KeyValuePair("format", hapFormat) },
};
var frame = new byte[width * height * 4];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < frameCount; i++)
{
FillGradient(frame, width, height, i);
encoder.WriteVideoFrame(frame, width * 4);
}
}
Assert.True(File.Exists(path));
Assert.True(new FileInfo(path).Length > 0, "encoded file is empty");
using var session = VideoDecoderSession.TryOpen(path, VideoPlaybackOptimization.FastSeeking, out var error);
Assert.Null(error);
Assert.NotNull(session);
Assert.Equal(width, session!.Width);
Assert.Equal(height, session.Height);
var decoded = 0;
while (session.TryReadNextFrame(out _))
decoded++;
Assert.True(decoded >= frameCount - 1, $"decoded {decoded} {hapFormat} frames, expected ~{frameCount}");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Fact]
public void Encode_WithAudio_MuxesVideoAndAacStreams()
{
const int width = 320;
const int height = 240;
const int frameCount = 30;
const int fps = 30;
const int sampleRate = 48000;
const int channels = 2;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-av-{Guid.NewGuid():N}.mp4");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(fps, 1),
BitRate = 2_000_000,
VideoCodecId = AVCodecID.Mpeg4,
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
EncodeAudio = true,
AudioSampleRate = sampleRate,
AudioChannels = channels,
AudioBitRate = 192_000,
};
var videoFrame = new byte[width * height * 4];
var samplesPerVideoFrame = sampleRate / fps;
var audioChunk = new byte[samplesPerVideoFrame * channels * sizeof(float)];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < frameCount; i++)
{
FillGradient(videoFrame, width, height, i);
encoder.WriteVideoFrame(videoFrame, width * 4);
FillSine(audioChunk, samplesPerVideoFrame, channels, i, sampleRate);
encoder.WriteAudioSamples(audioChunk);
}
}
using var fc = FormatContext.OpenInputUrl(path, null, null);
fc.LoadStreamInfo();
var video = fc.FindBestStreamOrNull(AVMediaType.Video);
Assert.NotNull(video);
var audio = fc.FindBestStreamOrNull(AVMediaType.Audio);
Assert.NotNull(audio);
var audioParams = audio!.Value.Codecpar;
Assert.NotNull(audioParams);
Assert.Equal(AVCodecID.Aac, audioParams!.CodecId);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Theory]
[InlineData(VideoExportCodec.ProRes)]
[InlineData(VideoExportCodec.VP9)]
[InlineData(VideoExportCodec.AV1)]
[InlineData(VideoExportCodec.FFV1)]
public void GetAvailability_NonH264Codec_IsSoftware(VideoExportCodec codec)
{
var availability = new FfmpegVideoEncoderFactory().GetAvailability(codec);
Assert.Equal(VideoEncoderKind.Software, availability.Kind);
Assert.False(string.IsNullOrEmpty(availability.EncoderName));
}
[Theory]
[InlineData(VideoExportCodec.Hap)]
[InlineData(VideoExportCodec.HapAlpha)]
[InlineData(VideoExportCodec.HapQ)]
public void GetAvailability_Hap_MatchesEncoderPresence(VideoExportCodec codec)
{
// HAP availability must track whether the build actually ships the encoder — Software when present,
// Unavailable otherwise (the bundled LGPL build is decode-only for HAP). Either way, never a crash.
var expected = Codec.FindEncoderByName("hap") == null
? VideoEncoderKind.Unavailable
: VideoEncoderKind.Software;
Assert.Equal(expected, new FfmpegVideoEncoderFactory().GetAvailability(codec).Kind);
}
[Fact]
public void GetAvailability_H264_IsHardwareOrSoftware()
{
var availability = new FfmpegVideoEncoderFactory().GetAvailability(VideoExportCodec.H264);
// H.264 resolves to a hardware encoder where the GPU supports one, otherwise an in-process software
// encoder (OpenH264, else MPEG-4) — never unavailable, never null.
var expected = HardwareEncoderProbe.H264HardwareEncoder == null
? VideoEncoderKind.Software
: VideoEncoderKind.Hardware;
Assert.Equal(expected, availability.Kind);
Assert.False(string.IsNullOrEmpty(availability.EncoderName));
}
[Fact]
public void GetAvailability_Hevc_IsHardwareOrSoftware()
{
// HEVC resolves to a hardware encoder where the GPU supports one, else software libkvazaar — never
// unavailable on a build that ships kvazaar (the bundled and test builds both do).
if (Codec.FindEncoderByName("libkvazaar") == null && HardwareEncoderProbe.HevcHardwareEncoder == null)
return;
var kind = new FfmpegVideoEncoderFactory().GetAvailability(VideoExportCodec.Hevc).Kind;
Assert.True(kind is VideoEncoderKind.Hardware or VideoEncoderKind.Software, $"unexpected HEVC availability: {kind}");
}
[Fact]
public void Encode_OpenH264_RoundTripsThroughDecoder()
{
// The no-hardware H.264 path uses OpenH264 in-process (BSD, not GPL libx264). Skips on a build without it.
if (Codec.FindEncoderByName("libopenh264") == null)
return;
const int width = 320;
const int height = 240;
const int frameCount = 20;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-openh264-{Guid.NewGuid():N}.mp4");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(30, 1),
BitRate = 4_000_000,
VideoEncoderName = "libopenh264",
EncoderPixelFormat = AVPixelFormat.Yuv420p,
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
};
var frame = new byte[width * height * 4];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < frameCount; i++)
{
FillGradient(frame, width, height, i);
encoder.WriteVideoFrame(frame, width * 4);
}
}
using var session = VideoDecoderSession.TryOpen(path, VideoPlaybackOptimization.FastSeeking, out var error);
Assert.Null(error);
Assert.NotNull(session);
Assert.Equal(width, session!.Width);
Assert.Equal(height, session.Height);
var decoded = 0;
while (session.TryReadNextFrame(out _))
decoded++;
Assert.True(decoded >= frameCount - 1, $"decoded {decoded} OpenH264 frames, expected ~{frameCount}");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
[Fact]
public void Encode_TagsBt709LimitedColor()
{
const int width = 320;
const int height = 240;
var path = Path.Combine(Path.GetTempPath(), $"tixl-encode-color-{Guid.NewGuid():N}.mp4");
try
{
var settings = new VideoEncoderSettings
{
FilePath = path,
Width = width,
Height = height,
FrameRate = new AVRational(30, 1),
BitRate = 2_000_000,
VideoCodecId = AVCodecID.Mpeg4,
SourceFormat = AVPixelFormat.Rgba,
SourceBytesPerPixel = 4,
};
var frame = new byte[width * height * 4];
using (var encoder = new VideoFileEncoder(settings))
{
for (var i = 0; i < 10; i++)
{
FillGradient(frame, width, height, i);
encoder.WriteVideoFrame(frame, width * 4);
}
}
using var fc = FormatContext.OpenInputUrl(path, null, null);
fc.LoadStreamInfo();
var video = fc.FindBestStreamOrNull(AVMediaType.Video);
Assert.NotNull(video);
var par = video!.Value.Codecpar!;
Assert.Equal(AVColorSpace.Bt709, par.ColorSpace);
Assert.Equal(AVColorPrimaries.Bt709, par.ColorPrimaries);
Assert.Equal(AVColorRange.Mpeg, par.ColorRange);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
// One video frame's worth of a 440 Hz stereo sine, as interleaved float32 bytes.
private static void FillSine(byte[] buffer, int samplesPerChannel, int channels, int frameIndex, int sampleRate)
{
var samples = MemoryMarshal.Cast(buffer.AsSpan());
var baseSample = (long)frameIndex * samplesPerChannel;
for (var i = 0; i < samplesPerChannel; i++)
{
var t = (baseSample + i) / (double)sampleRate;
var value = (float)(Math.Sin(2 * Math.PI * 440 * t) * 0.2);
for (var ch = 0; ch < channels; ch++)
samples[i * channels + ch] = value;
}
}
// A moving gradient so successive frames differ (gives the encoder real motion to compress).
private static void FillGradient(byte[] rgba, int width, int height, int frameIndex)
{
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var o = (y * width + x) * 4;
rgba[o + 0] = (byte)((x + frameIndex * 4) & 0xFF);
rgba[o + 1] = (byte)((y + frameIndex * 2) & 0xFF);
rgba[o + 2] = (byte)((frameIndex * 8) & 0xFF);
rgba[o + 3] = 255;
}
}
}
}