Created
October 2, 2025 07:37
-
-
Save EvidentlyCube/42599b74a2794db581cb45cbdef23e09 to your computer and use it in GitHub Desktop.
Godot GIF Recorder utility
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| namespace TheGame.Src; | |
| using System; | |
| using System.Collections.Generic; | |
| using System.Globalization; | |
| using System.Threading; | |
| using Godot; | |
| /// Utility for recording GIFs directly from your game. | |
| /// ffmpeg is required to be installed and in PATH to correctly convert the | |
| /// generated frames into the final gif file. | |
| /// | |
| /// By default it does the following: | |
| /// - Store recordings inside res://.recordings/ | |
| /// - Automatically stop the recording if it exceeds 3 minutes | |
| /// - Generate the GIF file and not keep the intermediary images | |
| /// - Start and stop the recording with F11 | |
| /// - Show a separate window when recording/saving | |
| public partial class GifRecorder : Node { | |
| private static readonly List<GifRecorder> instances = []; | |
| [Export(PropertyHint.Dir)] | |
| public string RecordingsPath = "res://.recordings"; | |
| [Export] | |
| public bool KeepFrameImages = false; | |
| [Export] | |
| public string RecordingNameFormat = "yyyyMMdd_HHmmss_fff"; | |
| [Export] | |
| public int AutoStopRecordingAfterSeconds = 180; | |
| [Export] | |
| public bool PrintLogs = true; | |
| [Export] | |
| public bool ShowRecordingWindow { | |
| get => isRecordingWindowEnabled; | |
| set { | |
| isRecordingWindow.IsEnabled = value; | |
| isRecordingWindowEnabled = value; | |
| } | |
| } | |
| /// Use `Ctrl+`, `Shift+` or `Alt+` (in this order) if you want a more complex | |
| /// hotkey, eg `Ctrl+Alt+F11` or `Shift+F3` | |
| [Export] | |
| public string Hotkey = "F11"; | |
| private bool isRecordingWindowEnabled = true; | |
| private readonly IsRecordingWindow isRecordingWindow; | |
| private string RecordingsAbsolutePath { | |
| get { | |
| var path = RecordingsPath; | |
| if (!OS.HasFeature("editor")) { | |
| path = path.Replace("res://", OS.GetExecutablePath().GetBaseDir() + "/"); | |
| } | |
| return ProjectSettings.GlobalizePath(path); | |
| } | |
| } | |
| private bool isRecording = false; | |
| private List<Image> frames; | |
| private readonly List<Thread> threads = []; | |
| public bool IsRecording => isRecording; | |
| private static double FPS => Engine.MaxFps > 0 ? Engine.MaxFps : 60; | |
| public GifRecorder() : base() { | |
| var existingGifRecorders = instances.FindAll(recorder => recorder.GetParent() != null); | |
| if (existingGifRecorders.Count > 0) { | |
| throw new Exception("There cannot be two Gif Recorders in the tree at once"); | |
| } | |
| instances.Add(this); | |
| isRecordingWindow = new IsRecordingWindow(this); | |
| } | |
| public override void _Process(double delta) { | |
| var existingGifRecorders = instances.FindAll(recorder => recorder.GetParent() != null); | |
| if (existingGifRecorders.Count > 1) { | |
| throw new Exception("Fatal error, you have more than one GIF Recorder in the tree."); | |
| } | |
| if (isRecording) { | |
| frames.Add(GetViewport().GetTexture().GetImage()); | |
| isRecordingWindow.labelText = $"GIF Recording In Progress {(frames.Count / FPS).ToString("N3")}s ({frames.Count} frames)"; | |
| if (frames.Count > FPS * AutoStopRecordingAfterSeconds) { | |
| StopRecording(); | |
| } | |
| } | |
| threads.RemoveAll(thread => !thread.IsAlive); | |
| isRecordingWindow.IsShown = isRecording || threads.Count > 0; | |
| isRecordingWindow.Process(); | |
| } | |
| public override void _Input(InputEvent @event) { | |
| if (@event is InputEventKey eKey && IsHotkey(eKey)) { | |
| ToggleRecording(); | |
| } | |
| } | |
| public void ToggleRecording() { | |
| if (isRecording) { | |
| StopRecording(); | |
| } else { | |
| StartRecording(); | |
| } | |
| } | |
| public void StartRecording() { | |
| if (isRecording) { | |
| Log("GIF Recorder -> Failed to start recording; already recording"); | |
| return; | |
| } | |
| Log("GIF Recorder -> Started recording"); | |
| isRecording = true; | |
| frames = []; | |
| } | |
| public void StopRecording() { | |
| if (!isRecording) { | |
| Log("GIF Recorder -> Failed to stop recording; not recording"); | |
| return; | |
| } | |
| isRecording = false; | |
| Log($"GIF Recorder -> Stopped recording after {(frames.Count / FPS).ToString("N3")} seconds ({frames.Count} frames)"); | |
| CommitRecording( | |
| frames, | |
| RecordingNameFormat, | |
| RecordingsAbsolutePath, | |
| KeepFrameImages, | |
| Log, | |
| text => isRecordingWindow.labelText = text, | |
| threads.Add | |
| ); | |
| } | |
| private static void CommitRecording( | |
| List<Image> frames, | |
| string recordingNameFormat, | |
| string recordingsAbsolutePath, | |
| bool keepFrameImages, | |
| Action<string> log, | |
| Action<string> setWindowLabel, | |
| Action<Thread> onStart | |
| ) { | |
| Thread thread = null; | |
| thread = new Thread(() => { | |
| var recordingName = DateTime.Now.ToString(recordingNameFormat, CultureInfo.InvariantCulture); | |
| var recordingAbsolutePath = $"{recordingsAbsolutePath}/{recordingName}"; | |
| DirAccess.MakeDirRecursiveAbsolute(recordingAbsolutePath); | |
| log("GIF Recorder -> Saving frames"); | |
| SaveFrames(frames, recordingAbsolutePath, setWindowLabel); | |
| log("GIF Recorder -> Generating palette"); | |
| setWindowLabel("Generating palette"); | |
| GeneratePalette(recordingAbsolutePath); | |
| log("GIF Recorder -> Generating GIF"); | |
| setWindowLabel("Generating GIF"); | |
| GenerateGif(recordingsAbsolutePath, recordingAbsolutePath, recordingName); | |
| if (!keepFrameImages) { | |
| log("GIF Recorder -> Deleting images"); | |
| DeleteRecordingDirectory(recordingAbsolutePath, setWindowLabel); | |
| } | |
| log($"GIF Recorder -> Gif stored in {recordingAbsolutePath}.gif"); | |
| }); | |
| onStart(thread); | |
| thread.Start(); | |
| } | |
| private static void SaveFrames(List<Image> frames, string recordingAbsolutePath, Action<string> setWindowLabel) { | |
| var index = 0; | |
| foreach (var frame in frames) { | |
| setWindowLabel($"Saving frame {index + 1}/{frames.Count}"); | |
| frame.SavePng($"{recordingAbsolutePath}/{index.ToString().PadLeft(5, '0')}.png"); | |
| index++; | |
| } | |
| frames.Clear(); | |
| } | |
| private static void GeneratePalette(string recordingAbsolutePath) { | |
| OS.Execute("ffmpeg", [ | |
| "-framerate", "60", | |
| "-i", $"{recordingAbsolutePath}/%05d.png", | |
| "-vf", "palettegen=max_colors=255", | |
| $"{recordingAbsolutePath}/palette.png" | |
| ]); | |
| } | |
| private static void GenerateGif(string recordingsAbsolutePath, string recordingAbsolutePath, string recordingName) { | |
| OS.Execute("ffmpeg", [ | |
| "-framerate", "60", | |
| "-i", $"{recordingAbsolutePath}/%05d.png", | |
| "-i", $"{recordingAbsolutePath}/palette.png", | |
| "-lavfi", "paletteuse=dither=none", | |
| $"{recordingsAbsolutePath}/{recordingName}.gif" | |
| ]); | |
| } | |
| private static void DeleteRecordingDirectory(string recordingAbsolutePath, Action<string> setWindowLabel) { | |
| var dir = DirAccess.Open(recordingAbsolutePath); | |
| var files = dir.GetFiles(); | |
| for (var index = 0; index < files.Length; index++) { | |
| setWindowLabel($"Deleting frame {index + 1}/{files.Length}"); | |
| dir.Remove(files[index]); | |
| } | |
| DirAccess.RemoveAbsolute(recordingAbsolutePath); | |
| } | |
| private void Log(string log) { | |
| if (PrintLogs) { | |
| GD.Print(log); | |
| } | |
| } | |
| private bool IsHotkey(InputEventKey e) { | |
| if (Hotkey == "" || Hotkey == null) { | |
| return false; | |
| } | |
| var pressedKey = (e.IsReleased() ? '-' : string.Empty) | |
| + (e.CtrlPressed ? "Ctrl+" : string.Empty) | |
| + (e.ShiftPressed ? "Shift+" : string.Empty) | |
| + (e.AltPressed ? "Alt+" : string.Empty) | |
| + e.Keycode; | |
| return pressedKey.Equals(Hotkey, StringComparison.CurrentCultureIgnoreCase); | |
| } | |
| private class IsRecordingWindow { | |
| private readonly Node parent; | |
| private readonly Window window; | |
| private readonly Label label; | |
| private bool oldGuiEmbedSubwindows; | |
| private bool isEnabled = true; | |
| private bool isShown; | |
| public string labelText; | |
| public bool IsEnabled { | |
| get => isEnabled; | |
| set { | |
| if (isEnabled != value) { | |
| isEnabled = value; | |
| RefreshVisibilityState(); | |
| } | |
| } | |
| } | |
| public bool IsShown { | |
| get => isShown; | |
| set { | |
| if (isShown != value) { | |
| isShown = value; | |
| RefreshVisibilityState(); | |
| } | |
| } | |
| } | |
| private Viewport ParentViewport => parent.GetViewport(); | |
| public IsRecordingWindow(Node parent) { | |
| this.parent = parent; | |
| window = new Window { | |
| Visible = false, | |
| ForceNative = true, | |
| Unfocusable = true, | |
| AlwaysOnTop = true | |
| }; | |
| label = new Label { | |
| Text = "GIF Recording In Progress" | |
| }; | |
| parent.AddChild(window); | |
| window.AddChild(label); | |
| } | |
| public void Process() { | |
| label.Text = labelText; | |
| window.Size = new((int)label.Size.X, (int)label.Size.Y); | |
| if (IsEnabled) { | |
| var mainWindow = ParentViewport.GetWindow(); | |
| window.Position = new Vector2I( | |
| mainWindow.Position.X + (mainWindow.Size.X - window.Size.X) / 2, | |
| Math.Max(0, mainWindow.Position.Y - window.Size.Y - 20) | |
| ); | |
| label.Modulate = DateTime.Now.Millisecond < 500 | |
| ? new Color(1, 1, 1) | |
| : new Color(1, 0, 0); | |
| } | |
| } | |
| private void RefreshVisibilityState() { | |
| if (isShown && IsEnabled && !window.Visible) { | |
| oldGuiEmbedSubwindows = ParentViewport.GuiEmbedSubwindows; | |
| window.Visible = true; | |
| window.GuiReleaseFocus(); | |
| ParentViewport.GetWindow().GrabFocus(); | |
| } else if (window.Visible && (!isShown || !isEnabled)) { | |
| window.Visible = false; | |
| window.GuiReleaseFocus(); | |
| ParentViewport.GuiEmbedSubwindows = oldGuiEmbedSubwindows; | |
| ParentViewport.GetWindow().GrabFocus(); | |
| } | |
| } | |
| } | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment