diff --git a/MaiChartManager/Controllers/Tools/FancyToolController.cs b/MaiChartManager/Controllers/Tools/FancyToolController.cs index 9fce9b69..e5cc2162 100644 --- a/MaiChartManager/Controllers/Tools/FancyToolController.cs +++ b/MaiChartManager/Controllers/Tools/FancyToolController.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; @@ -12,6 +14,7 @@ namespace MaiChartManager.Controllers.Tools; /// 开场视频 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")] @@ -39,6 +42,13 @@ public record IntroMovieInfo(bool HasSingle, bool HasLeft, bool HasRight, bool H public record CamouflageInfoDto(string? Name, string? Artist, string? NoteDesigner, bool HideNoteDesigner, double UnlockScore, bool HasJacket, string? JacketPath); + /// + /// 开场视频与曲目伪装的并集,供左侧列表一屏展示。 + /// 两个功能本来就是一套演出(视频 + 伪装 + 解锁门槛),分开列会让用户看不出哪首歌配了哪一半。 + /// + public record FancyEntry(int Id, bool HasSingle, bool HasLeft, bool HasRight, bool HasAudio, + bool HasCamouflage, string? CamouflageName, string? CamouflageArtist, bool HasJacket); + public class CamouflageModel { public string Name { get; set; } = ""; @@ -48,6 +58,102 @@ public class CamouflageModel public double UnlockScore { get; set; } } + #region 列表 + + /// + /// 列出所有做过开场视频 / 曲目伪装的曲目(并集)。 + /// + [HttpGet] + public IEnumerable GetFancyList() + { + var introMovies = ReadIntroMovieEntries().ToDictionary(entry => entry.Id); + // 同一曲目可能有两个只会互相覆盖的文件名(1.toml / 01.toml), + // 直接 ToDictionary 会抛异常让整个列表 500,这里取第一条就行 + var camouflages = ReadCamouflageEntries() + .GroupBy(entry => entry.Id) + .ToDictionary(group => group.Key, group => group.First()); + + return introMovies.Keys.Union(camouflages.Keys) + .OrderBy(id => id) + .Select(id => + { + introMovies.TryGetValue(id, out var movie); + camouflages.TryGetValue(id, out var camouflage); + return new FancyEntry( + id, + movie?.HasSingle ?? false, + movie?.HasLeft ?? false, + movie?.HasRight ?? false, + movie?.HasAudio ?? false, + camouflage is not null, + camouflage?.Name, + camouflage?.Artist, + camouflage?.HasJacket ?? false); + }) + .ToList(); + } + + private sealed record IntroMovieRow(int Id, bool HasSingle, bool HasLeft, bool HasRight, bool HasAudio); + + private sealed record CamouflageRow(int Id, string? Name, string? Artist, bool HasJacket); + + /// + /// 扫描 IntroMovies 目录。文件名规则与 AquaMai 侧一致:Enter_<id>.mp4 或 Enter_<id>_L/_R.mp4。 + /// + private IEnumerable ReadIntroMovieEntries() + { + if (!Directory.Exists(IntroMovieDir)) return []; + + var ids = new HashSet(); + foreach (var file in Directory.EnumerateFiles(IntroMovieDir, "*.mp4")) + { + var name = Path.GetFileNameWithoutExtension(file); + if (!name.StartsWith("Enter_", StringComparison.OrdinalIgnoreCase)) continue; + + var parts = name[6..].Split('_'); + if (parts.Length is < 1 or > 2) continue; + // 第三段只允许 L / R,其它(例如手工留下的 .tmp 中间产物)都不算数 + if (parts.Length == 2 && !parts[1].Equals("L", StringComparison.OrdinalIgnoreCase) + && !parts[1].Equals("R", StringComparison.OrdinalIgnoreCase)) continue; + if (int.TryParse(parts[0], out var id) && id > 0) ids.Add(id); + } + + return ids.Select(id => new IntroMovieRow( + id, + System.IO.File.Exists(Path.Combine(IntroMovieDir, VideoFileName(id, null))), + System.IO.File.Exists(Path.Combine(IntroMovieDir, VideoFileName(id, "L"))), + System.IO.File.Exists(Path.Combine(IntroMovieDir, VideoFileName(id, "R"))), + HasIntroAudio(id))); + } + + private IEnumerable ReadCamouflageEntries() + { + if (!Directory.Exists(CamouflageDir)) return []; + + var result = new List(); + foreach (var file in Directory.EnumerateFiles(CamouflageDir, "*.toml")) + { + if (!int.TryParse(Path.GetFileNameWithoutExtension(file), out var id) || id <= 0) continue; + + CamouflageModel? model; + try + { + model = Toml.ToModel(System.IO.File.ReadAllText(file), options: TomlOptions); + } + catch (Exception e) + { + logger.LogWarning(e, "解析曲目伪装配置失败: {path}", file); + model = null; + } + + result.Add(new CamouflageRow(id, model?.Name, model?.Artist, FindJacket(id) is not null)); + } + + return result; + } + + #endregion + #region 开场视频 [HttpGet] @@ -57,11 +163,13 @@ public IntroMovieInfo GetIntroMovie(int id) 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); + HasIntroAudio(id), singlePath, leftPath, rightPath); } + private static bool HasIntroAudio(int id) + => System.IO.File.Exists(Path.Combine(IntroMovieDir, $"Enter_{id}.acb")) + && System.IO.File.Exists(Path.Combine(IntroMovieDir, $"Enter_{id}.awb")); + /// /// 取开场视频文件供前端预览。未指定 side 时依次取单文件 / 左 / 右。 /// @@ -91,8 +199,7 @@ public ActionResult GetIntroMovieFile(int id, string? side) public ActionResult GetIntroAudioFile(int id) { var acbPath = Path.Combine(IntroMovieDir, $"Enter_{id}.acb"); - var awbPath = Path.Combine(IntroMovieDir, $"Enter_{id}.awb"); - if (!System.IO.File.Exists(acbPath) || !System.IO.File.Exists(awbPath)) return NotFound(); + if (!HasIntroAudio(id)) return NotFound(); var wavPath = GetCachedIntroAudio(acbPath, id); if (wavPath is null) return NotFound(); @@ -137,125 +244,174 @@ public ActionResult GetIntroAudioFile(int id) /// /// 上传并转换开场视频。side 为空表示左右屏播放同一个视频,L / R 表示只替换其中一侧。 /// 视频、音频、旧布局清理作为整体提交:任一步失败都会回滚到操作前的状态。 + /// 转码要几十秒到几分钟,所以走 SSE 回报进度(Progress / Success / Error,Error 的 data 是错误码)。 /// [HttpPost] [DisableRequestSizeLimit] - public async Task SetIntroMovie([FromForm] int id, [FromForm] string? side, IFormFile file) + public async Task SetIntroMovie([FromForm] int id, [FromForm] string? side, IFormFile file) { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); + Response.Headers.Append("Content-Type", "text/event-stream"); + + // 单写者队列:进度回调来自 ffmpeg 的读取线程,直接写 Response 会和请求线程上的收尾事件交错, + // 帧的先后顺序也没有保证(HttpResponse 不是线程安全的) + var frames = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + var writerTask = WriteSseFrames(frames.Reader); var gate = GetFileGate(id); await gate.WaitAsync(); try { - return await SetIntroMovieCore(id, side, file); + // 转码占 0-90%,剩下的留给出音频(HCA 编码没有进度回调) + var result = await SetIntroMovieCore(id, side, file, + percent => frames.Writer.TryWrite(SseFrame("Progress", (percent * 9 / 10).ToString()))); + await frames.Writer.WriteAsync( + SseFrame(result.Success ? "Success" : "Error", result.Success ? "ok" : result.Error ?? "write-failed")); + } + catch (Exception e) + { + logger.LogError(e, "转换开场视频失败: {id}", id); + await frames.Writer.WriteAsync(SseFrame("Error", "convert-failed")); } finally { gate.Release(); + frames.Writer.TryComplete(); + } + + await writerTask; + } + + private static string SseFrame(string eventName, string data) => $"event: {eventName}\ndata: {data}\n\n"; + + private async Task WriteSseFrames(ChannelReader reader) + { + try + { + await foreach (var frame in reader.ReadAllAsync(HttpContext.RequestAborted)) + { + await Response.WriteAsync(frame, HttpContext.RequestAborted); + await Response.Body.FlushAsync(HttpContext.RequestAborted); + } + } + catch (Exception) + { + // 客户端可能中途断开(关窗口),写失败不用管 } } - private async Task SetIntroMovieCore(int id, string? side, IFormFile file) + private async Task SetIntroMovieCore(int id, string? side, IFormFile file, Action onProgress) { if (id <= 0) return new FancyToolResult(false, "invalid-id"); var gamePathError = EnsureGamePath(); if (gamePathError is not null) return new FancyToolResult(false, gamePathError); -#if WINDOWS - if (IapManager.License != IapManager.LicenseStatus.Active) - return new FancyToolResult(false, "license-required"); -#endif var normalizedSide = string.IsNullOrWhiteSpace(side) ? null : side.ToUpperInvariant(); if (normalizedSide is not (null or "L" or "R")) return new FancyToolResult(false, "invalid-side"); - var tmpDir = Directory.CreateTempSubdirectory(); - var tx = new IntroFileTransaction(Path.Combine(tmpDir.FullName, "backup")); + // 上传的原始文件只给 ffmpeg 读,不参与 rename,放在系统临时目录就行 + var uploadDir = Directory.CreateTempSubdirectory(); + Directory.CreateDirectory(IntroMovieDir); + using var tx = new FancyFileTransaction(IntroMovieDir, logger); try { - var inputPath = Path.Combine(tmpDir.FullName, "input" + Path.GetExtension(file.FileName)); + var inputPath = Path.Combine(uploadDir.FullName, "input" + Path.GetExtension(file.FileName)); await using (var stream = System.IO.File.Create(inputPath)) { await file.CopyToAsync(stream); } - Directory.CreateDirectory(IntroMovieDir); - var targetPath = Path.Combine(IntroMovieDir, VideoFileName(id, normalizedSide)); - var tmpVideoPath = Path.Combine(tmpDir.FullName, "output.mp4"); + // 转码输出直接写进事务的 stage 目录(在 IntroMovies 里面), + // 最后只是一次同目录 rename,跨盘也不会退化成几百 MB 的整文件复制 + var stagedVideoPath = tx.StagePath($"Enter_{id}.mp4"); // 统一转成 H.264 MP4:游戏里的 VideoPlayer 只保证能播这种编码 await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions { InputPath = inputPath, - OutputPath = tmpVideoPath, + OutputPath = stagedVideoPath, UseH264 = true, NoScale = true, // 保持原分辨率,游戏里按比例缩放 Padding = 0, ContentType = file.ContentType, TaskbarProgress = false, - // 硬件编码器默认画质差别很大(h264_mf 默认很糊),这里按选中的编码器给画质参数 - ExtraArgs = VideoConvert.H264QualityArgs(VideoConvert.H264Encoder), + // 硬件编码器的默认画质差别很大(h264_mf 默认很糊),开场视频要按高质量转 + Quality = VideoConvert.Quality.High, + OnProgress = onProgress, }); - if (!System.IO.File.Exists(tmpVideoPath)) return new FancyToolResult(false, "convert-failed"); + if (!System.IO.File.Exists(stagedVideoPath)) return new FancyToolResult(false, "convert-failed"); - // 音轨只生成到临时目录,等视频布局确定后和视频一起提交 - var audio = await BuildIntroAudio(inputPath, id, tmpDir.FullName); + var audio = await BuildIntroAudio(inputPath, id, tx.StageDirPath); - // 这次会动到的文件先全部备份,失败时整体回滚 - foreach (var path in IntroFilePaths(id)) tx.Track(path); + // 从单文件切到分屏时,旧单文件一会儿要给另一侧用,先复制一份进 stage(同卷,很便宜) + var previousSingle = normalizedSide is null ? null : SnapshotSingleMovie(id, tx); - // 清理与新布局冲突的旧视频(单文件与 L/R 不能共存) - string[] conflictingFiles = normalizedSide is null - ? + // 只备份真正会被覆盖的文件,失败时整体还原。 + // 整段替换要连左右分屏的残留一起清掉;只换一侧时另一侧必须原地不动, + // 否则下面「对侧不存在就补一份」会把它当成缺失,把这次上传的视频复制过去,两侧就变成同一个了。 + var filesToBackup = normalizedSide is null + ? VideoFilePaths(id) + : [ - Path.Combine(IntroMovieDir, VideoFileName(id, "L")), - Path.Combine(IntroMovieDir, VideoFileName(id, "R")), - ] - : [Path.Combine(IntroMovieDir, VideoFileName(id, null))]; - if (!DeleteFilesWithRetry(conflictingFiles)) + Path.Combine(IntroMovieDir, VideoFileName(id, null)), + Path.Combine(IntroMovieDir, VideoFileName(id, normalizedSide)), + ]; + foreach (var path in filesToBackup) + { + if (!tx.Backup(path)) return new FancyToolResult(false, "file-locked"); + } + + // 音频是两边共用的:只换一侧时新视频没音轨就保留原来的 acb/awb,不跟着视频一起清掉 + if (normalizedSide is null || audio is not null) { - tx.Rollback(); - return new FancyToolResult(false, "delete-failed"); + foreach (var path in AudioFilePaths(id)) + { + if (!tx.Backup(path)) return new FancyToolResult(false, "file-locked"); + } } - MoveFileWithRetry(tmpVideoPath, targetPath); + var targetPath = Path.Combine(IntroMovieDir, VideoFileName(id, normalizedSide)); + tx.Place(stagedVideoPath, targetPath); if (normalizedSide is not null) { - // 只替换一侧时,另一侧还没有的话先复制一份,保证左右都在 + // 只替换一侧时,另一侧还没有就先补一份,保证左右都在(游戏要求 L、R 同时存在)。 + // 从单文件切到分屏时,另一侧要用原来那个单文件——否则两边都会变成这次上传的视频。 var otherSide = normalizedSide == "L" ? "R" : "L"; var otherPath = Path.Combine(IntroMovieDir, VideoFileName(id, otherSide)); - if (!System.IO.File.Exists(otherPath)) PlatformFile.CopyFile(targetPath, otherPath); + if (!System.IO.File.Exists(otherPath)) + { + // 复制也走事务:后面任何一步失败,这份多出来的文件要跟着一起撤掉 + var otherStagePath = tx.StagePath("other-side.mp4"); + PlatformFile.CopyFile(previousSingle ?? targetPath, otherStagePath); + tx.Place(otherStagePath, otherPath); + } } - // 音频必须和视频配套:这次没有音轨就把旧的 acb/awb 一起清掉 - var acbPath = Path.Combine(IntroMovieDir, $"Enter_{id}.acb"); - var awbPath = Path.Combine(IntroMovieDir, $"Enter_{id}.awb"); + // 整段替换视频时音频必须和视频配套(旧的已经被备份走,提交即删除);只换一侧时音频保留 if (audio is not null) { - MoveFileWithRetry(audio.AcbPath, acbPath); - MoveFileWithRetry(audio.AwbPath, awbPath); - } - else if (!DeleteFilesWithRetry(acbPath, awbPath)) - { - tx.Rollback(); - return new FancyToolResult(false, "delete-failed"); + tx.Place(audio.AcbPath, Path.Combine(IntroMovieDir, $"Enter_{id}.acb")); + tx.Place(audio.AwbPath, Path.Combine(IntroMovieDir, $"Enter_{id}.awb")); } + tx.Commit(); return new FancyToolResult(true, null); } catch (Exception e) { logger.LogError(e, "转换开场视频失败: {id}", id); - tx.Rollback(); - return new FancyToolResult(false, "convert-failed"); + return new FancyToolResult(false, e is FancyFileLockedException ? "file-locked" : "convert-failed"); } finally { try { - tmpDir.Delete(true); + uploadDir.Delete(true); } catch { @@ -264,6 +420,19 @@ await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions } } + /// + /// 把当前的「左右屏同一个视频」复制一份到 stage,供分屏补齐另一侧时使用。 + /// + private static string? SnapshotSingleMovie(int id, FancyFileTransaction tx) + { + var singlePath = Path.Combine(IntroMovieDir, VideoFileName(id, null)); + if (!System.IO.File.Exists(singlePath)) return null; + + var snapshotPath = tx.StagePath("previous-single.mp4"); + PlatformFile.CopyFile(singlePath, snapshotPath); + return snapshotPath; + } + /// /// 替换 / 新增开场音频。左右屏共用一套音频(Enter_<id>.acb / .awb), /// 任意 ffmpeg 能解码的音频都会先解成 wav,再封装成 acb/awb。 @@ -272,8 +441,6 @@ await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions [DisableRequestSizeLimit] public async Task SetIntroAudio([FromForm] int id, IFormFile file) { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - var gate = GetFileGate(id); await gate.WaitAsync(); try @@ -291,47 +458,42 @@ private async Task SetIntroAudioCore(int id, IFormFile file) if (id <= 0) return new FancyToolResult(false, "invalid-id"); var gamePathError = EnsureGamePath(); if (gamePathError is not null) return new FancyToolResult(false, gamePathError); -#if WINDOWS - if (IapManager.License != IapManager.LicenseStatus.Active) - return new FancyToolResult(false, "license-required"); -#endif if (file is null || file.Length <= 0) return new FancyToolResult(false, "invalid-audio"); - var tmpDir = Directory.CreateTempSubdirectory(); - var tx = new IntroFileTransaction(Path.Combine(tmpDir.FullName, "backup")); + var uploadDir = Directory.CreateTempSubdirectory(); + Directory.CreateDirectory(IntroMovieDir); + using var tx = new FancyFileTransaction(IntroMovieDir, logger); try { - Directory.CreateDirectory(IntroMovieDir); - var inputPath = Path.Combine(tmpDir.FullName, "input" + Path.GetExtension(file.FileName)); + var inputPath = Path.Combine(uploadDir.FullName, "input" + Path.GetExtension(file.FileName)); await using (var stream = System.IO.File.Create(inputPath)) { await file.CopyToAsync(stream); } - var audio = await BuildIntroAudio(inputPath, id, tmpDir.FullName); + var audio = await BuildIntroAudio(inputPath, id, tx.StageDirPath); if (audio is null) return new FancyToolResult(false, "invalid-audio"); var acbPath = Path.Combine(IntroMovieDir, $"Enter_{id}.acb"); var awbPath = Path.Combine(IntroMovieDir, $"Enter_{id}.awb"); - tx.Track(acbPath); - tx.Track(awbPath); + if (!tx.Backup(acbPath) || !tx.Backup(awbPath)) return new FancyToolResult(false, "file-locked"); - MoveFileWithRetry(audio.AcbPath, acbPath); - MoveFileWithRetry(audio.AwbPath, awbPath); + tx.Place(audio.AcbPath, acbPath); + tx.Place(audio.AwbPath, awbPath); + tx.Commit(); return new FancyToolResult(true, null); } catch (Exception e) { logger.LogError(e, "写入开场音频失败: {id}", id); - tx.Rollback(); - return new FancyToolResult(false, "write-failed"); + return new FancyToolResult(false, e is FancyFileLockedException ? "file-locked" : "write-failed"); } finally { try { - tmpDir.Delete(true); + uploadDir.Delete(true); } catch { @@ -346,8 +508,6 @@ private async Task SetIntroAudioCore(int id, IFormFile file) [HttpPost] public async Task DeleteIntroAudio(int id) { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - var gate = GetFileGate(id); await gate.WaitAsync(); try @@ -360,28 +520,58 @@ public async Task DeleteIntroAudio(int id) } } - private static FancyToolResult DeleteIntroAudioCore(int id) + private FancyToolResult DeleteIntroAudioCore(int id) { + if (id <= 0) return new FancyToolResult(false, "invalid-id"); var gamePathError = EnsureGamePath(); if (gamePathError is not null) return new FancyToolResult(false, gamePathError); var ok = DeleteFilesWithRetry( Path.Combine(IntroMovieDir, $"Enter_{id}.acb"), Path.Combine(IntroMovieDir, $"Enter_{id}.awb")); + if (ok) DropCachedIntroAudio(id); return new FancyToolResult(ok, ok ? null : "delete-failed"); } + /// + /// 删掉 acb 解码出来的预览缓存:不然 temp 里会一直留着几十 MB 没人用的 wav。 + /// + private void DropCachedIntroAudio(int id) + { + var cacheDir = Path.Combine(StaticSettings.tempPath, "IntroAudioPreview"); + if (!Directory.Exists(cacheDir)) return; + + try + { + foreach (var old in Directory.EnumerateFiles(cacheDir, $"Enter_{id}_*.wav")) + { + try + { + System.IO.File.Delete(old); + } + catch + { + // 还在被别人读着就下次再说 + } + } + } + catch (Exception e) + { + logger.LogWarning(e, "清理开场音频预览缓存失败: {id}", id); + } + } + private sealed record IntroAudioFiles(string AcbPath, string AwbPath); /// - /// 从上传的文件里提取音轨,生成到临时目录(acb/awb 成对)。 + /// 从上传的文件里提取音轨,生成到指定目录(acb/awb 成对)。 /// 没有音轨或转换失败返回 null,由调用方决定是否清掉旧的 acb/awb。 /// - private async Task BuildIntroAudio(string inputPath, int id, string tmpDir) + private async Task BuildIntroAudio(string inputPath, int id, string outputDir) { - var wavPath = Path.Combine(tmpDir, "audio.wav"); - var tmpAcbPath = Path.Combine(tmpDir, $"Enter_{id}.acb"); - var tmpAwbPath = Path.Combine(tmpDir, $"Enter_{id}.awb"); + var wavPath = Path.Combine(outputDir, "audio.wav"); + var tmpAcbPath = Path.Combine(outputDir, $"Enter_{id}.acb"); + var tmpAwbPath = Path.Combine(outputDir, $"Enter_{id}.awb"); try { @@ -402,71 +592,37 @@ private sealed record IntroAudioFiles(string AcbPath, string AwbPath); logger.LogWarning(e, "开场音频生成失败,按静音处理: {id}", id); return null; } + finally + { + try + { + if (System.IO.File.Exists(wavPath)) System.IO.File.Delete(wavPath); + } + catch + { + // 忽略清理失败 + } + } } - private static string[] IntroFilePaths(int id) => + private static string[] VideoFilePaths(int id) => [ Path.Combine(IntroMovieDir, VideoFileName(id, null)), Path.Combine(IntroMovieDir, VideoFileName(id, "L")), Path.Combine(IntroMovieDir, VideoFileName(id, "R")), + ]; + + private static string[] AudioFilePaths(int id) => + [ Path.Combine(IntroMovieDir, $"Enter_{id}.acb"), Path.Combine(IntroMovieDir, $"Enter_{id}.awb"), ]; - /// - /// 开场文件事务:先把这次会改动/删除的文件备份到临时目录, - /// 失败时整体回滚(原本有的还原,原本没有的删掉),避免留下半套文件。 - /// - private sealed class IntroFileTransaction(string backupDir) - { - private readonly List<(string Path, string BackupPath, bool Existed)> _entries = []; - private bool _rolledBack; - - public void Track(string path) - { - if (_entries.Any(entry => string.Equals(entry.Path, path, StringComparison.OrdinalIgnoreCase))) return; - - var existed = System.IO.File.Exists(path); - var backupPath = Path.Combine(backupDir, Guid.NewGuid().ToString("N")); - if (existed) - { - Directory.CreateDirectory(backupDir); - System.IO.File.Copy(path, backupPath, true); - } - - _entries.Add((path, backupPath, existed)); - } - - public void Rollback() - { - if (_rolledBack) return; - _rolledBack = true; + private static string[] IntroFilePaths(int id) => [.. VideoFilePaths(id), .. AudioFilePaths(id)]; - foreach (var (path, backupPath, existed) in _entries) - { - try - { - if (existed) - { - if (System.IO.File.Exists(backupPath)) System.IO.File.Copy(backupPath, path, true); - } - else if (System.IO.File.Exists(path)) - { - System.IO.File.Delete(path); - } - } - catch (Exception) - { - // 尽力回滚,单个文件失败不影响其它文件 - } - } - } - } [HttpPost] public async Task DeleteIntroMovie(int id) { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - var gate = GetFileGate(id); await gate.WaitAsync(); try @@ -479,221 +635,87 @@ public async Task DeleteIntroMovie(int id) } } - private static FancyToolResult DeleteIntroMovieCore(int id) + private FancyToolResult DeleteIntroMovieCore(int id) { + if (id <= 0) return new FancyToolResult(false, "invalid-id"); var gamePathError = EnsureGamePath(); if (gamePathError is not null) return new FancyToolResult(false, gamePathError); - var ok = DeleteFilesWithRetry( - Path.Combine(IntroMovieDir, VideoFileName(id, null)), - Path.Combine(IntroMovieDir, VideoFileName(id, "L")), - Path.Combine(IntroMovieDir, VideoFileName(id, "R")), - Path.Combine(IntroMovieDir, $"Enter_{id}.acb"), - Path.Combine(IntroMovieDir, $"Enter_{id}.awb")); + var ok = DeleteFilesWithRetry(IntroFilePaths(id)); + if (ok) DropCachedIntroAudio(id); return new FancyToolResult(ok, ok ? null : "delete-failed"); } - /// - /// 覆盖式移动文件:目标被占用(例如前端还在预览旧文件)时重试几次。 - /// - private static void MoveFileWithRetry(string source, string dest) + private static string VideoFileName(int id, string? side) => side is null ? $"Enter_{id}.mp4" : $"Enter_{id}_{side}.mp4"; + + private static string? ExistingPath(string fileName) + { + var path = Path.Combine(IntroMovieDir, fileName); + return System.IO.File.Exists(path) ? path : null; + } + + #endregion + + #region 曲目伪装 + + [HttpGet] + public CamouflageInfoDto GetCamouflage(int id) { - for (var attempt = 0; ; attempt++) + var path = Path.Combine(CamouflageDir, $"{id}.toml"); + var model = new CamouflageModel(); + if (System.IO.File.Exists(path)) { try { - PlatformFile.MoveFile(source, dest); - return; + model = Toml.ToModel(System.IO.File.ReadAllText(path), options: TomlOptions) + ?? new CamouflageModel(); } - catch (Exception) when (attempt < 10) + catch (Exception e) { - Thread.Sleep(150); + logger.LogWarning(e, "解析曲目伪装配置失败: {path}", path); } } + + var jacketPath = id > 0 ? FindJacket(id) : null; + return new CamouflageInfoDto(model.Name, model.Artist, model.NoteDesigner, model.HideNoteDesigner, + model.UnlockScore, jacketPath is not null, jacketPath); } /// - /// 删除文件(Windows 走回收站)。被占用时(例如前端还在预览这个视频、句柄没释放)重试几次, - /// 全部失败才返回 false,让前端提示「文件被占用」而不是静默失败。 + /// 取伪装封面供前端预览。 /// - private static bool DeleteFilesWithRetry(params string[] paths) + [HttpGet] + public ActionResult GetCamouflageJacket(int id) { - // 先等占用消失:这一步很便宜(只是尝试以独占方式打开文件),避免回收站删除在被占用时卡很久 - var deadline = DateTime.UtcNow.AddSeconds(3); - var pending = paths.Where(System.IO.File.Exists).ToList(); - while (pending.Count > 0 && DateTime.UtcNow < deadline && pending.Any(IsFileLocked)) - { - Thread.Sleep(100); - } - - var ok = true; - foreach (var path in pending) - { - if (!System.IO.File.Exists(path)) continue; - if (!TryDeleteFile(path)) ok = false; - } + var path = id > 0 ? FindJacket(id) : null; + if (path is null) return NotFound(); - return ok; + var contentType = Path.GetExtension(path).ToLowerInvariant() == ".png" ? "image/png" : "image/jpeg"; + return PhysicalFile(path, contentType, enableRangeProcessing: true); } - /// - /// 文件是否还被别人占着(能独占打开就说明没被占用)。 - /// - private static bool IsFileLocked(string path) + [HttpPost] + public async Task SetCamouflage([FromForm] int id, [FromForm] string? name, [FromForm] string? artist, + [FromForm] string? noteDesigner, [FromForm] bool hideNoteDesigner, [FromForm] double unlockScore) { + var gate = GetFileGate(id); + await gate.WaitAsync(); try { - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); - return false; - } - catch (IOException) - { - return true; + return SetCamouflageCore(id, name, artist, noteDesigner, hideNoteDesigner, unlockScore); } - catch (UnauthorizedAccessException) + finally { - return true; + gate.Release(); } } - private static bool TryDeleteFile(string path) + private FancyToolResult SetCamouflageCore(int id, string? name, string? artist, + string? noteDesigner, bool hideNoteDesigner, double unlockScore) { - for (var attempt = 0; attempt < 2; attempt++) - { - if (!System.IO.File.Exists(path)) return true; - if (IsFileLocked(path)) - { - Thread.Sleep(100); - continue; - } - - try - { - PlatformFile.DeleteFile(path); - if (!System.IO.File.Exists(path)) return true; - } - catch (Exception) - { - // 交给下面的兜底 - } - - Thread.Sleep(100); - } - - // 兜底:回收站删除失败时直接删,至少别让用户卡在「删不掉」 - try - { - if (System.IO.File.Exists(path)) System.IO.File.Delete(path); - return !System.IO.File.Exists(path); - } - catch (Exception) - { - return false; - } - } - - private static string VideoFileName(int id, string? side) => side is null ? $"Enter_{id}.mp4" : $"Enter_{id}_{side}.mp4"; - - private static string? ExistingPath(string fileName) - { - var path = Path.Combine(IntroMovieDir, fileName); - return System.IO.File.Exists(path) ? path : null; - } - - public record IntroMovieEntry(int Id, bool HasSingle, bool HasLeft, bool HasRight, bool HasAudio); - - /// - /// 列出 IntroMovies 目录里已有的开场视频。 - /// - [HttpGet] - public IEnumerable GetIntroMovieList() - { - if (!Directory.Exists(IntroMovieDir)) return []; - - var ids = new HashSet(); - foreach (var file in Directory.EnumerateFiles(IntroMovieDir, "*.mp4")) - { - var name = Path.GetFileNameWithoutExtension(file); - if (!name.StartsWith("Enter_", StringComparison.OrdinalIgnoreCase)) continue; - - var parts = name[6..].Split('_'); - if (parts.Length is < 1 or > 2) continue; - if (int.TryParse(parts[0], out var id) && id > 0) ids.Add(id); - } - - return ids.OrderBy(id => id).Select(id => new IntroMovieEntry( - id, - System.IO.File.Exists(Path.Combine(IntroMovieDir, VideoFileName(id, null))), - System.IO.File.Exists(Path.Combine(IntroMovieDir, VideoFileName(id, "L"))), - System.IO.File.Exists(Path.Combine(IntroMovieDir, VideoFileName(id, "R"))), - System.IO.File.Exists(Path.Combine(IntroMovieDir, $"Enter_{id}.acb")) - && System.IO.File.Exists(Path.Combine(IntroMovieDir, $"Enter_{id}.awb")))).ToList(); - } - - #endregion - - #region 曲目伪装 - - [HttpGet] - public CamouflageInfoDto GetCamouflage(int id) - { - var path = Path.Combine(CamouflageDir, $"{id}.toml"); - var model = new CamouflageModel(); - if (System.IO.File.Exists(path)) - { - try - { - model = Toml.ToModel(System.IO.File.ReadAllText(path), options: TomlOptions) - ?? new CamouflageModel(); - } - catch (Exception e) - { - logger.LogWarning(e, "解析曲目伪装配置失败: {path}", path); - } - } - - var jacketPath = FindJacket(id); - return new CamouflageInfoDto(model.Name, model.Artist, model.NoteDesigner, model.HideNoteDesigner, - model.UnlockScore, jacketPath is not null, jacketPath); - } - - /// - /// 取伪装封面供前端预览。 - /// - [HttpGet] - public ActionResult GetCamouflageJacket(int id) - { - var path = FindJacket(id); - if (path is null) return NotFound(); - - var contentType = Path.GetExtension(path).ToLowerInvariant() == ".png" ? "image/png" : "image/jpeg"; - return PhysicalFile(path, contentType, enableRangeProcessing: true); - } - - [HttpPost] - public async Task SetCamouflage([FromForm] int id, [FromForm] string? name, [FromForm] string? artist, - [FromForm] string? noteDesigner, [FromForm] bool hideNoteDesigner, [FromForm] double unlockScore) - { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - - var gate = GetFileGate(id); - await gate.WaitAsync(); - try - { - return SetCamouflageCore(id, name, artist, noteDesigner, hideNoteDesigner, unlockScore); - } - finally - { - gate.Release(); - } - } - - private FancyToolResult SetCamouflageCore(int id, string? name, string? artist, - string? noteDesigner, bool hideNoteDesigner, double unlockScore) - { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(artist)) - return new FancyToolResult(false, "invalid-text"); + if (id <= 0) return new FancyToolResult(false, "invalid-id"); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(artist)) + return new FancyToolResult(false, "invalid-text"); var gamePathError = EnsureGamePath(); if (gamePathError is not null) return new FancyToolResult(false, gamePathError); @@ -710,8 +732,7 @@ private FancyToolResult SetCamouflageCore(int id, string? name, string? artist, try { Directory.CreateDirectory(CamouflageDir); - WriteAllTextAtomic(Path.Combine(CamouflageDir, $"{id}.toml"), - Toml.FromModel(model, TomlOptions)); + WriteAllTextAtomic(Path.Combine(CamouflageDir, $"{id}.toml"), BuildCamouflageToml(model)); return new FancyToolResult(true, null); } catch (Exception e) @@ -721,12 +742,28 @@ private FancyToolResult SetCamouflageCore(int id, string? name, string? artist, } } + /// + /// 只写真正有意义的字段。AQMai 把缺失或空白的字段当作「不伪装」, + /// 但这是对方实现的细节;toml 是跨项目的文件契约,空值就不要写出去。 + /// + private static string BuildCamouflageToml(CamouflageModel model) + { + var table = new TomlTable + { + ["Name"] = model.Name, + ["Artist"] = model.Artist, + }; + if (!string.IsNullOrWhiteSpace(model.NoteDesigner)) table["NoteDesigner"] = model.NoteDesigner; + if (model.HideNoteDesigner) table["HideNoteDesigner"] = true; + if (model.UnlockScore != 0) table["UnlockScore"] = model.UnlockScore; + + return Toml.FromModel(table, TomlOptions); + } + [HttpPost] [DisableRequestSizeLimit] public async Task SetCamouflageJacket([FromForm] int id, IFormFile file) { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - var gate = GetFileGate(id); await gate.WaitAsync(); try @@ -749,53 +786,61 @@ private async Task SetCamouflageJacketCore(int id, IFormFile fi var gamePathError = EnsureGamePath(); if (gamePathError is not null) return new FancyToolResult(false, gamePathError); - var tmpPath = Path.Combine(CamouflageDir, $"{id}_jacket{ext}.tmp"); + Directory.CreateDirectory(CamouflageDir); + using var tx = new FancyFileTransaction(CamouflageDir, logger); try { - Directory.CreateDirectory(CamouflageDir); - var targetPath = Path.Combine(CamouflageDir, $"{id}_jacket{ext}"); - - // 先写临时文件再覆盖目标,避免写到一半把已有封面弄坏 - await using (var stream = System.IO.File.Create(tmpPath)) + var stagedPath = tx.StagePath($"jacket{ext}"); + await using (var stream = System.IO.File.Create(stagedPath)) { await file.CopyToAsync(stream); } - MoveFileWithRetry(tmpPath, targetPath); - // 同一首曲子只保留一张封面;清理失败不影响新封面,只记日志 - foreach (var otherExt in new[] { ".jpg", ".jpeg", ".png" }) + // 同一首曲子只保留一张封面:其它后缀的旧封面也一起备份掉(提交时进回收站) + foreach (var otherExt in JacketExts) { - var otherPath = Path.Combine(CamouflageDir, $"{id}_jacket{otherExt}"); - if (!string.Equals(otherPath, targetPath, StringComparison.OrdinalIgnoreCase) && - !DeleteFilesWithRetry(otherPath)) - logger.LogWarning("旧封面清理失败: {path}", otherPath); + if (!tx.Backup(Path.Combine(CamouflageDir, $"{id}_jacket{otherExt}"))) + return new FancyToolResult(false, "file-locked"); } + tx.Place(stagedPath, Path.Combine(CamouflageDir, $"{id}_jacket{ext}")); + + tx.Commit(); return new FancyToolResult(true, null); } catch (Exception e) { logger.LogError(e, "写入曲目伪装封面失败: {id}", id); - return new FancyToolResult(false, "write-failed"); + return new FancyToolResult(false, e is FancyFileLockedException ? "file-locked" : "write-failed"); + } + } + + /// + /// 只删封面,保留 toml。游戏侧在没有封面时显示默认的空封面,是合法状态。 + /// + [HttpPost] + public async Task DeleteCamouflageJacket(int id) + { + var gate = GetFileGate(id); + await gate.WaitAsync(); + try + { + if (id <= 0) return new FancyToolResult(false, "invalid-id"); + var gamePathError = EnsureGamePath(); + if (gamePathError is not null) return new FancyToolResult(false, gamePathError); + + var ok = DeleteFilesWithRetry(JacketExts.Select(ext => Path.Combine(CamouflageDir, $"{id}_jacket{ext}")).ToArray()); + return new FancyToolResult(ok, ok ? null : "delete-failed"); } finally { - try - { - if (System.IO.File.Exists(tmpPath)) System.IO.File.Delete(tmpPath); - } - catch - { - // 忽略清理失败 - } + gate.Release(); } } [HttpPost] public async Task DeleteCamouflage(int id) { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - var gate = GetFileGate(id); await gate.WaitAsync(); try @@ -810,20 +855,23 @@ public async Task DeleteCamouflage(int id) private static FancyToolResult DeleteCamouflageCore(int id) { + if (id <= 0) return new FancyToolResult(false, "invalid-id"); var gamePathError = EnsureGamePath(); if (gamePathError is not null) return new FancyToolResult(false, gamePathError); var ok = DeleteFilesWithRetry( [ Path.Combine(CamouflageDir, $"{id}.toml"), - .. new[] { ".jpg", ".jpeg", ".png" }.Select(ext => Path.Combine(CamouflageDir, $"{id}_jacket{ext}")), + .. JacketExts.Select(ext => Path.Combine(CamouflageDir, $"{id}_jacket{ext}")), ]); return new FancyToolResult(ok, ok ? null : "delete-failed"); } + private static readonly string[] JacketExts = [".jpg", ".jpeg", ".png"]; + private static string? FindJacket(int id) { - foreach (var ext in new[] { ".jpg", ".jpeg", ".png" }) + foreach (var ext in JacketExts) { var path = Path.Combine(CamouflageDir, $"{id}_jacket{ext}"); if (System.IO.File.Exists(path)) return path; @@ -832,49 +880,448 @@ private static FancyToolResult DeleteCamouflageCore(int id) return null; } - public record CamouflageEntry(int Id, string? Name, string? Artist, bool HasJacket); + private static void WriteAllTextAtomic(string path, string content) + { + var tempPath = $"{path}.{Guid.NewGuid():N}.tmp"; + try + { + System.IO.File.WriteAllText(tempPath, content); + System.IO.File.Move(tempPath, path, true); + } + finally + { + if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); + } + } + + #endregion + + #region 文件事务 + + /// 文件被别的进程占着(前端预览的句柄没释放),重试之后仍然搬不动。 + private sealed class FancyFileLockedException(string message) : IOException(message) + { + } /// - /// 列出 Camouflages 目录里已有的曲目伪装。 + /// 文件事务:stage 目录就建在目标目录里面,所以所有搬迁都是一次同卷 rename + /// —— 既不会因为临时目录在别的盘而退化成整文件复制,也不会像「复制备份」那样 + /// 让峰值占用变成两三倍。用法:Stage 出新文件 → Backup 掉会被覆盖的旧文件 → + /// Place 生效 → Commit 成功(旧文件进回收站)或 Dispose 时回滚。 /// - [HttpGet] - public IEnumerable GetCamouflageList() + private sealed class FancyFileTransaction : IDisposable { - if (!Directory.Exists(CamouflageDir)) return []; + private const string StageDirName = ".mcm-tmp"; + private const string BackupPrefix = "bak_"; + private const string JournalFileName = "journal"; + private const string BackupEntry = "bak:"; + private const string PlacedEntry = "placed:"; + private const string CommitMarker = "commit"; + + /// 正在跑的事务的 stage 目录,恢复遗留备份时要跳过它们 + private static readonly ConcurrentDictionary ActiveStageDirs = new(StringComparer.OrdinalIgnoreCase); + + private readonly string _targetDir; + private readonly ILogger _logger; + private readonly List<(string Target, string Backup)> _backups = []; + private readonly List _placedTargets = []; + private bool _finished; + /// 回滚没能把备份全部还原时保留 stage 目录,别把最后一份原件删了 + private bool _keepStageDir; + + public FancyFileTransaction(string targetDir, ILogger logger) + { + _targetDir = targetDir; + _logger = logger; + StageDirPath = Path.Combine(targetDir, StageDirName, Guid.NewGuid().ToString("N")); + ActiveStageDirs[StageDirPath] = 0; + RecoverStaleBackups(); + Directory.CreateDirectory(StageDirPath); + } - var result = new List(); - foreach (var file in Directory.EnumerateFiles(CamouflageDir, "*.toml")) + public string StageDirPath { get; } + + /// 新文件的暂存路径(和最终目标同卷)。 + public string StagePath(string fileName) => Path.Combine(StageDirPath, fileName); + + private string JournalPath => Path.Combine(StageDirPath, JournalFileName); + + /// + /// 进程被强杀 / 断电后,上一次事务可能停在「备份搬走了但还没提交」或者「新文件已生效但还没提交」的半路上。 + /// 这里扫一遍遗留的 stage 目录,没提交的整体撤回、已提交的清掉残留。 + /// + private void RecoverStaleBackups() { - if (!int.TryParse(Path.GetFileNameWithoutExtension(file), out var id) || id <= 0) continue; + var parent = Path.Combine(_targetDir, StageDirName); + if (!Directory.Exists(parent)) return; + + // 先把目录列出来再动:边删除边枚举同一个父目录在有些文件系统上会抛异常 + foreach (var stageDir in Directory.EnumerateDirectories(parent).ToList()) + { + if (ActiveStageDirs.ContainsKey(stageDir)) continue; + + try + { + RecoverStageDir(stageDir); + } + catch (Exception e) + { + _logger.LogWarning(e, "恢复上次中断的事务失败: {dir}", stageDir); + } + } + } + + private void RecoverStageDir(string stageDir) + { + var journalPath = Path.Combine(stageDir, JournalFileName); + var journal = System.IO.File.Exists(journalPath) ? System.IO.File.ReadAllLines(journalPath) : []; + + if (journal.Contains(CommitMarker)) + { + // 已经提交过了:正式路径上的新文件就是用户要的结果,剩下的只是没来得及清掉的备份 + Directory.Delete(stageDir, true); + return; + } + + var entriesOf = (string prefix) => journal + .Where(line => line.StartsWith(prefix, StringComparison.Ordinal)) + .Select(line => line[prefix.Length..]) + .ToList(); - var model = new CamouflageModel(); + // 先撤掉已经生效的新文件,再把备份搬回去——顺序反过来的话, + // 中途再挂一次就会留下「备份已还原、新文件还在」的混合状态 + foreach (var fileName in entriesOf(PlacedEntry)) + { + var target = Path.Combine(_targetDir, fileName); + try + { + if (System.IO.File.Exists(target)) System.IO.File.Delete(target); + } + catch (Exception e) + { + _logger.LogWarning(e, "清理未完成的事务文件失败: {path}", target); + } + } + + var hasJournal = journal.Length > 0; + var backups = entriesOf(BackupEntry); + if (!hasJournal) + { + // 没有事务日志(旧版本留下的目录):只能按「正式路径上缺什么就补什么」尽力恢复 + backups = Directory.EnumerateFiles(stageDir, $"{BackupPrefix}*") + .Select(Path.GetFileName) + .Select(name => name![BackupPrefix.Length..]) + .ToList(); + } + + foreach (var fileName in backups) + { + var backupPath = Path.Combine(stageDir, BackupPrefix + fileName); + var target = Path.Combine(_targetDir, fileName); + if (!System.IO.File.Exists(backupPath)) continue; + if (!hasJournal && System.IO.File.Exists(target)) continue; + + if (MoveWithRetry(backupPath, target) is null) + _logger.LogWarning("从上次中断的事务里恢复了 {target}", target); + } + + // 备份搬走之后,没来得及启用的新文件和事务日志都成了垃圾,一起清掉 + Directory.Delete(stageDir, true); + } + + /// + /// 把旧文件 rename 进 stage 当备份。文件不存在时什么都不做。 + /// 被占用(前端还在预览)时重试,最终失败返回 false,调用方应整体放弃。 + /// + public bool Backup(string path) + { + if (_backups.Any(entry => string.Equals(entry.Target, path, StringComparison.OrdinalIgnoreCase))) return true; + if (!System.IO.File.Exists(path)) return true; + + // 备份名带上原文件名,进程中途挂掉后下次能照着搬回原位 + var backupPath = StagePath(BackupPrefix + Path.GetFileName(path)); + if (MoveWithRetry(path, backupPath) is not null) return false; + + _backups.Add((path, backupPath)); + AppendJournal(BackupEntry + Path.GetFileName(path)); + return true; + } + + /// + /// 把事务进度记到 stage 里的日志上。进程被强杀后只有靠它才知道哪些文件已经生效、 + /// 哪些还躺在备份里——光看正式路径上有没有文件是判断不出来的(已生效的新文件也在那儿)。 + /// + private void AppendJournal(string line) + { try { - model = Toml.ToModel(System.IO.File.ReadAllText(file), options: TomlOptions) - ?? new CamouflageModel(); + System.IO.File.AppendAllText(JournalPath, line + Environment.NewLine); } catch (Exception e) { - logger.LogWarning(e, "解析曲目伪装配置失败: {path}", file); + _logger.LogWarning(e, "写入事务日志失败: {path}", JournalPath); + } + } + + /// 重写事务日志,只留还没处理完的条目。 + private void RewriteJournal(IReadOnlyCollection lines) + { + try + { + if (lines.Count == 0) + { + if (System.IO.File.Exists(JournalPath)) System.IO.File.Delete(JournalPath); + return; + } + + System.IO.File.WriteAllLines(JournalPath, lines); + } + catch (Exception e) + { + _logger.LogWarning(e, "重写事务日志失败: {path}", JournalPath); + } + } + + /// 把 stage 里的新文件放到目标位置(同卷 rename)。 + public void Place(string stagedPath, string targetPath) + { + var error = MoveWithRetry(stagedPath, targetPath); + if (error is null) + { + _placedTargets.Add(targetPath); + AppendJournal(PlacedEntry + Path.GetFileName(targetPath)); + return; + } + + // 只有目标确实被别人占着(预览句柄没释放)才提示「文件被占用」, + // 磁盘满、权限不足之类报成占用会让用户白关一遍预览 + if (System.IO.File.Exists(targetPath) && IsFileLocked(targetPath)) + throw new FancyFileLockedException($"无法写入 {targetPath}"); + throw new IOException($"无法写入 {targetPath}", error); + } + + /// + /// 提交:备份的旧文件送回收站(和项目里其它删除操作一致,用户还能捞回来),清掉 stage。 + /// 备份删不掉只是留垃圾,不能因此报失败——新文件此时已经就位了。 + /// + public void Commit() + { + // 先落提交标记再删备份:反过来的话,标记没写成而备份已经没了, + // 恢复逻辑会把这次成功写入的新文件当成半成品删掉 + AppendJournal(CommitMarker); + _finished = true; + + foreach (var (_, backupPath) in _backups) + { + try + { + if (System.IO.File.Exists(backupPath) && !TryDeleteFile(backupPath)) + _logger.LogWarning("旧文件清理失败: {path}", backupPath); + } + catch (Exception e) + { + _logger.LogWarning(e, "旧文件清理失败: {path}", backupPath); + } + } + + DropStageDir(); + } + + /// + /// 回滚:先把已经生效的新文件删掉,再把备份原样搬回去。 + /// + public void Rollback() + { + if (_finished) return; + _finished = true; + + var failedDeletes = new List(); + foreach (var target in _placedTargets) + { + try + { + if (System.IO.File.Exists(target)) System.IO.File.Delete(target); + } + catch (Exception) + { + // 尽力回滚,单个文件失败不影响其它文件 + failedDeletes.Add(target); + } + } + + var failedRestores = new List<(string Target, string BackupPath)>(); + foreach (var (target, backupPath) in _backups) + { + try + { + if (!System.IO.File.Exists(backupPath)) continue; + if (MoveWithRetry(backupPath, target) is not null) PlatformFile.CopyFile(backupPath, target); + if (!System.IO.File.Exists(target)) failedRestores.Add((target, backupPath)); + } + catch (Exception) + { + failedRestores.Add((target, backupPath)); + } + } + + // 没撤干净:还原失败的备份可能就是原文件唯一的副本,绝不能把 stage 目录清掉。 + // 把还没处理完的部分留在事务日志里,下次开事务时接着撤—— + // 已经处理掉的行必须去掉,否则下次会去删刚刚还原好的文件 + if (failedDeletes.Count > 0 || failedRestores.Count > 0) + { + _keepStageDir = true; + var restoredTargets = _backups + .Where(entry => failedRestores.All(failed => failed.Target != entry.Target)) + .Select(entry => entry.Target) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + RewriteJournal( + [ + .. failedDeletes.Where(target => !restoredTargets.Contains(target)) + .Select(target => PlacedEntry + Path.GetFileName(target)), + .. failedRestores.Select(entry => BackupEntry + Path.GetFileName(entry.Target)), + ]); + + foreach (var (target, backupPath) in failedRestores) + _logger.LogError("回滚失败,原文件仍在 {backup},需要还原到 {target}", backupPath, target); + return; } - result.Add(new CamouflageEntry(id, model.Name, model.Artist, FindJacket(id) is not null)); + DropStageDir(); } - return result.OrderBy(entry => entry.Id).ToList(); + public void Dispose() + { + if (!_finished) Rollback(); + if (!_keepStageDir) DropStageDir(); + ActiveStageDirs.TryRemove(StageDirPath, out _); + } + + private void DropStageDir() + { + try + { + if (Directory.Exists(StageDirPath)) Directory.Delete(StageDirPath, true); + // 顺手把空的 .mcm-tmp 父目录也收掉 + var parent = Path.Combine(_targetDir, StageDirName); + if (Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) + Directory.Delete(parent); + } + catch + { + // 忽略清理失败 + } + } } - private static void WriteAllTextAtomic(string path, string content) + #endregion + + #region 文件工具 + + /// + /// 覆盖式移动文件。目标被占用(例如前端还在预览旧文件)时等一会儿再试。 + /// 成功返回 null,失败返回最后一次的异常——调用方要能区分「被占用」和真的写不进去(磁盘满、权限)。 + /// + private static Exception? MoveWithRetry(string source, string dest) + { + var deadline = DateTime.UtcNow.AddSeconds(3); + Exception? lastError = null; + while (true) + { + try + { + PlatformFile.MoveFile(source, dest); + return null; + } + catch (Exception e) when (DateTime.UtcNow < deadline) + { + lastError = e; + Thread.Sleep(100); + } + catch (Exception e) + { + return e; + } + } + } + + /// + /// 删除文件(Windows 走回收站)。被占用时(例如前端还在预览这个视频、句柄没释放)重试几次, + /// 全部失败才返回 false,让前端提示「文件被占用」而不是静默失败。 + /// + private static bool DeleteFilesWithRetry(params string[] paths) + { + // 先等占用消失:这一步很便宜(只是尝试以独占方式打开文件),避免回收站删除在被占用时卡很久 + var deadline = DateTime.UtcNow.AddSeconds(3); + var pending = paths.Where(System.IO.File.Exists).ToList(); + while (pending.Count > 0 && DateTime.UtcNow < deadline && pending.Any(IsFileLocked)) + { + Thread.Sleep(100); + } + + var ok = true; + foreach (var path in pending) + { + if (!System.IO.File.Exists(path)) continue; + if (!TryDeleteFile(path)) ok = false; + } + + return ok; + } + + /// + /// 文件是否还被别人占着(能独占打开就说明没被占用)。 + /// + private static bool IsFileLocked(string path) { - var tempPath = $"{path}.{Guid.NewGuid():N}.tmp"; try { - System.IO.File.WriteAllText(tempPath, content); - System.IO.File.Move(tempPath, path, true); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); + return false; } - finally + catch (IOException) { - if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); + return true; + } + catch (UnauthorizedAccessException) + { + return true; + } + } + + private static bool TryDeleteFile(string path) + { + for (var attempt = 0; attempt < 2; attempt++) + { + if (!System.IO.File.Exists(path)) return true; + if (IsFileLocked(path)) + { + Thread.Sleep(100); + continue; + } + + try + { + PlatformFile.DeleteFile(path); + if (!System.IO.File.Exists(path)) return true; + } + catch (Exception) + { + // 交给下面的兜底 + } + + Thread.Sleep(100); + } + + // 兜底:回收站删除失败时直接删,至少别让用户卡在「删不掉」 + try + { + if (System.IO.File.Exists(path)) System.IO.File.Delete(path); + return !System.IO.File.Exists(path); + } + catch (Exception) + { + return false; } } diff --git a/MaiChartManager/Front/AGENTS.md b/MaiChartManager/Front/AGENTS.md index 036e25ee..9ad658e6 100644 --- a/MaiChartManager/Front/AGENTS.md +++ b/MaiChartManager/Front/AGENTS.md @@ -98,6 +98,18 @@ pnpm --filter @munet/ui typecheck # 只检查 UI 包 - 代码注释使用中文 - `.editorconfig`:2 空格、LF、UTF-8 +## 界面文案 + +界面上的文字只服务于「用户接下来要做什么」。默认不写说明段落,逐条自问,答不上来就删: + +- **删掉它,用户会做错什么吗?** 不会做错、只是「知道了」的信息一律不写。 +- **有没有实现细节?** 文件格式(mp4 / acb / awb / toml)、转码步骤、编码方式、目录和文件名规则、缓存策略、内部流程,都不该出现在界面上;用户只关心结果(有没有声音、能不能用)。 +- **UI 本身表达了吗?** 状态能用「有 / 无」这种短标记就别写句子;能靠禁用按钮、警告条、预览表达的就别写文字。 +- **是需要用户在别处操作的吗?** 比如「要在 AquaMai 里开启某个选项」——用检测 + 警告条(只在没开启时出现)表达,不要在静态说明里重复一遍。 +- **同一句话是不是在别的地方又说了一遍?** 重复即删除。 + +允许留下的:当前状态、按钮动作、用户自己解决不了的错误(哪个文件被占用、缺什么前置条件)、必须知道的外部事实(改完要重启游戏才生效)。 + ## ANTI-PATTERNS - 禁止手动编辑 `src/client/apiGen.ts` 和 `aquaMaiVersionConfigApiGen.ts`,修改会在下次生成时被覆盖 diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index 7491077e..495b21e0 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -125,14 +125,6 @@ export interface BatchSetPropsRequest { version?: number; } -export interface CamouflageEntry { - /** @format int32 */ - id?: number; - name?: string | null; - artist?: string | null; - hasJacket?: boolean; -} - export interface CamouflageInfoDto { name?: string | null; artist?: string | null; @@ -238,6 +230,19 @@ export interface EntryState { value?: any; } +export interface FancyEntry { + /** @format int32 */ + id?: number; + hasSingle?: boolean; + hasLeft?: boolean; + hasRight?: boolean; + hasAudio?: boolean; + hasCamouflage?: boolean; + camouflageName?: string | null; + camouflageArtist?: string | null; + hasJacket?: boolean; +} + export interface FancyToolResult { success?: boolean; error?: string | null; @@ -360,15 +365,6 @@ export interface InstallMmlLibsDto { useLegacy?: boolean; } -export interface IntroMovieEntry { - /** @format int32 */ - id?: number; - hasSingle?: boolean; - hasLeft?: boolean; - hasRight?: boolean; - hasAudio?: boolean; -} - export interface IntroMovieInfo { hasSingle?: boolean; hasLeft?: boolean; @@ -417,6 +413,8 @@ export interface MusicXmlWithABJacket { disable?: boolean; longMusic?: boolean; shiftMethod?: string | null; + /** @format int32 */ + utageBaseId?: number | null; charts?: Chart[] | null; assetBundleJacket?: string | null; pseudoAssetBundleJacket?: string | null; @@ -1474,6 +1472,21 @@ export class Api< ...params, }), + /** + * No description + * + * @tags FancyTool + * @name GetFancyList + * @request GET:/MaiChartManagerServlet/GetFancyListApi + */ + GetFancyList: (params: RequestParams = {}) => + this.request({ + path: `/MaiChartManagerServlet/GetFancyListApi`, + method: "GET", + format: "json", + ...params, + }), + /** * No description * @@ -1556,12 +1569,11 @@ export class Api< }, params: RequestParams = {}, ) => - this.request({ + this.request({ path: `/MaiChartManagerServlet/SetIntroMovieApi`, method: "POST", body: data, type: ContentType.FormData, - format: "json", ...params, }), @@ -1634,21 +1646,6 @@ export class Api< ...params, }), - /** - * No description - * - * @tags FancyTool - * @name GetIntroMovieList - * @request GET:/MaiChartManagerServlet/GetIntroMovieListApi - */ - GetIntroMovieList: (params: RequestParams = {}) => - this.request({ - path: `/MaiChartManagerServlet/GetIntroMovieListApi`, - method: "GET", - format: "json", - ...params, - }), - /** * No description * @@ -1750,10 +1747,10 @@ export class Api< * No description * * @tags FancyTool - * @name DeleteCamouflage - * @request POST:/MaiChartManagerServlet/DeleteCamouflageApi + * @name DeleteCamouflageJacket + * @request POST:/MaiChartManagerServlet/DeleteCamouflageJacketApi */ - DeleteCamouflage: ( + DeleteCamouflageJacket: ( query?: { /** @format int32 */ id?: number; @@ -1761,7 +1758,7 @@ export class Api< params: RequestParams = {}, ) => this.request({ - path: `/MaiChartManagerServlet/DeleteCamouflageApi`, + path: `/MaiChartManagerServlet/DeleteCamouflageJacketApi`, method: "POST", query: query, format: "json", @@ -1772,13 +1769,20 @@ export class Api< * No description * * @tags FancyTool - * @name GetCamouflageList - * @request GET:/MaiChartManagerServlet/GetCamouflageListApi + * @name DeleteCamouflage + * @request POST:/MaiChartManagerServlet/DeleteCamouflageApi */ - GetCamouflageList: (params: RequestParams = {}) => - this.request({ - path: `/MaiChartManagerServlet/GetCamouflageListApi`, - method: "GET", + DeleteCamouflage: ( + query?: { + /** @format int32 */ + id?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/MaiChartManagerServlet/DeleteCamouflageApi`, + method: "POST", + query: query, format: "json", ...params, }), diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 61ad8e72..4013bce1 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -454,45 +454,40 @@ mod: Leave empty to use the first detected device as 1P tools: fancy: + label: Intro Movie & Camouflage + pickMusic: Choose a song + filterPlaceholder: Filter existing songs + noEntries: No song has an intro movie or camouflage yet + noMatch: No matching song + searchHint: Click any row to edit that song + restartHint: Restart the game for changes to take effect + badgeIntro: Has an intro movie + badgeCamouflage: Has camouflage preview: Preview - path: File path noPreview: No file yet - existing: Existing none: None - load: Load - delete: Delete - introReplace: Replace video - camoJacketShort: with jacket - label: Intro movie & camo - title: Intro movie & track camouflage - music: Music - musicPlaceholder: Search by ID or name - musicPick: Choose a song - pickMusicFirst: Select a song first introTab: Intro movie camouflageTab: Camouflage - introHint: "Play a custom video before a track starts (AquaMai's CustomIntroCinematic). Both screens can share one video or use separate ones. Uploads are converted to a silent mp4; the audio track is extracted into Enter_.acb / .awb and played separately by the game." - introSingle: Single file - introAudio: Audio - introSide: Apply to + introModDisabled: Fancy.CustomIntroCinematic is not enabled in AquaMai, so this has no effect in game. Enable it in the Mod settings first. introSame: Same video for both screens - introSameHint: "When off, each screen has its own video and the preview plays both side by side at 1:2. Toggling the switch does not touch the files; they are only written when you replace a video." introLeft: Replace left video (L) introRight: Replace right video (R) - introAudioGenerated: "Saved: audio track extracted to acb/awb" - introAudioSilent: "Saved: no audio track, the intro will be silent" - introAudioReplace: Replace audio - introAudioAdd: Add audio - introAudioRemove: Remove audio - introAudioRemoveConfirm: "Remove the intro audio for song {id}? (both screens share one acb/awb)" - introAudioShared: shared by both screens - sideBoth: Same video on both screens - sideLeft: Left screen only (L) - sideRight: Right screen only (R) introUpload: Choose video + introReplace: Replace video introRemove: Delete intro movie introRemoveConfirm: Delete the intro movie of music {id}? - camouflageHint: Show another name, artist and jacket for this track in the music select and track start screens (AquaMai TrackCamouflage). Works well together with the intro movie. + introAudio: Audio + introAudioYes: Present + introAudioNo: None + introAudioAdd: Add audio + introAudioReplace: Replace audio + introAudioRemove: Remove audio + introAudioRemoveConfirm: Remove the intro audio for song {id}? + introAudioGenerated: "Saved: the video will have sound in game" + introAudioSilent: "Saved: no audio, the intro will be silent" + introEncodingAudio: Encoding audio… + camouflageHint: Shows another name, artist and jacket for this track in the music select and track start screens. + camoModDisabled: Fancy.TrackCamouflage is not enabled in AquaMai, so this has no effect in game. Enable it in the Mod settings first. camoName: Disguised name camoArtist: Disguised artist camoNoteDesigner: Disguised note designer @@ -501,6 +496,8 @@ tools: camoUnlockScoreHint: 0 means the camouflage disappears once the player has any score; use 102 to keep it forever camoJacketUpload: Choose jacket (jpg / png) camoJacketReplace: Replace jacket + camoJacketRemove: Remove jacket + camoJacketRemoveConfirm: Remove the camouflage jacket of music {id}? Name and other info are kept camoRemove: Clear camouflage camoRemoveConfirm: Clear the camouflage info and jacket of music {id}? save: Save @@ -511,12 +508,12 @@ tools: errorInvalidSide: Invalid video side errorInvalidText: Name and artist are required errorInvalidImage: Only jpg / png images are supported - errorLicense: A valid license is required to convert video + errorInvalidAudio: Unsupported or invalid audio file errorGamePath: Game directory is not configured errorConvert: Video conversion failed errorWrite: Failed to write file - errorDeleteLocked: "Delete failed: the file is still in use. Close the preview and try again." - errorInvalidAudio: "Unsupported or invalid audio file" + errorFileLocked: The file is still in use, close the preview and try again + errorDeleteFailed: Failed to delete the file, close the preview and try again title: Tools audioConvert: Audio Convert (ACB + AWB) videoConvert: Video Convert (DAT) diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index 40cc376f..c0fac9de 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -417,45 +417,40 @@ mod: pathDesc: USB 序號或連接埠路徑,例如 2.2。請使用設定工具中顯示的識別。留空則使用第一個偵測到的設備作為 1P tools: fancy: + label: 開場影片與曲目偽裝 + pickMusic: 選擇樂曲 + filterPlaceholder: 過濾已有樂曲 + noEntries: 還沒有做過開場影片或曲目偽裝的樂曲 + noMatch: 沒有符合的樂曲 + searchHint: 點擊任意一行即可編輯這首樂曲 + restartHint: 修改後需重啟遊戲才會生效 + badgeIntro: 有開場影片 + badgeCamouflage: 有曲目偽裝 preview: 預覽 - path: 檔案路徑 noPreview: 尚無檔案 - existing: 已有 none: 暫無 - load: 載入 - delete: 刪除 - introReplace: 更換影片 - camoJacketShort: 含封面 - label: 開場影片與曲目偽裝 - title: 開場影片與曲目偽裝 - music: 樂曲 - musicPlaceholder: 輸入 ID 或名稱搜尋 - musicPick: 選擇樂曲 - pickMusicFirst: 請先選擇一首樂曲 introTab: 開場影片 camouflageTab: 曲目偽裝 - introHint: 在遊戲內開始樂曲前播放自訂影片(AquaMai 的 CustomIntroCinematic)。左右螢幕可以共用一個影片,也可以分開兩個。上傳後影片會轉成靜音 mp4,音軌自動擷取成 Enter_.acb / .awb,由遊戲單獨播放。 - introSingle: 單一檔案 - introAudio: 音訊 - introSide: 套用到 + introModDisabled: AquaMai 的 Fancy.CustomIntroCinematic 沒有開啟,遊戲中不會生效。請到「Mod 設定」開啟後再來修改。 introSame: 左右螢幕使用同一影片 - introSameHint: 關閉後左右螢幕各用一個影片,預覽以 1:2 並排播放,可分別替換某一側;切換開關本身不會改動檔案,替換影片時才會寫入 - introLeft: 更換左側影片(L) - introRight: 更換右側影片(R) - introAudioGenerated: 已儲存:音軌已產生 acb/awb,遊戲中由它發聲 - introAudioSilent: 已儲存:影片沒有音軌,將靜音播放 - introAudioReplace: 更換音訊 - introAudioAdd: 新增音訊 - introAudioRemove: 刪除音訊 - introAudioRemoveConfirm: 確定要刪除樂曲 {id} 的開場音訊嗎?(左右螢幕共用一組 acb/awb) - introAudioShared: 左右螢幕共用 - sideBoth: 左右螢幕同一影片 - sideLeft: 僅左螢幕(L) - sideRight: 僅右螢幕(R) + introLeft: 替換左側影片(L) + introRight: 替換右側影片(R) introUpload: 選擇影片 + introReplace: 更換影片 introRemove: 刪除開場影片 introRemoveConfirm: 確定要刪除樂曲 {id} 的開場影片嗎? - camouflageHint: 在選曲與開始畫面把樂曲顯示成別的曲名、曲師與封面(AquaMai 的 TrackCamouflage),適合搭配開場影片使用。 + introAudio: 音訊 + introAudioYes: 有 + introAudioNo: 無 + introAudioAdd: 新增音訊 + introAudioReplace: 更換音訊 + introAudioRemove: 刪除音訊 + introAudioRemoveConfirm: 確定要刪除樂曲 {id} 的開場音訊嗎? + introAudioGenerated: 已儲存:遊戲內會有聲音 + introAudioSilent: 已儲存:沒有音訊,將靜音播放 + introEncodingAudio: 正在產生音訊… + camouflageHint: 在選曲與開始畫面把樂曲顯示成別的曲名、曲師與封面。 + camoModDisabled: AquaMai 的 Fancy.TrackCamouflage 沒有開啟,遊戲中不會生效。請到「Mod 設定」開啟後再來修改。 camoName: 偽裝後的曲名 camoArtist: 偽裝後的曲師 camoNoteDesigner: 偽裝後的譜師 @@ -464,6 +459,8 @@ tools: camoUnlockScoreHint: 0 表示有成績後就不再偽裝;填 102 可以永久偽裝 camoJacketUpload: 選擇封面(jpg / png) camoJacketReplace: 更換封面 + camoJacketRemove: 刪除封面 + camoJacketRemoveConfirm: 確定要刪除樂曲 {id} 的偽裝封面嗎?曲名等資訊會保留 camoRemove: 清除偽裝 camoRemoveConfirm: 確定要清除樂曲 {id} 的偽裝資訊與封面嗎? save: 儲存 @@ -474,12 +471,12 @@ tools: errorInvalidSide: 影片位置無效 errorInvalidText: 曲名與曲師不能為空 errorInvalidImage: 只支援 jpg / png 圖片 - errorLicense: 需要有效的授權才能轉換影片 + errorInvalidAudio: 音訊檔案無法辨識或轉換失敗 errorGamePath: 未設定遊戲目錄 errorConvert: 影片轉換失敗 errorWrite: 寫入檔案失敗 - errorDeleteLocked: 刪除失敗:檔案仍被占用,請關閉預覽後重試 - errorInvalidAudio: 音訊檔案無法辨識或轉換失敗 + errorFileLocked: 檔案仍被占用,請關閉預覽後重試 + errorDeleteFailed: 刪除檔案失敗,請關閉預覽後重試 title: 工具 audioConvert: 音訊轉換(ACB + AWB) videoConvert: 影片轉換(DAT) diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index cdc0d2af..5bd1d252 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -411,45 +411,40 @@ mod: pathDesc: USB 序列号或端口路径,例如 2.2。请使用配置工具中显示的标识。留空则使用第一个检测到的设备作为 1P tools: fancy: + label: 开场视频与曲目伪装 + pickMusic: 选择乐曲 + filterPlaceholder: 过滤已有曲目 + noEntries: 还没有做过开场视频或曲目伪装的曲目 + noMatch: 没有匹配的曲目 + searchHint: 点击任意一行即可编辑这首乐曲 + restartHint: 修改后需重启游戏才会生效 + badgeIntro: 有开场视频 + badgeCamouflage: 有曲目伪装 preview: 预览 - path: 文件路径 noPreview: 还没有文件 - existing: 已有 none: 暂无 - load: 载入 - delete: 删除 - introReplace: 更换视频 - camoJacketShort: 含封面 - label: 开场视频与曲目伪装 - title: 开场视频与曲目伪装 - music: 乐曲 - musicPlaceholder: 输入 ID 或名称搜索 - musicPick: 选择乐曲 - pickMusicFirst: 请先选择一首乐曲 introTab: 开场视频 camouflageTab: 曲目伪装 - introHint: 在游戏内开始乐曲前播放自定义视频(AquaMai 的 CustomIntroCinematic)。左右屏可以共用一个视频,也可以分开两个。上传后视频会转成静音 mp4,音轨自动提取成 Enter_.acb / .awb,由游戏单独播放。 - introSingle: 单文件 - introAudio: 音频 - introSide: 应用到 + introModDisabled: AquaMai 的 Fancy.CustomIntroCinematic 没有开启,游戏里不会生效。去「Mod 配置」里打开后再来修改。 introSame: 左右屏使用同一视频 - introSameHint: 关闭后左右屏各用一个视频,预览按 1:2 并排播放,可分别替换某一侧;切换开关本身不改动文件,替换视频时才会写入 introLeft: 替换左侧视频(L) introRight: 替换右侧视频(R) - introAudioGenerated: 已保存:音轨已生成 acb/awb,游戏里由它发声 - introAudioSilent: 已保存:视频没有音轨,将静音播放 - introAudioReplace: 替换音频 - introAudioAdd: 添加音频 - introAudioRemove: 删除音频 - introAudioRemoveConfirm: 确定要删除乐曲 {id} 的开场音频吗?(左右屏共用一组 acb/awb) - introAudioShared: 左右屏共用 - sideBoth: 左右屏同一视频 - sideLeft: 仅左屏(L) - sideRight: 仅右屏(R) introUpload: 选择视频 + introReplace: 更换视频 introRemove: 删除开场视频 introRemoveConfirm: 确定要删除乐曲 {id} 的开场视频吗? - camouflageHint: 在选曲和开始界面把乐曲显示成别的曲名、曲师和封面(AquaMai 的 TrackCamouflage),适合配合开场视频做伪装演出。 + introAudio: 音频 + introAudioYes: 有 + introAudioNo: 无 + introAudioAdd: 添加音频 + introAudioReplace: 替换音频 + introAudioRemove: 删除音频 + introAudioRemoveConfirm: 确定要删除乐曲 {id} 的开场音频吗? + introAudioGenerated: 已保存:游戏内会有声音 + introAudioSilent: 已保存:没有音频,将静音播放 + introEncodingAudio: 正在生成音频… + camouflageHint: 在选曲和开始界面把乐曲显示成别的曲名、曲师和封面。 + camoModDisabled: AquaMai 的 Fancy.TrackCamouflage 没有开启,游戏里不会生效。去「Mod 配置」里打开后再来修改。 camoName: 伪装后的曲名 camoArtist: 伪装后的曲师 camoNoteDesigner: 伪装后的谱师 @@ -458,6 +453,8 @@ tools: camoUnlockScoreHint: 0 表示打过一个成绩就不再伪装;填 102 可以永久伪装 camoJacketUpload: 选择封面(jpg / png) camoJacketReplace: 更换封面 + camoJacketRemove: 删除封面 + camoJacketRemoveConfirm: 确定要删除乐曲 {id} 的伪装封面吗?曲名等信息会保留 camoRemove: 清除伪装 camoRemoveConfirm: 确定要清除乐曲 {id} 的伪装信息和封面吗? save: 保存 @@ -468,12 +465,12 @@ tools: errorInvalidSide: 视频位置无效 errorInvalidText: 曲名和曲师不能为空 errorInvalidImage: 只支持 jpg / png 图片 - errorLicense: 需要有效的授权才能转换视频 + errorInvalidAudio: 音频文件无法识别或转换失败 errorGamePath: 未配置游戏目录 errorConvert: 视频转换失败 errorWrite: 写入文件失败 - errorDeleteLocked: 删除失败:文件仍被占用,请关闭预览后重试 - errorInvalidAudio: 音频文件无法识别或转换失败 + errorFileLocked: 文件仍被占用,请关闭预览后重试 + errorDeleteFailed: 删除文件失败,请关闭预览后重试 title: 工具 audioConvert: 音频转换(ACB + AWB) videoConvert: 视频转换(DAT) diff --git a/MaiChartManager/Front/src/router.ts b/MaiChartManager/Front/src/router.ts index e9a81d04..9b0e7738 100644 --- a/MaiChartManager/Front/src/router.ts +++ b/MaiChartManager/Front/src/router.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import Index from '@/views/Index'; import ChartPreviewPage from '@/views/ChartPreviewPage'; +import FancyTool from '@/views/FancyTool'; import Oobe from '@/views/Oobe'; const router = createRouter({ @@ -8,6 +9,7 @@ const router = createRouter({ routes: [ { path: '/', component: Index }, { path: '/chart-preview', component: ChartPreviewPage }, + { path: '/fancy-tool', component: FancyTool }, { path: '/oobe', component: Oobe, name: 'oobe' }, { path: '/server', component: Oobe, name: 'server' }, { path: '/set-mode', component: Oobe, name: 'set-mode' }, diff --git a/MaiChartManager/Front/src/utils/musicSearch.ts b/MaiChartManager/Front/src/utils/musicSearch.ts new file mode 100644 index 00000000..92aba7d7 --- /dev/null +++ b/MaiChartManager/Front/src/utils/musicSearch.ts @@ -0,0 +1,30 @@ +import type { MusicXmlWithABJacket } from '@/client/apiGen'; +import { isUtageId } from '@/consts'; + +/** DX 曲目 id 的起点:标准曲目是 4 位,DX 是 5 位 */ +const DX_ID_START = 1e4; + +export const MusicIdKind = { + Standard: 'standard', + DX: 'dx', + Utage: 'utage', +} as const; +export type MusicIdKind = typeof MusicIdKind[keyof typeof MusicIdKind]; + +/** 曲目 ID 分段,列表的「标准 / DX / 宴会场」筛选都走这里,免得两个表格各写一套阈值 */ +export const musicIdKind = (id?: number): MusicIdKind => + isUtageId(id) ? MusicIdKind.Utage : (id ?? 0) >= DX_ID_START ? MusicIdKind.DX : MusicIdKind.Standard; + +/** + * 曲目搜索的统一判定:名称 / 曲师 / 谱师 / ID 任意命中即算匹配。 + * 左侧列表的过滤和搜索页的过滤都走这里,免得两处口径不一致。 + */ +export const matchMusicKeyword = (music: MusicXmlWithABJacket, keyword: string) => { + const value = keyword.trim().toLowerCase(); + if (!value) return true; + + return (music.name ?? '').toLowerCase().includes(value) + || (music.artist ?? '').toLowerCase().includes(value) + || (music.id ?? 0).toString().includes(value) + || (music.charts ?? []).some(chart => chart.designer?.toLowerCase().includes(value)); +}; diff --git a/MaiChartManager/Front/src/utils/openWindow.ts b/MaiChartManager/Front/src/utils/openWindow.ts new file mode 100644 index 00000000..56a42748 --- /dev/null +++ b/MaiChartManager/Front/src/utils/openWindow.ts @@ -0,0 +1,35 @@ +import { isPhotino } from '@/client/api'; + +export interface OpenWindowOptions { + /** 站内 hash 路由,例如 /fancy-tool */ + path: string; + query?: Record; + title: string; + width: number; + height: number; +} + +/** + * 用独立窗口打开一个站内页面。 + * WebView2 宿主会走 NewWindowRequested 开一个新的 webview 窗口; + * WebKitGTK(Photino) 不支持 window.open 弹新窗口,改为通知宿主开内置子窗口。 + */ +export const openWindow = ({ path, query, title, width, height }: OpenWindowOptions) => { + const url = new URL(location.href); + url.hash = query ? `${path}?${new URLSearchParams(query).toString()}` : path; + + if (isPhotino) { + (window as any).external.sendMessage(JSON.stringify({ + type: 'open-window', + url: url.toString(), + title, + width, + height, + })); + return; + } + + const left = (screen.width - width) / 2; + const top = (screen.height - height) / 2; + window.open(url, '_blank', `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`); +}; diff --git a/MaiChartManager/Front/src/views/BatchAction/MusicSelector.tsx b/MaiChartManager/Front/src/views/BatchAction/MusicSelector.tsx index dc035efc..e5b8aa6f 100644 --- a/MaiChartManager/Front/src/views/BatchAction/MusicSelector.tsx +++ b/MaiChartManager/Front/src/views/BatchAction/MusicSelector.tsx @@ -7,6 +7,7 @@ import JacketBox from "@/components/JacketBox"; import { GenreOption } from "@/components/GenreInput"; import { LEVEL_COLOR, LEVELS } from "@/consts"; import _ from "lodash"; +import { matchMusicKeyword, MusicIdKind, musicIdKind } from "@/utils/musicSearch"; import { watchDebounced } from "@vueuse/core"; import { scrollToMusic } from "@/views/Charts/MusicList"; import { useI18n } from 'vue-i18n'; @@ -24,16 +25,7 @@ export default defineComponent({ const nameColumn = reactive({ title: t('music.list.tableTitle'), key: 'name', filterOptionValue: null as string | null, - filter: (value, row) => { - if (!value) return true; - value = value.toString().toLowerCase(); - return row.name!.toLowerCase().includes(value) || - row.artist!.toLowerCase().includes(value) || - row.charts!.some(chart => chart.designer?.toLowerCase().includes(value)) || - row.id!.toString().includes(value) || // match by ID - false; - - } + filter: (value, row) => matchMusicKeyword(row, value?.toString() ?? ''), } satisfies DataTableBaseColumn) const columns = computed(() => [ { type: 'selection' }, @@ -43,19 +35,13 @@ export default defineComponent({ key: 'id', width: '7em', sorter: 'default', - filterOptions: [t('music.list.filterStandard'), t('music.list.filterDX'), t('music.list.filterUtage')].map(it => ({ label: it, value: it })), - filter: (value, row) => { - switch (value) { - case t('music.list.filterStandard'): - return row.id! < 1e4; - case t('music.list.filterDX'): - return row.id! >= 1e4 && row.id! < 1e5; - case t('music.list.filterUtage'): - return row.id! >= 1e5; - default: - throw new Error('Invalid filter value'); - } - } + filterOptions: [ + { label: t('music.list.filterStandard'), value: MusicIdKind.Standard }, + { label: t('music.list.filterDX'), value: MusicIdKind.DX }, + { label: t('music.list.filterUtage'), value: MusicIdKind.Utage }, + ], + // 顺便修掉之前用翻译后的文案当筛选值来比较的写法 + filter: (value, row) => musicIdKind(row.id) === value, }, { title: t('music.list.tableCover'), diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx index 3eaf1615..10d6ba94 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx @@ -1,8 +1,8 @@ import { defineComponent } from "vue"; import { selectedADir } from "@/store/refs"; import { t } from "@/locales"; -import { isPhotino } from "@/client/api"; import { Button } from "@munet/ui"; +import { openWindow } from "@/utils/openWindow"; export type ChartSide = "L" | "R"; @@ -14,32 +14,19 @@ export default defineComponent({ }, setup(props) { const openPreview = () => { - const params = new URLSearchParams({ + const query: Record = { assetDir: selectedADir.value!, songId: String(props.songId), level: String(props.level), + }; + if (props.side) query.side = props.side; + openWindow({ + path: '/chart-preview', + query, + title: t('music.edit.previewChart'), + width: 960, + height: 640, }); - if (props.side) params.set('side', props.side); - const width = 960; - const height = 640; - const left = (screen.width - width) / 2; - const top = (screen.height - height) / 2; - const url = new URL(location.href); - url.hash = `/chart-preview?${params}`; - - if (isPhotino) { - // WebKitGTK 不支持 window.open 弹新窗口。改为通知 Photino 宿主开一个内置 webview 子窗口加载预览页。 - (window as any).external.sendMessage(JSON.stringify({ - type: 'open-window', - url: url.toString(), - title: t('music.edit.previewChart'), - width, - height, - })); - return; - } - - window.open(url, '_blank', `width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`); }; return () => ( diff --git a/MaiChartManager/Front/src/views/FancyTool/CamouflageTab.tsx b/MaiChartManager/Front/src/views/FancyTool/CamouflageTab.tsx new file mode 100644 index 00000000..7eb07652 --- /dev/null +++ b/MaiChartManager/Front/src/views/FancyTool/CamouflageTab.tsx @@ -0,0 +1,287 @@ +import { computed, defineComponent, ref, watch } from 'vue'; +import { Button, CheckBox, NumberInput, TextInput, addToast, showTransactionalDialog } from '@munet/ui'; +import { useI18n } from 'vue-i18n'; +import api, { getUrl } from '@/client/api'; +import type { CamouflageInfoDto } from '@/client/apiGen'; +import { globalCapture } from '@/store/refs'; +import { pickFile } from '@/utils/pickFile'; +import { fileToken, modFlags, refreshEntries, selectedId } from './refs'; +import { showFancyError } from './errors'; + +const emptyCamouflage = () => ({ + name: '', + artist: '', + noteDesigner: '', + hideNoteDesigner: false, + unlockScore: 0, + hasJacket: false, + jacketPath: '', +}); + +export default defineComponent({ + setup() { + const { t } = useI18n(); + + const busy = ref(false); + const loading = ref(false); + const camouflage = ref(emptyCamouflage()); + // 表单里现在装的是哪首曲目的数据:切曲目后、数据回来之前不能拿这份表单去保存 + const loadedId = ref(0); + + // 只在切换曲目时重置就绪状态:保存成功后重新读一遍不算「换了一首」, + // 这里若也清零,一次读失败就会把表单锁死到下次切曲目 + const load = async () => { + const id = selectedId.value; + if (!id) { + camouflage.value = emptyCamouflage(); + return; + } + + loading.value = true; + try { + const data: CamouflageInfoDto = (await api.GetCamouflage({ id })).data; + // 切曲目后旧请求可能才回来,丢弃过期响应 + if (id !== selectedId.value) return; + + camouflage.value = { + name: data.name ?? '', + artist: data.artist ?? '', + noteDesigner: data.noteDesigner ?? '', + hideNoteDesigner: !!data.hideNoteDesigner, + unlockScore: data.unlockScore ?? 0, + hasJacket: !!data.hasJacket, + jacketPath: data.jacketPath ?? '', + }; + loadedId.value = id; + } finally { + if (id === selectedId.value) loading.value = false; + } + }; + + // 表单内容确实属于当前曲目时才允许改动,否则切换曲目的一瞬间点保存会把上一首的内容写进这一首 + const ready = computed(() => !!selectedId.value && loadedId.value === selectedId.value); + const locked = computed(() => busy.value || loading.value || !ready.value); + + watch(selectedId, () => { + // 先清空,避免切走之后旧内容还留在表单里,也顺带把表单锁到读完为止 + camouflage.value = emptyCamouflage(); + loadedId.value = 0; + if (!selectedId.value) return; + load().catch(e => globalCapture(e, t('tools.fancy.errorLoad'))); + }, { immediate: true }); + + // 游戏侧曲名和曲师都是必填的(缺了会显示 ???),所以空着就不让保存 + const textMissing = computed(() => !camouflage.value.name.trim() || !camouflage.value.artist.trim()); + + const jacketPreviewUrl = computed(() => selectedId.value && camouflage.value.hasJacket && !busy.value + ? getUrl(`GetCamouflageJacketApi?id=${selectedId.value}&t=${fileToken.value}`) + : ''); + + // 只刷新封面状态,保留用户还在编辑、尚未保存的表单内容 + const refreshJacket = async () => { + const id = selectedId.value; + if (!id) return; + const data: CamouflageInfoDto = (await api.GetCamouflage({ id })).data; + if (id !== selectedId.value) return; + + camouflage.value.hasJacket = !!data.hasJacket; + camouflage.value.jacketPath = data.jacketPath ?? ''; + fileToken.value = Date.now(); + }; + + const save = async () => { + const id = selectedId.value; + if (!id || !ready.value) return; + + busy.value = true; + try { + const result = (await api.SetCamouflage({ + id, + name: camouflage.value.name, + artist: camouflage.value.artist, + noteDesigner: camouflage.value.noteDesigner, + hideNoteDesigner: camouflage.value.hideNoteDesigner, + unlockScore: camouflage.value.unlockScore, + })).data; + + if (!result.success) { + showFancyError(t, result.error); + return; + } + + addToast({ message: t('tools.fancy.saved'), type: 'success' }); + // 刷新失败只是没读到最新数据,文件其实已经写进去了,别报成「写入失败」 + await Promise.all([ + load().catch(e => globalCapture(e, t('tools.fancy.errorLoad'))), + refreshEntries(), + ]); + } catch (e) { + globalCapture(e, t('tools.fancy.errorWrite')); + } finally { + busy.value = false; + } + }; + + const uploadJacket = async (file: File) => { + const id = selectedId.value; + if (!id || !ready.value) return; + + busy.value = true; + try { + const result = (await api.SetCamouflageJacket({ id, file })).data; + if (!result.success) { + showFancyError(t, result.error); + return; + } + + addToast({ message: t('tools.fancy.saved'), type: 'success' }); + // 上传期间用户可能已经切到别的曲目:列表照刷,但不能再动当前表单 + const sameSong = id === selectedId.value; + if (sameSong) camouflage.value.hasJacket = true; + // 不要用 load:它会把服务端数据写回表单,冲掉还没保存的填写内容 + await Promise.all([ + sameSong ? refreshJacket().catch(e => globalCapture(e, t('tools.fancy.errorLoad'))) : null, + refreshEntries(), + ]); + } catch (e) { + globalCapture(e, t('tools.fancy.errorWrite')); + } finally { + busy.value = false; + } + }; + + const chooseJacket = async () => { + const file = await pickFile('image/png,image/jpeg'); + if (file) await uploadJacket(file); + }; + + const confirmThen = async (message: string, action: () => Promise) => { + const confirmed = await showTransactionalDialog( + t('tools.fancy.camouflageTab'), + message, + [{ text: t('common.cancel'), action: false }, { text: t('common.confirm'), action: true }] + ); + if (!confirmed) return; + + busy.value = true; + try { + await action(); + } catch (e) { + globalCapture(e, t('tools.fancy.errorWrite')); + } finally { + busy.value = false; + } + }; + + const removeJacket = () => { + const id = selectedId.value; + if (!id || !ready.value) return; + return confirmThen(t('tools.fancy.camoJacketRemoveConfirm', { id }), async () => { + const result = (await api.DeleteCamouflageJacket({ id })).data; + if (!result.success) { + showFancyError(t, result.error); + return; + } + const sameSong = id === selectedId.value; + // 先就地改掉状态:万一接下来的读取失败,界面也不能还显示着一张已经删掉的封面 + if (sameSong) + { + camouflage.value.hasJacket = false; + camouflage.value.jacketPath = ''; + } + await Promise.all([ + sameSong ? refreshJacket().catch(e => globalCapture(e, t('tools.fancy.errorLoad'))) : null, + refreshEntries(), + ]); + }); + }; + + const removeAll = () => { + const id = selectedId.value; + if (!id || !ready.value) return; + return confirmThen(t('tools.fancy.camoRemoveConfirm', { id }), async () => { + const result = (await api.DeleteCamouflage({ id })).data; + if (!result.success) { + showFancyError(t, result.error); + return; + } + // 删除成功立刻清空表单:万一接下来的读取失败,表单上留着的就是刚删掉的内容, + // 用户一按保存又把伪装写回去了。删除期间切走了就别动新曲目的表单 + const sameSong = id === selectedId.value; + if (sameSong) camouflage.value = emptyCamouflage(); + fileToken.value = Date.now(); + await Promise.all([ + sameSong ? load().catch(e => globalCapture(e, t('tools.fancy.errorLoad'))) : null, + refreshEntries(), + ]); + }); + }; + + const hasAny = computed(() => !!camouflage.value.name || camouflage.value.hasJacket); + + const renderWarning = () => modFlags.value.camouflage === false + ?
+ + {t('tools.fancy.camoModDisabled')} +
+ : null; + + return () =>
+ {renderWarning()} +
{t('tools.fancy.camouflageHint')}
+ +
+
+ {camouflage.value.hasJacket + ? + :
+ {t('tools.fancy.noPreview')} +
} +
+ + {camouflage.value.hasJacket && + } +
+
+ +
+
+
{t('tools.fancy.camoName')}
+ +
+
+
{t('tools.fancy.camoArtist')}
+ +
+
+
{t('tools.fancy.camoNoteDesigner')}
+ +
+ + {t('tools.fancy.camoHideNoteDesigner')} + +
+
{t('tools.fancy.camoUnlockScore')}
+ +
{t('tools.fancy.camoUnlockScoreHint')}
+
+
+
+ +
+ + +
+
; + }, +}); diff --git a/MaiChartManager/Front/src/views/FancyTool/IntroTab.tsx b/MaiChartManager/Front/src/views/FancyTool/IntroTab.tsx new file mode 100644 index 00000000..3b401756 --- /dev/null +++ b/MaiChartManager/Front/src/views/FancyTool/IntroTab.tsx @@ -0,0 +1,410 @@ +import { computed, defineComponent, ref, watch } from 'vue'; +import { Button, CheckBox, Progress, addToast, showTransactionalDialog } from '@munet/ui'; +import { useI18n } from 'vue-i18n'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import api, { getUrl } from '@/client/api'; +import type { IntroMovieInfo } from '@/client/apiGen'; +import { globalCapture } from '@/store/refs'; +import { pickFile } from '@/utils/pickFile'; +import { handleSseOpen } from '@/utils/sseOpen'; +import { fileToken, modFlags, refreshEntries, selectedId } from './refs'; +import { isKnownErrorCode, showFancyError } from './errors'; + +type Side = '' | 'L' | 'R'; + +export default defineComponent({ + setup() { + const { t } = useI18n(); + + const busy = ref(false); + // 上传后要转码,单独拿一个状态出来显示进度 + const uploading = ref(false); + const progress = ref(0); + const introInfo = ref(null); + // 左右屏共用一个视频 / 各用一个。切换开关本身不动文件,替换视频时才写入 + const introSame = ref(true); + // 视频实际分辨率:开场视频大多是 1:1,显示出来方便确认没有被压扁 + const sameMeta = ref(''); + const leftMeta = ref(''); + const rightMeta = ref(''); + + // 上传/删除时先不渲染预览:播放器还读着文件的话,Windows 会因占用删不掉 + const suppressPreview = computed(() => busy.value || uploading.value); + + const load = async () => { + const id = selectedId.value; + if (!id) { + introInfo.value = null; + return; + } + const data = (await api.GetIntroMovie({ id })).data; + // 切曲目后旧请求可能才回来,丢弃过期响应 + if (id !== selectedId.value) return; + + introInfo.value = data; + sameMeta.value = ''; + leftMeta.value = ''; + rightMeta.value = ''; + // 单文件优先;只有左右各一个文件时才进分屏模式 + introSame.value = !data || !!data.hasSingle || !(data.hasLeft && data.hasRight); + }; + + watch(selectedId, () => { + if (!selectedId.value) return; + load().catch(e => globalCapture(e, t('tools.fancy.errorLoad'))); + }, { immediate: true }); + + const hasIntroMovie = computed(() => + !!(introInfo.value?.hasSingle || introInfo.value?.hasLeft || introInfo.value?.hasRight)); + + const url = (side?: 'L' | 'R') => suppressPreview.value + ? '' + : getUrl(`GetIntroMovieFileApi?id=${selectedId.value}${side ? `&side=${side}` : ''}&t=${fileToken.value}`); + + const samePreviewSrc = computed(() => { + const info = introInfo.value; + if (!selectedId.value || !info) return ''; + if (info.hasSingle) return url(); + if (info.hasLeft) return url('L'); + if (info.hasRight) return url('R'); + return ''; + }); + + // 左右分开时,某一侧还没有自己的文件就先用单文件顶替,方便对比 + const leftPreviewSrc = computed(() => { + const info = introInfo.value; + if (!selectedId.value || !info) return ''; + return info.hasLeft ? url('L') : (info.hasSingle ? url() : ''); + }); + + const rightPreviewSrc = computed(() => { + const info = introInfo.value; + if (!selectedId.value || !info) return ''; + return info.hasRight ? url('R') : (info.hasSingle ? url() : ''); + }); + + // 视频一律静音播放,声音单独播一份 acb 解码后的 wav(左右分屏也只有这一路声音,不会重声) + const audioSrc = computed(() => selectedId.value && introInfo.value?.hasAudio && !suppressPreview.value + ? getUrl(`GetIntroAudioFileApi?id=${selectedId.value}&t=${fileToken.value}`) + : ''); + + let sameVideoEl: HTMLVideoElement | null = null; + let leftVideoEl: HTMLVideoElement | null = null; + let rightVideoEl: HTMLVideoElement | null = null; + let audioEl: HTMLAudioElement | null = null; + + const readMeta = (e: Event) => { + const video = e.target as HTMLVideoElement; + return video.videoWidth && video.videoHeight ? `${video.videoWidth} × ${video.videoHeight}` : ''; + }; + + const playAudio = (playing: boolean) => { + if (!audioEl) return; + if (playing) audioEl.play().catch(() => {}); + else audioEl.pause(); + }; + + const syncAudioSeek = (from: HTMLVideoElement | null) => { + if (!audioEl || !from || !Number.isFinite(from.currentTime)) return; + if (Math.abs(audioEl.currentTime - from.currentTime) > 0.15) audioEl.currentTime = from.currentTime; + }; + + // 分屏预览时同步两侧播放状态,方便看出游戏里两边一起播的效果 + const syncPlay = (from: HTMLVideoElement | null, to: HTMLVideoElement | null) => { + if (!from) return; + const playing = !from.paused; + if (to) { + if (playing) to.play().catch(() => {}); + else to.pause(); + } + playAudio(playing); + }; + + // 手动拖动进度条时把另一侧也拖到同一时间点;差值小于阈值就不动,避免两侧互相触发死循环 + const syncSeek = (from: HTMLVideoElement | null, to: HTMLVideoElement | null) => { + syncAudioSeek(from); + if (!from || !to) return; + if (!Number.isFinite(from.currentTime)) return; + if (Math.abs(to.currentTime - from.currentTime) <= 0.15) return; + const target = Number.isFinite(to.duration) ? Math.min(from.currentTime, to.duration) : from.currentTime; + to.currentTime = Math.max(0, target); + }; + + // 主动停掉并卸载预览,让文件句柄尽快释放(上传/删除前调用,避免 Windows 因占用删不掉) + const releaseMedia = () => { + for (const el of [sameVideoEl, leftVideoEl, rightVideoEl]) { + if (!el) continue; + try { + el.pause(); + el.removeAttribute('src'); + el.load(); + } catch { + // 忽略 + } + } + try { + audioEl?.pause(); + } catch { + // 忽略 + } + }; + + const afterChange = async () => { + fileToken.value = Date.now(); + await Promise.all([load(), refreshEntries()]); + }; + + /** 上传并转码:后端用 SSE 回报进度,Error 事件的 data 是错误码 */ + const uploadMovie = async (file: File, side: Side) => { + const id = selectedId.value; + if (!id) return; + + uploading.value = true; + progress.value = 0; + releaseMedia(); + + const body = new FormData(); + body.append('id', String(id)); + // 注意:生成的 FormData 会把 undefined 变成字符串 "undefined", + // 所以这里必须传空字符串(后端会当成「单文件」)而不是 undefined。 + body.append('side', side); + body.append('file', file); + + const controller = new AbortController(); + try { + await new Promise((resolve, reject) => { + fetchEventSource(getUrl('SetIntroMovieApi'), { + signal: controller.signal, + method: 'POST', + body, + onopen: handleSseOpen, + onerror(e) { + reject(e); + controller.abort(); + throw new Error('disable retry onerror'); + }, + onclose() { + reject(new Error('EventSource Close')); + controller.abort(); + throw new Error('disable retry onclose'); + }, + openWhenHidden: true, + onmessage: (e) => { + switch (e.event) { + case 'Progress': + progress.value = parseInt(e.data) || 0; + break; + case 'Success': + controller.abort(); + resolve(); + break; + case 'Error': + controller.abort(); + reject(new Error(e.data)); + break; + } + }, + }); + }); + + await afterChange(); + // 转码期间可能已经切到别的曲目,这时候提示里的「有没有声音」会是别人家的 + if (id !== selectedId.value) return; + // mp4 是静音的,声音来自自动生成的 acb/awb + addToast({ + message: introInfo.value?.hasAudio + ? t('tools.fancy.introAudioGenerated') + : t('tools.fancy.introAudioSilent'), + type: 'success', + }); + } catch (e: any) { + if (e?.name === 'AbortError') return; + if (isKnownErrorCode(e?.message)) showFancyError(t, e.message); + else globalCapture(e, t('tools.fancy.errorConvert')); + } finally { + uploading.value = false; + } + }; + + const chooseMovie = async (side: Side = '') => { + const file = await pickFile('video/*'); + if (file) await uploadMovie(file, side); + }; + + // 左右屏共用同一套音频(Enter_.acb / .awb),所以不区分 side + const uploadAudio = async (file: File) => { + if (!selectedId.value) return; + busy.value = true; + try { + const result = (await api.SetIntroAudio({ id: selectedId.value, file })).data; + if (!result.success) { + showFancyError(t, result.error); + return; + } + + // 音频换了 URL 必须跟着变,否则