From adea9f0afbd3f8e498f5cb5403eac82b8859109a Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 04:31:15 +0800 Subject: [PATCH 01/26] =?UTF-8?q?refactor(front):=20=E6=8A=BD=E5=87=BA=20o?= =?UTF-8?q?penWindow=20=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 utils/openWindow.ts,统一处理 WebView2 的 window.open 和 Photino 的 open-window 消息 - 谱面预览改用这个工具函数,后续新窗口(工具页)可以直接复用 --- MaiChartManager/Front/src/utils/openWindow.ts | 35 +++++++++++++++++++ .../Charts/MusicEdit/PreviewChartButton.tsx | 33 ++++++----------- 2 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 MaiChartManager/Front/src/utils/openWindow.ts 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/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 () => ( From c4ab14cd93b801888bc6e43559c6a4595d88cf83 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 04:31:19 +0800 Subject: [PATCH 02/26] =?UTF-8?q?refactor(front):=20=E6=9B=B2=E7=9B=AE?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=88=A4=E5=AE=9A=E6=8A=BD=E6=88=90=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 utils/musicSearch.ts,统一按 名称 / 曲师 / 谱师 / ID 匹配 - 批量操作的曲目选择器改用这个判定,和新的工具页搜索保持同一口径 --- MaiChartManager/Front/src/utils/musicSearch.ts | 15 +++++++++++++++ .../Front/src/views/BatchAction/MusicSelector.tsx | 12 ++---------- 2 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 MaiChartManager/Front/src/utils/musicSearch.ts diff --git a/MaiChartManager/Front/src/utils/musicSearch.ts b/MaiChartManager/Front/src/utils/musicSearch.ts new file mode 100644 index 00000000..0cabf718 --- /dev/null +++ b/MaiChartManager/Front/src/utils/musicSearch.ts @@ -0,0 +1,15 @@ +import type { MusicXmlWithABJacket } from '@/client/apiGen'; + +/** + * 曲目搜索的统一判定:名称 / 曲师 / 谱师 / 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/views/BatchAction/MusicSelector.tsx b/MaiChartManager/Front/src/views/BatchAction/MusicSelector.tsx index dc035efc..3491f1ea 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 } 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' }, From d1e8425f7137f324ab981003c15f91c3313c8d19 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 04:31:24 +0800 Subject: [PATCH 03/26] =?UTF-8?q?fix(tools):=20=E6=9B=B2=E7=9B=AE=E4=BC=AA?= =?UTF-8?q?=E8=A3=85=E7=9A=84=20toml=20=E5=8F=AA=E5=86=99=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E7=9A=84=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 不再把空的 NoteDesigner、值为 0 的 UnlockScore、false 的 HideNoteDesigner 写进配置文件 - 这些字段缺失时 AquaMai 本来就按「不伪装」处理,但那是对方实现细节,toml 作为跨项目契约不应该依赖它 --- .../Controllers/Tools/FancyToolController.cs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/MaiChartManager/Controllers/Tools/FancyToolController.cs b/MaiChartManager/Controllers/Tools/FancyToolController.cs index 9fce9b69..6c3f29da 100644 --- a/MaiChartManager/Controllers/Tools/FancyToolController.cs +++ b/MaiChartManager/Controllers/Tools/FancyToolController.cs @@ -710,8 +710,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,6 +720,24 @@ private FancyToolResult SetCamouflageCore(int id, string? name, string? artist, } } + /// + /// 只写真正有意义的字段。AquaMai 把缺失或空白的字段当作「不伪装」, + /// 但这是对方实现的细节;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) From a5c1396396329676a650ce98caf2e5758be4a5a4 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 04:31:36 +0800 Subject: [PATCH 04/26] =?UTF-8?q?fix(video):=20H264=20=E7=94=BB=E8=B4=A8?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=94=B9=E6=88=90=E8=B4=A8=E9=87=8F=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=EF=BC=8C=E6=9B=BF=E6=8E=89=20ExtraArgs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VideoConvertOptions 新增 Quality 档位(Default / High),High 时按选中的编码器给显式画质参数 - 删掉上一版临时加的 ExtraArgs 逃生口,画质参数归位到 VideoConvert 内部,不再依赖调用方传 ffmpeg 参数 - 顺便把 H264QualityArgs 收回 private,只有这里的代码该关心它 --- .../Controllers/Tools/FancyToolController.cs | 4 +-- MaiChartManager/Utils/VideoConvert.cs | 32 +++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/MaiChartManager/Controllers/Tools/FancyToolController.cs b/MaiChartManager/Controllers/Tools/FancyToolController.cs index 6c3f29da..4b5b00f1 100644 --- a/MaiChartManager/Controllers/Tools/FancyToolController.cs +++ b/MaiChartManager/Controllers/Tools/FancyToolController.cs @@ -193,8 +193,8 @@ await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions Padding = 0, ContentType = file.ContentType, TaskbarProgress = false, - // 硬件编码器默认画质差别很大(h264_mf 默认很糊),这里按选中的编码器给画质参数 - ExtraArgs = VideoConvert.H264QualityArgs(VideoConvert.H264Encoder), + // 硬件编码器的默认画质差别很大(h264_mf 默认很糊),开场视频要按高质量转 + Quality = VideoConvert.Quality.High, }); if (!System.IO.File.Exists(tmpVideoPath)) return new FancyToolResult(false, "convert-failed"); diff --git a/MaiChartManager/Utils/VideoConvert.cs b/MaiChartManager/Utils/VideoConvert.cs index a82d93de..ca53c795 100644 --- a/MaiChartManager/Utils/VideoConvert.cs +++ b/MaiChartManager/Utils/VideoConvert.cs @@ -53,8 +53,8 @@ private static List BuildPostArgs(VideoEncoderProfile p, VideoConvertOpt if (options.UseYuv420p) post.Add("-pix_fmt yuv420p"); } post.AddRange(p.ExtraOutputArgs); - // 调用方指定的额外参数(画质等)放最后,可以覆盖前面的默认值 - if (options.ExtraArgs is { Length: > 0 }) post.AddRange(options.ExtraArgs); + // 画质参数放最后,可以覆盖前面的默认值 + post.AddRange(QualityArgs(p, options)); return post; } @@ -82,7 +82,7 @@ public static async Task CheckHardwareAcceleration() /// 按当前选中的 H264 编码器给出画质参数。 /// 不同硬件编码器的默认画质差很多——例如 h264_mf 不指定 quality 时会用很低的默认值,转出来非常糊。 /// - public static string[] H264QualityArgs(string codec) => codec switch + private static string[] H264QualityArgs(string encoder) => encoder switch { "libx264" => ["-crf", "18", "-preset", "medium"], "h264_mf" => ["-rate_control", "quality", "-quality", "90"], @@ -94,6 +94,24 @@ public static async Task CheckHardwareAcceleration() _ => [], }; + private static string[] QualityArgs(VideoEncoderProfile p, VideoConvertOptions options) + => options.Quality == Quality.High && p.Kind == VideoCodecKind.H264 + ? H264QualityArgs(p.Name) + : []; + + public enum Quality + { + /// + /// 编码器默认参数。批量转换(DAT / PV)用这个,速度优先。 + /// + Default, + + /// + /// 高画质。硬件编码器的默认参数通常很糊,这里按编码器给显式的画质参数。 + /// + High, + } + public class VideoConvertOptions { /// @@ -112,9 +130,9 @@ public class VideoConvertOptions public bool UseYuv420p { get; set; } /// - /// 额外的 ffmpeg 输出参数(例如画质),追加在编码参数的最后 + /// 画质档位,默认用编码器自己的参数 /// - public string[] ExtraArgs { get; set; } = []; + public Quality Quality { get; set; } = Quality.Default; /// /// 视频 padding(秒),正数为前置空白,负数为裁剪开头 @@ -399,8 +417,8 @@ private static async Task RunConcatenate(string vf, VideoEncoderProfile profile, postArgs.Add("-pix_fmt yuv420p"); } postArgs.AddRange(profile.ExtraOutputArgs); - // 调用方指定的额外参数(画质等)同样要带上,位置保持在默认参数之后 - if (options.ExtraArgs is { Length: > 0 }) postArgs.AddRange(options.ExtraArgs); + // 画质参数同样要带上,位置保持在默认参数之后 + postArgs.AddRange(QualityArgs(profile, options)); #if DEBUG Console.WriteLine($"[ffargs] concat profile={profile.Name} pre=[{string.Join(" ", profile.PreInputArgs)}] post=[{string.Join(" ", postArgs)}]"); From 82e27c424850f351a23e395a0758d907a64651f9 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 04:31:52 +0800 Subject: [PATCH 05/26] =?UTF-8?q?fix(tools):=20=E5=8E=BB=E6=8E=89=E9=80=90?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9A=84=E6=8E=88=E6=9D=83=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E5=9C=A8=E6=89=93=E5=BC=80=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=97=B6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 开场视频 / 开场音频 / 伪装三个写入接口不再各自判断授权,和「视频转换」等工具的入口校验方式保持一致 - 授权校验挪到前端打开工具的入口,拦一次就够,开源项目没必要把复杂度堆到每个接口上 --- MaiChartManager/Controllers/Tools/FancyToolController.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/MaiChartManager/Controllers/Tools/FancyToolController.cs b/MaiChartManager/Controllers/Tools/FancyToolController.cs index 4b5b00f1..87af94f8 100644 --- a/MaiChartManager/Controllers/Tools/FancyToolController.cs +++ b/MaiChartManager/Controllers/Tools/FancyToolController.cs @@ -12,6 +12,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")] @@ -161,10 +162,6 @@ private async Task SetIntroMovieCore(int id, string? side, IFor 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"); @@ -291,10 +288,6 @@ 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(); From bf071ca3f88a5a618093899c69e0c3212be16671 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 04:32:16 +0800 Subject: [PATCH 06/26] =?UTF-8?q?fix(tools):=20=E5=BC=80=E5=9C=BA=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E4=B8=8E=E4=BC=AA=E8=A3=85=E7=9A=84=E5=86=99=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E6=88=90=E5=90=8C=E5=8D=B7=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=B0=83=E6=95=B4=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 临时文件和备份目录改到目标目录里的 .mcm-tmp,所有搬迁都是一次同卷 rename:修掉临时目录在别的文件系统时 File.Move 直接失败(Linux 上 /tmp 是 tmpfs 必踩),也不用再复制几百 MB 旧文件做备份,峰值占用从 3 倍回到 1 倍 - 被预览占用搬不动时单独返回 file-locked,前端据此提示「文件仍被占用」,不再是笼统的删除失败 - 新增 GetFancyListApi 返回开场视频与曲目伪装的并集,替掉 GetIntroMovieListApi / GetCamouflageListApi - 新增 DeleteCamouflageJacketApi:只删封面保留 toml,游戏侧没有封面时用默认空封面,是合法状态 - 删除开场视频/音频时顺手清掉 acb 解码出来的预览缓存,不再在 temp 里留几十 MB 死文件 - 列表解析只认 Enter_[_L|_R].mp4,中间产物不会被当成交付文件 --- .../Controllers/Tools/FancyToolController.cs | 774 +++++++++++------- 1 file changed, 466 insertions(+), 308 deletions(-) diff --git a/MaiChartManager/Controllers/Tools/FancyToolController.cs b/MaiChartManager/Controllers/Tools/FancyToolController.cs index 87af94f8..34197b36 100644 --- a/MaiChartManager/Controllers/Tools/FancyToolController.cs +++ b/MaiChartManager/Controllers/Tools/FancyToolController.cs @@ -40,6 +40,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; } = ""; @@ -49,6 +56,98 @@ public class CamouflageModel public double UnlockScore { get; set; } } + #region 列表 + + /// + /// 列出所有做过开场视频 / 曲目伪装的曲目(并集)。 + /// + [HttpGet] + public IEnumerable GetFancyList() + { + var introMovies = ReadIntroMovieEntries().ToDictionary(entry => entry.Id); + var camouflages = ReadCamouflageEntries().ToDictionary(entry => entry.Id); + + 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] @@ -58,11 +157,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 时依次取单文件 / 左 / 右。 /// @@ -92,8 +193,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(); @@ -143,8 +243,6 @@ public ActionResult GetIntroAudioFile(int id) [DisableRequestSizeLimit] public async Task SetIntroMovie([FromForm] int id, [FromForm] string? side, IFormFile file) { - if (id <= 0) return new FancyToolResult(false, "invalid-id"); - var gate = GetFileGate(id); await gate.WaitAsync(); try @@ -166,25 +264,27 @@ private async Task SetIntroMovieCore(int id, string? side, IFor 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, @@ -194,65 +294,47 @@ await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions Quality = VideoConvert.Quality.High, }); - if (!System.IO.File.Exists(tmpVideoPath)) return new FancyToolResult(false, "convert-failed"); - - // 音轨只生成到临时目录,等视频布局确定后和视频一起提交 - var audio = await BuildIntroAudio(inputPath, id, tmpDir.FullName); + if (!System.IO.File.Exists(stagedVideoPath)) return new FancyToolResult(false, "convert-failed"); - // 这次会动到的文件先全部备份,失败时整体回滚 - foreach (var path in IntroFilePaths(id)) tx.Track(path); + var audio = await BuildIntroAudio(inputPath, id, tx.StageDirPath); - // 清理与新布局冲突的旧视频(单文件与 L/R 不能共存) - string[] conflictingFiles = normalizedSide is null - ? - [ - Path.Combine(IntroMovieDir, VideoFileName(id, "L")), - Path.Combine(IntroMovieDir, VideoFileName(id, "R")), - ] - : [Path.Combine(IntroMovieDir, VideoFileName(id, null))]; - if (!DeleteFilesWithRetry(conflictingFiles)) + // 这次会动到的文件先全部挪进 stage 备份,失败时整体还原 + foreach (var path in IntroFilePaths(id)) { - tx.Rollback(); - return new FancyToolResult(false, "delete-failed"); + 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); } // 音频必须和视频配套:这次没有音轨就把旧的 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 IOException ? "file-locked" : "convert-failed"); } finally { try { - tmpDir.Delete(true); + uploadDir.Delete(true); } catch { @@ -269,8 +351,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 @@ -290,41 +370,40 @@ private async Task SetIntroAudioCore(int id, IFormFile file) if (gamePathError is not null) return new FancyToolResult(false, gamePathError); 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 IOException ? "file-locked" : "write-failed"); } finally { try { - tmpDir.Delete(true); + uploadDir.Delete(true); } catch { @@ -339,8 +418,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 @@ -353,28 +430,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 { @@ -395,6 +502,17 @@ 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) => @@ -406,60 +524,9 @@ private static string[] IntroFilePaths(int id) => 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; - - 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 @@ -472,119 +539,17 @@ 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) - { - for (var attempt = 0; ; attempt++) - { - try - { - PlatformFile.MoveFile(source, dest); - return; - } - catch (Exception) when (attempt < 10) - { - Thread.Sleep(150); - } - } - } - - /// - /// 删除文件(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) - { - try - { - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); - return false; - } - catch (IOException) - { - 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; - } - } - private static string VideoFileName(int id, string? side) => side is null ? $"Enter_{id}.mp4" : $"Enter_{id}_{side}.mp4"; private static string? ExistingPath(string fileName) @@ -593,36 +558,6 @@ private static bool TryDeleteFile(string path) 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 曲目伪装 @@ -645,7 +580,7 @@ public CamouflageInfoDto GetCamouflage(int id) } } - var jacketPath = FindJacket(id); + 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); } @@ -656,7 +591,7 @@ public CamouflageInfoDto GetCamouflage(int id) [HttpGet] public ActionResult GetCamouflageJacket(int id) { - var path = FindJacket(id); + var path = id > 0 ? FindJacket(id) : null; if (path is null) return NotFound(); var contentType = Path.GetExtension(path).ToLowerInvariant() == ".png" ? "image/png" : "image/jpeg"; @@ -667,8 +602,6 @@ public ActionResult GetCamouflageJacket(int id) 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 @@ -714,7 +647,7 @@ private FancyToolResult SetCamouflageCore(int id, string? name, string? artist, } /// - /// 只写真正有意义的字段。AquaMai 把缺失或空白的字段当作「不伪装」, + /// 只写真正有意义的字段。AQMai 把缺失或空白的字段当作「不伪装」, /// 但这是对方实现的细节;toml 是跨项目的文件契约,空值就不要写出去。 /// private static string BuildCamouflageToml(CamouflageModel model) @@ -735,8 +668,6 @@ private static string BuildCamouflageToml(CamouflageModel model) [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 @@ -759,53 +690,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 IOException ? "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 @@ -820,20 +759,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; @@ -842,49 +784,265 @@ 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 文件事务 /// - /// 列出 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"; - var result = new List(); - foreach (var file in Directory.EnumerateFiles(CamouflageDir, "*.toml")) + private readonly string _targetDir; + private readonly ILogger _logger; + private readonly List<(string Target, string Backup)> _backups = []; + private readonly List _placedTargets = []; + private bool _finished; + + public FancyFileTransaction(string targetDir, ILogger logger) { - if (!int.TryParse(Path.GetFileNameWithoutExtension(file), out var id) || id <= 0) continue; + _targetDir = targetDir; + _logger = logger; + StageDirPath = Path.Combine(targetDir, StageDirName, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(StageDirPath); + } + + public string StageDirPath { get; } + + /// 新文件的暂存路径(和最终目标同卷)。 + public string StagePath(string fileName) => Path.Combine(StageDirPath, fileName); + + /// + /// 把旧文件 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($"bak_{Guid.NewGuid():N}"); + if (!MoveWithRetry(path, backupPath)) return false; + + _backups.Add((path, backupPath)); + return true; + } + + /// 把 stage 里的新文件放到目标位置(同卷 rename)。 + public void Place(string stagedPath, string targetPath) + { + if (!MoveWithRetry(stagedPath, targetPath)) + throw new IOException($"无法写入 {targetPath}"); + _placedTargets.Add(targetPath); + } + + /// + /// 提交:备份的旧文件送回收站(和项目里其它删除操作一致,用户还能捞回来),清掉 stage。 + /// 备份删不掉只是留垃圾,不能因此报失败——新文件此时已经就位了。 + /// + public void Commit() + { + _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; + + foreach (var target in _placedTargets) + { + try + { + if (System.IO.File.Exists(target)) System.IO.File.Delete(target); + } + catch (Exception) + { + // 尽力回滚,单个文件失败不影响其它文件 + } + } + + foreach (var (target, backupPath) in _backups) + { + try + { + if (!System.IO.File.Exists(backupPath)) continue; + if (!MoveWithRetry(backupPath, target)) PlatformFile.CopyFile(backupPath, target); + } + catch (Exception) + { + // 尽力回滚 + } + } - var model = new CamouflageModel(); + DropStageDir(); + } + + public void Dispose() + { + if (!_finished) Rollback(); + DropStageDir(); + } + + private void DropStageDir() + { try { - model = Toml.ToModel(System.IO.File.ReadAllText(file), options: TomlOptions) - ?? new CamouflageModel(); + 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 (Exception e) + catch { - logger.LogWarning(e, "解析曲目伪装配置失败: {path}", file); + // 忽略清理失败 + } + } + } + + #endregion + + #region 文件工具 + + /// + /// 覆盖式移动文件。目标被占用(例如前端还在预览旧文件)时等一会儿再试,最终失败返回 false。 + /// + private static bool MoveWithRetry(string source, string dest) + { + var deadline = DateTime.UtcNow.AddSeconds(3); + while (true) + { + try + { + PlatformFile.MoveFile(source, dest); + return true; } + catch (Exception) when (DateTime.UtcNow < deadline) + { + Thread.Sleep(100); + } + catch (Exception) + { + return false; + } + } + } - result.Add(new CamouflageEntry(id, model.Name, model.Artist, FindJacket(id) is not null)); + /// + /// 删除文件(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); } - return result.OrderBy(entry => entry.Id).ToList(); + var ok = true; + foreach (var path in pending) + { + if (!System.IO.File.Exists(path)) continue; + if (!TryDeleteFile(path)) ok = false; + } + + return ok; } - private static void WriteAllTextAtomic(string path, string content) + /// + /// 文件是否还被别人占着(能独占打开就说明没被占用)。 + /// + 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; } } From 1f608cdc78621847b5863d2ae074219eeafd9d10 Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 29 Oct 2026 04:32:40 +0800 Subject: [PATCH 07/26] =?UTF-8?q?feat(tools):=20=E5=BC=80=E5=9C=BA?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E4=B8=8E=E6=9B=B2=E7=9B=AE=E4=BC=AA=E8=A3=85?= =?UTF-8?q?=E6=94=B9=E6=88=90=E5=85=A8=E5=B1=8F=E5=B7=A5=E5=85=B7=E7=AA=97?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具从弹窗改成独立的 hash 路由页 /fancy-tool,用 openWindow 打开(和谱面预览同一套机制):要放 1:1 视频预览和上千首的曲目搜索,弹窗宽度和高度都不够 - 左侧列已做过开场视频或曲目伪装的曲目(按 id 并集),带开场视频/伪装图标徽标;顶部是选择乐曲按钮和本次列表的本地过滤 - 右侧用 WhateverNaviBar 切「开场视频 / 曲目伪装」;点选择乐曲时右侧整块切到搜索页,选中即回到编辑界面并自动落到有内容的那个 tab - 搜索页复用批量操作的表格范式:虚拟滚动 + 名称/曲师/谱师/ID 搜索,带封面列和「已有」标记 - 拆成 MusicSidebar / MusicSearch / IntroTab / CamouflageTab / refs / errors,原来 742 行的单文件拆完 - 新增「删除封面」,曲名或曲师留空时不允许保存(游戏侧缺字段会显示 ???) - 页面顶部提示改动需要重启游戏才生效(AquaMai 只在启动时扫一次目录) - 授权校验挪到工具卡片入口,未授权直接弹购买对话框 - 清理上一版没用到的 6 个文案 key(三种语言),操作状态改成每个 tab 各自管理,不再一操作就锁整页 --- MaiChartManager/Front/src/client/apiGen.ts | 91 ++- MaiChartManager/Front/src/locales/en.yaml | 53 +- MaiChartManager/Front/src/locales/zh-TW.yaml | 57 +- MaiChartManager/Front/src/locales/zh.yaml | 53 +- MaiChartManager/Front/src/router.ts | 2 + .../src/views/FancyTool/CamouflageTab.tsx | 237 ++++++ .../Front/src/views/FancyTool/IntroTab.tsx | 375 +++++++++ .../Front/src/views/FancyTool/MusicSearch.tsx | 104 +++ .../src/views/FancyTool/MusicSidebar.tsx | 69 ++ .../Front/src/views/FancyTool/errors.ts | 25 + .../Front/src/views/FancyTool/index.tsx | 77 ++ .../Front/src/views/FancyTool/refs.ts | 72 ++ .../src/views/Tools/IntroCamouflageModal.tsx | 742 ------------------ .../Front/src/views/Tools/index.tsx | 23 +- 14 files changed, 1102 insertions(+), 878 deletions(-) create mode 100644 MaiChartManager/Front/src/views/FancyTool/CamouflageTab.tsx create mode 100644 MaiChartManager/Front/src/views/FancyTool/IntroTab.tsx create mode 100644 MaiChartManager/Front/src/views/FancyTool/MusicSearch.tsx create mode 100644 MaiChartManager/Front/src/views/FancyTool/MusicSidebar.tsx create mode 100644 MaiChartManager/Front/src/views/FancyTool/errors.ts create mode 100644 MaiChartManager/Front/src/views/FancyTool/index.tsx create mode 100644 MaiChartManager/Front/src/views/FancyTool/refs.ts delete mode 100644 MaiChartManager/Front/src/views/Tools/IntroCamouflageModal.tsx diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index 7491077e..bb2743bb 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; @@ -824,7 +822,7 @@ export class HttpClient { } /** - * @title MaiChartManager + * @title MaiChartManager.GenClient * @version 1.0 */ export class Api< @@ -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 * @@ -1634,21 +1647,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 +1748,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 +1759,7 @@ export class Api< params: RequestParams = {}, ) => this.request({ - path: `/MaiChartManagerServlet/DeleteCamouflageApi`, + path: `/MaiChartManagerServlet/DeleteCamouflageJacketApi`, method: "POST", query: query, format: "json", @@ -1772,13 +1770,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..93a749e9 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -454,44 +454,37 @@ 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 + 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 encoded into Enter_.acb / .awb and played separately by the game." 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}? + introAudio: Audio + introAudioShared: shared by both screens + introAudioAdd: Add audio + introAudioReplace: Replace audio + introAudioRemove: Remove audio + introAudioRemoveConfirm: "Remove the intro audio for song {id}? (both screens share one acb/awb)" + introAudioGenerated: "Saved: audio track encoded to acb/awb, the game plays it" + introAudioSilent: "Saved: no audio track, the intro will be silent" 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. camoName: Disguised name camoArtist: Disguised artist @@ -499,8 +492,11 @@ tools: camoHideNoteDesigner: Hide note designer (shows -) camoUnlockScore: Score required to remove camouflage camoUnlockScoreHint: 0 means the camouflage disappears once the player has any score; use 102 to keep it forever + camoTextHint: Name and artist are required; empty fields are not written to the config file 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 +507,11 @@ 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 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..b6629134 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -417,44 +417,37 @@ 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: 套用到 + introHint: 在遊戲內開始樂曲前播放自訂影片(AquaMai 的 CustomIntroCinematic)。左右螢幕可以共用一個影片,也可以分開兩個。上傳後影片會轉成靜音 mp4,音軌自動編碼成 Enter_.acb / .awb,由遊戲單獨播放。 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} 的開場影片嗎? + introAudio: 音訊 + introAudioShared: 左右螢幕共用 + introAudioAdd: 新增音訊 + introAudioReplace: 更換音訊 + introAudioRemove: 刪除音訊 + introAudioRemoveConfirm: 確定要刪除樂曲 {id} 的開場音訊嗎?(左右螢幕共用一組 acb/awb) + introAudioGenerated: 已儲存:音軌已產生 acb/awb,遊戲中由它發聲 + introAudioSilent: 已儲存:影片沒有音軌,將靜音播放 camouflageHint: 在選曲與開始畫面把樂曲顯示成別的曲名、曲師與封面(AquaMai 的 TrackCamouflage),適合搭配開場影片使用。 camoName: 偽裝後的曲名 camoArtist: 偽裝後的曲師 @@ -462,8 +455,11 @@ tools: camoHideNoteDesigner: 隱藏譜師(顯示為 -) camoUnlockScore: 解除偽裝所需的達成率 camoUnlockScoreHint: 0 表示有成績後就不再偽裝;填 102 可以永久偽裝 + camoTextHint: 曲名與曲師必填;留空的欄位不會寫入設定檔 camoJacketUpload: 選擇封面(jpg / png) camoJacketReplace: 更換封面 + camoJacketRemove: 刪除封面 + camoJacketRemoveConfirm: 確定要刪除樂曲 {id} 的偽裝封面嗎?(曲名等資訊會保留) camoRemove: 清除偽裝 camoRemoveConfirm: 確定要清除樂曲 {id} 的偽裝資訊與封面嗎? save: 儲存 @@ -474,12 +470,11 @@ tools: errorInvalidSide: 影片位置無效 errorInvalidText: 曲名與曲師不能為空 errorInvalidImage: 只支援 jpg / png 圖片 - errorLicense: 需要有效的授權才能轉換影片 + errorInvalidAudio: 音訊檔案無法辨識或轉換失敗 errorGamePath: 未設定遊戲目錄 errorConvert: 影片轉換失敗 errorWrite: 寫入檔案失敗 - errorDeleteLocked: 刪除失敗:檔案仍被占用,請關閉預覽後重試 - errorInvalidAudio: 音訊檔案無法辨識或轉換失敗 + errorFileLocked: 檔案仍被占用,請關閉預覽後重試 title: 工具 audioConvert: 音訊轉換(ACB + AWB) videoConvert: 影片轉換(DAT) diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index cdc0d2af..4a951f25 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -411,44 +411,37 @@ 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: 应用到 + introHint: 在游戏内开始乐曲前播放自定义视频(AquaMai 的 CustomIntroCinematic)。左右屏可以共用一个视频,也可以分开两个。上传后视频会转成静音 mp4,音轨自动编码成 Enter_.acb / .awb,由游戏单独播放。 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} 的开场视频吗? + introAudio: 音频 + introAudioShared: 左右屏共用 + introAudioAdd: 添加音频 + introAudioReplace: 替换音频 + introAudioRemove: 删除音频 + introAudioRemoveConfirm: 确定要删除乐曲 {id} 的开场音频吗?(左右屏共用一组 acb/awb) + introAudioGenerated: 已保存:音轨已生成 acb/awb,游戏里由它发声 + introAudioSilent: 已保存:视频没有音轨,将静音播放 camouflageHint: 在选曲和开始界面把乐曲显示成别的曲名、曲师和封面(AquaMai 的 TrackCamouflage),适合配合开场视频做伪装演出。 camoName: 伪装后的曲名 camoArtist: 伪装后的曲师 @@ -456,8 +449,11 @@ tools: camoHideNoteDesigner: 隐藏谱师(显示为 -) camoUnlockScore: 解除伪装所需的达成率 camoUnlockScoreHint: 0 表示打过一个成绩就不再伪装;填 102 可以永久伪装 + camoTextHint: 曲名和曲师必填;留空的字段不会写进配置文件 camoJacketUpload: 选择封面(jpg / png) camoJacketReplace: 更换封面 + camoJacketRemove: 删除封面 + camoJacketRemoveConfirm: 确定要删除乐曲 {id} 的伪装封面吗?(曲名等信息会保留) camoRemove: 清除伪装 camoRemoveConfirm: 确定要清除乐曲 {id} 的伪装信息和封面吗? save: 保存 @@ -468,12 +464,11 @@ tools: errorInvalidSide: 视频位置无效 errorInvalidText: 曲名和曲师不能为空 errorInvalidImage: 只支持 jpg / png 图片 - errorLicense: 需要有效的授权才能转换视频 + errorInvalidAudio: 音频文件无法识别或转换失败 errorGamePath: 未配置游戏目录 errorConvert: 视频转换失败 errorWrite: 写入文件失败 - errorDeleteLocked: 删除失败:文件仍被占用,请关闭预览后重试 - errorInvalidAudio: 音频文件无法识别或转换失败 + errorFileLocked: 文件仍被占用,请关闭预览后重试 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/views/FancyTool/CamouflageTab.tsx b/MaiChartManager/Front/src/views/FancyTool/CamouflageTab.tsx new file mode 100644 index 00000000..ae252ceb --- /dev/null +++ b/MaiChartManager/Front/src/views/FancyTool/CamouflageTab.tsx @@ -0,0 +1,237 @@ +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, 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 camouflage = ref(emptyCamouflage()); + + const load = async () => { + const id = selectedId.value; + if (!id) { + camouflage.value = emptyCamouflage(); + return; + } + 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 ?? '', + }; + }; + + watch(selectedId, () => { + 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) 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(), refreshEntries()]); + } catch (e) { + globalCapture(e, t('tools.fancy.errorWrite')); + } finally { + busy.value = false; + } + }; + + const uploadJacket = async (file: File) => { + const id = selectedId.value; + if (!id) 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' }); + // 不要用 load:它会把服务端数据写回表单,冲掉还没保存的填写内容 + await Promise.all([refreshJacket(), 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) return; + return confirmThen(t('tools.fancy.camoJacketRemoveConfirm', { id }), async () => { + const result = (await api.DeleteCamouflageJacket({ id })).data; + if (!result.success) { + showFancyError(t, result.error); + return; + } + await Promise.all([refreshJacket(), refreshEntries()]); + }); + }; + + const removeAll = () => { + const id = selectedId.value; + if (!id) return; + return confirmThen(t('tools.fancy.camoRemoveConfirm', { id }), async () => { + const result = (await api.DeleteCamouflage({ id })).data; + if (!result.success) { + showFancyError(t, result.error); + return; + } + await Promise.all([load(), refreshEntries()]); + fileToken.value = Date.now(); + }); + }; + + const hasAny = computed(() => !!camouflage.value.name || camouflage.value.hasJacket); + + return () =>
+
{t('tools.fancy.camouflageHint')}
+ +
+
+ {camouflage.value.hasJacket + ? + :
+ {t('tools.fancy.noPreview')} +
} +
+ + {camouflage.value.hasJacket && + } +
+ {!!camouflage.value.jacketPath && +
{camouflage.value.jacketPath}
} +
+ +
+
+
{t('tools.fancy.camoName')}
+ +
+
+
{t('tools.fancy.camoArtist')}
+ +
+
+
{t('tools.fancy.camoNoteDesigner')}
+ +
+ + {t('tools.fancy.camoHideNoteDesigner')} + +
+
{t('tools.fancy.camoUnlockScore')}
+ +
{t('tools.fancy.camoUnlockScoreHint')}
+
+
+
+ +
{t('tools.fancy.camoTextHint')}
+ +
+ + +
+
; + }, +}); diff --git a/MaiChartManager/Front/src/views/FancyTool/IntroTab.tsx b/MaiChartManager/Front/src/views/FancyTool/IntroTab.tsx new file mode 100644 index 00000000..cbdc56c5 --- /dev/null +++ b/MaiChartManager/Front/src/views/FancyTool/IntroTab.tsx @@ -0,0 +1,375 @@ +import { computed, defineComponent, ref, watch } from 'vue'; +import { Button, CheckBox, addToast, showTransactionalDialog } from '@munet/ui'; +import { useI18n } from 'vue-i18n'; +import api, { getUrl } from '@/client/api'; +import type { IntroMovieInfo } from '@/client/apiGen'; +import { globalCapture } from '@/store/refs'; +import { pickFile } from '@/utils/pickFile'; +import { fileToken, refreshEntries, selectedId } from './refs'; +import { showFancyError } from './errors'; + +type Side = '' | 'L' | 'R'; + +export default defineComponent({ + setup() { + const { t } = useI18n(); + + const busy = ref(false); + 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); + + 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() : ''); + }); + + const samePreviewPath = computed(() => { + const info = introInfo.value; + return info?.singlePath ?? info?.leftPath ?? info?.rightPath ?? ''; + }); + + const leftPreviewPath = computed(() => { + const info = introInfo.value; + return info?.leftPath ?? info?.singlePath ?? ''; + }); + + const rightPreviewPath = computed(() => { + const info = introInfo.value; + return info?.rightPath ?? info?.singlePath ?? ''; + }); + + // 视频一律静音播放,声音单独播一份 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()]); + }; + + const uploadMovie = async (file: File, side: Side) => { + if (!selectedId.value) return; + busy.value = true; + releaseMedia(); + try { + const result = (await api.SetIntroMovie({ + id: selectedId.value, + // 注意:生成的 FormData 序列化会把 undefined 变成字符串 "undefined", + // 所以这里必须传空字符串(后端会当成「单文件」)而不是 undefined。 + side, + file, + })).data; + + if (!result.success) { + showFancyError(t, result.error); + return; + } + + await afterChange(); + // mp4 是静音的,声音来自自动生成的 acb/awb + addToast({ + message: introInfo.value?.hasAudio + ? t('tools.fancy.introAudioGenerated') + : t('tools.fancy.introAudioSilent'), + type: 'success', + }); + } catch (e) { + globalCapture(e, t('tools.fancy.errorConvert')); + } finally { + busy.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 必须跟着变,否则