Compare commits

7 Commits
12 changed files with 496 additions and 20 deletions

No files matched your search

+17
View File
@@ -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();
+22 -3
View File
@@ -940,6 +940,14 @@ internal class RenderManager : MonoBehaviour
// Cancelled or failed during the countdown: do not start the encoder
if (stage != RenderStage.OnScene) yield break;
if (Configuration.AdvancedConfig.DisableTileCulling && scrLevelMaker.instance)
{
foreach (var floor in scrLevelMaker.instance.listFloors)
{
if (floor) floor.enabled = true;
}
}
frameCount = 0; framesSent = 0;
_renderStopwatch.Restart();
@@ -1045,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;
+14 -4
View File
@@ -102,14 +102,24 @@ public class AdvancedConfig
/// </summary>
public bool HighResBloomCorrection { get; set; } = false;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
/// <summary>
/// When true, fixes in-game and render bugs during SetFramerate events (where objects disappear on the first frame
/// or parallax/tiles lag behind) by deferring manual camera rendering to the end of the frame after all LateUpdates.
/// Default true.
/// </summary>
public bool FixCustomFrameRate { get; set; } = true;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
/// <summary>
/// When true, prevents floor tiles from being disabled when outside the camera frustum (scrFloor.OnBecameInvisible),
/// eliminating the 1-frame update lag/glitch when tiles enter the screen. Default false.
/// </summary>
public bool DisableTileCulling { get; set; } = false;
/// <summary>
/// When true, hides placeholder decorations (default tree-in-circle sprite) that failed to load or are empty.
/// Default false.
/// </summary>
public bool HideFailedDecorations { get; set; } = false;
/// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary>
public string FileExtension { get; set; } = "mkv";
@@ -137,9 +147,9 @@ public class AdvancedConfig
ProcessAudio = source.ProcessAudio;
NonTile = source.NonTile;
HighResBloomCorrection = source.HighResBloomCorrection;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
FixCustomFrameRate = source.FixCustomFrameRate;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
DisableTileCulling = source.DisableTileCulling;
HideFailedDecorations = source.HideFailedDecorations;
FileExtension = source.FileExtension;
firePlanetColor = source.firePlanetColor;
icePlanetColor = source.icePlanetColor;
+5
View File
@@ -39,11 +39,16 @@
"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",
"ui.render_config.high_res_bloom_correction": "High-Resolution Bloom Correction",
"ui.render_config.fix_custom_frame_rate": "Fix Custom FPS Render Timing",
"ui.render_config.disable_tile_culling": "Disable Tile Culling",
"ui.render_config.hide_failed_decorations": "Hide Failed Decorations",
"ui.render_config.random_seed": "Random Seed (Optional)",
"ui.render_config.validation_title": "Invalid Settings",
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
+5
View File
@@ -39,11 +39,16 @@
"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": "논타일 모드",
"ui.render_config.high_res_bloom_correction": "고해상도 블룸 보정",
"ui.render_config.fix_custom_frame_rate": "커스텀 FPS 렌더 타이밍 보정",
"ui.render_config.disable_tile_culling": "타일 컬링 비활성화",
"ui.render_config.hide_failed_decorations": "로딩 실패한 장식 숨기기",
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
"ui.render_config.validation_title": "설정 오류",
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
-2
View File
@@ -6,7 +6,6 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
/// <summary>
/// Fixes the in-game and render bug where objects (floors, decorations, planets) disappear on the first frame of
/// a SetFramerate event (due to camera Z=0 near clip culling) and where parallax/tiles lag behind on subsequent frames.
@@ -112,4 +111,3 @@ internal class CustomFrameRateDeferredRunner : MonoBehaviour
CustomFrameRateDeferPatch.ExecuteDeferredRender();
}
}
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
+93
View File
@@ -0,0 +1,93 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Patches decoration rendering to hide decorations that failed to load (or are empty) and fell back to the default
/// placeholder sprite (tree inside a circle) during a render when <c>AdvancedConfig.HideFailedDecorations</c> is enabled.
/// </summary>
public static class DecorationPatch
{
/// <summary>
/// True while a render is loading or active and <c>HideFailedDecorations</c> is enabled in the configuration.
/// </summary>
public static bool IsEnabled =>
RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration?.AdvancedConfig?.HideFailedDecorations == true;
/// <summary>
/// Checks whether the given sprite is the default placeholder or not-found sprite of the decoration manager.
/// </summary>
public static bool IsPlaceholderSprite(Sprite? sprite, scrDecorationManager? manager = null)
{
if (!sprite) return false;
manager ??= scrDecorationManager.instance;
if (!manager) return false;
return sprite == manager.defaultSprite || (manager.notFoundSprite && sprite == manager.notFoundSprite);
}
/// <summary>
/// Sweeps all visual decorations in <c>scrDecorationManager.instance.allDecorations</c> and clears any placeholder sprites.
/// Called from <see cref="scnGamePatch.ProcessFloors"/> when <c>scnGame.Play</c> runs.
/// </summary>
public static void ProcessDecorations()
{
if (!IsEnabled) return;
var manager = scrDecorationManager.instance;
if (!manager || manager.allDecorations == null) return;
foreach (var dec in manager.allDecorations)
{
if (dec is scrVisualDecoration visualDec && visualDec.spriteRenderer)
{
if (IsPlaceholderSprite(visualDec.spriteRenderer.sprite, manager))
{
visualDec.SetSprite((Sprite)null!);
}
}
}
}
/// <summary>
/// Prefix of <c>scrVisualDecoration.SetSprite(Sprite)</c>: replaces placeholder default/not-found sprites with null
/// when <see cref="IsEnabled"/> is true.
/// </summary>
[HarmonyPatch(typeof(scrVisualDecoration), nameof(scrVisualDecoration.SetSprite), typeof(Sprite))]
// ReSharper disable once InconsistentNaming
public static class VisualDecorationSetSpritePatch
{
[HarmonyPrefix]
public static void Prefix(scrVisualDecoration __instance, ref Sprite? sprite)
{
if (!IsEnabled || !sprite) return;
if (IsPlaceholderSprite(sprite, __instance.manager))
{
sprite = null;
}
}
}
/// <summary>
/// Prefix of <c>scrDecorationManager.ShowEmptyDecorations(bool)</c>: suppresses showing placeholder default sprites
/// during render when <see cref="IsEnabled"/> is true.
/// </summary>
[HarmonyPatch(typeof(scrDecorationManager), nameof(scrDecorationManager.ShowEmptyDecorations))]
public static class ShowEmptyDecorationsPatch
{
[HarmonyPrefix]
public static bool Prefix(bool show)
{
if (show && IsEnabled)
{
return false;
}
return true;
}
}
}
+15 -2
View File
@@ -30,8 +30,21 @@ public static class scnGamePatch
[HarmonyPatch("Play")]
public static void ProcessFloors()
{
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
foreach (var floor in scrLevelMaker.instance.listFloors)
{
if (floor) floor.enabled = true;
}
}
DecorationPatch.ProcessDecorations();
var nonTile = RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration?.AdvancedConfig?.NonTile == true;
if (!nonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors)
+37
View File
@@ -33,4 +33,41 @@ public class scrFloorPatch
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile;
}
/// <summary>
/// Postfix of <c>scrFloor.Start</c>.
/// The game sets <c>enabled = isVisible</c> in Start(), which disables all off-screen tiles right at spawn.
/// When <c>DisableTileCulling</c> is enabled, force every floor to remain enabled.
/// </summary>
[HarmonyPostfix]
[HarmonyPatch("Start")]
public static void ForceEnableOnStart(scrFloor __instance)
{
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
__instance.enabled = true;
}
}
/// <summary>
/// Prefix of <c>scrFloor.OnBecameInvisible</c>.
/// Skips disabling the floor component (<c>enabled = false</c>) while rendering with
/// <c>AdvancedConfig.DisableTileCulling</c> enabled, preventing the 1-frame update lag/glitch
/// when tiles enter the camera view.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch("OnBecameInvisible")]
// ReSharper disable once InconsistentNaming
public static bool PreventTileCulling(scrFloor __instance)
{
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
return false;
}
return true;
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine;
using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.2-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.3-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer;
+95 -8
View File
@@ -135,14 +135,19 @@ 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;
private static O5Toggle? _nonTileToggle;
private static O5Toggle? _highResBloomCorrectionToggle;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
private static O5Toggle? _fixCustomFrameRateToggle;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
private static O5Toggle? _disableTileCullingToggle;
private static O5Toggle? _hideFailedDecorationsToggle;
// Planet color overrides: the toggle enables the override, the picker holds the color. A disabled toggle stores
// null in AdvancedConfig (no override is applied by PersistencePatch) and blocks the picker.
@@ -698,6 +703,43 @@ 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);
CreateButton(
ctx, globalContent,
"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
@@ -761,6 +803,22 @@ internal static class RenderConfigWindow
generateLabel: false
);
// Disable Tile Culling (AdvancedConfig.DisableTileCulling): prevents tiles from being disabled outside the frustum
_disableTileCullingToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.DisableTileCulling,
toggleTextKey: "ui.render_config.disable_tile_culling",
id: "toggle_disable_tile_culling",
onChanged: val =>
{
config.AdvancedConfig.DisableTileCulling = val;
MarkConfigModified();
},
generateLabel: false
);
// Random Seed (AdvancedConfig.RandomSeed): integer passed to UnityEngine.Random.InitState before the game scene
// loads; empty text stores null (no seeding). Text that is neither empty nor an integer keeps the previous value.
_randomSeedInput = CreateInputField(
@@ -794,7 +852,6 @@ internal static class RenderConfigWindow
generateLabel: false
);
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
// Fix SetFramerate Render Timing (AdvancedConfig.FixCustomFrameRate): defers manual camera rendering to frame end
_fixCustomFrameRateToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
@@ -810,7 +867,22 @@ internal static class RenderConfigWindow
},
generateLabel: false
);
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
// Hide Failed Decorations (AdvancedConfig.HideFailedDecorations): hides placeholder tree decorations when decoration images fail to load
_hideFailedDecorationsToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.HideFailedDecorations,
toggleTextKey: "ui.render_config.hide_failed_decorations",
id: "toggle_hide_failed_decorations",
onChanged: val =>
{
config.AdvancedConfig.HideFailedDecorations = val;
MarkConfigModified();
},
generateLabel: false
);
// 7-1. Planet Color Settings (Optional).
// For each planet: the toggle turns the override on/off (off stores null and blocks/collapses the picker);
@@ -1015,15 +1087,16 @@ 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);
_processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false);
_nonTileToggle?.Set(config.AdvancedConfig.NonTile, false);
_highResBloomCorrectionToggle?.Set(config.AdvancedConfig.HighResBloomCorrection, false);
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
_fixCustomFrameRateToggle?.Set(config.AdvancedConfig.FixCustomFrameRate, false);
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
_disableTileCullingToggle?.Set(config.AdvancedConfig.DisableTileCulling, false);
_hideFailedDecorationsToggle?.Set(config.AdvancedConfig.HideFailedDecorations, false);
// Planet colors: toggle on when an override is stored; the picker shows the stored color or the default and is
// blocked without an override
@@ -1141,6 +1214,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);
@@ -1206,9 +1293,9 @@ internal static class RenderConfigWindow
if (_processAudioToggle != null) target.AdvancedConfig.ProcessAudio = _processAudioToggle.Value;
if (_nonTileToggle != null) target.AdvancedConfig.NonTile = _nonTileToggle.Value;
if (_highResBloomCorrectionToggle != null) target.AdvancedConfig.HighResBloomCorrection = _highResBloomCorrectionToggle.Value;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
if (_fixCustomFrameRateToggle != null) target.AdvancedConfig.FixCustomFrameRate = _fixCustomFrameRateToggle.Value;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
if (_disableTileCullingToggle != null) target.AdvancedConfig.DisableTileCulling = _disableTileCullingToggle.Value;
if (_hideFailedDecorationsToggle != null) target.AdvancedConfig.HideFailedDecorations = _hideFailedDecorationsToggle.Value;
// Planet Colors
target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null)
+192
View File
@@ -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 &lt;color=...&gt;, &lt;b&gt;, 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;
}
}