#nullable enable
using System;
using System.IO;
using System.Threading;
using Sdcb.FFmpeg.Raw;
using Sdcb.FFmpeg.Swscales;
using Sdcb.FFmpeg.Utils;
using T3.Core.Logging;
using T3.Core.Video;
namespace T3.VideoServices;
///
/// Generates a seek-friendly proxy of a video by transcoding it file→file: decode the source (software) and
/// re-encode every frame to an all-intra codec (ProRes / HAP), optionally downscaled. CPU-byte-level — no D3D
/// device and no render pipeline — so it runs on a background worker and is unit-testable. Reuses the
/// render-export encoder (); a proxy never uses H.264/HEVC (libx264 =
/// GPL). Video-only — audio is not part of the preview proxy.
///
public static class ProxyTranscoder
{
/// Transcodes to . Blocking — call on a
/// background thread. is 0.1–1.0 of the source resolution. Returns null on success,
/// otherwise an error message (and removes any partial output).
public static unsafe string? Generate(string sourcePath, string proxyPath, VideoExportCodec proxyCodec,
double scale, IProgress? progress, CancellationToken cancel)
{
using var decoder = VideoDecoderSession.TryOpen(sourcePath, VideoPlaybackOptimization.FastSeeking, out var openError);
if (decoder == null)
return openError ?? $"Could not open source video '{sourcePath}'.";
// Proxy dimensions: source × scale, rounded even (and to a ×4 block for HAP's DXT).
var clampedScale = Math.Clamp(scale, 0.1, 1.0);
var rawW = (int)Math.Round(decoder.Width * clampedScale) & ~1;
var rawH = (int)Math.Round(decoder.Height * clampedScale) & ~1;
var (proxyW, proxyH) = proxyCodec.RoundToEncoderBlock(Math.Max(2, rawW), Math.Max(2, rawH));
var fps = decoder.FrameRate > 0 ? decoder.FrameRate : 30;
// Encode to a temp sibling and atomically swap into place on success. The engine substitutes a proxy the
// moment its file exists, but a MOV/MP4 has no playable moov atom until the muxer finalizes — so writing the
// final path directly lets preview pick up a half-written file ("moov atom not found"). The swap also means a
// crash mid-encode can't leave a broken-but-present proxy. Keeps the .mov extension so the muxer is inferred.
var tempPath = Path.Combine(Path.GetDirectoryName(proxyPath) ?? ".",
Path.GetFileNameWithoutExtension(proxyPath) + ".partial" + Path.GetExtension(proxyPath));
var settings = new VideoExportSettings
{
FilePath = tempPath,
Width = proxyW,
Height = proxyH,
FrameRate = fps,
Codec = proxyCodec,
ExportAudio = false,
};
var writer = new FfmpegVideoEncoderFactory().TryCreateWriter(settings, out var encodeError);
if (writer == null)
return encodeError ?? "Could not create the proxy encoder.";
string? error = null;
var converter = new VideoFrameConverter();
var rgba = Frame.CreateVideo(proxyW, proxyH, AVPixelFormat.Rgba);
rgba.EnsureBuffer(1);
var frameIndex = 0;
try
{
var totalFrames = decoder.DurationSeconds > 0 ? decoder.DurationSeconds * fps : 0;
while (decoder.TryReadNextFrame(out _))
{
if (cancel.IsCancellationRequested)
{
error = "Proxy generation cancelled.";
break;
}
// Decode is YUV at source size; scale + convert to the proxy's RGBA in one swscale pass.
converter.ConvertFrame(decoder.CurrentFrame, rgba, SWS.Bilinear);
var span = new ReadOnlySpan((void*)rgba.Data[0], proxyH * rgba.Linesize[0]);
writer.AddVideoFrame(span, rgba.Linesize[0]);
frameIndex++;
if (totalFrames > 0)
progress?.Report(Math.Min(1.0, frameIndex / totalFrames));
}
if (error == null)
{
writer.Finish();
progress?.Report(1.0);
}
}
catch (Exception e)
{
error = "Proxy generation failed: " + e.Message;
}
finally
{
// Dispose closes the file handle — must happen before the swap can move it.
writer.Dispose();
rgba.Dispose();
converter.Free();
}
// Swap the finished temp file into place only now that it's complete and closed.
if (error == null)
{
try
{
File.Move(tempPath, proxyPath, overwrite: true);
Log.Gated.VideoRender($"Proxy generated: {proxyPath} ({proxyW}×{proxyH} {proxyCodec}, {frameIndex} frames)");
}
catch (Exception e)
{
error = "Could not finalize proxy: " + e.Message;
}
}
if (error != null)
TryDelete(tempPath);
return error;
}
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch (Exception e)
{
Log.Debug($"Could not delete partial proxy '{path}': {e.Message}");
}
}
}