Files
2026-07-13 13:13:17 +08:00

723 lines
29 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#nullable enable
using System.IO;
using T3.Core.Animation;
using T3.Core.Audio;
using T3.Core.DataTypes;
using T3.Core.DataTypes.Vector;
using T3.Core.Utils;
using T3.Core.Video;
using T3.Editor.Gui.Interaction;
using T3.Editor.Gui.Interaction.Keyboard;
using T3.Editor.Gui.UiHelpers;
using T3.Editor.Gui.Windows.Layouts;
using T3.Editor.Gui.Windows.Output;
using T3.Editor.UiModel;
using T3.Editor.UiModel.ProjectHandling;
namespace T3.Editor.Gui.Windows.RenderExport;
internal static class RenderProcess
{
public static OutputWindow? OutputWindow;
public static Type? MainOutputType { get; private set; }
public static Texture2D? MainOutputTexture;
public static bool IsExporting => State == States.Exporting;
public static States State;
public enum States
{
Undefined,
NoOutputWindow,
NoValidOutputType,
NoValidOutputTexture,
ReadyForExport,
Exporting,
}
public static string LastHelpString { get; private set; } = string.Empty;
public static string LastTargetDirectory { get; private set; } = string.Empty;
/// <summary>Export progress in 0..1, or a negative value when the active export is open-ended
/// (<see cref="RenderSettings.TimeRanges.Continuous"/>) and has no determinate end — the footer shows an
/// activity indicator in that case.</summary>
public static double Progress => IsContinuousActive
? -1.0
: (_activeExportSession == null || _activeExportSession.FrameCount <= 1)
? 0.0
: (_activeExportSession.FrameIndex / (double)(_activeExportSession.FrameCount - 1));
/// <summary>True while an open-ended continuous capture is running.</summary>
public static bool IsContinuousActive
=> _activeExportSession is { Settings.TimeRange: RenderSettings.TimeRanges.Continuous };
/// <summary>Frames written so far by the active export (for the continuous-capture activity readout).</summary>
public static int CapturedFrameCount => _activeExportSession?.FrameIndex ?? 0;
/// <summary>Seconds since the active export began (0 when none is running).</summary>
public static double ExportElapsedSeconds
=> _activeExportSession == null ? 0.0 : Playback.RunTimeInSecs - _activeExportSession.ExportStartedTime;
#region main API methods
public static void TryRenderScreenShot()
{
if (MainOutputTexture == null) return;
var project = ProjectView.Focused?.OpenedProject;
if (project == null) return;
var projectFolder = project.Package.Folder;
var folder = Path.Combine(projectFolder, "Screenshots");
if (!Directory.Exists(folder))
Directory.CreateDirectory(folder);
var filename = Path.Join(folder, $"{DateTime.Now:yyyy_MM_dd-HH_mm_ss_fff}.png");
ScreenshotWriter.StartSavingToFile(RenderProcess.MainOutputTexture, filename, ScreenshotWriter.FileFormats.Png);
Log.Debug("Screenshot saved in: " + folder);
}
public static bool TryStartVideoExport()
{
if (MainOutputTexture == null || MainOutputTexture.IsDisposed)
return false;
var settings = RenderSettings.Current.Clone();
// Realtime continuous capture grabs the live output and doesn't take over playback, so there is no
// synced audio path yet — keep the writer audio-free regardless of the (greyed) checkbox.
if (settings.TimeRange == RenderSettings.TimeRanges.Continuous
&& settings.ContinuousClock == RenderSettings.ContinuousCaptureClock.Realtime)
{
settings.ExportAudio = false;
settings.ResolutionFactor = 1f; // realtime grabs the live texture as-is — capture at native size
}
if (State != States.ReadyForExport)
{
Log.Warning("Export not available");
return false;
}
var targetFilePath = GetTargetFilePath(settings.RenderMode);
var directory = string.Empty;
try
{
directory = Path.GetDirectoryName(targetFilePath) ?? string.Empty;
}
catch (Exception e)
{
Log.Warning($"Can't get directory for path: {targetFilePath}:" + e.Message);
}
if (!RenderPaths.ValidateOrCreateTargetFolder(targetFilePath))
return false;
TryGetRenderResolution(settings, out var requestedResolution);
var newSession = new ExportSession
{
Settings = settings,
FrameCount = RenderTiming.ComputeFrameCount(settings),
ExportStartedTime = Playback.RunTimeInSecs,
FrameIndex = 0,
RenderToFileResolution = requestedResolution,
TargetFilePath = targetFilePath,
TargetDirectory = directory,
};
switch (newSession.Settings.RenderMode)
{
case RenderSettings.RenderModes.Video:
if (File.Exists(targetFilePath))
{
try
{
File.Delete(targetFilePath);
}
catch (Exception e)
{
Log.Warning($"Can't write {targetFilePath}:" + e.Message);
return false;
}
}
Log.Gated.VideoRender($"""
Initializing video export with: path={targetFilePath}
renderedSize={newSession.RenderToFileResolution.Width}x{newSession.RenderToFileResolution.Height}
bitrate={settings.Bitrate}
framerate={settings.FrameRate}
audio={settings.ExportAudio}
channels={RenderAudioInfo.SoundtrackChannels()}
sampleRate={RenderAudioInfo.SoundtrackSampleRate()}
""");
break;
case RenderSettings.RenderModes.ImageSequence:
default:
ScreenshotWriter.ClearQueue();
break;
}
LastTargetDirectory = Path.GetDirectoryName(targetFilePath) ?? string.Empty;
State = States.Exporting;
LastHelpString = "Rendering...";
_activeExportSession = newSession;
return true;
}
public static void Cancel(string? reason = null)
{
if (_activeExportSession == null)
{
State = States.Undefined;
return;
}
var duration = Playback.RunTimeInSecs - _activeExportSession.ExportStartedTime;
LastHelpString = reason ?? $"Render cancelled after {StringUtils.HumanReadableDurationFromSeconds(duration)}";
CleanupSession();
}
/// <summary>Finalizes an open-ended continuous capture as a success — the file is muxed and kept, and the
/// version auto-increments like a normal render. (Unlike <see cref="Cancel"/>, which reads as discarded.)</summary>
public static void StopContinuous()
{
if (_activeExportSession == null)
{
State = States.Undefined;
return;
}
var session = _activeExportSession;
var settings = session.Settings;
var duration = Playback.RunTimeInSecs - session.ExportStartedTime;
LastHelpString = $"Captured {session.FrameIndex} frames ({StringUtils.HumanReadableDurationFromSeconds(duration)}) "
+ $"to {GetTargetFilePath(settings.RenderMode)}";
Log.Debug(LastHelpString);
if (settings.RenderMode == RenderSettings.RenderModes.Video && settings.AutoIncrementVersionNumber)
{
RenderPaths.TryIncrementVideoFileName();
ProjectView.Focused?.CompositionInstance?.Symbol.GetSymbolUi()?.FlagAsModified();
}
CleanupSession();
}
#endregion
private static bool TryInitVideoWriterWithFinalResolution(ExportSession session)
{
try
{
var codec = session.Settings.VideoCodec;
if (VideoEncoderAvailabilityCache.GetBlocking(codec).Kind == VideoEncoderKind.Unavailable)
{
LastHelpString = $"Can't render {codec}: this FFmpeg build has no encoder for it.";
Log.Warning(LastHelpString);
CleanupSession();
return false;
}
if (FfmpegVideoExportWriter.TryCreate(session, out var ffmpegError) is not { } ffmpegWriter)
{
LastHelpString = "Can't render: the FFmpeg video encoder is unavailable. " + ffmpegError;
Log.Error(LastHelpString);
CleanupSession();
return false;
}
session.VideoWriter = ffmpegWriter;
Log.Debug("Render-export: using the FFmpeg encoder.");
Log.Gated.VideoRender($"FFmpeg video writer initialized: " +
$"Codec={session.Settings.VideoCodec} " +
$"Bitrate={session.Settings.Bitrate} " +
$"Framerate={session.Settings.FrameRate} " +
$"Channels={RenderAudioInfo.SoundtrackChannels()} " +
$"SampleRate={RenderAudioInfo.SoundtrackSampleRate()}");
}
catch (Exception ex)
{
var msg = $"Failed to initialize the FFmpeg video writer: {ex.Message}\n{ex.StackTrace}";
Log.Error(msg);
LastHelpString = msg;
CleanupSession();
return false;
}
return true;
}
/// <summary>
/// Needs to be called once per frame
/// </summary>
public static void Update()
{
if (OutputWindow.TryGetPrimaryOutputWindow(out OutputWindow))
{
MainOutputTexture = OutputWindow.GetCurrentTexture();
MainOutputType = OutputWindow.ShownInstance?.Outputs.FirstOrDefault()?.ValueType;
}
else if (LayoutHandling.FocusMode && ProjectView.Focused?.GraphImageBackground is { IsActive: true } background)
{
// In focus mode the output window is hidden and the output renders into the graph background,
// so source the texture from there to keep render shortcuts working.
OutputWindow = null;
MainOutputTexture = background.GetCurrentTexture();
MainOutputType = background.OutputInstance?.Outputs.FirstOrDefault()?.ValueType;
}
else
{
State = States.NoOutputWindow;
return;
}
if (MainOutputTexture == null || MainOutputTexture.IsDisposed)
{
State = States.NoValidOutputTexture;
return;
}
if (MainOutputType != typeof(Texture2D))
{
State = States.NoValidOutputType;
return;
}
if (State != States.Exporting)
{
State = States.ReadyForExport;
}
HandleRenderShortCuts();
if (!IsExporting)
return;
if (_activeExportSession == null)
{
Log.Warning("Reverting inconsistent render export state");
State = States.Undefined;
return;
}
ExportOutputTexture(MainOutputTexture, _activeExportSession);
}
private static void ExportOutputTexture(Texture2D mainOutputTexture, ExportSession session)
{
var settings = session.Settings;
var savingSuccessful = false;
// Ensure resolution is valid
var currentResolution = new Int2(mainOutputTexture.Description.Width, mainOutputTexture.Description.Height);
var resolutionMatches = currentResolution == session.RenderToFileResolution;
if (!resolutionMatches)
{
if (++session.ResolutionMismatchCount < 2)
{
Log.Debug($"Waiting for resolution {session.RenderToFileResolution.ToResolutionString()}...");
return;
}
Log.Debug("Falling back to " + currentResolution.ToResolutionString());
session.RenderToFileResolution = currentResolution;
}
if (session.VideoWriter == null)
{
if (!TryInitVideoWriterWithFinalResolution(session))
return;
}
var isRealtimeContinuous = settings.TimeRange == RenderSettings.TimeRanges.Continuous
&& settings.ContinuousClock == RenderSettings.ContinuousCaptureClock.Realtime;
if (isRealtimeContinuous)
{
// Grab the live output without taking over playback, paced to the target FPS.
savingSuccessful = WriteRealtimeContinuousFrames(session, mainOutputTexture);
}
else
{
switch (settings.RenderMode)
{
case RenderSettings.RenderModes.Video:
{
var audioFrame = ComputeAudioBufferForVideoFrame(session);
savingSuccessful = SaveVideoFrameAndAdvance(session, mainOutputTexture, ref audioFrame, RenderAudioInfo.SoundtrackChannels(),
RenderAudioInfo.SoundtrackSampleRate());
break;
}
case RenderSettings.RenderModes.ImageSequence:
// Process audio for this frame to drive animations
var audioFrameFloat = AudioRendering.GetFullMixDownBuffer(1.0 / session.Settings.FrameRate);
// Update audio metering for UI/graph
double localFxTime = session.FrameIndex / session.Settings.FrameRate;
AudioRendering.EvaluateAllAudioMeteringOutputs(localFxTime, audioFrameFloat);
savingSuccessful = TrySaveImageFrameAndAdvance(mainOutputTexture);
break;
}
}
// Open-ended capture has no frame-count target: it ends only on an error or the user's stop press.
if (settings.TimeRange == RenderSettings.TimeRanges.Continuous)
{
if (savingSuccessful)
return;
LastHelpString = "Continuous capture stopped after an error.";
Log.Warning(LastHelpString);
CleanupSession();
return;
}
// Update stats
var effectiveFrameCount = settings.RenderMode == RenderSettings.RenderModes.Video ? session.FrameCount : session.FrameCount + 2;
var exportedFrameIndex = session.FrameIndex - WarmupFramesToSkip;
var currentFrame = settings.RenderMode == RenderSettings.RenderModes.Video
? exportedFrameIndex
: session.FrameIndex + 1;
var completed = currentFrame >= effectiveFrameCount || !savingSuccessful;
if (!completed)
return;
var duration = Playback.RunTimeInSecs - session.ExportStartedTime;
var successful = savingSuccessful ? "successfully" : "unsuccessfully";
LastHelpString = $"Render {GetTargetFilePath(settings.RenderMode)} finished {successful} in {StringUtils.HumanReadableDurationFromSeconds(duration)}";
Log.Debug(LastHelpString);
if (savingSuccessful)
{
var incremented = false;
if (settings.RenderMode == RenderSettings.RenderModes.Video && settings.AutoIncrementVersionNumber)
{
RenderPaths.TryIncrementVideoFileName();
incremented = true;
}
else if (settings.RenderMode == RenderSettings.RenderModes.ImageSequence && settings.AutoIncrementSubFolder)
{
if (settings.CreateSubFolder)
{
RenderSettings.Current.SequenceFileName = RenderPaths.GetNextIncrementedPath(RenderSettings.Current.SequenceFileName);
}
else
{
RenderSettings.Current.SequencePrefix = RenderPaths.GetNextIncrementedPath(RenderSettings.Current.SequencePrefix);
}
incremented = true;
}
if (incremented)
{
ProjectView.Focused?.CompositionInstance?.Symbol.GetSymbolUi()?.FlagAsModified();
}
}
CleanupSession();
}
/// <summary>
/// Computes the final render to file resolution from the current texture size and resolution scale factor.
/// </summary>
/// <remarks>
/// Eventually this should also include rounding to even file sizes or clamping to the codec sizes provided in the setting.
/// </remarks>
public static bool TryGetRenderResolution(RenderSettings settings, out Int2 resolution)
{
resolution = Int2.Zero;
if (State != States.ReadyForExport && State != States.Exporting)
return false;
if (MainOutputTexture == null || MainOutputTexture.IsDisposed)
return false;
// TODO: clamp for valid encoding resolutions...
resolution = new Int2(
((int)(MainOutputTexture.Description.Width * settings.ResolutionFactor)).Clamp(1, 16384),
((int)(MainOutputTexture.Description.Height * settings.ResolutionFactor)).Clamp(1, 16384));
return true;
}
public static RenderSettings GetActiveOrRequestedSettings()
{
return State == States.Exporting && _activeExportSession != null
? _activeExportSession.Settings
: RenderSettings.Current;
}
public static bool TryGetActiveExportResolution(out Int2 resolution)
{
resolution = Int2.One;
if (State != States.Exporting || _activeExportSession == null)
return false;
resolution = _activeExportSession.RenderToFileResolution;
return true;
}
private static byte[] ComputeAudioBufferForVideoFrame(ExportSession session)
{
// Use the new full mixdown buffer for audio export
double localFxTime = session.FrameIndex / session.Settings.FrameRate;
Log.Gated.VideoRender($"Requested recording from {0.0000:F4} to {(session.FrameCount / session.Settings.FrameRate):F4} seconds");
Log.Gated.VideoRender($"Actually recording from {(session.FrameIndex / session.Settings.FrameRate):F4} to {((session.FrameIndex + 1) / session.Settings.FrameRate):F4} seconds due to frame raster");
var audioFrameFloat = AudioRendering.GetFullMixDownBuffer(1.0 / session.Settings.FrameRate);
// Safety: ensure audioFrameFloat is valid and sized
if (audioFrameFloat == null || audioFrameFloat.Length == 0)
{
Log.Error($"RenderProcess: AudioRendering.GetFullMixDownBuffer returned null or empty at frame {session.FrameIndex}");
var sampleRate = RenderAudioInfo.SoundtrackSampleRate();
var channels = RenderAudioInfo.SoundtrackChannels();
var floatCount = (int)Math.Max(Math.Round((1.0 / session.Settings.FrameRate) * sampleRate), 0.0) * channels;
audioFrameFloat = new float[floatCount]; // silence
}
// Convert float[] to byte[] for the writer
var audioFrame = new byte[audioFrameFloat.Length * sizeof(float)];
Buffer.BlockCopy(audioFrameFloat, 0, audioFrame, 0, audioFrame.Length);
// Force metering outputs to update for UI/graph
AudioRendering.EvaluateAllAudioMeteringOutputs(localFxTime, audioFrameFloat);
return audioFrame;
}
private static void HandleRenderShortCuts()
{
if (MainOutputTexture == null)
return;
if (UserActions.RenderAnimation.Triggered())
{
if (IsExporting)
{
// For continuous capture the second press is a normal stop (keep the file), not a cancel.
if (IsContinuousActive)
StopContinuous();
else
Cancel();
}
else
{
TryStartVideoExport();
}
}
if (UserActions.RenderScreenshot.Triggered())
{
TryRenderScreenShot();
}
}
private static string GetTargetFilePath(RenderSettings.RenderModes renderMode) => RenderPaths.GetTargetFilePath(renderMode);
private static void CleanupSession()
{
if (_activeExportSession == null)
return;
if (_activeExportSession.Settings.RenderMode == RenderSettings.RenderModes.Video)
{
try
{
_activeExportSession.VideoWriter?.Dispose();
_activeExportSession.VideoWriter = null;
}
catch (Exception e)
{
Log.Debug("Failed to cleanup video writer: " + e.Message);
}
}
// Audio restoration is now handled automatically by AudioRendering.EndRecording()
// which is called during the rendering process
// Realtime continuous capture never takes over playback, so there is nothing to release — doing so
// would force PlaybackSpeed to 0 and yank the user's live playback when they stop the capture.
var wasRealtimeContinuous = _activeExportSession.Settings.TimeRange == RenderSettings.TimeRanges.Continuous
&& _activeExportSession.Settings.ContinuousClock == RenderSettings.ContinuousCaptureClock.Realtime;
if (!wasRealtimeContinuous)
{
// Release playback time before nulling _activeSession
RenderTiming.ReleasePlaybackTime(ref _activeExportSession.Settings, ref _activeExportSession.Runtime);
}
_activeExportSession = null;
State = States.ReadyForExport;
}
private static bool SaveVideoFrameAndAdvance(ExportSession session, Texture2D outputTexture, ref byte[] audioFrame, int channels, int sampleRate)
{
if (Playback.OpNotReady)
{
Log.Debug("Waiting for operators to complete");
return true;
}
try
{
if (UserSettings.Config.ShowRenderProfilingLogs)
{
Log.Debug($"""
SaveVideoFrameAndAdvance: frame={session.FrameIndex}
MainOutputTexture null? {MainOutputTexture == null}
audioFrame.Length={audioFrame.Length}
channels={channels}
sampleRate={sampleRate}
""");
}
RenderTiming.SetPlaybackTimeForFrame(session);
// Skip the warmup frame(s): the output texture needs one frame to settle on the new playback time.
if (session.FrameIndex >= WarmupFramesToSkip)
session.VideoWriter?.ProcessFrames(outputTexture, ref audioFrame, channels, sampleRate);
session.FrameIndex++;
return true;
}
catch (Exception e)
{
var wasFileWriteException = e is SharpDX.SharpDXException dxEx && (uint)dxEx.HResult == 0x80070020;
var msg = wasFileWriteException
? "The output file is in use by another process. Please close any application using it and try again."
: $"Exception in SaveVideoFrameAndAdvance at frame {session.FrameIndex}: {e.Message}\n{e.StackTrace}";
Log.Error(msg);
LastHelpString = msg;
CleanupSession();
}
return false;
}
// Realtime continuous capture: write as many frames of the current live texture as the wall clock says are
// due, so the file stays at a constant target FPS even though the editor refresh rate varies. Playback is
// left untouched (the user controls it live).
private static bool WriteRealtimeContinuousFrames(ExportSession session, Texture2D texture)
{
var fps = session.Settings.FrameRate;
if (fps <= 0)
return false;
if (!session.RealtimeClockAnchored)
{
// Start the wall clock from the first frame the (lazily created) writer is ready for, so the
// init delay doesn't count as captured time.
session.ExportStartedTime = Playback.RunTimeInSecs;
session.RealtimeClockAnchored = true;
return true;
}
var elapsed = Playback.RunTimeInSecs - session.ExportStartedTime;
var framesDue = (int)(elapsed * fps);
var framesToWrite = framesDue - session.FrameIndex;
if (framesToWrite <= 0)
return true; // editor running faster than the target FPS — skip a grab this frame
try
{
// A long stall (modal open, editor paused) shouldn't flood the file with duplicates: write one
// frame and resync the wall-clock baseline rather than filling the whole gap.
const int maxCatchUpFrames = 4;
if (framesToWrite > maxCatchUpFrames)
{
WriteOneRealtimeFrame(session, texture);
session.ExportStartedTime = Playback.RunTimeInSecs - session.FrameIndex / fps;
return true;
}
for (var i = 0; i < framesToWrite; i++)
WriteOneRealtimeFrame(session, texture);
return true;
}
catch (Exception e)
{
Log.Error($"Continuous capture failed at frame {session.FrameIndex}: {e.Message}");
return false;
}
}
private static void WriteOneRealtimeFrame(ExportSession session, Texture2D texture)
{
if (session.Settings.RenderMode == RenderSettings.RenderModes.Video)
{
var noAudio = Array.Empty<byte>();
session.VideoWriter?.ProcessFrames(texture, ref noAudio,
RenderAudioInfo.SoundtrackChannels(), RenderAudioInfo.SoundtrackSampleRate());
}
else
{
ScreenshotWriter.StartSavingToFile(texture, GetSequenceFilePath(), session.Settings.FileFormat);
}
session.FrameIndex++;
}
private static string GetSequenceFilePath()
{
var prefix = RenderPaths.SanitizeFilename(RenderSettings.Current.SequencePrefix);
return Path.Combine(_activeExportSession!.TargetDirectory,
$"{prefix}_{_activeExportSession.FrameIndex:0000}.{_activeExportSession.Settings.FileFormat.ToString().ToLower()}");
}
private static bool TrySaveImageFrameAndAdvance(Texture2D mainOutputTexture)
{
try
{
if (!ScreenshotWriter.StartSavingToFile(mainOutputTexture, GetSequenceFilePath(), _activeExportSession!.Settings.FileFormat))
return false;
_activeExportSession.FrameIndex++;
RenderTiming.SetPlaybackTimeForFrame(_activeExportSession);
return true;
}
catch (Exception e)
{
Log.Warning(e.Message);
return false;
}
}
private static string ToResolutionString(this Int2 resolution)
{
return $"{resolution.Width}×{resolution.Height}";
}
internal sealed class ExportSession
{
public IRenderVideoWriter? VideoWriter;
public string TargetDirectory = string.Empty;
public string TargetFilePath = string.Empty;
public double ExportStartedTime;
public int FrameIndex;
public int FrameCount;
public RenderSettings Settings = null!;
public RenderTiming.Runtime Runtime;
public Int2 RenderToFileResolution;
public int ResolutionMismatchCount;
// Realtime continuous capture only: set once the writer is ready and the wall clock is anchored.
public bool RealtimeClockAnchored;
}
private static ExportSession? _activeExportSession;
// The output texture needs one frame to settle on each new playback time, so the first rendered frame is a
// warmup that isn't written. The frame-count math accounts for it.
private const int WarmupFramesToSkip = 1;
}