Skip to content

Instantly share code, notes, and snippets.

@EvidentlyCube
Created October 2, 2025 07:37
Show Gist options
  • Select an option

  • Save EvidentlyCube/42599b74a2794db581cb45cbdef23e09 to your computer and use it in GitHub Desktop.

Select an option

Save EvidentlyCube/42599b74a2794db581cb45cbdef23e09 to your computer and use it in GitHub Desktop.
Godot GIF Recorder utility
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