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; + } +}