diff --git a/MaiChartManager/Controllers/Tools/FancyToolController.cs b/MaiChartManager/Controllers/Tools/FancyToolController.cs new file mode 100644 index 00000000..9fce9b69 --- /dev/null +++ b/MaiChartManager/Controllers/Tools/FancyToolController.cs @@ -0,0 +1,887 @@ +using MaiChartManager.Platform; +using MaiChartManager.Utils; +using Microsoft.AspNetCore.Mvc; +using Tomlyn; +using Tomlyn.Model; + +namespace MaiChartManager.Controllers.Tools; + +/// +/// 配合 AquaMai 的两个花活功能的编辑工具: +/// +/// 开场视频 CustomIntroCinematic:LocalAssets/IntroMovies/Enter_<id>.mp4(或 Enter_<id>_L/_R.mp4 左右分开) +/// 曲目伪装 TrackCamouflage:LocalAssets/Camouflages/<id>.toml + <id>_jacket.jpg/png +/// +/// +[ApiController] +[Route("MaiChartManagerServlet/[action]Api")] +public class FancyToolController(ILogger logger) : ControllerBase +{ + private static readonly TomlModelOptions TomlOptions = new() { ConvertPropertyName = name => name }; + + private static string IntroMovieDir => Path.Combine(StaticSettings.GamePath, "LocalAssets", "IntroMovies"); + private static string CamouflageDir => Path.Combine(StaticSettings.GamePath, "LocalAssets", "Camouflages"); + + /// + /// 同一个曲目的文件操作串行化:避免两个请求交错提交,留下音画 / 伪装不匹配的文件。 + /// 用固定数量的「分段锁」而不是按 id 建字典,内存恒定(不同 id 偶尔落到同一段,可以接受)。 + /// + private static readonly SemaphoreSlim[] FileGates = + Enumerable.Range(0, 64).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + + private static SemaphoreSlim GetFileGate(int id) => FileGates[(uint)id % (uint)FileGates.Length]; + + public record FancyToolResult(bool Success, string? Error); + + public record IntroMovieInfo(bool HasSingle, bool HasLeft, bool HasRight, bool HasAudio, + string? SinglePath, string? LeftPath, string? RightPath); + + public record CamouflageInfoDto(string? Name, string? Artist, string? NoteDesigner, bool HideNoteDesigner, + double UnlockScore, bool HasJacket, string? JacketPath); + + public class CamouflageModel + { + public string Name { get; set; } = ""; + public string Artist { get; set; } = ""; + public string? NoteDesigner { get; set; } + public bool HideNoteDesigner { get; set; } + public double UnlockScore { get; set; } + } + + #region 开场视频 + + [HttpGet] + public IntroMovieInfo GetIntroMovie(int id) + { + var singlePath = ExistingPath(VideoFileName(id, null)); + var leftPath = ExistingPath(VideoFileName(id, "L")); + var rightPath = ExistingPath(VideoFileName(id, "R")); + return new IntroMovieInfo(singlePath is not null, leftPath is not null, rightPath is not null, + System.IO.File.Exists(Path.Combine(IntroMovieDir, $"Enter_{id}.acb")) + && System.IO.File.Exists(Path.Combine(IntroMovieDir, $"Enter_{id}.awb")), + singlePath, leftPath, rightPath); + } + + /// + /// 取开场视频文件供前端预览。未指定 side 时依次取单文件 / 左 / 右。 + /// + [HttpGet] + public ActionResult GetIntroMovieFile(int id, string? side) + { + var path = side?.ToUpperInvariant() switch + { + "L" => ExistingPath(VideoFileName(id, "L")), + "R" => ExistingPath(VideoFileName(id, "R")), + _ => ExistingPath(VideoFileName(id, null)) + ?? ExistingPath(VideoFileName(id, "L")) + ?? ExistingPath(VideoFileName(id, "R")), + }; + + if (path is null) return NotFound(); + // enableRangeProcessing: