diff --git a/Core/Data/GlobalSettings.cs b/Core/Data/GlobalSettings.cs
index f4bb28a..0587e52 100644
--- a/Core/Data/GlobalSettings.cs
+++ b/Core/Data/GlobalSettings.cs
@@ -17,6 +17,11 @@ internal class GlobalConfig
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
///
public string? OutputDirectory { get; set; }
+ ///
+ /// File name format template chosen in the render configuration window; null or empty selects
+ /// GlobalSettings.DefaultFileNameFormat ("{date} {time}").
+ ///
+ public string? FileNameFormat { get; set; }
/// Name of the preset last selected or saved in the render configuration window; selected again when it opens.
public string? LastPresetName { get; set; }
///
@@ -72,6 +77,18 @@ internal static class GlobalSettings
}
}
+ /// Default file name format template: "{date} {time}".
+ public const string DefaultFileNameFormat = "{date} {time}";
+
+ ///
+ /// File name format used for renders without an explicit output path: Current.FileNameFormat when it is set,
+ /// otherwise .
+ ///
+ public static string EffectiveFileNameFormat =>
+ string.IsNullOrWhiteSpace(Current.FileNameFormat)
+ ? DefaultFileNameFormat
+ : Current.FileNameFormat;
+
static GlobalSettings()
{
Load();
diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs
index 30a7b15..93a7494 100644
--- a/Core/RenderManager.cs
+++ b/Core/RenderManager.cs
@@ -1053,9 +1053,20 @@ internal class RenderManager : MonoBehaviour
var startTime = DateTime.Now;
if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
else {
- var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
- $".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
- CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName);
+ var format = GlobalSettings.EffectiveFileNameFormat;
+ var ext = Configuration.AdvancedConfig.FileExtension;
+ var baseName = RenderFileNameUtil.FormatFileName(
+ format,
+ startTime,
+ Configuration,
+ GlobalSettings.Current.LastPresetName,
+ levelPath
+ );
+ CurrentOutPath = RenderFileNameUtil.GetUniqueFilePath(
+ GlobalSettings.EffectiveOutputDirectory,
+ baseName,
+ ext
+ );
}
FFmpegNativeEncoder? videoFFmpeg = null;
diff --git a/Localization/Locale/en.json b/Localization/Locale/en.json
index 344ecc8..39e42d9 100644
--- a/Localization/Locale/en.json
+++ b/Localization/Locale/en.json
@@ -39,6 +39,9 @@
"ui.render_config.output_dir": "Output Directory",
"ui.render_config.browse": "Browse...",
"ui.render_config.reset_default": "Reset to Default",
+ "ui.render_config.filename_format": "Filename Format",
+ "ui.render_config.filename_format_hint": "Tags: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
+ "ui.render_config.reset_filename_default": "Reset to Default",
"ui.render_config.file_extension": "File Extension",
"ui.render_config.process_audio": "Process Audio",
"ui.render_config.non_tile": "Non-Tile Mode",
diff --git a/Localization/Locale/ko.json b/Localization/Locale/ko.json
index 152f838..ee2c0c7 100644
--- a/Localization/Locale/ko.json
+++ b/Localization/Locale/ko.json
@@ -39,6 +39,9 @@
"ui.render_config.output_dir": "출력 디렉토리",
"ui.render_config.browse": "찾아보기...",
"ui.render_config.reset_default": "기본값으로 초기화",
+ "ui.render_config.filename_format": "파일 이름 형식",
+ "ui.render_config.filename_format_hint": "태그: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
+ "ui.render_config.reset_filename_default": "기본값으로 초기화",
"ui.render_config.file_extension": "파일 확장자",
"ui.render_config.process_audio": "오디오 처리",
"ui.render_config.non_tile": "논타일 모드",
diff --git a/UI/Window/RenderConfigWindow.cs b/UI/Window/RenderConfigWindow.cs
index 1ce74a2..e5e2535 100644
--- a/UI/Window/RenderConfigWindow.cs
+++ b/UI/Window/RenderConfigWindow.cs
@@ -135,6 +135,11 @@ internal static class RenderConfigWindow
///
private static O5InputField? _outputDirInput;
+ ///
+ /// File name format template input. Bound to GlobalSettings.Current.FileNameFormat.
+ ///
+ private static O5InputField? _fileNameFormatInput;
+
private static O5Dropdown? _fileExtensionDropdown;
private static O5InputField? _randomSeedInput;
private static O5Toggle? _processAudioToggle;
@@ -698,6 +703,44 @@ internal static class RenderConfigWindow
}
);
+ // Filename format template: typing updates GlobalSettings.Current.FileNameFormat in memory only.
+ _fileNameFormatInput = CreateFullWidthInputField(
+ ctx, globalContent,
+ "ui.render_config.filename_format",
+ GlobalSettings.DefaultFileNameFormat,
+ GlobalSettings.EffectiveFileNameFormat,
+ "input_filename_format",
+ val =>
+ {
+ var trimmed = val?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(trimmed) ||
+ string.Equals(trimmed, GlobalSettings.DefaultFileNameFormat, StringComparison.Ordinal))
+ {
+ GlobalSettings.Current.FileNameFormat = null;
+ }
+ else
+ {
+ GlobalSettings.Current.FileNameFormat = trimmed;
+ }
+ }
+ );
+
+ var fnHint = CreateLabel(ctx, globalContent, "ui.render_config.filename_format_hint", 11f);
+ fnHint.color = new Color(ctx.Theme.TextDim.r, ctx.Theme.TextDim.g, ctx.Theme.TextDim.b, ctx.Theme.TextDim.a * 0.7f);
+
+ var (_, fnRight) = CreateTwoColumnPanel(globalContent);
+ CreateButton(
+ ctx, fnRight,
+ "ui.render_config.reset_filename_default",
+ "btn_reset_filename_format",
+ () =>
+ {
+ _fileNameFormatInput.Set(GlobalSettings.DefaultFileNameFormat);
+ GlobalSettings.Current.FileNameFormat = null;
+ GlobalSettings.Save();
+ }
+ );
+
// ---------------------------------------------------------------------------------------------
// Section 6: Advanced Settings card (RenderConfig.AdvancedConfig)
// File Extension | Random Seed
@@ -1045,6 +1088,7 @@ internal static class RenderConfigWindow
_audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false, noFilter: true);
_outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false);
+ _fileNameFormatInput?.Set(GlobalSettings.EffectiveFileNameFormat, false);
_fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false);
_randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false);
@@ -1171,6 +1215,20 @@ internal static class RenderConfigWindow
}
}
+ if (_fileNameFormatInput != null)
+ {
+ var format = _fileNameFormatInput.Value.Trim();
+ if (string.IsNullOrWhiteSpace(format) ||
+ string.Equals(format, GlobalSettings.DefaultFileNameFormat, StringComparison.Ordinal))
+ {
+ GlobalSettings.Current.FileNameFormat = null;
+ }
+ else
+ {
+ GlobalSettings.Current.FileNameFormat = format;
+ }
+ }
+
GlobalSettings.Save();
SetWindowActive(false);
diff --git a/Util/RenderFileNameUtil.cs b/Util/RenderFileNameUtil.cs
new file mode 100644
index 0000000..83f0285
--- /dev/null
+++ b/Util/RenderFileNameUtil.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using ADOFAIRenderer.Core.Data;
+using ADOFAIRenderer.Data;
+
+namespace ADOFAIRenderer.Util;
+
+///
+/// Formats and sanitizes render output file names according to customizable template patterns.
+///
+internal static class RenderFileNameUtil
+{
+ private static readonly Regex RichTextRegex = new(@"<[^>]+>", RegexOptions.Compiled);
+ private static readonly Regex TokenRegex = new(@"\{([a-zA-Z0-9_]+)\}", RegexOptions.Compiled);
+
+ private static readonly HashSet ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
+ };
+
+ ///
+ /// Removes Unity rich text tags (such as <color=...>, <b>, etc.) from the given text.
+ ///
+ public static string StripRichText(string? input)
+ {
+ if (string.IsNullOrEmpty(input)) return string.Empty;
+ return RichTextRegex.Replace(input, string.Empty);
+ }
+
+ ///
+ /// Replaces invalid file name characters within a single token with an underscore.
+ ///
+ public static string SanitizeToken(string? token)
+ {
+ if (string.IsNullOrEmpty(token)) return string.Empty;
+
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var sb = new StringBuilder(token.Length);
+ foreach (var c in token)
+ {
+ sb.Append(invalidChars.Contains(c) ? '_' : c);
+ }
+
+ return sb.ToString().Trim(' ', '.');
+ }
+
+ ///
+ /// Sanitizes the final formatted file name, ensuring no illegal characters or Windows reserved device names.
+ ///
+ public static string SanitizeFileName(string? fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName)) return string.Empty;
+
+ var invalidChars = Path.GetInvalidFileNameChars();
+ var sb = new StringBuilder(fileName.Length);
+ foreach (var c in fileName)
+ {
+ sb.Append(invalidChars.Contains(c) ? '_' : c);
+ }
+
+ var result = sb.ToString().Trim(' ', '.');
+ if (ReservedWindowsNames.Contains(result))
+ {
+ result = "_" + result;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Formats a file name without extension by substituting template variables.
+ /// Available tokens: {song}, {title}, {artist}, {author}, {level}, {preset},
+ /// {date}, {time}, {year}, {month}, {day}, {hour}, {minute}, {second},
+ /// {resolution}, {width}, {height}, {fps}.
+ ///
+ public static string FormatFileName(
+ string? formatTemplate,
+ DateTime startTime,
+ RenderConfig config,
+ string? presetName = null,
+ string? levelPath = null)
+ {
+ var scn = scnGame.instance;
+ string rawSong = "";
+ string rawArtist = "";
+ string rawAuthor = "";
+
+ if (scn != null && scn.levelData != null)
+ {
+ rawSong = scn.levelData.song ?? "";
+ rawArtist = scn.levelData.artist ?? "";
+ rawAuthor = scn.levelData.author ?? "";
+ }
+
+ if (string.IsNullOrWhiteSpace(rawSong) && !string.IsNullOrWhiteSpace(levelPath))
+ {
+ var dir = Path.GetDirectoryName(levelPath);
+ rawSong = !string.IsNullOrWhiteSpace(dir)
+ ? Path.GetFileName(dir)
+ : Path.GetFileNameWithoutExtension(levelPath);
+ }
+
+ var song = SanitizeToken(StripRichText(rawSong));
+ var artist = SanitizeToken(StripRichText(rawArtist));
+ var author = SanitizeToken(StripRichText(rawAuthor));
+ var level = !string.IsNullOrWhiteSpace(levelPath)
+ ? SanitizeToken(Path.GetFileNameWithoutExtension(levelPath))
+ : song;
+ var preset = SanitizeToken(presetName ?? "");
+
+ var date = startTime.ToString("yyyy-MM-dd");
+ var time = startTime.ToString("HH-mm-ss");
+ var year = startTime.ToString("yyyy");
+ var month = startTime.ToString("MM");
+ var day = startTime.ToString("dd");
+ var hour = startTime.ToString("HH");
+ var minute = startTime.ToString("mm");
+ var second = startTime.ToString("ss");
+
+ var width = config.RenderWidth.ToString();
+ var height = config.RenderHeight.ToString();
+ var resolution = $"{config.RenderWidth}x{config.RenderHeight}";
+
+ var effectiveFps = Math.Min(config.VideoFPS, config.RenderFPS);
+ var fps = Math.Abs(effectiveFps % 1) < 0.001
+ ? ((int)effectiveFps).ToString()
+ : effectiveFps.ToString("0.##");
+
+ var tokens = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "song", song },
+ { "title", song },
+ { "artist", artist },
+ { "author", author },
+ { "level", level },
+ { "preset", preset },
+ { "date", date },
+ { "time", time },
+ { "year", year },
+ { "month", month },
+ { "day", day },
+ { "hour", hour },
+ { "minute", minute },
+ { "second", second },
+ { "width", width },
+ { "height", height },
+ { "resolution", resolution },
+ { "fps", fps }
+ };
+
+ var template = string.IsNullOrWhiteSpace(formatTemplate)
+ ? GlobalSettings.DefaultFileNameFormat
+ : formatTemplate;
+
+ var replaced = TokenRegex.Replace(template, match =>
+ {
+ var key = match.Groups[1].Value;
+ return tokens.TryGetValue(key, out var val) ? val : match.Value;
+ });
+
+ var sanitized = SanitizeFileName(replaced);
+ return string.IsNullOrWhiteSpace(sanitized)
+ ? startTime.ToString("yyyy-MM-dd HH-mm-ss")
+ : sanitized;
+ }
+
+ ///
+ /// Returns a collision-free output file path by appending (1), (2), etc. if a file with the given name already exists.
+ ///
+ public static string GetUniqueFilePath(string directory, string fileNameWithoutExt, string ext)
+ {
+ var normalizedExt = "." + ext.TrimStart('.').ToLowerInvariant();
+ var fileName = $"{fileNameWithoutExt}{normalizedExt}";
+ var fullPath = Path.Combine(directory, fileName);
+
+ var counter = 1;
+ while (File.Exists(fullPath))
+ {
+ fileName = $"{fileNameWithoutExt} ({counter}){normalizedExt}";
+ fullPath = Path.Combine(directory, fileName);
+ counter++;
+ }
+
+ return fullPath;
+ }
+}