#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; /// Export progress in 0..1, or a negative value when the active export is open-ended /// () and has no determinate end — the footer shows an /// activity indicator in that case. public static double Progress => IsContinuousActive ? -1.0 : (_activeExportSession == null || _activeExportSession.FrameCount <= 1) ? 0.0 : (_activeExportSession.FrameIndex / (double)(_activeExportSession.FrameCount - 1)); /// True while an open-ended continuous capture is running. public static bool IsContinuousActive => _activeExportSession is { Settings.TimeRange: RenderSettings.TimeRanges.Continuous }; /// Frames written so far by the active export (for the continuous-capture activity readout). public static int CapturedFrameCount => _activeExportSession?.FrameIndex ?? 0; /// Seconds since the active export began (0 when none is running). 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(); } /// 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 , which reads as discarded.) 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; } /// /// Needs to be called once per frame /// 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(); } /// /// Computes the final render to file resolution from the current texture size and resolution scale factor. /// /// /// Eventually this should also include rounding to even file sizes or clamping to the codec sizes provided in the setting. /// 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(); 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; }