SHA256
Add Customizable Output Filename Formatting
This commit is contained in:
1 parent
b66d774cb9
commit
d90d06c095
6 files changed
+287
-3
No files matched your search
@@ -17,6 +17,11 @@ internal class GlobalConfig
|
||||
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
|
||||
/// </summary>
|
||||
public string? OutputDirectory { get; set; }
|
||||
/// <summary>
|
||||
/// File name format template chosen in the render configuration window; null or empty selects
|
||||
/// GlobalSettings.DefaultFileNameFormat ("{date} {time}").
|
||||
/// </summary>
|
||||
public string? FileNameFormat { get; set; }
|
||||
/// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary>
|
||||
public string? LastPresetName { get; set; }
|
||||
/// <summary>
|
||||
@@ -72,6 +77,18 @@ internal static class GlobalSettings
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default file name format template: "{date} {time}".</summary>
|
||||
public const string DefaultFileNameFormat = "{date} {time}";
|
||||
|
||||
/// <summary>
|
||||
/// File name format used for renders without an explicit output path: Current.FileNameFormat when it is set,
|
||||
/// otherwise <see cref="DefaultFileNameFormat"/>.
|
||||
/// </summary>
|
||||
public static string EffectiveFileNameFormat =>
|
||||
string.IsNullOrWhiteSpace(Current.FileNameFormat)
|
||||
? DefaultFileNameFormat
|
||||
: Current.FileNameFormat;
|
||||
|
||||
static GlobalSettings()
|
||||
{
|
||||
Load();
|
||||
|
||||
+14
-3
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "논타일 모드",
|
||||
|
||||
@@ -135,6 +135,11 @@ internal static class RenderConfigWindow
|
||||
/// </summary>
|
||||
private static O5InputField? _outputDirInput;
|
||||
|
||||
/// <summary>
|
||||
/// File name format template input. Bound to <c>GlobalSettings.Current.FileNameFormat</c>.
|
||||
/// </summary>
|
||||
private static O5InputField? _fileNameFormatInput;
|
||||
|
||||
private static O5Dropdown<string>? _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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Formats and sanitizes render output file names according to customizable template patterns.
|
||||
/// </summary>
|
||||
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<string> 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"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Removes Unity rich text tags (such as <color=...>, <b>, etc.) from the given text.
|
||||
/// </summary>
|
||||
public static string StripRichText(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return string.Empty;
|
||||
return RichTextRegex.Replace(input, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces invalid file name characters within a single token with an underscore.
|
||||
/// </summary>
|
||||
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(' ', '.');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes the final formatted file name, ensuring no illegal characters or Windows reserved device names.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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}.
|
||||
/// </summary>
|
||||
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<string, string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a collision-free output file path by appending (1), (2), etc. if a file with the given name already exists.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user