diff --git a/.gitmodules b/.gitmodules index 2e0a6100..4c23ec6e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,7 +9,7 @@ url = git@github.com:clansty/SonicAudioTools.git [submodule "XV2-Tools"] path = XV2-Tools - url = git@github.com:clansty/XV2-Tools.git + url = git@github.com:MuNET-OSS/XV2-Tools.git [submodule "AquaMai"] path = AquaMai url = git@github.com:MewoLab/AquaMai.git diff --git a/Directory.Build.props b/Directory.Build.props index 36df3cda..417c6961 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,9 +1,10 @@ - + + win-x64 true - - true - diff --git a/MaiChartManager.CLI/AssemblyInfo.cs b/MaiChartManager.CLI/AssemblyInfo.cs index 086cc6c2..d919fa6b 100644 --- a/MaiChartManager.CLI/AssemblyInfo.cs +++ b/MaiChartManager.CLI/AssemblyInfo.cs @@ -7,5 +7,7 @@ [assembly: AssemblyProduct("MaiChartManager CLI")] [assembly: AssemblyTitle("MaiChartManager CLI")] [assembly: AssemblyVersion(AppMain.Version)] +#if WINDOWS [assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows10.0.17763.0")] -[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows10.0.17134.0")] \ No newline at end of file +[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows10.0.17134.0")] +#endif \ No newline at end of file diff --git a/MaiChartManager.CLI/Commands/DebugCommand.cs b/MaiChartManager.CLI/Commands/DebugCommand.cs index cbd3841e..9300f398 100644 --- a/MaiChartManager.CLI/Commands/DebugCommand.cs +++ b/MaiChartManager.CLI/Commands/DebugCommand.cs @@ -6,6 +6,7 @@ public class DebugCommand : Command { public override int Execute(CommandContext context, CancellationToken cancellationToken) { +#if WINDOWS // WebView2 (COM) 要求 STA 线程;CLI 的 async 入口运行在 MTA 上下文中, // 而 Program.Main 上的 [STAThread] 作为普通方法调用时不生效,需手动建 STA 线程 Exception? exception = null; @@ -30,5 +31,10 @@ public override int Execute(CommandContext context, CancellationToken cancellati throw exception; } return 0; +#else + // Linux:直接启动 Photino 主程序(无 COM/STA 限制),控制台可见日志 + MaiChartManager.LinuxProgram.Main([]); + return 0; +#endif } } diff --git a/MaiChartManager.CLI/MaiChartManager.CLI.csproj b/MaiChartManager.CLI/MaiChartManager.CLI.csproj index bd925faf..b17282e3 100644 --- a/MaiChartManager.CLI/MaiChartManager.CLI.csproj +++ b/MaiChartManager.CLI/MaiChartManager.CLI.csproj @@ -1,26 +1,38 @@ + true Exe - net10.0-windows10.0.17763.0 enable enable - win-x64 - true False true false False False true - ..\Packaging\Pack - Debug;Release;Crack + Debug;Release;Crack;LinuxDebug;LinuxRelease AnyCPU - false - icon.ico False + + + net10.0-windows10.0.17763.0 + win-x64 + true + ..\Packaging\Pack + icon.ico + $(DefineConstants);WINDOWS + + + + + net10.0 + linux-x64 + false + + CRACK true diff --git a/MaiChartManager.CLI/Program.cs b/MaiChartManager.CLI/Program.cs index 78296910..d49a5c79 100644 --- a/MaiChartManager.CLI/Program.cs +++ b/MaiChartManager.CLI/Program.cs @@ -21,8 +21,15 @@ #endif }); +#if WINDOWS AppMain.InitConfiguration(true); await IapManager.Init(); +#else + // Linux:复用 LinuxProgram 的无头初始化(加载配置 + 配置系统 ffmpeg), + // 不走 Windows-only 的 AppMain/IapManager。 + LinuxProgram.InitConfiguration(); + LinuxProgram.ConfigureFfmpeg(); +#endif var app = new CommandApp(); diff --git a/MaiChartManager/AppMain.cs b/MaiChartManager/AppMain.cs index b499b80d..a01617a2 100644 --- a/MaiChartManager/AppMain.cs +++ b/MaiChartManager/AppMain.cs @@ -8,7 +8,7 @@ using Microsoft.Web.WebView2.Core; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; -using Xabe.FFmpeg; +using FFMpegCore; namespace MaiChartManager; @@ -75,7 +75,13 @@ public void Run() ApplicationConfiguration.Initialize(); SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); UiContext = SynchronizationContext.Current; - FFmpeg.SetExecutablesPath(StaticSettings.exeDir); + // FFMpegCore:Windows 内置 ffmpeg.exe/ffprobe.exe 在 exeDir,临时文件用 tempPath。 + // FFMpegCore 会按 OS 自动给可执行名补 .exe 后缀。 + GlobalFFOptions.Configure(o => + { + o.BinaryFolder = StaticSettings.exeDir; + o.TemporaryFilesFolder = StaticSettings.tempPath; + }); VideoConvert.CheckHardwareAcceleration(); Directory.CreateDirectory(StaticSettings.appData); diff --git a/MaiChartManager/AppMain.g.cs b/MaiChartManager/AppMain.g.cs index e0ce32b7..1d943ec8 100644 --- a/MaiChartManager/AppMain.g.cs +++ b/MaiChartManager/AppMain.g.cs @@ -3,5 +3,5 @@ namespace MaiChartManager; public partial class AppMain { - public const string Version = "26.2"; + public const string Version = "26.3"; } diff --git a/MaiChartManager/Config.cs b/MaiChartManager/Config.cs index b3d1a09f..a5d6d954 100644 --- a/MaiChartManager/Config.cs +++ b/MaiChartManager/Config.cs @@ -24,12 +24,17 @@ public class Config public MovieCodec MovieCodec { get; set; } = MovieCodec.ForceVP9; public bool Yuv420p { get; set; } = true; public bool NoScale { get; set; } = false; + // 强制视频走软件编码:硬件 H264 产物若游戏不认,改 config.json 设为 true 即可一键退回软件。 + public bool ForceSoftwareVideo { get; set; } = false; public bool IgnoreLevel { get; set; } = false; public bool DisableBga { get; set; } = false; public bool UseLegacyMaiLib { get; set; } = false; public bool ConvertJacketToAssetBundle { get; set; } = true; public int UiZoom { get; set; } = 0; + // 记住上次文件夹选择对话框选中的目录,下次打开时从这里开始(而不是每次都回到 Documents)。 + public string? LastDialogFolder { get; set; } = null; + public void Save() { var json = JsonSerializer.Serialize(this); diff --git a/MaiChartManager/Controllers/App/AppLicenseController.cs b/MaiChartManager/Controllers/App/AppLicenseController.cs index 5045368c..96410d4c 100644 --- a/MaiChartManager/Controllers/App/AppLicenseController.cs +++ b/MaiChartManager/Controllers/App/AppLicenseController.cs @@ -1,4 +1,6 @@ -using Windows.Services.Store; +#if WINDOWS +using Windows.Services.Store; +#endif using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.App; @@ -7,6 +9,7 @@ namespace MaiChartManager.Controllers.App; [Route("MaiChartManagerServlet/[action]Api")] public class AppLicenseController : Controller { +#if WINDOWS public record RequestPurchaseResult(string? ErrorMessage, StorePurchaseStatus Status); [HttpPost] @@ -32,4 +35,22 @@ public async Task VerifyOfflineKey([FromBody] string key) IapManager.SetOfflineLicenseActive(); return true; } -} \ No newline at end of file +#else + // Linux:始终已授权——不支持商店 / IAP + public record RequestPurchaseResult(string? ErrorMessage, int Status); + + [HttpPost] + public Task RequestPurchase() + { + // StorePurchaseStatus.Succeeded = 0 + return Task.FromResult(new RequestPurchaseResult(null, 0)); + } + + [HttpPost] + public Task VerifyOfflineKey([FromBody] string key) + { + // Linux 不做离线密钥验证;始终视为已授权 + return Task.FromResult(true); + } +#endif +} diff --git a/MaiChartManager/Controllers/App/AppVersionController.cs b/MaiChartManager/Controllers/App/AppVersionController.cs index 2eed099a..01e1454b 100644 --- a/MaiChartManager/Controllers/App/AppVersionController.cs +++ b/MaiChartManager/Controllers/App/AppVersionController.cs @@ -1,4 +1,4 @@ -using MaiChartManager.Utils; +using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.App; @@ -7,6 +7,7 @@ namespace MaiChartManager.Controllers.App; [Route("MaiChartManagerServlet/[action]Api")] public class AppVersionController(StaticSettings settings, ILogger logger) : ControllerBase { +#if WINDOWS public record AppVersionResult(string Version, int GameVersion, IapManager.LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale); [HttpGet] @@ -14,4 +15,21 @@ public AppVersionResult GetAppVersion() { return new AppVersionResult(Application.ProductVersion, settings.gameVersion, IapManager.License, VideoConvert.HardwareAcceleration, VideoConvert.H264Encoder, StaticSettings.CurrentLocale); } -} \ No newline at end of file +#else + public enum LicenseStatus { Pending, Active, Inactive } + public record AppVersionResult(string Version, int GameVersion, LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale); + + [HttpGet] + public AppVersionResult GetAppVersion() + { + // 与 Windows 的 Application.ProductVersion 语义一致:取程序集 InformationalVersion + //(由 PKGBUILD 在 publish 时通过 -p:InformationalVersion 注入 git 派生的版本号), + // 去掉 SourceLink 可能附带的 "+" 后缀。 + var asm = System.Reflection.Assembly.GetExecutingAssembly(); + var info = (System.Reflection.AssemblyInformationalVersionAttribute?)System.Attribute + .GetCustomAttribute(asm, typeof(System.Reflection.AssemblyInformationalVersionAttribute)); + var version = info?.InformationalVersion?.Split('+')[0] ?? "linux"; + return new AppVersionResult(version, settings.gameVersion, LicenseStatus.Active, VideoConvert.HardwareAcceleration, VideoConvert.H264Encoder, StaticSettings.CurrentLocale); + } +#endif +} diff --git a/MaiChartManager/Controllers/App/LocaleController.cs b/MaiChartManager/Controllers/App/LocaleController.cs index d7655271..5b45ff5e 100644 --- a/MaiChartManager/Controllers/App/LocaleController.cs +++ b/MaiChartManager/Controllers/App/LocaleController.cs @@ -6,7 +6,7 @@ namespace MaiChartManager.Controllers.App; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class LocaleController(StaticSettings settings, ILogger logger) : ControllerBase +public class LocaleController(StaticSettings settings, ILogger logger, MaiChartManager.Platform.IAppShell appShell) : ControllerBase { [HttpGet] public string GetCurrentLocale() @@ -17,6 +17,31 @@ public string GetCurrentLocale() [HttpPost] public void SetLocale([FromBody] string locale) { - AppMain.SetLocale(locale); + if (locale != "zh" && locale != "zh-TW" && locale != "en") + { + throw new ArgumentException("Invalid locale. Must be 'zh', 'zh-TW', or 'en'"); + } + + StaticSettings.CurrentLocale = locale; + StaticSettings.Config.Locale = locale; + + // 设置 Locale 资源管理器的 Culture(这会影响所有线程) + var culture = locale switch + { + "zh" => new CultureInfo("zh-CN"), + "zh-TW" => new CultureInfo("zh-TW"), + _ => new CultureInfo("en-US"), + }; + Locale.Culture = culture; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + + // 给外部依赖库设置Locale + MuConvert.utils.Utils.SetLocale(new CultureInfo(locale)); + + StaticSettings.Config.Save(); + + // 刷新原生 UI(Windows: 窗口/托盘;Linux: no-op) + appShell.ReloadLocale(locale); } } \ No newline at end of file diff --git a/MaiChartManager/Controllers/App/OobeController.cs b/MaiChartManager/Controllers/App/OobeController.cs index e8e4f335..2bf5d03f 100644 --- a/MaiChartManager/Controllers/App/OobeController.cs +++ b/MaiChartManager/Controllers/App/OobeController.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Sockets; using System.Text.Json; -using System.Windows.Forms; +using MaiChartManager.Platform; using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.App; @@ -10,7 +10,11 @@ public record CompleteSetupRequest(bool Export, bool UseAuth, string? AuthUserna [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class OobeController(StaticSettings settings, ILogger logger) : ControllerBase +public class OobeController( + StaticSettings settings, + ILogger logger, + IAppShell appShell, + IDesktopDialogService dialogService) : ControllerBase { [HttpGet] public string? GetGamePath() @@ -45,11 +49,7 @@ public IActionResult SetGamePath([FromBody] string path, bool save = false) StaticSettings.Config.Save(); } - AppMain.UiContext?.Post(_ => - { - if (AppMain.BrowserWin is { IsDisposed: false }) - AppMain.BrowserWin.Text = $"MaiChartManager ({StaticSettings.GamePath})"; - }, null); + appShell.UpdateMainWindowTitle(StaticSettings.GamePath); return Ok(); } @@ -77,19 +77,7 @@ public async Task InitializeGameData() [HttpGet] public string? OpenFolderDialog() { - string? result = null; - AppMain.UiContext?.Send(_ => - { - var dialog = new FolderBrowserDialog - { - ShowNewFolderButton = false, - }; - if (dialog.ShowDialog(AppMain.ActiveForm) == DialogResult.OK) - { - result = dialog.SelectedPath; - } - }, null); - return result; + return dialogService.PickFolder(); } [HttpGet] @@ -113,17 +101,9 @@ public async Task CompleteSetup([FromBody] CompleteSetupRequest r if (exportChanged) { - AppLifecycleManager.DisposeTrayIcon(); + appShell.DisposeTrayIcon(); // 管理开机启动 - try - { - var startupTask = await Windows.ApplicationModel.StartupTask.GetAsync("MaiChartManagerStartupId"); - if (request.Export && request.StartupEnabled) - await startupTask.RequestEnableAsync(); - else - startupTask.Disable(); - } - catch { } + await appShell.SetStartupEnabledAsync(request.Export && request.StartupEnabled); _ = Task.Run(async () => { await Task.Delay(100); @@ -136,26 +116,18 @@ public async Task CompleteSetup([FromBody] CompleteSetupRequest r if (StaticSettings.Config.Export) { // 局域网模式:服务器重启后端口变了,需要把新 URL 注入回 OOBE 浏览器 - AppMain.UiContext?.Post(_ => AppMain.OobeBrowser?.InjectBackendUrl(url), null); + appShell.InjectOobeBackendUrl(url); return; } - AppLifecycleManager.ShowBrowser(url); - AppMain.UiContext?.Post(_ => - { - AppMain.OobeBrowser?.Dispose(); - AppMain.OobeBrowser = null; - }, null); + appShell.ShowBrowser(url); + appShell.CloseOobeBrowser(); }); }); } else if (!request.Export) { - AppLifecycleManager.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); - AppMain.UiContext?.Post(_ => - { - AppMain.OobeBrowser?.Dispose(); - AppMain.OobeBrowser = null; - }, null); + appShell.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); + appShell.CloseOobeBrowser(); } return Ok(); @@ -164,13 +136,13 @@ public async Task CompleteSetup([FromBody] CompleteSetupRequest r [HttpPost] public void OpenMainUI() { - AppLifecycleManager.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); + appShell.ShowBrowser(ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null")); } [HttpPost] public void SwitchToSetMode() { var url = ServerManager.GetLoopbackUrl() ?? throw new InvalidOperationException("Loopback URL is null"); - AppLifecycleManager.GoToModeSwitch(url); + appShell.GoToModeSwitch(url); } } diff --git a/MaiChartManager/Controllers/App/SettingsController.cs b/MaiChartManager/Controllers/App/SettingsController.cs index 6a99f512..928953e4 100644 --- a/MaiChartManager/Controllers/App/SettingsController.cs +++ b/MaiChartManager/Controllers/App/SettingsController.cs @@ -18,7 +18,7 @@ public class SettingsDto [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class SettingsController : ControllerBase +public class SettingsController(MaiChartManager.Platform.IAppShell appShell) : ControllerBase { [HttpGet] public SettingsDto GetSettings() @@ -33,7 +33,7 @@ public SettingsDto GetSettings() UseLegacyMaiLib = StaticSettings.Config.UseLegacyMaiLib, ConvertJacketToAssetBundle = StaticSettings.Config.ConvertJacketToAssetBundle, UiZoom = StaticSettings.Config.UiZoom, - TargetDpiScale = Browser.TargetDpiScale, + TargetDpiScale = appShell.GetTargetDpiScale(), }; } diff --git a/MaiChartManager/Controllers/App/ShellController.cs b/MaiChartManager/Controllers/App/ShellController.cs new file mode 100644 index 00000000..6645b5ff --- /dev/null +++ b/MaiChartManager/Controllers/App/ShellController.cs @@ -0,0 +1,38 @@ +using MaiChartManager.Platform; +using Microsoft.AspNetCore.Mvc; + +namespace MaiChartManager.Controllers.App; + +/// +/// 外壳相关接口(用系统能力打开 URL 等)。 +/// 主要给 Linux/Photino 用:WebKitGTK 不支持 window.open 弹新窗口, +/// 预览谱面等"开新窗口"的场景改为用系统浏览器(xdg-open)打开 loopback 上的对应页面。 +/// +[ApiController] +[Route("MaiChartManagerServlet/[action]Api")] +public class ShellController(IShellService shellService, ILogger logger) : ControllerBase +{ + public record OpenExternalUrlRequest(string Url); + + [HttpPost] + public IActionResult OpenExternalUrl([FromBody] OpenExternalUrlRequest request) + { + // 只允许 http/https,避免被诱导打开任意协议 + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return BadRequest("Only http/https URLs are allowed"); + } + + try + { + shellService.OpenUrl(request.Url); + return Ok(); + } + catch (Exception e) + { + logger.LogError(e, "打开外部 URL 失败:{Url}", request.Url); + return StatusCode(500, e.Message); + } + } +} diff --git a/MaiChartManager/Controllers/AssetDir/AssetDirController.cs b/MaiChartManager/Controllers/AssetDir/AssetDirController.cs index 2e23805c..41f69906 100644 --- a/MaiChartManager/Controllers/AssetDir/AssetDirController.cs +++ b/MaiChartManager/Controllers/AssetDir/AssetDirController.cs @@ -1,15 +1,15 @@ using MaiChartManager.Attributes; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Net.Http.Headers; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.AssetDir; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class AssetDirController(StaticSettings settings, ILogger logger) : ControllerBase +public class AssetDirController(StaticSettings settings, ILogger logger, IDesktopDialogService dialogService) : ControllerBase { [HttpPost] public void CreateAssetDir([FromBody] string dir) @@ -20,7 +20,7 @@ public void CreateAssetDir([FromBody] string dir) [HttpDelete] public void DeleteAssetDir([FromBody] string dir) { - FileSystem.DeleteDirectory(Path.Combine(StaticSettings.StreamingAssets, dir), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteDirectory(Path.Combine(StaticSettings.StreamingAssets, dir), showDialog: true); } public record GetAssetsDirsResult(string DirName, IEnumerable SubFiles, string Version); @@ -91,7 +91,7 @@ public string GetAssetDirTxtValue([FromBody] GetAssetDirTxtValueRequest req) [HttpDelete] public void DeleteAssetDirTxt([FromBody] GetAssetDirTxtValueRequest req) { - FileSystem.DeleteFile(Path.Combine(StaticSettings.StreamingAssets, req.DirName, req.FileName), UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(Path.Combine(StaticSettings.StreamingAssets, req.DirName, req.FileName)); } public record PutAssetDirTxtValueRequest(string DirName, string FileName, string Content); @@ -105,15 +105,9 @@ public void PutAssetDirTxtValue([FromBody] PutAssetDirTxtValueRequest req) [HttpPost] public async Task RequestLocalImportDir() { - var dialog = new FolderBrowserDialog - { - Description = Locale.SelectAssetDirectory, - ShowNewFolderButton = false, - }; - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) return; - var src = dialog.SelectedPath; - logger.LogInformation("LocalImportDir: {src}", src); + var src = dialogService.PickFolder(Locale.SelectAssetDirectory); if (src is null) return; + logger.LogInformation("LocalImportDir: {src}", src); var destName = Path.GetFileName(src); if (!StaticSettings.ADirRegex().IsMatch(destName)) { @@ -132,7 +126,7 @@ public async Task RequestLocalImportDir() var dest = Path.Combine(StaticSettings.StreamingAssets, destName); logger.LogInformation("Src: {src} Dest: {dest}", src, dest); - FileSystem.CopyDirectory(src, dest, UIOption.AllDialogs); + PlatformFile.CopyDirectory(src, dest); await settings.RescanAll(); } diff --git a/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs b/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs index c98d9015..1042df97 100644 --- a/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs +++ b/MaiChartManager/Controllers/AssetDir/CheckConflictController.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using MaiChartManager.Models; +using MaiChartManager.Platform; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.AssetDir; @@ -116,7 +116,7 @@ public void DeleteAssets([FromBody] DeleteAssetRequest[] requests) }; logger.LogInformation("Delete file {path}", Path.Combine(path, req.FileName)); - FileSystem.DeleteFile(Path.Combine(path, req.FileName), UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(Path.Combine(path, req.FileName)); } } } diff --git a/MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs b/MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs new file mode 100644 index 00000000..c547a223 --- /dev/null +++ b/MaiChartManager/Controllers/AssetDir/ImportBrowseController.cs @@ -0,0 +1,75 @@ +using MaiChartManager.Platform; +using Microsoft.AspNetCore.Mvc; + +namespace MaiChartManager.Controllers.AssetDir; + +// maidata 导入用的后端目录浏览接口。 +// 背景:WebKitGTK(Linux/Photino)既没有 showDirectoryPicker, 也只能选单文件, +// 所以前端没法在浏览器侧拿到目录内容。改为:后端弹原生选文件夹对话框,再通过下面 3 个接口 +// 把所选目录的内容提供给前端的 ImportDirectory 适配器(见 Front/src/utils/httpImportDirectory.ts)。 +// +// 安全说明:这些接口可以读取任意本地路径,仅限「本地桌面 + 仅 loopback」场景使用。 +// 切勿在 export / 远程模式下启用——否则等于把整个文件系统暴露给局域网。 +// 因此每个接口都先校验 !StaticSettings.Config.Export。 +[ApiController] +[Route("MaiChartManagerServlet/[action]Api")] +public class ImportBrowseController(IDesktopDialogService dialogService, ILogger logger) : ControllerBase +{ + // 子项列表的返回结构:name 显示名,path 子项绝对路径,isDirectory 是否为目录 + public record ImportDirEntry(string Name, string Path, bool IsDirectory); + + // 弹原生选文件夹对话框,返回选中的绝对路径;取消返回 null + [HttpGet] + public ActionResult PickImportFolder() + { + // 仅本地桌面场景,export / 远程模式下禁止 + if (StaticSettings.Config.Export) return Forbid(); + var path = dialogService.PickFolder(); + logger.LogInformation("PickImportFolder: {path}", path); + // 取消时 PickFolder 返回 null,这里原样返回(前端按取消处理) + return Ok(path); + } + + // 列出目录下的直接子项(不递归)。path 不存在或不是目录时返回空数组。 + [HttpGet] + public ActionResult> ListImportDir([FromQuery] string path) + { + // 仅本地桌面场景,export / 远程模式下禁止 + if (StaticSettings.Config.Export) return Forbid(); + if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) + { + return Ok(Array.Empty()); + } + + var result = new List(); + // 子目录 + foreach (var dir in Directory.EnumerateDirectories(path)) + { + result.Add(new ImportDirEntry(Path.GetFileName(dir), dir, true)); + } + // 子文件 + foreach (var file in Directory.EnumerateFiles(path)) + { + result.Add(new ImportDirEntry(Path.GetFileName(file), file, false)); + } + return Ok(result); + } + + // 读取文件内容。 + // 支持两种调用方式(解决跨平台路径分隔符问题): + // ReadImportFileApi?path=<文件完整路径> → 直接读 path + // ReadImportFileApi?path=<目录>&name=<文件名> → 后端 Path.Combine(path, name) 后再读 + // 这样前端 getFileHandle 不用自己拼路径。文件不存在返回 404。 + [HttpGet] + public IActionResult ReadImportFile([FromQuery] string path, [FromQuery] string? name = null) + { + // 仅本地桌面场景,export / 远程模式下禁止 + if (StaticSettings.Config.Export) return Forbid(); + var fullPath = string.IsNullOrEmpty(name) ? path : Path.Combine(path, name); + if (string.IsNullOrEmpty(fullPath) || !System.IO.File.Exists(fullPath)) + { + return NotFound(); + } + return PhysicalFile(Path.GetFullPath(fullPath), "application/octet-stream"); + } +} diff --git a/MaiChartManager/Controllers/Mod/InstallationController.cs b/MaiChartManager/Controllers/Mod/InstallationController.cs index 377b3c8b..af1f6e44 100644 --- a/MaiChartManager/Controllers/Mod/InstallationController.cs +++ b/MaiChartManager/Controllers/Mod/InstallationController.cs @@ -1,9 +1,9 @@ using System.Diagnostics; using System.IO.Compression; using System.Security.Cryptography; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.Mod; @@ -100,7 +100,7 @@ private static bool GetIsJudgeDisplay4BInstalled() #region ADX HID 冲突检测和删除 - private static readonly string[] HidModPaths = [@"Mods\Mai2InputMod.dll", @"Mods\hid_input_lib.dll", "hid_input_lib.dll", "mai2io.dll"]; + private static readonly string[] HidModPaths = [Path.Combine("Mods", "Mai2InputMod.dll"), Path.Combine("Mods", "hid_input_lib.dll"), "hid_input_lib.dll", "mai2io.dll"]; [NonAction] private static bool GetIsHidConflictExist() @@ -123,7 +123,7 @@ public void DeleteHidConflict() { if (System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, mod))) { - FileSystem.DeleteFile(Path.Combine(StaticSettings.GamePath, mod), UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(Path.Combine(StaticSettings.GamePath, mod)); } } } @@ -133,17 +133,17 @@ public void DeleteHidConflict() [NonAction] private static bool GetIsAdxHidIoModAbsent() { - return !System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Mods\ADXHIDIOMod.dll")); + return !System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Mods", "ADXHIDIOMod.dll")); } [NonAction] private static bool GetIsMmlLegacyLibsInstalled() { - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\hidapi.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "hidapi.dll"))) { return false; } - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\libadxhid.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "libadxhid.dll"))) { return false; } @@ -165,18 +165,18 @@ public record InstallMmlLibsDto(bool UseLegacy); public void InstallMmlLibs([FromBody] InstallMmlLibsDto req) { var isMaimollerLegacyModeEnabled = req.UseLegacy; - if (System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Mods\ADXHIDIOMod.dll"))) + if (System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Mods", "ADXHIDIOMod.dll"))) { - System.IO.File.Delete(Path.Combine(StaticSettings.GamePath, @"Mods\ADXHIDIOMod.dll")); + System.IO.File.Delete(Path.Combine(StaticSettings.GamePath, "Mods", "ADXHIDIOMod.dll")); } if (!isMaimollerLegacyModeEnabled || GetIsMmlLegacyLibsInstalled()) return; - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\hidapi.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "hidapi.dll"))) { - CopyFile(Path.Combine(StaticSettings.exeDir, @"hidapi.dll"), Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\hidapi.dll")); + CopyFile(Path.Combine(StaticSettings.exeDir, @"hidapi.dll"), Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "hidapi.dll")); } - if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\libadxhid.dll"))) + if (!System.IO.File.Exists(Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "libadxhid.dll"))) { - CopyFile(Path.Combine(StaticSettings.exeDir, @"libadxhid.dll"), Path.Combine(StaticSettings.GamePath, @"Sinmai_Data\Plugins\libadxhid.dll")); + CopyFile(Path.Combine(StaticSettings.exeDir, @"libadxhid.dll"), Path.Combine(StaticSettings.GamePath, "Sinmai_Data", "Plugins", "libadxhid.dll")); } } @@ -196,7 +196,7 @@ public void InstallMelonLoader() { if (IsMelonInstalled()) { - logger.LogInformation("MelonLoader is already installed."); + logger.LogInformation("MelonLoader 已安装,跳过。"); return; } @@ -249,9 +249,9 @@ public async Task InstallAquaMaiOnline(InstallAquaMaiOnlineDto req) "ci" => CI_KEY, "slow" => CI_KEY, "release" => RELEASE_KEY, - _ => throw new ArgumentException("Invalid type", nameof(req.Type)), + _ => throw new ArgumentException("无效的类型", nameof(req.Type)), }; - // Download from url + // 从 URL 下载 using var client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(15); Exception? lastException = null; @@ -264,17 +264,17 @@ public async Task InstallAquaMaiOnline(InstallAquaMaiOnlineDto req) if (!VerifyBinary(data, req.Sign, key)) { - throw new InvalidOperationException("Invalid signature"); + throw new InvalidOperationException("签名无效"); } } catch (Exception e) { - logger.LogError(e, "Failed to download AquaMai from {Url}", url); + logger.LogError(e, "从 {Url} 下载 AquaMai 失败", url); lastException = e; continue; } - // Save to Mods folder - var dest = Path.Combine(StaticSettings.GamePath, @"Mods\AquaMai.dll"); + // 保存到 Mods 目录 + var dest = Path.Combine(StaticSettings.GamePath, "Mods", "AquaMai.dll"); Directory.CreateDirectory(Path.GetDirectoryName(dest)); await System.IO.File.WriteAllBytesAsync(dest, data); if (System.IO.File.Exists(ModPaths.MuModDllInstalledPath)) @@ -283,7 +283,7 @@ public async Task InstallAquaMaiOnline(InstallAquaMaiOnlineDto req) } return; } - throw new InvalidOperationException("Failed to download AquaMai from all urls", lastException); + throw new InvalidOperationException("从所有 URL 下载 AquaMai 均失败", lastException); } [HttpPost] @@ -307,7 +307,7 @@ public async Task InstallMuMod() } catch (Exception e) { - logger.LogError(e, "Failed to download MuMod cache during install, but DLL was installed successfully"); + logger.LogError(e, "安装期间下载 MuMod 缓存失败,但 DLL 已成功安装"); } } @@ -316,7 +316,7 @@ public void DeleteAquaMai() { if (System.IO.File.Exists(ModPaths.AquaMaiDllInstalledPath)) { - FileSystem.DeleteFile(ModPaths.AquaMaiDllInstalledPath, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(ModPaths.AquaMaiDllInstalledPath); } } @@ -325,7 +325,7 @@ public void DeleteMuMod() { if (System.IO.File.Exists(ModPaths.MuModDllInstalledPath)) { - FileSystem.DeleteFile(ModPaths.MuModDllInstalledPath, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(ModPaths.MuModDllInstalledPath); } } diff --git a/MaiChartManager/Controllers/Mod/ModPaths.cs b/MaiChartManager/Controllers/Mod/ModPaths.cs index fa570d31..01c6a2c4 100644 --- a/MaiChartManager/Controllers/Mod/ModPaths.cs +++ b/MaiChartManager/Controllers/Mod/ModPaths.cs @@ -4,10 +4,10 @@ public static class ModPaths { public static string AquaMaiConfigPath => Path.Combine(StaticSettings.GamePath, "AquaMai.toml"); public static string AquaMaiConfigBackupDirPath => Path.Combine(StaticSettings.GamePath, "AquaMai.toml.bak"); - public static string AquaMaiDllInstalledPath => Path.Combine(StaticSettings.GamePath, @"Mods\AquaMai.dll"); + public static string AquaMaiDllInstalledPath => Path.Combine(StaticSettings.GamePath, "Mods", "AquaMai.dll"); - public static string MuModDllInstalledPath => Path.Combine(StaticSettings.GamePath, @"Mods\MuMod.dll"); + public static string MuModDllInstalledPath => Path.Combine(StaticSettings.GamePath, "Mods", "MuMod.dll"); public static string MuModDllBuiltinPath => Path.Combine(StaticSettings.exeDir, "MuMod.dll"); public static string MuModConfigPath => Path.Combine(StaticSettings.GamePath, "MuMod.toml"); - public static string MuModDefaultCachePath => Path.Combine(StaticSettings.GamePath, @"LocalAssets\MuMod.cache"); + public static string MuModDefaultCachePath => Path.Combine(StaticSettings.GamePath, "LocalAssets", "MuMod.cache"); } \ No newline at end of file diff --git a/MaiChartManager/Controllers/Mod/MuModService.cs b/MaiChartManager/Controllers/Mod/MuModService.cs index bdc97a73..5944f37e 100644 --- a/MaiChartManager/Controllers/Mod/MuModService.cs +++ b/MaiChartManager/Controllers/Mod/MuModService.cs @@ -9,7 +9,7 @@ public class MuModService(ILogger logger, IHttpClientFactory httpC { private const string CosVersionApiUrl = "https://munet-version-config-1251600285.cos.ap-shanghai.myqcloud.com/aquamai.json"; private const string CfVersionApiUrl = "https://aquamai-version-config.mumur.net/api/config"; - private const string DefaultCacheRelativePath = @"LocalAssets\MuMod.cache"; + private const string DefaultCacheRelativePath = "LocalAssets/MuMod.cache"; private enum VersionSource { @@ -51,7 +51,7 @@ public void WriteChannel(string channel) { if (channel != "slow" && channel != "fast") { - throw new ArgumentException("Channel must be slow or fast", nameof(channel)); + throw new ArgumentException("Channel 只能为 slow 或 fast", nameof(channel)); } var config = ReadConfig(); @@ -107,21 +107,21 @@ public async Task EnsureCache(CancellationToken ct = default) if (hasOldCache && string.Equals(NormalizeVersion(oldVersion), targetVersion, StringComparison.OrdinalIgnoreCase)) { - logger.LogInformation("MuMod cache is up-to-date: {Version}", oldVersion); + logger.LogInformation("MuMod 缓存已是最新版本:{Version}", oldVersion); return new EnsureCacheResult(true, oldVersion, null); } var downloadUrls = BuildDownloadUrls(versionInfo, source).ToArray(); if (downloadUrls.Length == 0) { - throw new InvalidOperationException("No valid download urls found in version API response"); + throw new InvalidOperationException("版本 API 响应中未找到有效的下载地址"); } var data = await DownloadFromUrlsAsync(downloadUrls, ct); var verifyResult = AquaMaiSignatureV2.VerifySignature(data); if (verifyResult.Status != AquaMaiSignatureV2.VerifyStatus.Valid) { - throw new InvalidOperationException($"MuMod cache signature verification failed: {verifyResult.Status}"); + throw new InvalidOperationException($"MuMod 缓存签名验证失败:{verifyResult.Status}"); } var cacheDir = Path.GetDirectoryName(cachePath); @@ -145,15 +145,15 @@ public async Task EnsureCache(CancellationToken ct = default) } var finalVersion = ReadProductVersion(cachePath) ?? targetVersion; - logger.LogInformation("MuMod cache updated successfully to version {Version}", finalVersion); + logger.LogInformation("MuMod 缓存已成功更新至版本 {Version}", finalVersion); return new EnsureCacheResult(true, finalVersion, null); } catch (Exception ex) { - logger.LogError(ex, "Failed to ensure MuMod cache"); + logger.LogError(ex, "更新 MuMod 缓存失败"); if (hasOldCache) { - logger.LogWarning("Using existing MuMod cache due to update failure"); + logger.LogWarning("因更新失败,继续使用现有 MuMod 缓存"); return new EnsureCacheResult(true, oldVersion, null); } @@ -183,7 +183,7 @@ private static string ResolveApiType(string channel) { "slow" => "slow", "fast" => "ci", - _ => throw new InvalidOperationException($"Unsupported MuMod channel: {channel}"), + _ => throw new InvalidOperationException($"不支持的 MuMod 频道:{channel}"), }; } @@ -217,26 +217,26 @@ private static string ResolveApiType(string channel) var firstMatch = firstVersions.FirstOrDefault(v => string.Equals(v.Type, apiType, StringComparison.OrdinalIgnoreCase)); if (firstMatch != null) { - logger.LogInformation("MuMod version metadata resolved from {Source}", firstSource); + logger.LogInformation("MuMod 版本元数据已从 {Source} 获取", firstSource); return (firstMatch, firstSource); } - throw new InvalidOperationException($"Version metadata from {firstSource} has no '{apiType}' item"); + throw new InvalidOperationException($"来自 {firstSource} 的版本元数据中没有 '{apiType}' 条目"); } catch (Exception ex) { firstError = ex; - logger.LogWarning(ex, "Failed to use version metadata from {Source}", firstSource); + logger.LogWarning(ex, "使用来自 {Source} 的版本元数据失败", firstSource); } var secondVersions = await secondTask; var secondMatch = secondVersions.FirstOrDefault(v => string.Equals(v.Type, apiType, StringComparison.OrdinalIgnoreCase)); if (secondMatch == null) { - throw new InvalidOperationException($"Version metadata from both sources has no '{apiType}' item", firstError); + throw new InvalidOperationException($"两个来源的版本元数据中均没有 '{apiType}' 条目", firstError); } - logger.LogInformation("MuMod version metadata resolved from fallback source {Source}", secondSource); + logger.LogInformation("MuMod 版本元数据已从备用来源 {Source} 获取", secondSource); return (secondMatch, secondSource); } @@ -298,6 +298,6 @@ private async Task DownloadFromUrlsAsync(IReadOnlyList urls, Can } } - throw new InvalidOperationException("Failed to download MuMod cache from all urls", lastError); + throw new InvalidOperationException("从所有 URL 下载 MuMod 缓存均失败", lastError); } } diff --git a/MaiChartManager/Controllers/Music/CueConvertController.cs b/MaiChartManager/Controllers/Music/CueConvertController.cs index b6ef6171..ac971aab 100644 --- a/MaiChartManager/Controllers/Music/CueConvertController.cs +++ b/MaiChartManager/Controllers/Music/CueConvertController.cs @@ -27,8 +27,8 @@ public async Task GetMusicWav(int id, string assetDir) public void SetAudio(int id, [FromForm] float padding, IFormFile file, IFormFile? awb, IFormFile? preview, string assetDir, [FromForm] bool ignoreGapless = false) { id %= 10000; - var targetAcbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"SoundData\music{id:000000}.acb"); - var targetAwbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"SoundData\music{id:000000}.awb"); + var targetAcbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "SoundData", $"music{id:000000}.acb"); + var targetAwbPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "SoundData", $"music{id:000000}.awb"); Directory.CreateDirectory(Path.GetDirectoryName(targetAcbPath)); if (Path.GetExtension(file.FileName).Equals(".acb", StringComparison.InvariantCultureIgnoreCase)) @@ -53,7 +53,9 @@ public record SetAudioPreviewRequest(double StartTime, double EndTime); [HttpPost] public async Task SetAudioPreview(int id, [FromBody] SetAudioPreviewRequest request, string assetDir) { +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) return; +#endif id %= 10000; var cachePath = await AudioConvert.GetCachedWavPath(id); var targetAcbPath = StaticSettings.AcbAwb[$"music{id:000000}.acb"]; diff --git a/MaiChartManager/Controllers/Music/MovieConvertController.cs b/MaiChartManager/Controllers/Music/MovieConvertController.cs index d7db76e4..81a98318 100644 --- a/MaiChartManager/Controllers/Music/MovieConvertController.cs +++ b/MaiChartManager/Controllers/Music/MovieConvertController.cs @@ -30,7 +30,7 @@ public async Task SetMovie(int id, [FromForm] double padding, IFormFile file, st if (Path.GetExtension(file.FileName).Equals(".dat", StringComparison.InvariantCultureIgnoreCase)) { - var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"MovieData\{id:000000}.dat"); + var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "MovieData", $"{id:000000}.dat"); Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); await using var stream = System.IO.File.Open(targetPath, FileMode.Create); await file.CopyToAsync(stream); @@ -38,7 +38,9 @@ public async Task SetMovie(int id, [FromForm] double padding, IFormFile file, st return; } +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) return; +#endif Response.Headers.Append("Content-Type", "text/event-stream"); var tmpDir = Directory.CreateTempSubdirectory(); @@ -54,7 +56,7 @@ public async Task SetMovie(int id, [FromForm] double padding, IFormFile file, st } // 目标路径 - var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, $@"MovieData\{id:000000}.{(StaticSettings.Config.MovieCodec == MovieCodec.ForceH264 ? "mp4" : "dat")}"); + var targetPath = Path.Combine(StaticSettings.StreamingAssets, assetDir, "MovieData", $"{id:000000}.{(StaticSettings.Config.MovieCodec == MovieCodec.ForceH264 ? "mp4" : "dat")}"); // 使用工具类转换视频 await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions diff --git a/MaiChartManager/Controllers/Music/MusicController.cs b/MaiChartManager/Controllers/Music/MusicController.cs index 40e5ed7a..fb236149 100644 --- a/MaiChartManager/Controllers/Music/MusicController.cs +++ b/MaiChartManager/Controllers/Music/MusicController.cs @@ -1,9 +1,7 @@ using System.Diagnostics; -using AssetStudio; using MaiChartManager.Models; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; using MusicXml = MaiChartManager.Models.MusicXml; namespace MaiChartManager.Controllers.Music; @@ -167,7 +165,7 @@ public string SetMusicJacket(int id, IFormFile file, string assetDir) if (music is null) return "Music not found!"; music.DeleteJacket(); // 删除老的jacket - var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, @"AssetBundleImages\jacket"); + var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "AssetBundleImages", "jacket"); Directory.CreateDirectory(abiDir); if (StaticSettings.Config.ConvertJacketToAssetBundle) diff --git a/MaiChartManager/Controllers/Music/MusicTransferController.cs b/MaiChartManager/Controllers/Music/MusicTransferController.cs index e8018fc9..6372b903 100644 --- a/MaiChartManager/Controllers/Music/MusicTransferController.cs +++ b/MaiChartManager/Controllers/Music/MusicTransferController.cs @@ -4,22 +4,28 @@ using System.Security.Cryptography; using System.Text.RegularExpressions; using MaiChartManager.Models; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; using MuConvert.mai; using NAudio.Lame; -using Vanara.Windows.Forms; -using FolderBrowserDialog = System.Windows.Forms.FolderBrowserDialog; namespace MaiChartManager.Controllers.Music; [ApiController] [Route("MaiChartManagerServlet/[action]Api/{assetDir}/{id:int}")] -public partial class MusicTransferController(StaticSettings settings, ILogger logger) : ControllerBase +public partial class MusicTransferController( + StaticSettings settings, + ILogger logger, + IDesktopDialogService dialogService, + ITaskbarProgress taskbarProgress, + IProgressController progressController) : ControllerBase { public record RequestCopyToRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool removeEvents, bool legacyFormat); + // 原生选目录导出 maidata 的请求体:music 为要导出的歌曲列表,ignoreVideo 控制是否跳过 PV 视频。 + public record RequestExportMaidataRequest(MusicBatchController.MusicIdAndAssetDirPair[] music, bool ignoreVideo = false); + private static int[] GetAudioCandidateIds(MusicXmlWithABJacket music) { return [music.CueId, music.Id, music.NonDxId]; @@ -40,10 +46,10 @@ private static int GetBatchExportMaxConcurrency() } /// - /// Given an AssetBundle jacket path (e.g. ".../AssetBundleImages/jacket/ui_jacket_000123.ab"), - /// compute the companion small jacket path in the sibling "jacket_s" directory - /// (e.g. ".../AssetBundleImages/jacket_s/ui_jacket_000123_s.ab"). - /// Returns null if the path shape is unexpected. + /// 根据 AssetBundle 封面路径(如 ".../AssetBundleImages/jacket/ui_jacket_000123.ab"), + /// 计算同级 "jacket_s" 目录下的小封面路径 + /// (如 ".../AssetBundleImages/jacket_s/ui_jacket_000123_s.ab")。 + /// 若路径格式不符则返回 null。 /// private static string? GetAssetBundleJacketSmallPath(string assetBundleJacketPath) { @@ -173,7 +179,7 @@ private void CopyMusicToDirectory( return; } - // copy music + // 复制音乐数据 var musicDestDir = Path.Combine(musicRootDir, $"music{music.Id:000000}"); CopyDirectoryIfChanged(musicDir, musicDestDir); @@ -218,7 +224,7 @@ private void CopyMusicToDirectory( } } - // copy jacket + // 复制封面 if (music.JacketPath is not null) { var jacketDest = Path.Combine(jacketRootDir, $"ui_jacket_{music.NonDxId:000000}{Path.GetExtension(music.JacketPath)}"); @@ -233,7 +239,7 @@ private void CopyMusicToDirectory( CopySharedFileIfNeeded(music.AssetBundleJacket + ".manifest", Path.Combine(jacketRootDir, jacketFileName + ".manifest"), copiedSharedDestinations); } - // Issue #42: jacket_s lives in a sibling directory, must be exported into AssetBundleImages/jacket_s/ + // Issue #42: jacket_s 位于同级目录,导出时必须写入 AssetBundleImages/jacket_s/ var jacketSPath = GetAssetBundleJacketSmallPath(music.AssetBundleJacket); if (jacketSPath is not null && System.IO.File.Exists(jacketSPath)) { @@ -252,7 +258,7 @@ private void CopyMusicToDirectory( CopySharedFileIfNeeded(music.PseudoAssetBundleJacket, Path.Combine(jacketRootDir, jacketFileName), copiedSharedDestinations); } - // copy acbawb + // 复制 ACB/AWB 音频 if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) && acb is not null && awb is not null) @@ -265,7 +271,7 @@ private void CopyMusicToDirectory( logger.LogWarning("{message}", BuildAudioResolveErrorMessage(music)); } - // copy movie data + // 复制视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) { CopySharedFileIfNeeded(movie, Path.Combine(movieRootDir, $"{music.NonDxId:000000}{Path.GetExtension(movie)}"), copiedSharedDestinations); @@ -276,37 +282,23 @@ private void CopyMusicToDirectory( [Route("/MaiChartManagerServlet/[action]Api")] public void RequestCopyTo(RequestCopyToRequest request) { - var dialog = new FolderBrowserDialog - { - Description = Locale.SelectTargetLocation - }; - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) return; - var dest = dialog.SelectedPath; + var dest = dialogService.PickFolder(Locale.SelectTargetLocation); + if (dest is null) return; logger.LogInformation("CopyTo: {dest}", dest); - ShellProgressDialog? progress = null; - if (request.music.Length > 1) - { - progress = new ShellProgressDialog() - { - AutoTimeEstimation = false, - Title = Locale.Exporting, - Description = string.Format(Locale.ExportingMultipleMusic, request.music.Length), - CancelMessage = Locale.Cancelling, - HideTimeRemaining = true, - }; - progress.Start(AppMain.BrowserWin!); - progress.UpdateProgress(0, (ulong)request.music.Length); - } + var showProgress = request.music.Length > 1; + using var progress = showProgress + ? progressController.Begin(Locale.Exporting, string.Format(Locale.ExportingMultipleMusic, request.music.Length), Locale.Cancelling) + : null; + progress?.Report(0, (ulong)request.music.Length); if (request.music.Length == 0) { - progress?.Stop(); return; } var musicRootDir = Path.Combine(dest, "music"); - var jacketRootDir = Path.Combine(dest, @"AssetBundleImages\jacket"); + var jacketRootDir = Path.Combine(dest, "AssetBundleImages", "jacket"); var soundRootDir = Path.Combine(dest, "SoundData"); var movieRootDir = Path.Combine(dest, "MovieData"); Directory.CreateDirectory(musicRootDir); @@ -319,19 +311,19 @@ public void RequestCopyTo(RequestCopyToRequest request) musicIndex.TryAdd((music.Id, music.AssetDir), music); } - var cancellation = new CancellationTokenSource(); var progressLock = new object(); var completed = 0; var maxConcurrency = GetBatchExportMaxConcurrency(); var progressStep = Math.Max(1, request.music.Length / 100); var copiedSharedDestinations = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var cancellation = new CancellationTokenSource(); try { Parallel.ForEach(request.music, new ParallelOptions { MaxDegreeOfParallelism = maxConcurrency, - CancellationToken = cancellation.Token + CancellationToken = cancellation.Token, }, (musicId, state) => { if (progress is not null) @@ -363,23 +355,14 @@ public void RequestCopyTo(RequestCopyToRequest request) { lock (progressLock) { - if (currentMusicName is not null) - { - progress.Detail = currentMusicName; - } - - progress.UpdateProgress((ulong)done, (ulong)request.music.Length); + progress.Report((ulong)done, (ulong)request.music.Length, currentMusicName); } } }); } catch (OperationCanceledException) { - logger.LogInformation("Batch export cancelled by user."); - } - finally - { - progress?.Stop(); + logger.LogInformation("批量导出被用户取消。"); } } @@ -399,12 +382,12 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l var zipStream = HttpContext.Response.BodyWriter.AsStream(); using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); - // copy music + // 复制音乐数据 foreach (var file in Directory.EnumerateFiles(musicDir)) { if (Path.GetFileName(file).Equals("Music.xml", StringComparison.InvariantCultureIgnoreCase) && removeEvents) { - logger.LogInformation("Remove events and rights from Music.xml"); + logger.LogInformation("从 Music.xml 中移除 Events 和版权信息"); var xmlDoc = music.GetXmlWithoutEventsAndRights(); var entry = zipArchive.CreateEntry($"music/music{music.Id:000000}/Music.xml"); using var stream = entry.Open(); @@ -442,7 +425,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l } } - // copy jacket + // 复制封面 if (music.JacketPath is not null) { zipArchive.CreateEntryFromFile(music.JacketPath, $"AssetBundleImages/jacket/ui_jacket_{music.NonDxId:000000}{Path.GetExtension(music.JacketPath)}"); @@ -455,7 +438,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l zipArchive.CreateEntryFromFile(music.AssetBundleJacket + ".manifest", $"AssetBundleImages/jacket/{Path.GetFileName(music.AssetBundleJacket)}.manifest"); } - // Issue #42: jacket_s lives in a sibling directory, must be exported into AssetBundleImages/jacket_s/ + // Issue #42: jacket_s 位于同级目录,导出时必须写入 AssetBundleImages/jacket_s/ var jacketSPath = GetAssetBundleJacketSmallPath(music.AssetBundleJacket); if (jacketSPath is not null && System.IO.File.Exists(jacketSPath)) { @@ -471,7 +454,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l zipArchive.CreateEntryFromFile(music.PseudoAssetBundleJacket, $"AssetBundleImages/jacket/{Path.GetFileName(music.PseudoAssetBundleJacket)}"); } - // copy acbawb + // 复制 ACB/AWB 音频 if (!AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out var resolvedAudioId, out var acb, out var awb) || acb is null || awb is null) { var message = BuildAudioResolveErrorMessage(music); @@ -481,7 +464,7 @@ public void ExportOpt(int id, string assetDir, bool removeEvents = false, bool l zipArchive.CreateEntryFromFile(acb, $"SoundData/music{resolvedAudioId:000000}.acb"); zipArchive.CreateEntryFromFile(awb, $"SoundData/music{resolvedAudioId:000000}.awb"); - // copy movie data + // 复制视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) { zipArchive.CreateEntryFromFile(movie, $"MovieData/{music.NonDxId:000000}{Path.GetExtension(movie)}"); @@ -495,22 +478,22 @@ private void DeleteIfExists(params string[] path) if (Directory.Exists(p)) { logger.LogInformation("Delete directory: {p}", p); - FileSystem.DeleteDirectory(p, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteDirectory(p); } if (System.IO.File.Exists(p)) { logger.LogInformation("Delete file: {p}", p); - FileSystem.DeleteFile(p, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(p); } } } private void DeleteAb(string abPath) { - FileSystem.DeleteFile(abPath, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(abPath); if (System.IO.File.Exists(abPath + ".manifest")) - FileSystem.DeleteFile(abPath + ".manifest", UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + PlatformFile.DeleteFile(abPath + ".manifest"); } private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string assetDir) @@ -519,8 +502,8 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string // 当新ID和旧ID的 NonDx部分相同时(例如 5003 -> 15003),封面、音频、视频文件的目标路径与源路径完全一致,因此既不需要也不应该删除/移动它们。 if (newNonDxId == music.NonDxId) return; - var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, @"AssetBundleImages\jacket"); - var abiSDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, @"AssetBundleImages\jacket_s"); + var abiDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "AssetBundleImages", "jacket"); + var abiSDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "AssetBundleImages", "jacket_s"); Directory.CreateDirectory(abiDir); Directory.CreateDirectory(abiSDir); var abJacketTarget = Path.Combine(abiDir, $"ui_jacket_{newNonDxId:000000}.ab"); @@ -537,7 +520,7 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string var localJacketTarget = Path.Combine(abiDir, $"ui_jacket_{newNonDxId:000000}{Path.GetExtension(jacketSourcePath)}"); DeleteIfExists(localJacketTarget); logger.LogInformation("Move jacket: {jacketSourcePng} -> {localJacketTarget}", jacketSourcePath, localJacketTarget); - FileSystem.MoveFile(jacketSourcePath, localJacketTarget, UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(jacketSourcePath, localJacketTarget); } else if (music.AssetBundleJacket is not null) { @@ -591,20 +574,20 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.acb", out var acb)) { logger.LogInformation("Move acb: {acb} -> {acbawbTarget}.acb", acb, acbawbTarget); - FileSystem.MoveFile(acb, acbawbTarget + ".acb", UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(acb, acbawbTarget + ".acb"); } if (StaticSettings.AcbAwb.TryGetValue($"music{music.NonDxId:000000}.awb", out var awb)) { logger.LogInformation("Move awb: {awb} -> {acbawbTarget}.awb", awb, acbawbTarget); - FileSystem.MoveFile(awb, acbawbTarget + ".awb", UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(awb, acbawbTarget + ".awb"); } - // movie data + // 视频数据 if (StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movie)) { logger.LogInformation("Move movie: {movie} -> {movieTarget}", movie, movieTarget); - FileSystem.MoveFile(movie, movieTarget + Path.GetExtension(movie), UIOption.OnlyErrorDialogs); + PlatformFile.MoveFile(movie, movieTarget + Path.GetExtension(movie)); } #endregion } @@ -612,7 +595,9 @@ private void MoveJacketSoundVideo(MusicXmlWithABJacket music, int newId, string [HttpPost] public async Task ModifyId(int id, [FromBody] int newId, string assetDir) { +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) return; +#endif var music = settings.GetMusic(id, assetDir); if (music is null) return; var musicDir = Path.GetDirectoryName(music.FilePath); @@ -638,18 +623,18 @@ public async Task ModifyId(int id, [FromBody] int newId, string assetDir) if (!System.IO.File.Exists(Path.Combine(oldMusicDir, chart.Path))) continue; var newFileName = $"{newId:000000}_0{i}.ma2"; logger.LogInformation("Move chart: {chart.Path} -> {newFileName}", chart.Path, newFileName); - FileSystem.MoveFile(Path.Combine(oldMusicDir, chart.Path), Path.Combine(oldMusicDir, newFileName)); + PlatformFile.MoveFile(Path.Combine(oldMusicDir, chart.Path), Path.Combine(oldMusicDir, newFileName)); chart.Path = newFileName; } - // xml + // 保存 XML music.Id = newId; music.Save(); Directory.CreateDirectory(Path.Combine(StaticSettings.StreamingAssets, assetDir, "music")); logger.LogInformation("Move music dir: {oldMusicDir} -> {newMusicDir}", oldMusicDir, newMusicDir); - FileSystem.MoveDirectory(oldMusicDir, newMusicDir, UIOption.OnlyErrorDialogs); + PlatformFile.MoveDirectory(oldMusicDir, newMusicDir); - // rescan all + // 重新扫描全部 await settings.RescanAll(); } @@ -679,7 +664,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa var version = StaticSettings.VersionList.FirstOrDefault(it => it.Id == music.AddVersionId); if (version is not null) simaiFile["version"] = version.GenreName; - // demo_seek + // demo_seek(预览起止时间) try { if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var previewAcb, out _) && previewAcb is not null) @@ -693,7 +678,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } catch (Exception e) { - logger.LogWarning(e, "ExportAsMaidata: Failed to get audio preview time, ignoring."); + logger.LogWarning(e, "ExportAsMaidata: 获取音频预览时间失败,已忽略。"); } for (var i = 0; i < music.Charts.Length; i++) @@ -740,7 +725,8 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } } - simaiFile["chartconverter"] = $"MaiChartManager v{Application.ProductVersion}"; + var appVersion = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; + simaiFile["chartconverter"] = $"MaiChartManager v{appVersion}"; await using var zipStream = HttpContext.Response.BodyWriter.AsStream(); using var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true); @@ -750,7 +736,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa await maidataStream.WriteAsync(Encoding.UTF8.GetBytes(simaiFile.ToString())); maidataStream.Close(); - // copy jacket + // 复制封面 var img = music.GetMusicJacketPngData(); if (img is not null) { @@ -785,6 +771,7 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa AudioConvert.ConvertWavToMp3Stream(wav, soundStream, tag); soundStream.Close(); + if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) { DirectoryInfo? tmpDir = null; @@ -813,7 +800,184 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } catch (Exception ex) { - logger.LogWarning(ex, "Failed to export pv.mp4 for music {musicId} ({name}), skipping video.", music.Id, music.Name); + logger.LogWarning(ex, "导出音乐 {musicId}({name})的 pv.mp4 失败,跳过视频。", music.Id, music.Name); + } + finally + { + if (tmpDir is not null) + { + try + { + tmpDir.Delete(true); + } + catch + { + // 忽略清理错误 + } + } + } + } + } + + // 把单首歌导出为 maidata 文件(maidata.txt + 封面 + 音频)写入 targetDir。 + // 该方法供原生选目录导出复用;与 zip 版 ExportAsMaidata 产物保持一致(Linux 下不导出音频)。 + private async Task WriteMaidataToDirectory(int id, string assetDir, string targetDir, bool ignoreVideo) + { + var music = settings.GetMusic(id, assetDir); + if (music is null) return; + var musicDir = Path.GetDirectoryName(music.FilePath); + if (string.IsNullOrWhiteSpace(musicDir) || !Directory.Exists(musicDir)) + { + var message = $"Invalid source directory for music {music.Id}: {music.FilePath}"; + logger.LogError("{message}", message); + throw new DirectoryNotFoundException(message); + } + + Directory.CreateDirectory(targetDir); + + var simaiFile = new Maidata(); + simaiFile.Title = music.Name; + simaiFile.Artist = music.Artist; + simaiFile.WholeBpm = music.Bpm; + simaiFile.First = 0; + simaiFile["shortid"] = music.Id.ToString(); + simaiFile["genreid"] = music.GenreId.ToString(); + var genre = StaticSettings.GenreList.FirstOrDefault(it => it.Id == music.GenreId); + if (genre is not null) simaiFile["genre"] = genre.GenreName; + simaiFile["versionid"] = music.AddVersionId.ToString(); + var version = StaticSettings.VersionList.FirstOrDefault(it => it.Id == music.AddVersionId); + if (version is not null) simaiFile["version"] = version.GenreName; + + // demo_seek(预览起止时间),依赖 CriUtils + try + { + if (AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var previewAcb, out _) && previewAcb is not null) + { + var previewTime = CriUtils.GetAudioPreviewTime(previewAcb); + if (previewTime.StartTime >= 0 && previewTime.EndTime > previewTime.StartTime) + { + simaiFile.Demo = ((float)previewTime.StartTime, (float)(previewTime.EndTime - previewTime.StartTime)); + } + } + } + catch (Exception e) + { + logger.LogWarning(e, "WriteMaidataToDirectory: 获取音频预览时间失败,已忽略。"); + } + + for (var i = 0; i < music.Charts.Length; i++) + { + var chart = music.Charts[i]; + if (chart is null || !chart.Enable || string.IsNullOrWhiteSpace(chart.Path)) continue; + + var chartPath = Path.Combine(musicDir, chart.Path); + if (!System.IO.File.Exists(chartPath)) + { + var fallbackPath = Path.Combine(musicDir, chart.Path.Replace(".ma2", "_L.ma2", StringComparison.OrdinalIgnoreCase)); + if (!System.IO.File.Exists(fallbackPath)) continue; + chartPath = fallbackPath; + } + + try + { + if (StaticSettings.Config.UseLegacyMaiLib) + { + simaiFile["ChartConvertTool"] = $"MaiLib"; + var parser = new MaiLib.Ma2Parser(); + var ma2Content = await System.IO.File.ReadAllLinesAsync(chartPath); + var ma2 = parser.ChartOfToken(ma2Content); + var simai = ma2.Compose(MaiLib.ChartEnum.ChartVersion.SimaiFes); + + var lvStr = $"{chart.Level}.{chart.LevelDecimal}"; + simaiFile.AddLevel(i + 2, new MaidataLevel(simai, lvStr, chart.Designer), false); + } + else + { + var ma2Content = await System.IO.File.ReadAllTextAsync(chartPath); + var (cvtChart, _) = new MA2Parser().Parse(ma2Content); + var (simai, _) = new SimaiGenerator().Generate(cvtChart); + + var lvStr = $"{chart.Level}.{chart.LevelDecimal}"; + simaiFile.AddLevel(i + 2, new MaidataLevel(simai, lvStr, chart.Designer)); + simaiFile.ClockCount = cvtChart.ClockCount; // 通过多次写入,自然实现取最后一个有效难度的clockCount,作为写入maidata中的 + } + } + catch (Exception e) + { + logger.LogError("WriteMaidataToDirectory FAILED! {title}, {filename}: {e}", music.Name, chartPath, e); + throw; + } + } + + var appVersion = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? ""; + simaiFile["chartconverter"] = $"MaiChartManager v{appVersion}"; + + // 写 maidata.txt + await System.IO.File.WriteAllTextAsync(Path.Combine(targetDir, "maidata.txt"), simaiFile.ToString(), Encoding.UTF8); + + // 写封面 bg{ext} + var img = music.GetMusicJacketPngData(); + if (img is not null) + { + var imgExt = (Path.GetExtension(music.RealJacketPath) ?? ".png").ToLowerInvariant(); + if (imgExt == ".ab") imgExt = ".png"; + await System.IO.File.WriteAllBytesAsync(Path.Combine(targetDir, $"bg{imgExt}"), img); + } + + // 导出音频 track.mp3,依赖 AudioConvert/CriUtils + var tag = new ID3TagData + { + Title = music.Name, + Artist = music.Artist, + Album = genre?.GenreName, + Track = music.Id.ToString(), + Comment = version?.GenreName, + AlbumArt = img, + }; + + if (!AudioConvert.TryResolveAcbAwb(GetAudioCandidateIds(music), out _, out var acbPath, out var awbPath) || acbPath is null || awbPath is null) + { + var message = BuildAudioResolveErrorMessage(music); + logger.LogError("{message}", message); + throw new FileNotFoundException(message); + } + var wav = Audio.AcbToWav(acbPath); + await using (var soundStream = System.IO.File.Create(Path.Combine(targetDir, "track.mp3"))) + { + AudioConvert.ConvertWavToMp3Stream(wav, soundStream, tag); + } + + + // 导出 PV 视频 pv.mp4(与 zip 版保持一致,未加 #if WINDOWS 限制) + if (!ignoreVideo && StaticSettings.MovieDataMap.TryGetValue(music.NonDxId, out var movieUsmPath)) + { + DirectoryInfo? tmpDir = null; + try + { + string? pvMp4Path = null; + var ext = Path.GetExtension(movieUsmPath).ToLowerInvariant(); + + if (ext == ".dat" || ext == ".usm") + { + tmpDir = Directory.CreateTempSubdirectory(); + logger.LogInformation("Temp dir: {tmpDir}", tmpDir.FullName); + pvMp4Path = Path.Combine(tmpDir.FullName, "pv.mp4"); + + await VideoConvert.ConvertUsmToMp4(movieUsmPath, pvMp4Path); + } + else if (ext == ".mp4") + { + pvMp4Path = movieUsmPath; + } + + if (pvMp4Path is not null && System.IO.File.Exists(pvMp4Path)) + { + System.IO.File.Copy(pvMp4Path, Path.Combine(targetDir, "pv.mp4"), true); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "导出音乐 {musicId}({name})的 pv.mp4 失败,跳过视频。", music.Id, music.Name); } finally { @@ -825,10 +989,95 @@ public async Task ExportAsMaidata(int id, string assetDir, bool ignoreVideo = fa } catch { - // ignore cleanup errors + // 忽略清理错误 + } + } + } + } + } + + // 把文件名中的非法字符替换为下划线,空结果回退到 fallback。 + private static string SanitizeFileNameSegment(string name, string fallback) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(name.Length); + foreach (var ch in name) + { + builder.Append(Array.IndexOf(invalidChars, ch) >= 0 ? '_' : ch); + } + + var result = builder.ToString().Trim(); + return string.IsNullOrEmpty(result) ? fallback : result; + } + + // 原生选目录导出 maidata:弹出系统目录选择对话框,对每首歌在子目录中写入 maidata。 + // 用于没有 File System Access API 的环境(Linux/Photino/WebKitGTK)。 + [HttpPost] + [Route("/MaiChartManagerServlet/[action]Api")] + public async Task RequestExportMaidata([FromBody] RequestExportMaidataRequest request) + { + var dest = dialogService.PickFolder(Locale.SelectTargetLocation); + if (dest is null) return; + logger.LogInformation("ExportMaidata: {dest}", dest); + + if (request.music.Length == 0) return; + + var showProgress = request.music.Length > 1; + using var progress = showProgress + ? progressController.Begin(Locale.Exporting, string.Format(Locale.ExportingMultipleMusic, request.music.Length), Locale.Cancelling) + : null; + progress?.Report(0, (ulong)request.music.Length); + + // 记录已使用的子目录名,避免不同歌曲互相覆盖 + var usedDirNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var done = 0; + var total = request.music.Length; + + foreach (var musicId in request.music) + { + if (progress?.IsCancelled == true) + { + logger.LogInformation("批量导出 maidata 被用户取消。"); + break; + } + + var music = settings.GetMusic(musicId.Id, musicId.AssetDir); + if (music is null) + { + logger.LogWarning("Skip export: music {id} in {assetDir} not found.", musicId.Id, musicId.AssetDir); + done++; + progress?.Report((ulong)done, (ulong)total); + continue; + } + + try + { + // 子目录命名:安全化歌名 + DX 后缀(与前端 remoteExport 保持一致);为空回退到 id。 + var suffix = music.Id is > 10000 and < 20000 ? " [DX]" : ""; + var baseName = SanitizeFileNameSegment(music.Name ?? "", music.Id.ToString()) + suffix; + + // 处理重名:追加 id,再不行追加序号 + var dirName = baseName; + if (!usedDirNames.Add(dirName)) + { + dirName = $"{baseName}_{music.Id}"; + var n = 1; + while (!usedDirNames.Add(dirName)) + { + dirName = $"{baseName}_{music.Id}_{n++}"; } } + + var targetDir = Path.Combine(dest, dirName); + await WriteMaidataToDirectory(musicId.Id, musicId.AssetDir, targetDir, request.ignoreVideo); + } + catch (Exception e) + { + logger.LogError(e, "导出音乐 {id}({name})的 maidata 失败,已跳过。", music.Id, music.Name); } + + done++; + progress?.Report((ulong)done, (ulong)total, music.Name); } } diff --git a/MaiChartManager/Controllers/Music/VrcProcessController.cs b/MaiChartManager/Controllers/Music/VrcProcessController.cs index 0a693b80..71a2e273 100644 --- a/MaiChartManager/Controllers/Music/VrcProcessController.cs +++ b/MaiChartManager/Controllers/Music/VrcProcessController.cs @@ -1,6 +1,6 @@ using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Xabe.FFmpeg; +using FFMpegCore; namespace MaiChartManager.Controllers.Music; @@ -38,11 +38,21 @@ public void GenAllMusicPreviewMp3ForVrc([FromForm] string targetDir, [FromForm] } var mp3Path = Path.Combine(targetDir, $"{musicId}.mp3"); // logger.LogInformation("转换中 {musicId}", musicId); - var conversion = await FFmpeg.Conversions.FromSnippet - .Split(wav, mp3Path, TimeSpan.FromSeconds(previewTime.StartTime), TimeSpan.FromSeconds(previewTime.EndTime - previewTime.StartTime)); - await conversion.SetOutputFormat(Format.mp3) - .Start(); + // 原本用 Xabe 的 FFmpeg.Conversions.FromSnippet.Split(...).SetOutputFormat(Format.mp3), + // 它对每个音视频流加 PostInput 的 -ss/-t(从源截取 [start, start+duration)), + // 加 -map,再设输出格式 mp3。这里输入是单音轨 wav(无 SetCodec → 不显式指定 -c:a, + // 由 ffmpeg 按 mp3 容器选默认编码器)。 + // 等价命令行:ffmpeg -i -ss -t -map 0:0 -f mp3 + // 时间格式沿用 Xabe 的 H:MM:SS.mmm,保证参数一致。 + var start = TimeSpan.FromSeconds(previewTime.StartTime); + var duration = TimeSpan.FromSeconds(previewTime.EndTime - previewTime.StartTime); + + await FFMpegArguments + .FromFileInput(wav, verifyExists: false) + .OutputToFile(mp3Path, overwrite: true, o => o.WithCustomArgument( + $"-ss {FormatFFmpegTime(start)} -t {FormatFFmpegTime(duration)} -map 0:0 -f mp3")) + .ProcessAsynchronously(); } catch (Exception ex) { @@ -58,4 +68,12 @@ await conversion.SetOutputFormat(Format.mp3) // await Task.WhenAll(tasks); }); } + + /// + /// 把 TimeSpan 格式化为 ffmpeg 时间字符串 H:MM:SS.mmm, + /// 与原 Xabe TimeExtensions.ToFFmpeg 行为一致("{0:D}:{1:D2}:{2:D2}.{3:D3}", + /// 参数为 (int)TotalHours, Minutes, Seconds, Milliseconds)。 + /// + private static string FormatFFmpegTime(TimeSpan time) => + $"{(int)time.TotalHours:D}:{time.Minutes:D2}:{time.Seconds:D2}.{time.Milliseconds:D3}"; } \ No newline at end of file diff --git a/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs b/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs index 40d46caf..2a9b20d4 100644 --- a/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/AudioConvertToolController.cs @@ -1,25 +1,21 @@ -using MaiChartManager.Utils; +using MaiChartManager.Platform; +using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.Tools; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class AudioConvertToolController : ControllerBase +public class AudioConvertToolController(IDesktopDialogService dialogService) : ControllerBase { [HttpPost] public IActionResult AudioConvertTool() { - var dialog = new OpenFileDialog() - { - Title = Locale.SelectAudioToConvert, - Filter = Locale.AudioFileFilter, - }; - - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) + // 用平台原生文件对话框选音频文件(Windows=WinForms,Linux=Photino),转换逻辑跨平台。 + var inputFile = dialogService.PickFile(Locale.SelectAudioToConvert, Locale.AudioFileFilter); + if (inputFile is null) return BadRequest(Locale.FileNotSelected); - var inputFile = dialog.FileName; var extension = Path.GetExtension(inputFile).ToLowerInvariant(); var directory = Path.GetDirectoryName(inputFile); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(inputFile); diff --git a/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs b/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs index 8f027b39..0fa5eade 100644 --- a/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs +++ b/MaiChartManager/Controllers/Tools/ImageToAbToolController.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; @@ -6,7 +7,7 @@ namespace MaiChartManager.Controllers.Tools; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public partial class ImageToAbToolController(StaticSettings settings, ILogger logger) : ControllerBase +public partial class ImageToAbToolController(StaticSettings settings, ILogger logger, IDesktopDialogService dialogService) : ControllerBase { [GeneratedRegex(@"^(?\d+)\.(png|jpg|jpeg)$", RegexOptions.IgnoreCase)] private static partial Regex NumericFileRegex(); @@ -26,34 +27,29 @@ public async Task ImageToAbTool() { Response.Headers.Append("Content-Type", "text/event-stream"); - var dialog = new FolderBrowserDialog - { - Description = Locale.SelectImageFolder, - ShowNewFolderButton = false, - }; + var selectedPath = dialogService.PickFolder(Locale.SelectImageFolder); - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) + if (selectedPath is null) { await WriteEvent(ImageToAbEventType.Error, Locale.FileNotSelected); return; } - var selectedPath = dialog.SelectedPath; if (string.IsNullOrWhiteSpace(selectedPath) || !Directory.Exists(selectedPath)) { await WriteEvent(ImageToAbEventType.Error, Locale.FileNotSelected); return; } - - // 所选择的路径是否是正规的OPT内jacket路径。方法是判断路径结尾是否是AssetBundleImages\jacket - var isIngameJacketPath = selectedPath.TrimEnd('\\').EndsWith(@"AssetBundleImages\jacket", StringComparison.OrdinalIgnoreCase); + + // 所选择的路径是否是正规的OPT内jacket路径。方法是判断路径结尾是否是AssetBundleImages/jacket + var normalizedPath = selectedPath.TrimEnd('/', '\\').Replace('\\', '/'); + var isIngameJacketPath = normalizedPath.EndsWith("AssetBundleImages/jacket", StringComparison.OrdinalIgnoreCase); var deleteOriginalPngAfterSuccess = false; if (isIngameJacketPath) { - deleteOriginalPngAfterSuccess = MessageBox.Show( - Locale.ImageToAbDeleteOriginalPngQuestion, Locale.ImageToAb, MessageBoxButtons.YesNo, - MessageBoxIcon.Question) == DialogResult.Yes; + deleteOriginalPngAfterSuccess = dialogService.Confirm( + Locale.ImageToAbDeleteOriginalPngQuestion, Locale.ImageToAb); } var candidates = Directory.EnumerateFiles(selectedPath) diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index 835771ec..45cb3401 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -1,14 +1,14 @@ using System.Text.Json; using System.Threading.Channels; +using MaiChartManager.Platform; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.Tools; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class VideoConvertToolController(ILogger logger) : ControllerBase +public class VideoConvertToolController(ILogger logger, IDesktopDialogService dialogService) : ControllerBase { public enum VideoConvertEventType { @@ -22,20 +22,15 @@ public async Task VideoConvertTool() { Response.Headers.Append("Content-Type", "text/event-stream"); - var dialog = new OpenFileDialog() - { - Title = Locale.SelectVideoToConvert, - Filter = Locale.VideoFileFilter, - }; + var inputFile = dialogService.PickFile(Locale.SelectVideoToConvert, Locale.VideoFileFilter); - if (WinUtils.ShowDialog(dialog) != DialogResult.OK) + if (inputFile is null) { await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: {Locale.FileNotSelected}\n\n"); await Response.Body.FlushAsync(); return; } - var inputFile = dialog.FileName; var directory = Path.GetDirectoryName(inputFile); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(inputFile); var inputExt = Path.GetExtension(inputFile).ToLowerInvariant(); @@ -137,11 +132,13 @@ public async Task BatchConvertPvTool([FromQuery] string folderPath, [FromQuery] Response.Headers.Append("Content-Type", "text/event-stream"); // PV 转换属于赞助功能 +#if WINDOWS if (IapManager.License != IapManager.LicenseStatus.Active) { await WriteBatchError(BatchConvertPvErrorCode.NeedLicense, Locale.BatchConvertPvNeedLicense); return; } +#endif if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath)) { @@ -356,7 +353,7 @@ private async Task WriteSseFrames(ChannelReader reader, CancellationToke } catch (OperationCanceledException) { - // ignore + // 忽略取消异常 } } } diff --git a/MaiChartManager/Front/src/client/api.ts b/MaiChartManager/Front/src/client/api.ts index e31578e6..2655781a 100644 --- a/MaiChartManager/Front/src/client/api.ts +++ b/MaiChartManager/Front/src/client/api.ts @@ -7,6 +7,10 @@ declare global { // 在 WebView2 环境中,域名是 mcm.invalid,backendUrl 会通过 PostWebMessageAsString 注入 // 在远程浏览器(export 模式)中,直接用相对路径(当前 origin) export const isWebView = location.hostname === 'mcm.invalid'; +// 本地桌面宿主:Windows WebView2(mcm.invalid) 或 Photino/本机浏览器(loopback)。 +// 这些情况下后端在同机,文件/目录操作走后端原生对话框,而不是浏览器的 File System Access API +//(WebKitGTK 不支持后者)。远程浏览器(局域网 IP 访问 export 模式)则为 false。 +export const isLocalHost = isWebView || ['127.0.0.1', 'localhost', '[::1]'].includes(location.hostname); const getBaseUrl = () => (globalThis as any).backendUrl ?? (isWebView ? undefined : ''); export const apiClient = new Api({ @@ -31,6 +35,47 @@ export const aquaMaiVersionConfig = new AquaMaiVersionConfigApi({ }).api export const getUrl = (suffix: string) => { + // 必须返回绝对地址:部分代码(如 fetchEventSource)内部会 new URL(getUrl(...)), + // 相对地址在 WebKitGTK 上会抛 "The string did not match the expected pattern"。 + // WebView2 下 backendUrl 已注入为绝对地址;Photino/远程浏览器/export 下回退到当前 origin(同源)。 // @ts-ignore - return `${globalThis.backendUrl ?? ''}/MaiChartManagerServlet/${suffix}`; + const base = (globalThis.backendUrl as string | undefined) ?? location.origin; + return `${base}/MaiChartManagerServlet/${suffix}`; +} + +// 是否运行在 Photino(WebKitGTK) 宿主:本地宿主但不是 Windows WebView2。 +// WebKitGTK 不支持 window.open 弹新窗口,需要走后端用系统浏览器打开。 +export const isPhotino = isLocalHost && !isWebView; + +// 用系统浏览器打开一个 http/https URL(后端 xdg-open 等)。 +// 给 Photino 用:WebKitGTK 弹不出 window.open 的新窗口,预览谱面等改为外部浏览器打开。 +export const openExternalUrl = async (url: string) => { + const res = await fetch(getUrl('OpenExternalUrlApi'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); + if (!res.ok) { + throw new Error(`OpenExternalUrl 失败: ${res.status} ${res.statusText}`); + } +} + +// 本地宿主(Photino/WebKitGTK、WebView2)专用的 maidata 导出: +// 后端弹原生选目录对话框,并把每首歌的 maidata 写进所选目录(每首一个子目录)。 +// 这里手写 fetch 而不是用生成的 apiGen,是因为本环境无法连接后端重新生成 client。 +// 接口:POST /MaiChartManagerServlet/RequestExportMaidataApi +// body: { music: [{ id, assetDir }], ignoreVideo?: boolean } +export const requestExportMaidata = async ( + music: { id: number; assetDir: string }[], + ignoreVideo = false, +) => { + const res = await fetch(getUrl('RequestExportMaidataApi'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ music, ignoreVideo }), + }); + if (!res.ok) { + throw new Error(`RequestExportMaidata 失败: ${res.status} ${res.statusText}`); + } + return res; } diff --git a/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx b/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx index b729525e..cbe1c0f4 100644 --- a/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx +++ b/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx @@ -11,6 +11,7 @@ import { Chart, ImportChartCheckResult, ImportChartResult, ShiftMethod } from "@ import ImportAlert from "@/views/Charts/ImportCreateChartButton/ImportChartButton/ImportAlert"; import { defaultTempOptions, ImportChartMessageEx, TempOptions } from "@/views/Charts/ImportCreateChartButton/ImportChartButton/types"; import ShiftModeSelector from "@/views/Charts/ImportCreateChartButton/ImportChartButton/ShiftModeSelector"; +import { pickFile } from "@/utils/pickFile"; // noinspection JSUnusedLocalSymbols export let prepareReplaceChart = async (fileHandle?: FileSystemFileHandle) => { @@ -20,7 +21,7 @@ export default defineComponent({ setup() { const checking = ref(false); - const fileHandle = shallowRef(null); + const file = shallowRef(null); const show = ref<"" | "ma2" | "maidata" | "failed">(""); const apiResp = ref(null) @@ -35,30 +36,24 @@ export default defineComponent({ // 注:本功能的逻辑是,如果选择的是ma2文件,则只替换指定难度的谱面;如果选择的是maidata,则替换整首歌的所有难度。 prepareReplaceChart = async (fHandle?: FileSystemFileHandle) => { + let selectedFile: File; if (!fHandle) { - [fHandle] = await window.showOpenFilePicker({ - id: 'chart', - startIn: 'downloads', - types: [ - { - description: t('music.edit.supportedFileTypes'), - accept: { - "application/x-supported": [".ma2", ".txt"], // 没办法限定只匹配maidata.txt,就只好先把一切txt都作为匹配 - }, - }, - ], - }); + // 换谱面文件(maidata.txt 或 ma2),使用通用单文件选择(兼容 WebKitGTK) + // 没办法限定只匹配maidata.txt,就只好先把一切txt都作为匹配 + const picked = await pickFile('.ma2,.txt'); + if (!picked) return; // 用户未选择文件 + selectedFile = picked; + } else { + selectedFile = await fHandle.getFile(); } - if (!fHandle) return; // 用户未选择文件 - fileHandle.value = fHandle + file.value = selectedFile; - const name = fHandle.name; + const name = selectedFile.name; // 对maidata.txt和ma2分类讨论,前者执行ImportCheck if (name === "maidata.txt") { try { checking.value = true; - const file = await fHandle.getFile(); - const r = (await api.ImportChartCheck({file, isReplacement: true})).data; + const r = (await api.ImportChartCheck({file: selectedFile, isReplacement: true})).data; if (!checking.value) return; // 说明检查期间用户点击了关闭按钮、取消了操作。则不再执行后续流程。 apiResp.value = r; @@ -77,13 +72,13 @@ export default defineComponent({ } const replaceChart = async () => { - if (!fileHandle.value) return; + if (!file.value) return; try { - const file = await fileHandle.value.getFile(); - fileHandle.value = null; + const uploadFile = file.value; + file.value = null; const level = show.value === "maidata" ? -1 : selectedLevel.value; show.value = ""; - const result = (await api.ReplaceChart(selectMusicId.value, level, selectedADir.value, { file, shift: tempOption.value.shift })).data; + const result = (await api.ReplaceChart(selectMusicId.value, level, selectedADir.value, { file: uploadFile, shift: tempOption.value.shift })).data; if (!result.fatal) { addToast({ type:'success', message: t('music.edit.replaceChartSuccess') }) } else { @@ -116,7 +111,7 @@ export default defineComponent({ default: () =>
{show.value === "ma2" && <> {t('music.edit.replaceChartConfirm', { level: DIFFICULTY[selectedLevel.value!] })} -
{fileHandle.value?.name}
+
{file.value?.name}
} {(show.value === "maidata" || show.value === "failed") && } diff --git a/MaiChartManager/Front/src/components/JacketBox.tsx b/MaiChartManager/Front/src/components/JacketBox.tsx index 74e55dc3..e1b2e4f0 100644 --- a/MaiChartManager/Front/src/components/JacketBox.tsx +++ b/MaiChartManager/Front/src/components/JacketBox.tsx @@ -5,6 +5,7 @@ import { showTransactionalDialog } from "@munet/ui"; import { globalCapture, selectedADir, selectedMusic } from "@/store/refs"; import { MusicXmlWithABJacket } from "@/client/apiGen"; import { useI18n } from 'vue-i18n'; +import { pickFile } from "@/utils/pickFile"; export let upload = async (fileHandle?: FileSystemFileHandle) => { } @@ -24,23 +25,15 @@ export default defineComponent({ upload = async (fileHandle?: FileSystemFileHandle) => { if (!props.upload) return; try { + let file: File; if (!fileHandle) { - [fileHandle] = await window.showOpenFilePicker({ - id: 'jacket', - startIn: 'downloads', - types: [ - { - description: t('genre.imageDescription'), - accept: { - "application/jpeg": [".jpeg", ".jpg"], - "application/png": [".png"], - }, - }, - ], - }); + // 封面图片,使用通用单文件选择(兼容 WebKitGTK) + const picked = await pickFile('image/jpeg,image/png'); + if (!picked) return; + file = picked; + } else { + file = await fileHandle.getFile(); } - if (!fileHandle) return; - const file = await fileHandle.getFile(); const res = await api.SetMusicJacket(props.info.id!, selectedADir.value, { file }); if (res.error) { diff --git a/MaiChartManager/Front/src/utils/httpImportDirectory.ts b/MaiChartManager/Front/src/utils/httpImportDirectory.ts new file mode 100644 index 00000000..3c10762e --- /dev/null +++ b/MaiChartManager/Front/src/utils/httpImportDirectory.ts @@ -0,0 +1,70 @@ +import { ImportDirectory, ImportFileHandle } from "@/utils/importDirectory"; +import { getUrl } from "@/client/api"; + +// 后端返回的子项结构,与 ImportBrowseController.ImportDirEntry 对应 +interface BackendEntry { + name: string; + path: string; + isDirectory: boolean; +} + +// 取路径最后一段作为显示名;同时按 '/' 和 '\\' 切分,兼容 Windows 风格路径 +function basename(p: string): string { + const parts = p.split(/[/\\]/).filter(Boolean); + return parts.length ? parts[parts.length - 1] : p; +} + +// 读文件: +// - 只传 path 时,后端直接读该完整路径(用于 values() 里已知绝对路径的文件项) +// - 传 path(目录) + name 时,后端 Path.Combine(path, name)(用于 getFileHandle,避免前端跨平台拼路径) +async function readFile(path: string, displayName: string, name?: string): Promise { + let url = getUrl('ReadImportFileApi') + '?path=' + encodeURIComponent(path); + if (name !== undefined) { + url += '&name=' + encodeURIComponent(name); + } + console.log('[imp] readFile fetch:', url); + const res = await fetch(url); + if (!res.ok) throw new Error('文件不存在: ' + displayName); + return new File([await res.blob()], displayName); +} + +// 基于后端 3 个接口实现的 ImportDirectory 适配器。 +// 供 WebKitGTK / Photino 等没有 File System Access API 的本地宿主使用。 +// absPath:目录绝对路径;name:显示名(默认取 absPath 最后一段) +export function httpImportDirectory(absPath: string, name?: string): ImportDirectory { + return { + kind: 'directory', + name: name ?? basename(absPath), + + // 按名取目录下文件的句柄。不在这里拼路径,交给后端 Path.Combine(传 absPath + name)。 + // 不存在时 getFile 会抛错,由 tryGetFile 兜住。 + async getFileHandle(fileName: string): Promise { + return { + kind: 'file', + name: fileName, + getFile: () => readFile(absPath, fileName, fileName), + }; + }, + + // 迭代目录直接子项:目录递归构造适配器,文件构造文件句柄 + async *values(): AsyncIterableIterator { + const listUrl = getUrl('ListImportDirApi') + '?path=' + encodeURIComponent(absPath); + console.log('[imp] listDir fetch:', listUrl); + const res = await fetch(listUrl); + if (!res.ok) return; + const entries: BackendEntry[] = await res.json(); + for (const child of entries) { + if (child.isDirectory) { + yield httpImportDirectory(child.path, child.name); + } else { + yield { + kind: 'file', + name: child.name, + // 已知子项绝对路径,只传 path 即可 + getFile: () => readFile(child.path, child.name), + } satisfies ImportFileHandle; + } + } + }, + }; +} diff --git a/MaiChartManager/Front/src/utils/importDirectory.ts b/MaiChartManager/Front/src/utils/importDirectory.ts new file mode 100644 index 00000000..37847f3d --- /dev/null +++ b/MaiChartManager/Front/src/utils/importDirectory.ts @@ -0,0 +1,22 @@ +// 导入流程用的「目录句柄」抽象接口。 +// 真实的浏览器 FileSystemDirectoryHandle(Chromium / WebView2 / 远程 Chrome)和 +// WebKitGTK 下基于 的适配器都要实现它, +// 这样 startProcess / prepareFolder / tryGetFile 就不用写死 FileSystemDirectoryHandle, +// 避免在不支持 File System Access API 的内核上类型/运行时出错。 + +// 文件项:能拿到底层 File +export interface ImportFileHandle { + readonly kind: 'file'; + readonly name: string; + getFile(): Promise; +} + +// 目录项:能按名取文件、能迭代子项 +export interface ImportDirectory { + readonly kind: 'directory'; + readonly name: string; + // 取目录下某个文件的句柄;不存在时按 File System Access API 的语义抛错(由 tryGetFile 兜住) + getFileHandle(name: string): Promise; + // 迭代子项(文件或子目录),与 FileSystemDirectoryHandle.values() 形状一致 + values(): AsyncIterableIterator; +} diff --git a/MaiChartManager/Front/src/utils/pickDirectory.ts b/MaiChartManager/Front/src/utils/pickDirectory.ts new file mode 100644 index 00000000..b267c561 --- /dev/null +++ b/MaiChartManager/Front/src/utils/pickDirectory.ts @@ -0,0 +1,51 @@ +import { ImportDirectory } from "@/utils/importDirectory"; +import { httpImportDirectory } from "@/utils/httpImportDirectory"; +import { getUrl, isLocalHost, isPhotino } from "@/client/api"; + +// 抛一个 AbortError,与 showDirectoryPicker 取消时的语义一致(startProcess 里会 catch 掉) +function abort(message = '用户取消选择目录'): never { + const err = new Error(message); + err.name = 'AbortError'; + throw err; +} + +// 走后端:弹原生选文件夹对话框,再用 httpImportDirectory 适配器通过 HTTP 提供目录内容。 +async function pickViaBackend(): Promise { + const res = await fetch(getUrl('PickImportFolderApi')); + if (!res.ok) abort('选择目录失败'); + // 后端 Ok(string) 走 ASP.NET 的 string 特例,以 text/plain 返回裸路径(不是 JSON),取消时为空。 + // 所以必须用 res.text() 而不是 res.json()。 + const path = (await res.text()) || null; + if (!path) abort(); + return httpImportDirectory(path); +} + +// 通用选目录: +// - Photino(WebKitGTK):一律走后端原生对话框。WebKitGTK 即使暴露了 window.showDirectoryPicker, +// 其实现也有问题(调用后访问 handle 会抛 "The string did not match the expected pattern"), +// 所以**不能**用 typeof 检测来决定,必须在 showDirectoryPicker 之前优先判 isPhotino。 +// - 其它有真实 File System Access API 的环境(WebView2 / 远程 Chrome):用真实 handle,行为不变。 +// - 其余本地宿主但无可用 picker:兜底走后端。 +// - 都不满足(远程浏览器且无 File System Access API):不支持,按取消处理。 +export async function pickDirectory( + options?: { id?: string; startIn?: string }, +): Promise { + // Photino/WebKitGTK:优先后端,绕开有问题的 webkit showDirectoryPicker + if (isPhotino) { + return pickViaBackend(); + } + + // 真实 File System Access API(WebView2 / Chromium / 远程 Chrome) + if (typeof window.showDirectoryPicker === 'function') { + // 真实 FileSystemDirectoryHandle 在结构上满足 ImportDirectory + return window.showDirectoryPicker(options as any) as unknown as Promise; + } + + // 其它本地桌面宿主(无 showDirectoryPicker):后端原生选目录 + if (isLocalHost) { + return pickViaBackend(); + } + + // 远程浏览器且无 File System Access API:不支持,按取消处理 + abort('当前环境不支持选择目录'); +} diff --git a/MaiChartManager/Front/src/utils/pickFile.ts b/MaiChartManager/Front/src/utils/pickFile.ts new file mode 100644 index 00000000..c6945154 --- /dev/null +++ b/MaiChartManager/Front/src/utils/pickFile.ts @@ -0,0 +1,16 @@ +// 通用单文件选择:用 ,WebKitGTK / Chromium / 远程浏览器都支持, +// 替代 WebKitGTK 不支持的 window.showOpenFilePicker。 +export function pickFile(accept?: string): Promise { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + if (accept) input.accept = accept; + input.style.display = 'none'; + document.body.appendChild(input); + let settled = false; + input.addEventListener('change', () => { settled = true; resolve(input.files?.[0] ?? null); input.remove(); }); + // 取消时多数内核不触发 change;用 window focus 兜底判定取消 + window.addEventListener('focus', () => setTimeout(() => { if (!settled) { resolve(null); input.remove(); } }, 500), { once: true }); + input.click(); + }); +} diff --git a/MaiChartManager/Front/src/utils/tryGetFile.ts b/MaiChartManager/Front/src/utils/tryGetFile.ts index 4d35c538..cf64d34a 100644 --- a/MaiChartManager/Front/src/utils/tryGetFile.ts +++ b/MaiChartManager/Front/src/utils/tryGetFile.ts @@ -1,4 +1,9 @@ -export default async (dir: FileSystemDirectoryHandle, file: string): Promise => { +import { ImportDirectory } from "@/utils/importDirectory"; + +// 取目录下指定文件,找不到返回 undefined。 +// 参数类型用 ImportDirectory 而非写死 FileSystemDirectoryHandle, +// 这样真实 handle 和 WebKitGTK 适配器都能传进来。 +export default async (dir: ImportDirectory, file: string): Promise => { try { const handle = await dir.getFileHandle(file); return await handle.getFile(); diff --git a/MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts b/MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts new file mode 100644 index 00000000..e6d393b4 --- /dev/null +++ b/MaiChartManager/Front/src/utils/webkitDirectoryAdapter.ts @@ -0,0 +1,93 @@ +import { ImportDirectory, ImportFileHandle } from "@/utils/importDirectory"; + +// WebKitGTK 没有 window.showDirectoryPicker,只能用 , +// 拿到的是扁平的 FileList,每个 File 带 webkitRelativePath(形如 "顶层目录/子目录/文件名")。 +// 这里把扁平列表重建成目录树,并包装成实现 ImportDirectory 接口的对象, +// 让现有导入流程(startProcess / prepareFolder / tryGetFile)无需改动即可消费。 + +// 内部目录树节点 +interface DirNode { + name: string; + files: Map; // 直接子文件:文件名 -> File + dirs: Map; // 直接子目录:目录名 -> 节点 +} + +// 文件句柄适配器 +class FileHandleAdapter implements ImportFileHandle { + readonly kind = 'file' as const; + constructor(readonly name: string, private readonly file: File) {} + async getFile(): Promise { + return this.file; + } +} + +// 目录句柄适配器 +class DirectoryAdapter implements ImportDirectory { + readonly kind = 'directory' as const; + constructor(private readonly node: DirNode) {} + + get name(): string { + return this.node.name; + } + + async getFileHandle(name: string): Promise { + const file = this.node.files.get(name); + if (!file) { + // 对齐 File System Access API 的语义:找不到就抛 NotFoundError,由 tryGetFile 的 try/catch 兜住 + const err = new Error(`未找到文件: ${name}`); + err.name = 'NotFoundError'; + throw err; + } + return new FileHandleAdapter(name, file); + } + + async *values(): AsyncIterableIterator { + for (const [name, file] of this.node.files) { + yield new FileHandleAdapter(name, file); + } + for (const child of this.node.dirs.values()) { + yield new DirectoryAdapter(child); + } + } +} + +// 创建空节点 +const makeNode = (name: string): DirNode => ({ name, files: new Map(), dirs: new Map() }); + +// 把扁平 FileList(带 webkitRelativePath)重建为目录树,返回根目录适配器。 +// 选目录时浏览器会把所选目录名作为 webkitRelativePath 的第一段,因此根节点名取该第一段。 +export function buildDirectoryFromFileList(files: FileList | File[]): ImportDirectory { + const list = Array.from(files); + // 用一个虚拟根承载,最终若只有单一顶层目录则把它作为返回根 + const virtualRoot = makeNode(''); + + for (const file of list) { + // webkitRelativePath 形如 "topDir/sub/file.txt";个别实现可能为空,退回用文件名 + const relPath = (file as any).webkitRelativePath as string || file.name; + const parts = relPath.split('/').filter(Boolean); + if (parts.length === 0) continue; + + const fileName = parts[parts.length - 1]; + const dirParts = parts.slice(0, -1); + + let cursor = virtualRoot; + for (const part of dirParts) { + let next = cursor.dirs.get(part); + if (!next) { + next = makeNode(part); + cursor.dirs.set(part, next); + } + cursor = next; + } + cursor.files.set(fileName, file); + } + + // 通常 webkitdirectory 选择后只有一个顶层目录,直接返回它, + // 这样根目录名 = 用户所选目录名,与 showDirectoryPicker 的行为一致。 + if (virtualRoot.files.size === 0 && virtualRoot.dirs.size === 1) { + const only = virtualRoot.dirs.values().next().value as DirNode; + return new DirectoryAdapter(only); + } + + return new DirectoryAdapter(virtualRoot); +} diff --git a/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx b/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx index 2bfc5993..c4f2efbc 100644 --- a/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx +++ b/MaiChartManager/Front/src/views/BatchAction/ChooseAction.tsx @@ -2,7 +2,7 @@ import { defineComponent, PropType, ref } from "vue"; import { MusicXmlWithABJacket } from "@/client/apiGen"; import { Button, Radio, Select, Popover, addToast } from "@munet/ui"; import { STEP } from "@/views/BatchAction/index"; -import api, { isWebView } from "@/client/api"; +import api, { isLocalHost, requestExportMaidata } from "@/client/api"; import { showNeedPurchaseDialog, updateMusicList, version } from "@/store/refs"; import remoteExport from "@/views/BatchAction/remoteExport"; import TransitionVertical from "@/components/TransitionVertical.vue"; @@ -53,14 +53,14 @@ export default defineComponent({ break; case OPTIONS.CreateNewOpt: case OPTIONS.CreateNewOptCompatible: - if (isWebView) { + if (isLocalHost) { props.continue(STEP.Select); await api.RequestCopyTo({music: props.selectedMusic, removeEvents: selectedOption.value === OPTIONS.CreateNewOptCompatible, legacyFormat: false}); addToast({message: t('music.batch.exportSuccess'), type: 'success'}); break; } case OPTIONS.CreateNewOptMa2_103: - if (isWebView) { + if (isLocalHost) { props.continue(STEP.Select); await api.RequestCopyTo({music: props.selectedMusic, removeEvents: true, legacyFormat: true}); addToast({message: t('music.batch.exportSuccess'), type: 'success'}); @@ -73,6 +73,24 @@ export default defineComponent({ showNeedPurchaseDialog.value = true break; } + if (isLocalHost) { + // 本地宿主(Photino/WebKitGTK、WebView2):走后端 RequestExportMaidata,弹原生选目录对话框。 + // 注意:ConvertToMaidataById(按 ID 命名子目录)在本地路径下无法精确还原—— + // 后端目前按「歌名 + DX」命名子目录,因此本地路径下 ById 等同于普通 maidata 导出。 + // 远程路径(remoteExport)仍按 ID 命名,保持原样。 + load.value = true; + try { + await requestExportMaidata( + props.selectedMusic!.map(it => ({id: it.id!, assetDir: it.assetDir!})), + selectedOption.value === OPTIONS.ConvertToMaidataIgnoreVideo, + ); + addToast({message: t('music.batch.exportSuccess'), type: 'success'}); + } finally { + load.value = false; + } + props.continue(STEP.Select); + break; + } remoteExport(props.continue as any, props.selectedMusic!, selectedOption.value, selectedMaidataSubdir.value); break; } diff --git a/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx b/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx index 794a334b..638a9f38 100644 --- a/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx +++ b/MaiChartManager/Front/src/views/Charts/AssetDirsManager/ImportLocalButton.tsx @@ -1,6 +1,6 @@ import { defineComponent, ref } from "vue"; import { Button, Modal, Progress, addToast } from "@munet/ui"; -import api, { getUrl, isWebView } from "@/client/api"; +import api, { getUrl, isLocalHost } from "@/client/api"; import { updateAssetDirs } from "@/store/refs"; import axios from "axios"; import { UploadAssetDirResult } from "@/client/apiGen"; @@ -15,7 +15,7 @@ export default defineComponent({ const importLocal = async () => { importWait.value = true; - if (!isWebView) { + if (!isLocalHost) { // 浏览器模式 let folderHandle: FileSystemDirectoryHandle; try { diff --git a/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx b/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx index 5762d9d7..8b4550bb 100644 --- a/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/CopyToButton/index.tsx @@ -1,5 +1,5 @@ import { computed, defineComponent, ref } from "vue"; -import api, { getUrl, isWebView } from "@/client/api"; +import api, { getUrl, isLocalHost, requestExportMaidata } from "@/client/api"; import { globalCapture, selectedADir, selectedMusic, selectMusicId, showNeedPurchaseDialog, version } from "@/store/refs"; import { DropMenu, addToast } from "@munet/ui"; import { BlobWriter, ZipReader } from "@zip.js/zip.js"; @@ -28,8 +28,8 @@ export default defineComponent({ const copy = async (type: CopyType) => { wait.value = true; - if (!isWebView || type === CopyType.exportMaidata || type === CopyType.exportMaidataIgnoreVideo) { - // 浏览器模式,使用 zip.js 获取并解压 + if (!isLocalHost) { + // 远程浏览器模式:用 File System Access API(showDirectoryPicker + zip.js 获取并解压写盘) let folderHandle: FileSystemDirectoryHandle; try { folderHandle = await window.showDirectoryPicker({ @@ -78,12 +78,21 @@ export default defineComponent({ } return; } + // 本地宿主(Photino/WebKitGTK、WebView2):所有类型都走后端原生对话框,不用浏览器 File System Access API try { - // 本地 webview 打开,使用本地模式 - await api.RequestCopyTo({ - music: [{id: selectMusicId.value, assetDir: selectedADir.value}], - removeEvents: false, - }); + if (type === CopyType.export) { + // Opt 导出沿用已有的 RequestCopyTo + await api.RequestCopyTo({ + music: [{id: selectMusicId.value, assetDir: selectedADir.value}], + removeEvents: false, + }); + } else { + // maidata 导出改用后端新接口 RequestExportMaidata + await requestExportMaidata( + [{id: selectMusicId.value, assetDir: selectedADir.value}], + type === CopyType.exportMaidataIgnoreVideo, + ); + } } finally { wait.value = false; } diff --git a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx index 71f85b75..1b62d2a3 100644 --- a/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx +++ b/MaiChartManager/Front/src/views/Charts/ImportCreateChartButton/ImportChartButton/index.tsx @@ -17,8 +17,10 @@ import getNextUnusedMusicId from "@/utils/getNextUnusedMusicId"; import { useI18n } from 'vue-i18n'; import { createImportFatal, createVideoConvertWarning, getCaptureTarget, isAbortError } from "./importErrors"; import tryGetFile from "@/utils/tryGetFile"; +import { ImportDirectory } from "@/utils/importDirectory"; +import { pickDirectory } from "@/utils/pickDirectory"; -export let startProcess = (_dir?: FileSystemDirectoryHandle | FileSystemDirectoryHandle[]) => { } +export let startProcess = (_dir?: ImportDirectory | ImportDirectory[]) => { } export default defineComponent({ setup() { @@ -39,7 +41,7 @@ export default defineComponent({ modalReject.value && modalReject.value({ name: 'AbortError' }); } - const prepareFolder = async (dir: FileSystemDirectoryHandle, id: number) => { + const prepareFolder = async (dir: ImportDirectory, id: number) => { let reject = false; const maidata = await tryGetFile(dir, 'maidata.txt'); @@ -208,7 +210,7 @@ export default defineComponent({ } } - startProcess = async (dir?: FileSystemDirectoryHandle | FileSystemDirectoryHandle[]) => { + startProcess = async (dir?: ImportDirectory | ImportDirectory[]) => { let id = getNextUnusedMusicId(); const usedIds = [] as number[]; errors.value = []; @@ -218,18 +220,32 @@ export default defineComponent({ currentProcessing.value = dummyMeta; try { if (!dir) { - dir = await window.showDirectoryPicker({ + // pickDirectory:支持 showDirectoryPicker 时返回真实 handle,否则用 webkitdirectory 适配器 + dir = await pickDirectory({ id: 'maidata-dir', startIn: 'downloads', }); } step.value = STEP.checking; - if (dir instanceof FileSystemDirectoryHandle && await tryGetFile(dir, 'maidata.txt')) { + // 不再依赖 instanceof FileSystemDirectoryHandle(适配器不是它)。 + // 统一逻辑:单个目录句柄时,先看根目录有没有 maidata.txt,有就当作单首谱面导入; + // 没有(或传入的是数组)就遍历子目录。真实 handle 与适配器都走得通。 + if (!Array.isArray(dir) && await tryGetFile(dir, 'maidata.txt')) { await prepareFolder(dir, id); } else { - for await (const entry of dir.values()) { - if (entry.kind !== 'directory') continue; + // 数组(拖拽多个)时直接用这些句柄;单目录时遍历其子项。两种都只取目录项。 + const entries: (ImportDirectory)[] = []; + if (Array.isArray(dir)) { + for (const entry of dir) { + if (entry.kind === 'directory') entries.push(entry); + } + } else { + for await (const entry of dir.values()) { + if (entry.kind === 'directory') entries.push(entry); + } + } + for (const entry of entries) { if (await prepareFolder(entry, id)) { usedIds.push(id); id = getNextUnusedMusicId(usedIds); @@ -264,7 +280,9 @@ export default defineComponent({ } } catch (e) { if (isAbortError(e)) return - console.log(e) + // WebKit 的 console 直接 log 异常对象时无法正确转文本,这里显式打印字符串便于定位 + const err = e as any; + console.log('[imp] FAILED step=' + step.value + ' message=' + String(err?.message ?? err) + ' stack=' + String(err?.stack ?? '(无栈)')); globalCapture(e, t('chart.import.error.importErrorGlobal')) } finally { if (step.value !== STEP.showResultError) diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx index 11a225dd..527b5979 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/AcbAwb.tsx @@ -8,6 +8,7 @@ import api, { getUrl } from "@/client/api"; import AudioPreviewEditorButton from "@/views/Charts/MusicEdit/AudioPreviewEditorButton"; import SetMovieButton from "@/views/Charts/MusicEdit/SetMovieButton"; import { t } from "@/locales"; +import { pickFile } from "@/utils/pickFile"; export let uploadFlow = async (fileHandle?: FileSystemFileHandle) => { @@ -34,44 +35,27 @@ export default defineComponent({ uploadFlow = async (fileHandle?: FileSystemFileHandle) => { tipShow.value = true try { + let file: File; if (!fileHandle) { - [fileHandle] = await window.showOpenFilePicker({ - id: 'acbawb', - startIn: 'downloads', - types: [ - { - description: t('music.edit.supportedFileTypes'), - accept: { - "application/x-supported": [".mp3", ".wav", ".ogg", ".acb"], - }, - }, - ], - }); + // 音频文件,使用通用单文件选择(兼容 WebKitGTK) + const picked = await pickFile('.mp3,.wav,.ogg,.acb'); + tipShow.value = false; + if (!picked) return; + file = picked; + } else { + tipShow.value = false; + file = await fileHandle.getFile() as File; } - tipShow.value = false; - if (!fileHandle) return; - const file = await fileHandle.getFile() as File; let res: HttpResponse; if (file.name.endsWith('.acb')) { tipSelectAwbShow.value = true; - const [fileHandle] = await window.showOpenFilePicker({ - id: 'acbawb', - startIn: 'downloads', - types: [ - { - description: t('music.edit.supportedFileTypes'), - accept: { - "application/x-supported": [".awb"], - }, - }, - ], - }); + // 对应的 awb 文件,使用通用单文件选择(兼容 WebKitGTK) + const awb = await pickFile('.awb'); tipSelectAwbShow.value = false; - if (!fileHandle) return; + if (!awb) return; load.value = true; - const awb = await fileHandle.getFile() as File; res = await api.SetAudio(props.song.id!, selectedADir.value, { file, awb, padding: 0 }); } else { offset.value = 0; diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx index c2123c47..2dc520f9 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/PreviewChartButton.tsx @@ -1,6 +1,7 @@ import { defineComponent } from "vue"; import { selectedADir } from "@/store/refs"; import { t } from "@/locales"; +import { isPhotino } from "@/client/api"; export default defineComponent({ props: { @@ -20,6 +21,19 @@ export default defineComponent({ 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`); }; diff --git a/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx b/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx index fcc90896..dd8abda6 100644 --- a/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx +++ b/MaiChartManager/Front/src/views/Charts/MusicEdit/SetMovieButton.tsx @@ -8,6 +8,7 @@ import { globalCapture, selectedADir, showNeedPurchaseDialog, version } from "@/ import { fetchEventSource } from "@microsoft/fetch-event-source"; import { handleSseOpen } from "@/utils/sseOpen"; import { t } from "@/locales"; +import { pickFile } from "@/utils/pickFile"; enum STEP { None, @@ -80,24 +81,17 @@ export default defineComponent({ uploadFlow = async (fileHandle?: FileSystemFileHandle) => { step.value = STEP.Select try { + let file: File; if (!fileHandle) { - [fileHandle] = await window.showOpenFilePicker({ - id: 'movie', - startIn: 'downloads', - types: [ - { - description: t('music.edit.supportedFileTypes'), - accept: { - "video/*": [".dat"], - "image/*": [], - }, - }, - ], - }); + // 视频/图片文件,使用通用单文件选择(兼容 WebKitGTK) + const picked = await pickFile('video/*,image/*,.dat'); + step.value = STEP.None + if (!picked) return; + file = picked; + } else { + step.value = STEP.None + file = await fileHandle.getFile() as File; } - step.value = STEP.None - if (!fileHandle) return; - const file = await fileHandle.getFile() as File; if (file.name.endsWith('.dat')) { load.value = true; @@ -168,7 +162,8 @@ export default defineComponent({ >
-
{progress.value === 100 ? t('tools.videoOptions.processing') : `${progress.value}%`}
+ {/* 进度条自带百分比,这里只在 100% 后(ffmpeg 完成、仍在打包 USM)提示「正在处理」 */} + {progress.value === 100 &&
{t('tools.videoOptions.processing')}
}
; diff --git a/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx b/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx index 77b45415..027d2444 100644 --- a/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx +++ b/MaiChartManager/Front/src/views/GenreVersionManager/SetImageButton.tsx @@ -6,6 +6,7 @@ import SelectFileTypeTip from "./SelectFileTypeTip"; import { globalCapture, updateAddVersionList, updateGenreList } from "@/store/refs"; import { EDIT_TYPE } from "./index"; import { useI18n } from 'vue-i18n'; +import { pickFile } from "@/utils/pickFile"; export default defineComponent({ props: { @@ -30,23 +31,11 @@ export default defineComponent({ const startProcess = async () => { showTip.value = true; try { - const [fileHandle] = await window.showOpenFilePicker({ - id: 'genreTitle', - startIn: 'downloads', - types: [ - { - description: t('genre.imageDescription'), - accept: { - "application/jpeg": [".jpeg", ".jpg"], - "application/png": [".png"], - }, - }, - ], - }); + // 分类/版本图片,使用通用单文件选择(兼容 WebKitGTK) + const file = await pickFile('image/jpeg,image/png'); showTip.value = false; - if (!fileHandle) return; - const file = await fileHandle.getFile(); + if (!file) return; await (props.type === EDIT_TYPE.Genre ? api.SetGenreTitleImage : api.SetVersionTitleImage)({id: props.genre.id!, image: file}); await updateGenreList(); diff --git a/MaiChartManager/Front/src/views/Index.tsx b/MaiChartManager/Front/src/views/Index.tsx index 2a8ee4d0..014c31ef 100644 --- a/MaiChartManager/Front/src/views/Index.tsx +++ b/MaiChartManager/Front/src/views/Index.tsx @@ -15,6 +15,7 @@ import Settings from './Settings'; import Splash from '@/components/Splash'; import { ensureBackendUrl } from '@/utils/ensureBackendUrl'; import ChangelogModal from '@/components/ChangelogModal'; +import { isLocalHost } from '@/client/api'; export default defineComponent({ setup() { @@ -30,7 +31,9 @@ export default defineComponent({ }); }); - if (window.showDirectoryPicker === undefined) { + // 本地宿主(Windows WebView2 / Photino)下目录操作走后端原生对话框,不需要 File System Access API; + // 仅当是远程浏览器时才检测并提示浏览器不支持 + if (!isLocalHost && window.showDirectoryPicker === undefined) { const showError = () => { showTransactionalDialog( t('error.browserUnsupported.title'), diff --git a/MaiChartManager/Libs/AssetStudio.PInvoke.dll b/MaiChartManager/Libs/AssetStudio.PInvoke.dll deleted file mode 100644 index d91e3145..00000000 Binary files a/MaiChartManager/Libs/AssetStudio.PInvoke.dll and /dev/null differ diff --git a/MaiChartManager/Libs/AssetStudio.dll b/MaiChartManager/Libs/AssetStudio.dll deleted file mode 100644 index bbc1147b..00000000 Binary files a/MaiChartManager/Libs/AssetStudio.dll and /dev/null differ diff --git a/MaiChartManager/Libs/AssetStudioFBXWrapper.dll b/MaiChartManager/Libs/AssetStudioFBXWrapper.dll deleted file mode 100644 index bcbdc71a..00000000 Binary files a/MaiChartManager/Libs/AssetStudioFBXWrapper.dll and /dev/null differ diff --git a/MaiChartManager/Libs/AssetStudioUtility.dll b/MaiChartManager/Libs/AssetStudioUtility.dll deleted file mode 100644 index 4922e624..00000000 Binary files a/MaiChartManager/Libs/AssetStudioUtility.dll and /dev/null differ diff --git a/MaiChartManager/Libs/K4os.Compression.LZ4.dll b/MaiChartManager/Libs/K4os.Compression.LZ4.dll deleted file mode 100644 index 96542a6e..00000000 Binary files a/MaiChartManager/Libs/K4os.Compression.LZ4.dll and /dev/null differ diff --git a/MaiChartManager/Libs/Mono.Cecil.Mdb.dll b/MaiChartManager/Libs/Mono.Cecil.Mdb.dll deleted file mode 100644 index 7e1a0eab..00000000 Binary files a/MaiChartManager/Libs/Mono.Cecil.Mdb.dll and /dev/null differ diff --git a/MaiChartManager/Libs/Mono.Cecil.Pdb.dll b/MaiChartManager/Libs/Mono.Cecil.Pdb.dll deleted file mode 100644 index f001d333..00000000 Binary files a/MaiChartManager/Libs/Mono.Cecil.Pdb.dll and /dev/null differ diff --git a/MaiChartManager/Libs/Mono.Cecil.Rocks.dll b/MaiChartManager/Libs/Mono.Cecil.Rocks.dll deleted file mode 100644 index 74061b83..00000000 Binary files a/MaiChartManager/Libs/Mono.Cecil.Rocks.dll and /dev/null differ diff --git a/MaiChartManager/Libs/Mono.Cecil.dll b/MaiChartManager/Libs/Mono.Cecil.dll deleted file mode 100644 index f583a788..00000000 Binary files a/MaiChartManager/Libs/Mono.Cecil.dll and /dev/null differ diff --git a/MaiChartManager/Libs/Texture2DDecoderWrapper.dll b/MaiChartManager/Libs/Texture2DDecoderWrapper.dll deleted file mode 100644 index a6947251..00000000 Binary files a/MaiChartManager/Libs/Texture2DDecoderWrapper.dll and /dev/null differ diff --git a/MaiChartManager/Libs/WinBlur.dll b/MaiChartManager/Libs/WinBlur.dll deleted file mode 100644 index 8ccf8e3e..00000000 Binary files a/MaiChartManager/Libs/WinBlur.dll and /dev/null differ diff --git a/MaiChartManager/LinuxProgram.cs b/MaiChartManager/LinuxProgram.cs new file mode 100644 index 00000000..9e3ce943 --- /dev/null +++ b/MaiChartManager/LinuxProgram.cs @@ -0,0 +1,181 @@ +#if !WINDOWS +using System.Globalization; +using System.Text.Json; +using Photino.NET; +using FFMpegCore; + +namespace MaiChartManager; + +public static class LinuxProgram +{ + public static void Main(string[] args) + { + Directory.CreateDirectory(StaticSettings.appData); + Directory.CreateDirectory(StaticSettings.tempPath); + InitConfiguration(); + ConfigureFfmpeg(); + + // 启动进程内 Kestrel:loopback + 伺服 SPA(wwwroot)+ API 同源,但不开 LAN 端口。 + // Kestrel 在后台线程运行(StartApp 内部 Task.Run),主线程留给 Photino 开窗。 + var serverReady = new ManualResetEventSlim(false); + string? backendUrl = null; + ServerManager.StartApp(export: false, serveSpa: true, onStart: url => + { + backendUrl = url; + serverReady.Set(); + }); + + // 等待后端就绪拿到 loopback url,超时 30 秒视为启动失败。 + if (!serverReady.Wait(TimeSpan.FromSeconds(30)) || string.IsNullOrWhiteSpace(backendUrl)) + { + Console.Error.WriteLine("后端在 30 秒内未能就绪,退出。"); + Environment.Exit(1); + return; + } + + Console.WriteLine($"MaiChartManager backend listening at {backendUrl}"); + + // 决定初始路由(对齐 Windows AppMain 的逻辑): + // 未配置有效游戏目录时加载 OOBE 引导页(#/oobe),否则加载主界面(根路由)。 + // 直接加载主界面会让 SPA 立刻调用依赖 GamePath 的接口,导致一连串异常。 + var startUrl = string.IsNullOrEmpty(StaticSettings.GamePath) + ? $"{backendUrl.TrimEnd('/')}/#/oobe" + : backendUrl; + + // Photino 必须在主线程创建并显示窗口。Linux 下底层走系统 WebKitGTK。 + // 加载 Kestrel 的 loopback 地址:SPA 与 API 同源,前端无需注入 backendUrl。 + var window = new PhotinoWindow() + .SetTitle("MaiChartManager") + .SetUseOsDefaultSize(false) + .SetSize(1600, 800) + .Center() + .Load(new Uri(startUrl)); + + // 把窗口实例交给平台服务持有者,供 Linux 的对话框服务与应用外壳(导航/标题等)使用。 + Platform.Linux.PhotinoWindowHolder.Current = window; + + // 处理前端发来的「开新窗口」请求(预览谱面等)。WebKitGTK 不支持 window.open, + // 前端改为 window.external.sendMessage 通知宿主,由宿主开一个内置 webview 子窗口。 + window.RegisterWebMessageReceivedHandler((sender, message) => HandleWebMessage(window, message)); + + window.WaitForClose(); + } + + /// + /// 处理前端通过 window.external.sendMessage 发来的消息。 + /// 目前支持 { type:"open-window", url, title, width, height }:开一个内置 webview 子窗口加载 url。 + /// + private static void HandleWebMessage(PhotinoWindow parent, string message) + { + try + { + using var doc = JsonDocument.Parse(message); + var root = doc.RootElement; + if (!root.TryGetProperty("type", out var typeProp) || typeProp.GetString() != "open-window") return; + + var url = root.TryGetProperty("url", out var urlProp) ? urlProp.GetString() : null; + if (string.IsNullOrWhiteSpace(url)) return; + var title = root.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "MaiChartManager" : "MaiChartManager"; + var width = root.TryGetProperty("width", out var wProp) && wProp.TryGetInt32(out var w) ? w : 960; + var height = root.TryGetProperty("height", out var hProp) && hProp.TryGetInt32(out var h) ? h : 640; + + // 在宿主 UI 线程上创建子窗口(消息回调本身就在 UI 线程)。 + // child.WaitForClose() 会进入一个嵌套的 GTK 事件循环:父窗口仍可交互,子窗口关闭后返回。 + var child = new PhotinoWindow(parent) + .SetTitle(title) + .SetUseOsDefaultSize(false) + .SetSize(width, height) + .Center() + .Load(new Uri(url)); + child.WaitForClose(); + } + catch (Exception e) + { + Console.Error.WriteLine($"处理 WebMessage 失败:{e}"); + } + } + + /// + /// 配置 FFMpegCore 使用系统 ffmpeg/ffprobe。 + /// Windows 版在 AppMain 里指向内置的 ffmpeg.exe;Linux 不内置,改用系统 PATH 里的 ffmpeg 所在目录。 + /// FFMpegCore 用参数数组传给 ffmpeg(无引号问题),按 OS 自动补可执行名后缀。 + /// 公开以便 CLI 等其它 Linux 入口复用。 + /// + public static void ConfigureFfmpeg() + { + var dir = ResolveExecutableDir("ffmpeg") ?? "/usr/bin"; + GlobalFFOptions.Configure(o => + { + o.BinaryFolder = dir; + o.TemporaryFilesFolder = StaticSettings.tempPath; + }); + // 检测硬件加速(与 Windows 的 AppMain 一致,失败不影响主流程) + _ = MaiChartManager.Utils.VideoConvert.CheckHardwareAcceleration(); + } + + /// 在 $PATH 中查找可执行文件所在目录,找不到返回 null。 + private static string? ResolveExecutableDir(string exe) + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) return null; + foreach (var d in path.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(d)) continue; + if (File.Exists(Path.Combine(d, exe))) return d; + } + return null; + } + + /// + /// Linux 的最小化无头配置加载。对应 AppMain.InitConfiguration,但去掉了 + /// Sentry / MessageBox / WinForms 相关部分(这些代码在被排除的 AppMain.cs 中)。 + /// 公开以便 CLI 等其它 Linux 入口复用。 + /// + public static void InitConfiguration() + { + var cfgFilePath = Path.Combine(StaticSettings.appData, "config.json"); + if (File.Exists(cfgFilePath)) + { + try + { + var cfg = JsonSerializer.Deserialize(File.ReadAllText(cfgFilePath)); + if (cfg != null) + { + StaticSettings.Config = cfg; + } + } + catch + { + // 配置文件损坏:丢弃并使用默认值继续(进入 OOBE 流程)。 + try { File.Delete(cfgFilePath); } + catch { /* ignore */ } + } + } + + // 应用持久化的语言区域(AppMain.SetLocale 仅限 Windows)。 + var locale = string.IsNullOrWhiteSpace(StaticSettings.Config.Locale) ? "zh" : StaticSettings.Config.Locale; + if (locale != "zh" && locale != "zh-TW" && locale != "en") + locale = "zh"; + + StaticSettings.CurrentLocale = locale; + StaticSettings.Config.Locale = locale; + + var culture = locale switch + { + "zh" => new CultureInfo("zh-CN"), + "zh-TW" => new CultureInfo("zh-TW"), + _ => new CultureInfo("en-US"), + }; + Locale.Culture = culture; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + MuConvert.utils.Utils.SetLocale(new CultureInfo(locale)); + + // 如果已持久化有效的游戏路径,则恢复它,使应用以管理模式启动。 + if (!string.IsNullOrWhiteSpace(StaticSettings.Config.GamePath) && Directory.Exists(StaticSettings.Config.GamePath)) + { + StaticSettings.GamePath = StaticSettings.Config.GamePath; + } + } +} +#endif diff --git a/MaiChartManager/Locale.zh-hans.resx b/MaiChartManager/Locale.zh-Hans.resx similarity index 100% rename from MaiChartManager/Locale.zh-hans.resx rename to MaiChartManager/Locale.zh-Hans.resx diff --git a/MaiChartManager/Locale.zh-hant.resx b/MaiChartManager/Locale.zh-Hant.resx similarity index 100% rename from MaiChartManager/Locale.zh-hant.resx rename to MaiChartManager/Locale.zh-Hant.resx diff --git a/MaiChartManager/MaiChartManager.csproj b/MaiChartManager/MaiChartManager.csproj index 8dad9d20..e7dfa550 100644 --- a/MaiChartManager/MaiChartManager.csproj +++ b/MaiChartManager/MaiChartManager.csproj @@ -1,31 +1,49 @@ - net10.0-windows10.0.17763.0 + true enable enable false x64 True True - WinExe False - win-x64 False False true true - MaiChartManager.Program False PerMonitorV2 - true NU1605 true - Debug;Release;Crack + Debug;Release;Crack;LinuxDebug;LinuxRelease ..\Packaging\Pack true false icon.ico + + $(DefineConstants);WINDOWS + net10.0-windows10.0.17763.0 + WinExe + win-x64 + true + MaiChartManager.Program + + + net10.0 + Exe + linux-x64 + MaiChartManager.LinuxProgram + + false + + false + False TRACE;CRACK @@ -33,7 +51,7 @@ True - + true @@ -41,53 +59,72 @@ CRACK true + + False + TRACE + + + True + true + x64 - + + - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + Form - + Form @@ -96,33 +133,49 @@ True Resources.resx - True True Locale.resx + + + + + + + + + + + + + + + + + + - - - + + LICENSE PreserveNewest - - - - - PreserveNewest - + + PreserveNewest - + + %(Filename)%(Extension) PreserveNewest @@ -133,18 +186,8 @@ %(Filename)%(Extension) PreserveNewest - - - - - Libs\AssetStudio.dll - - - Libs\AssetStudioUtility.dll - - - Libs\Mono.Cecil.dll - + @@ -155,11 +198,11 @@ ResXFileCodeGenerator Locale.Designer.cs - - Locale.resx - - - Locale.resx - + + Locale.resx + + + Locale.resx + - + \ No newline at end of file diff --git a/MaiChartManager/Models/GenreXml.cs b/MaiChartManager/Models/GenreXml.cs index 557600fe..1a3cbe88 100644 --- a/MaiChartManager/Models/GenreXml.cs +++ b/MaiChartManager/Models/GenreXml.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; using System.Xml; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Models; @@ -12,7 +11,7 @@ public class GenreXml // name.str 在游戏里不会被用到 public int Id { get; } - public string FilePath => Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicGenre/musicgenre{Id:000000}/MusicGenre.xml"); + public string FilePath => MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicGenre", $"musicgenre{Id:000000}", "MusicGenre.xml"); public GenreXml(int id, string assetDir, string gamePath) { @@ -25,7 +24,7 @@ public GenreXml(int id, string assetDir, string gamePath) public static GenreXml CreateNew(int id, string assetDir, string gamePath) { - var dir = Path.Combine(gamePath, @"Sinmai_Data\StreamingAssets", assetDir, $"musicGenre/musicgenre{id:000000}"); + var dir = MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, "musicGenre", $"musicgenre{id:000000}"); Directory.CreateDirectory(dir); var text = $""" @@ -96,6 +95,7 @@ public void Save() public void Delete() { - FileSystem.DeleteDirectory(Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicGenre/musicgenre{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); + // 跨平台删除(Windows 进回收站,Linux 直接删除) + MaiChartManager.Platform.PlatformFile.DeleteDirectory(MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicGenre", $"musicgenre{Id:000000}")); } } diff --git a/MaiChartManager/Models/MusicXml.cs b/MaiChartManager/Models/MusicXml.cs index 4996871c..df8768d0 100644 --- a/MaiChartManager/Models/MusicXml.cs +++ b/MaiChartManager/Models/MusicXml.cs @@ -203,7 +203,7 @@ public static MusicXml CreateNew(int dxId, string gamePath, string assetDir) 0 """; - var path = Path.Combine(gamePath, @"Sinmai_Data\StreamingAssets", assetDir, $@"music\music{dxId:000000}"); + var path = Path.Combine(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, "music", $"music{dxId:000000}"); Directory.CreateDirectory(path); File.WriteAllText(Path.Combine(path, "Music.xml"), data); return new MusicXml(Path.Combine(path, "Music.xml"), gamePath); diff --git a/MaiChartManager/Models/MusicXmlWithABJacket.cs b/MaiChartManager/Models/MusicXmlWithABJacket.cs index 75b1e580..d6e1f567 100644 --- a/MaiChartManager/Models/MusicXmlWithABJacket.cs +++ b/MaiChartManager/Models/MusicXmlWithABJacket.cs @@ -1,5 +1,6 @@ using System.Xml; -using Microsoft.VisualBasic.FileIO; +using MaiChartManager.Platform; +using MaiChartManager.Utils; namespace MaiChartManager.Models; @@ -123,16 +124,16 @@ public List Problems internal bool DeleteJacket() { - bool shouldDelete = HasJacket && RealJacketPath?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false; + bool shouldDelete = HasJacket && PathUtils.ContainsSegment(RealJacketPath, "A000") == false; if (!shouldDelete) return false; var assetBundleJacket = this.AssetBundleJacket; Console.WriteLine("删除 jacket: " + RealJacketPath); try { - FileSystem.DeleteFile(RealJacketPath); + PlatformFile.DeleteFile(RealJacketPath); if (RealJacketPath.EndsWith(".ab") && File.Exists(RealJacketPath + ".manifest")) // .ab的情况,要额外把manifest也干掉 - FileSystem.DeleteFile(RealJacketPath + ".manifest"); + PlatformFile.DeleteFile(RealJacketPath + ".manifest"); } catch { @@ -143,7 +144,7 @@ internal bool DeleteJacket() StaticSettings.AssetBundleJacketMap.Remove(NonDxId); StaticSettings.PseudoAssetBundleJacketMap.Remove(NonDxId); - // Issue #42: AB jackets come with a companion jacket_s/ui_jacket_xxx_s.ab, delete it too to avoid orphan + // Issue #42: AB 封面带有同级目录 jacket_s/ui_jacket_xxx_s.ab,一并删除以避免孤立文件 if (assetBundleJacket is not null) { var abDir = Path.GetDirectoryName(assetBundleJacket); @@ -152,14 +153,14 @@ internal bool DeleteJacket() { var jacketSPath = Path.Combine(parentDir, "jacket_s", Path.GetFileNameWithoutExtension(assetBundleJacket) + "_s" + Path.GetExtension(assetBundleJacket)); - if (File.Exists(jacketSPath) && !jacketSPath.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase)) + if (File.Exists(jacketSPath) && !PathUtils.ContainsSegment(jacketSPath, "A000")) { Console.WriteLine("删除 jacket_s: " + jacketSPath); try { - FileSystem.DeleteFile(jacketSPath); + PlatformFile.DeleteFile(jacketSPath); if (File.Exists(jacketSPath + ".manifest")) - FileSystem.DeleteFile(jacketSPath + ".manifest"); + PlatformFile.DeleteFile(jacketSPath + ".manifest"); } catch { @@ -175,12 +176,12 @@ public void Delete() { DeleteJacket(); - if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.acb", out var acb) && acb?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false) + if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.acb", out var acb) && PathUtils.ContainsSegment(acb, "A000") == false) { Console.WriteLine("删除 acb: " + acb); try { - FileSystem.DeleteFile(acb); + PlatformFile.DeleteFile(acb); } catch { @@ -188,12 +189,12 @@ public void Delete() } } - if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.awb", out var awb) && awb?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false) + if (StaticSettings.AcbAwb.TryGetValue($"music{NonDxId:000000}.awb", out var awb) && PathUtils.ContainsSegment(awb, "A000") == false) { Console.WriteLine("删除 awb: " + awb); try { - FileSystem.DeleteFile(awb); + PlatformFile.DeleteFile(awb); } catch { @@ -201,12 +202,12 @@ public void Delete() } } - if (StaticSettings.MovieDataMap.TryGetValue(NonDxId, out var movieData) && movieData?.Contains(@"\A000\", StringComparison.InvariantCultureIgnoreCase) == false) + if (StaticSettings.MovieDataMap.TryGetValue(NonDxId, out var movieData) && PathUtils.ContainsSegment(movieData, "A000") == false) { Console.WriteLine("删除 movieData: " + movieData); try { - FileSystem.DeleteFile(movieData); + PlatformFile.DeleteFile(movieData); } catch { @@ -217,7 +218,7 @@ public void Delete() try { Console.WriteLine("删除目录: " + Path.GetDirectoryName(FilePath)); - FileSystem.DeleteDirectory(Path.GetDirectoryName(FilePath), DeleteDirectoryOption.DeleteAllContents); + PlatformFile.DeleteDirectoryPermanent(Path.GetDirectoryName(FilePath)); } catch { diff --git a/MaiChartManager/Models/VersionXml.cs b/MaiChartManager/Models/VersionXml.cs index 72b04e04..2ecccd6f 100644 --- a/MaiChartManager/Models/VersionXml.cs +++ b/MaiChartManager/Models/VersionXml.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; using System.Xml; -using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Models; @@ -12,7 +11,7 @@ public class VersionXml // name.str 在游戏里不会被用到 public int Id { get; } - public string FilePath => Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicVersion/MusicVersion{Id:000000}/MusicVersion.xml"); + public string FilePath => MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicVersion", $"MusicVersion{Id:000000}", "MusicVersion.xml"); public VersionXml(int id, string assetDir, string gamePath) { @@ -25,7 +24,7 @@ public VersionXml(int id, string assetDir, string gamePath) public static VersionXml CreateNew(int id, string assetDir, string gamePath) { - var dir = Path.Combine(gamePath, @"Sinmai_Data\StreamingAssets", assetDir, $"musicVersion/MusicVersion{id:000000}"); + var dir = MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(gamePath, "Sinmai_Data", "StreamingAssets", assetDir, "musicVersion", $"MusicVersion{id:000000}"); Directory.CreateDirectory(dir); var text = $""" @@ -103,6 +102,7 @@ public void Save() public void Delete() { - FileSystem.DeleteDirectory(Path.Combine(GamePath, @"Sinmai_Data\StreamingAssets", AssetDir, $"musicVersion/MusicVersion{Id:000000}"), UIOption.AllDialogs, RecycleOption.SendToRecycleBin); + // 跨平台删除(Windows 进回收站,Linux 直接删除) + MaiChartManager.Platform.PlatformFile.DeleteDirectory(MaiChartManager.Utils.PathUtils.ResolveIgnoreCase(GamePath, "Sinmai_Data", "StreamingAssets", AssetDir, "musicVersion", $"MusicVersion{Id:000000}")); } } diff --git a/MaiChartManager/Platform/IAppShell.cs b/MaiChartManager/Platform/IAppShell.cs new file mode 100644 index 00000000..541fcb66 --- /dev/null +++ b/MaiChartManager/Platform/IAppShell.cs @@ -0,0 +1,39 @@ +namespace MaiChartManager.Platform; + +/// +/// Web 控制器使用的桌面外壳 / 原生窗口操作接口。 +/// 在 Windows 上委托给 WinForms(AppLifecycleManager / AppMain / Browser / Application / UWP StartupTask)。 +/// 在 Linux 上为空操作或返回默认值(第三阶段 Photino 会接入真正的原生行为)。 +/// +public interface IAppShell +{ + /// 显示(或聚焦并刷新)指定回环地址的主浏览器窗口。 + void ShowBrowser(string loopbackUrl); + + /// 切换到指定回环地址的 OOBE / 模式切换窗口。 + void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode"); + + /// 关闭并释放 OOBE 浏览器窗口(若存在)。 + void CloseOobeBrowser(); + + /// 向 OOBE 浏览器窗口注入(可能已更新的)后端地址。 + void InjectOobeBackendUrl(string loopbackUrl); + + /// 根据当前游戏路径更新主窗口标题。 + void UpdateMainWindowTitle(string gamePath); + + /// 显示 / 隐藏托盘图标(导出模式 + 开机启动模式)。 + void DisposeTrayIcon(); + + /// 启用或禁用系统"开机自启"任务,成功返回 true。 + Task SetStartupEnabledAsync(bool enabled); + + /// 将语言区域变更应用到原生 UI(窗口装饰、内嵌库等)。 + void ReloadLocale(string locale); + + /// 主窗口的 DPI 缩放比例,用于报告默认 UI 缩放值。 + double GetTargetDpiScale(); + + /// 退出整个应用程序。 + void ExitApp(); +} diff --git a/MaiChartManager/Platform/IDesktopDialogService.cs b/MaiChartManager/Platform/IDesktopDialogService.cs new file mode 100644 index 00000000..7882a013 --- /dev/null +++ b/MaiChartManager/Platform/IDesktopDialogService.cs @@ -0,0 +1,9 @@ +namespace MaiChartManager.Platform; + +public interface IDesktopDialogService +{ + string? PickFolder(string? title = null); + string? PickFile(string? title = null, string? filter = null); + bool Confirm(string message, string title, bool defaultResult = false); + void ShowError(string message, string title); +} diff --git a/MaiChartManager/Platform/IProgressController.cs b/MaiChartManager/Platform/IProgressController.cs new file mode 100644 index 00000000..5adeffd6 --- /dev/null +++ b/MaiChartManager/Platform/IProgressController.cs @@ -0,0 +1,13 @@ +namespace MaiChartManager.Platform; + +public interface IProgressController +{ + /// 创建并显示一个可取消的批量进度会话;Dispose 时关闭。 + IProgressSession Begin(string title, string description, string cancelMessage); +} + +public interface IProgressSession : IDisposable +{ + void Report(ulong value, ulong total, string? detail = null); + bool IsCancelled { get; } +} diff --git a/MaiChartManager/Platform/IShellService.cs b/MaiChartManager/Platform/IShellService.cs new file mode 100644 index 00000000..24eb02d8 --- /dev/null +++ b/MaiChartManager/Platform/IShellService.cs @@ -0,0 +1,8 @@ +namespace MaiChartManager.Platform; + +public interface IShellService +{ + void RevealInFileManager(string path); + void OpenUrl(string url); + void OpenPath(string path); +} diff --git a/MaiChartManager/Platform/ITaskbarProgress.cs b/MaiChartManager/Platform/ITaskbarProgress.cs new file mode 100644 index 00000000..c3729347 --- /dev/null +++ b/MaiChartManager/Platform/ITaskbarProgress.cs @@ -0,0 +1,8 @@ +namespace MaiChartManager.Platform; + +public interface ITaskbarProgress +{ + void Set(ulong value, ulong total = 100); + void SetIndeterminate(); + void Clear(); +} diff --git a/MaiChartManager/Platform/Linux/HeadlessProgressController.cs b/MaiChartManager/Platform/Linux/HeadlessProgressController.cs new file mode 100644 index 00000000..2cdc4117 --- /dev/null +++ b/MaiChartManager/Platform/Linux/HeadlessProgressController.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; + +namespace MaiChartManager.Platform.Linux; + +public class HeadlessProgressController(ILogger logger) : IProgressController +{ + public IProgressSession Begin(string title, string description, string cancelMessage) + { + logger.LogInformation("{title}: {description}", title, description); + return new HeadlessProgressSession(logger); + } +} + +public sealed class HeadlessProgressSession(ILogger logger) : IProgressSession +{ + public void Report(ulong value, ulong total, string? detail = null) + { + if (detail is not null) + logger.LogInformation("Progress {value}/{total}: {detail}", value, total, detail); + } + + public bool IsCancelled => false; + public void Dispose() { } +} diff --git a/MaiChartManager/Platform/Linux/LinuxShellService.cs b/MaiChartManager/Platform/Linux/LinuxShellService.cs new file mode 100644 index 00000000..81d7c673 --- /dev/null +++ b/MaiChartManager/Platform/Linux/LinuxShellService.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; +using MaiChartManager.Platform; +using Microsoft.Extensions.Logging; + +namespace MaiChartManager.Platform.Linux; + +/// 通过 xdg-open / xdg-utils 实现 Linux 系统集成。 +public class LinuxShellService(ILogger logger) : IShellService +{ + public void RevealInFileManager(string path) + { + // Linux 文件管理器没有通用的"选中文件"功能;改为打开所在目录。 + var target = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path; + XdgOpen(target); + } + + public void OpenUrl(string url) => XdgOpen(url); + + public void OpenPath(string path) => XdgOpen(path); + + private void XdgOpen(string arg) + { + try + { + Process.Start(new ProcessStartInfo("xdg-open", $"\"{arg}\"") { UseShellExecute = false }); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to xdg-open {Arg}", arg); + } + } +} diff --git a/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs b/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs new file mode 100644 index 00000000..321c27a3 --- /dev/null +++ b/MaiChartManager/Platform/Linux/NoopTaskbarProgress.cs @@ -0,0 +1,11 @@ +using MaiChartManager.Platform; + +namespace MaiChartManager.Platform.Linux; + +/// Linux 上的空操作任务栏进度(无 Windows 任务栏)。 +public class NoopTaskbarProgress : ITaskbarProgress +{ + public void Set(ulong value, ulong total = 100) { } + public void SetIndeterminate() { } + public void Clear() { } +} diff --git a/MaiChartManager/Platform/Linux/PhotinoAppShell.cs b/MaiChartManager/Platform/Linux/PhotinoAppShell.cs new file mode 100644 index 00000000..aee40261 --- /dev/null +++ b/MaiChartManager/Platform/Linux/PhotinoAppShell.cs @@ -0,0 +1,106 @@ +#if !WINDOWS +using MaiChartManager.Platform; +using Microsoft.Extensions.Logging; + +namespace MaiChartManager.Platform.Linux; + +/// +/// Linux 下基于 Photino 单窗口的应用外壳实现。 +/// Windows 是多窗口(OOBE 窗口 + 主窗口),Linux 只有一个 Photino 窗口, +/// 因此「打开主界面 / 切换模式」等操作统一转化为对同一个窗口的导航(Load)。 +/// 托盘 / 开机启动等 Windows 专属能力在 Linux 上为空操作。 +/// +public class PhotinoAppShell(ILogger logger) : IAppShell +{ + /// 在窗口的 UI 线程上把窗口导航到指定地址(Photino 的 Load 需在 UI 线程调用)。 + private void Navigate(string targetUrl) + { + var window = PhotinoWindowHolder.Current; + if (window is null) + { + logger.LogWarning("Photino 窗口尚未就绪,无法导航到 {Url}", targetUrl); + return; + } + + window.Invoke(() => + { + try + { + window.Load(new Uri(targetUrl)); + } + catch (Exception e) + { + logger.LogError(e, "导航到 {Url} 失败", targetUrl); + } + }); + } + + /// 拼出 SPA 的 hash 路由地址,例如 http://127.0.0.1:port/#/oobe + private static string HashUrl(string loopbackUrl, string hash) + => $"{loopbackUrl.TrimEnd('/')}/#{hash}"; + + // 打开主界面:导航到 loopback 根路由,SPA 进入主界面(此时 GamePath 已配置)。 + public void ShowBrowser(string loopbackUrl) => Navigate(loopbackUrl); + + // 切换模式:导航到对应 hash 路由(单窗口,等价于在当前窗口换路由)。 + public void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode") + => Navigate(HashUrl(loopbackUrl, hash)); + + // 单窗口模型下没有独立的 OOBE 窗口,ShowBrowser 已经完成了导航,这里无需操作。 + public void CloseOobeBrowser() + => logger.LogDebug("CloseOobeBrowser:Linux 单窗口,无需关闭独立 OOBE 窗口"); + + // 局域网(export)模式下后端会重启并换端口,需要把窗口导航到新的 OOBE 地址以重新连接。 + public void InjectOobeBackendUrl(string loopbackUrl) + => Navigate(HashUrl(loopbackUrl, "/oobe")); + + public void UpdateMainWindowTitle(string gamePath) + { + var window = PhotinoWindowHolder.Current; + if (window is null) return; + window.Invoke(() => + { + try + { + window.SetTitle($"MaiChartManager ({gamePath})"); + } + catch (Exception e) + { + logger.LogError(e, "设置窗口标题失败"); + } + }); + } + + // Linux 不做托盘。 + public void DisposeTrayIcon() => logger.LogDebug("DisposeTrayIcon:Linux 无托盘,空操作"); + + // Linux 不做开机自启。 + public Task SetStartupEnabledAsync(bool enabled) + { + logger.LogInformation("SetStartupEnabledAsync:Linux 不支持开机自启,返回 false(请求值 {Enabled})", enabled); + return Task.FromResult(false); + } + + // 语言切换由前端通过接口自行处理;Linux 原生窗口无需额外刷新。 + public void ReloadLocale(string locale) => logger.LogDebug("ReloadLocale:Linux 无需刷新原生 UI({Locale})", locale); + + // Linux 暂不实现 DPI 缩放上报,返回 1.0。 + public double GetTargetDpiScale() => 1.0; + + public void ExitApp() + { + var window = PhotinoWindowHolder.Current; + if (window is null) + { + Environment.Exit(0); + return; + } + window.Invoke(() => + { + try { window.Close(); } + catch (Exception e) { logger.LogError(e, "关闭窗口失败"); Environment.Exit(0); } + }); + } +} + +#endif diff --git a/MaiChartManager/Platform/Linux/PhotinoDialogService.cs b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs new file mode 100644 index 00000000..89a59a21 --- /dev/null +++ b/MaiChartManager/Platform/Linux/PhotinoDialogService.cs @@ -0,0 +1,155 @@ +#if !WINDOWS +using MaiChartManager.Platform; +using Microsoft.Extensions.Logging; +using Photino.NET; + +namespace MaiChartManager.Platform.Linux; + +/// +/// Linux 平台基于 Photino 原生对话框的 实现。 +/// 底层走 WebKitGTK,弹出 GTK 原生的文件/文件夹选择与消息框。 +/// +/// +/// Photino 的对话框是 的实例方法,且必须在窗口的 UI 线程上执行。 +/// Controller 在 Kestrel 的请求线程调用本服务,因此这里通过 +/// 把调用 marshal 到 UI 线程,并用 阻塞等待结果返回。 +/// +public class PhotinoDialogService(ILogger logger) : IDesktopDialogService +{ + public string? PickFolder(string? title = null) + { + var window = PhotinoWindowHolder.Current; + if (window is null) + { + // 理论上不会发生:窗口在 LinuxProgram.Main 创建后即赋值。 + logger.LogWarning("PickFolder:Photino 主窗口尚未就绪,返回 null。title={Title}", title); + return null; + } + + string[]? result = null; + var done = new ManualResetEventSlim(); + // 上次选过的目录作为初始目录(没有则交给系统默认) + var startDir = StaticSettings.Config.LastDialogFolder is { Length: > 0 } d && Directory.Exists(d) ? d : ""; + // 必须在 UI 线程调用 ShowOpenFolder。 + window.Invoke(() => + { + try + { + // ShowOpenFolder(string title, string defaultPath, bool multiSelect) + result = window.ShowOpenFolder(title ?? "", startDir, false); + } + catch (Exception e) + { + logger.LogError(e, "PickFolder:弹出文件夹选择对话框失败。"); + } + finally + { + done.Set(); + } + }); + done.Wait(); + var picked = result is { Length: > 0 } ? result[0] : null; + if (picked is not null) + { + // 记住本次目录,下次从这里开始 + StaticSettings.Config.LastDialogFolder = picked; + try { StaticSettings.Config.Save(); } catch (Exception e) { logger.LogWarning(e, "保存 LastDialogFolder 失败"); } + } + return picked; + } + + public string? PickFile(string? title = null, string? filter = null) + { + var window = PhotinoWindowHolder.Current; + if (window is null) + { + logger.LogWarning("PickFile:Photino 主窗口尚未就绪,返回 null。title={Title}", title); + return null; + } + + string[]? result = null; + var done = new ManualResetEventSlim(); + window.Invoke(() => + { + try + { + // 忽略 WinForms 风格的 filter 字符串,传 null 表示允许所有文件, + // 避免 WinForms→Photino 的 filter 格式转换出错。 + // ShowOpenFile(string title, string defaultPath, bool multiSelect, (string,string[])[] filters) + result = window.ShowOpenFile(title ?? "", null, false, null); + } + catch (Exception e) + { + logger.LogError(e, "PickFile:弹出文件选择对话框失败。"); + } + finally + { + done.Set(); + } + }); + done.Wait(); + return result is { Length: > 0 } ? result[0] : null; + } + + public bool Confirm(string message, string title, bool defaultResult = false) + { + var window = PhotinoWindowHolder.Current; + if (window is null) + { + logger.LogWarning("Confirm:Photino 主窗口尚未就绪,返回默认值 {Default}。title={Title}", defaultResult, title); + return defaultResult; + } + + var confirmed = false; + var done = new ManualResetEventSlim(); + window.Invoke(() => + { + try + { + // ShowMessage(string title, string text, PhotinoDialogButtons buttons, PhotinoDialogIcon icon) + var ret = window.ShowMessage(title, message, PhotinoDialogButtons.YesNo, PhotinoDialogIcon.Question); + confirmed = ret == PhotinoDialogResult.Yes; + } + catch (Exception e) + { + logger.LogError(e, "Confirm:弹出确认对话框失败,回退到默认值 {Default}。", defaultResult); + confirmed = defaultResult; + } + finally + { + done.Set(); + } + }); + done.Wait(); + return confirmed; + } + + public void ShowError(string message, string title) + { + var window = PhotinoWindowHolder.Current; + if (window is null) + { + logger.LogError("ShowError ({Title}): {Message}", title, message); + return; + } + + var done = new ManualResetEventSlim(); + window.Invoke(() => + { + try + { + window.ShowMessage(title, message, PhotinoDialogButtons.Ok, PhotinoDialogIcon.Error); + } + catch (Exception e) + { + logger.LogError(e, "ShowError:弹出错误对话框失败。原始消息 ({Title}): {Message}", title, message); + } + finally + { + done.Set(); + } + }); + done.Wait(); + } +} +#endif diff --git a/MaiChartManager/Platform/Linux/PhotinoWindowHolder.cs b/MaiChartManager/Platform/Linux/PhotinoWindowHolder.cs new file mode 100644 index 00000000..4af1c399 --- /dev/null +++ b/MaiChartManager/Platform/Linux/PhotinoWindowHolder.cs @@ -0,0 +1,13 @@ +#if !WINDOWS +using Photino.NET; + +namespace MaiChartManager.Platform.Linux; + +/// +/// 持有当前 Photino 主窗口引用,供 Linux 平台服务(对话框等)使用。 +/// +public static class PhotinoWindowHolder +{ + public static PhotinoWindow? Current { get; set; } +} +#endif diff --git a/MaiChartManager/Platform/PlatformFile.cs b/MaiChartManager/Platform/PlatformFile.cs new file mode 100644 index 00000000..5007daf7 --- /dev/null +++ b/MaiChartManager/Platform/PlatformFile.cs @@ -0,0 +1,64 @@ +namespace MaiChartManager.Platform; + +/// +/// 跨平台文件删除助手。 +/// Windows 走 Microsoft.VisualBasic 的回收站删除(与原行为一致); +/// Linux 直接永久删除(无回收站概念,且 VisualBasic 的回收站/对话框选项在 Linux 运行时会抛异常)。 +/// +public static class PlatformFile +{ + /// 删除文件:Windows 送回收站,Linux 直接删除。文件不存在时静默忽略。 + public static void DeleteFile(string path) + { +#if WINDOWS + if (File.Exists(path)) + Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(path, + Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, + Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin); +#else + if (File.Exists(path)) File.Delete(path); +#endif + } + + /// 删除目录(递归):Windows 送回收站,Linux 直接删除。目录不存在时静默忽略。 + /// showDialog=true 时 Windows 显示删除进度对话框(对应原 UIOption.AllDialogs)。 + public static void DeleteDirectory(string path, bool showDialog = false) + { +#if WINDOWS + if (Directory.Exists(path)) + Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(path, + showDialog ? Microsoft.VisualBasic.FileIO.UIOption.AllDialogs : Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs, + Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin); +#else + if (Directory.Exists(path)) Directory.Delete(path, true); +#endif + } + + /// 永久删除目录(递归,不进回收站)。对应原 DeleteDirectoryOption.DeleteAllContents。 + public static void DeleteDirectoryPermanent(string path) + { + if (Directory.Exists(path)) Directory.Delete(path, true); + } + + /// 复制文件(跨平台)。 + public static void CopyFile(string source, string dest, bool overwrite = true) + => File.Copy(source, dest, overwrite); + + /// 移动文件(跨平台)。 + public static void MoveFile(string source, string dest, bool overwrite = true) + => File.Move(source, dest, overwrite); + + /// 移动目录(跨平台)。 + public static void MoveDirectory(string source, string dest) + => Directory.Move(source, dest); + + /// 递归复制目录(跨平台)。dest 已存在时合并,文件按 overwrite 覆盖。 + public static void CopyDirectory(string source, string dest, bool overwrite = true) + { + Directory.CreateDirectory(dest); + foreach (var file in Directory.EnumerateFiles(source)) + File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite); + foreach (var dir in Directory.EnumerateDirectories(source)) + CopyDirectory(dir, Path.Combine(dest, Path.GetFileName(dir)), overwrite); + } +} diff --git a/MaiChartManager/Platform/Windows/WinFormsDialogService.cs b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs new file mode 100644 index 00000000..82a628c3 --- /dev/null +++ b/MaiChartManager/Platform/Windows/WinFormsDialogService.cs @@ -0,0 +1,52 @@ +#if WINDOWS +using System.Windows.Forms; +using MaiChartManager.Utils; + +namespace MaiChartManager.Platform.Windows; + +/// +/// 基于 WinForms 的对话框服务,对应原来 WinUtils.ShowDialog + +/// 各控制器中 FolderBrowserDialog/OpenFileDialog/MessageBox 的用法。 +/// +public class WinFormsDialogService : IDesktopDialogService +{ + public string? PickFolder(string? title = null) + { + using var dialog = new FolderBrowserDialog + { + ShowNewFolderButton = false, + }; + if (title is not null) dialog.Description = title; + // Windows 的 Vista 风格文件夹对话框本身会记住上次目录,无需额外处理 + return WinUtils.ShowDialog(dialog) == DialogResult.OK ? dialog.SelectedPath : null; + } + + public string? PickFile(string? title = null, string? filter = null) + { + using var dialog = new OpenFileDialog(); + if (title is not null) dialog.Title = title; + if (filter is not null) dialog.Filter = filter; + return WinUtils.ShowDialog(dialog) == DialogResult.OK ? dialog.FileName : null; + } + + public bool Confirm(string message, string title, bool defaultResult = false) + { + var owner = AppMain.ActiveForm ?? AppMain.BrowserWin; + if (owner == null) + return MessageBox.Show(message, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes; + return owner.Invoke(() => + MessageBox.Show(owner, message, title, MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes); + } + + public void ShowError(string message, string title) + { + var owner = AppMain.ActiveForm ?? AppMain.BrowserWin; + if (owner == null) + { + MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + owner.Invoke(() => MessageBox.Show(owner, message, title, MessageBoxButtons.OK, MessageBoxIcon.Error)); + } +} +#endif diff --git a/MaiChartManager/Platform/Windows/WindowsAppShell.cs b/MaiChartManager/Platform/Windows/WindowsAppShell.cs new file mode 100644 index 00000000..924493cd --- /dev/null +++ b/MaiChartManager/Platform/Windows/WindowsAppShell.cs @@ -0,0 +1,70 @@ +#if WINDOWS +using System.Windows.Forms; +using Windows.ApplicationModel; + +namespace MaiChartManager.Platform.Windows; + +/// +/// Windows 应用外壳,委托给 AppLifecycleManager / AppMain / Browser / +/// Application / UWP StartupTask,与原来各控制器的实现完全一致。 +/// +public class WindowsAppShell : IAppShell +{ + public void ShowBrowser(string loopbackUrl) => AppLifecycleManager.ShowBrowser(loopbackUrl); + + public void GoToModeSwitch(string loopbackUrl, string hash = "/set-mode") + => AppLifecycleManager.GoToModeSwitch(loopbackUrl, hash); + + public void CloseOobeBrowser() + { + AppMain.UiContext?.Post(_ => + { + AppMain.OobeBrowser?.Dispose(); + AppMain.OobeBrowser = null; + }, null); + } + + public void InjectOobeBackendUrl(string loopbackUrl) + { + AppMain.UiContext?.Post(_ => AppMain.OobeBrowser?.InjectBackendUrl(loopbackUrl), null); + } + + public void UpdateMainWindowTitle(string gamePath) + { + AppMain.UiContext?.Post(_ => + { + if (AppMain.BrowserWin is { IsDisposed: false }) + AppMain.BrowserWin.Text = $"MaiChartManager ({gamePath})"; + }, null); + } + + public void DisposeTrayIcon() => AppLifecycleManager.DisposeTrayIcon(); + + public async Task SetStartupEnabledAsync(bool enabled) + { + try + { + var startupTask = await StartupTask.GetAsync("MaiChartManagerStartupId"); + if (enabled) + await startupTask.RequestEnableAsync(); + else + startupTask.Disable(); + return true; + } + catch + { + return false; + } + } + + public void ReloadLocale(string locale) + { + // 语言区域状态(CurrentLocale/Config/Culture)已由 LocaleController 以平台无关的方式应用。 + // WinForms 外壳目前不需要额外刷新任何内容。 + } + + public double GetTargetDpiScale() => Browser.TargetDpiScale; + + public void ExitApp() => Application.Exit(); +} +#endif diff --git a/MaiChartManager/Platform/Windows/WindowsProgressController.cs b/MaiChartManager/Platform/Windows/WindowsProgressController.cs new file mode 100644 index 00000000..1651e574 --- /dev/null +++ b/MaiChartManager/Platform/Windows/WindowsProgressController.cs @@ -0,0 +1,39 @@ +#if WINDOWS +using Vanara.Windows.Forms; + +namespace MaiChartManager.Platform.Windows; + +public class WindowsProgressController : IProgressController +{ + public IProgressSession Begin(string title, string description, string cancelMessage) + => new WindowsProgressSession(title, description, cancelMessage); +} + +public sealed class WindowsProgressSession : IProgressSession +{ + private readonly ShellProgressDialog _dialog; + + public WindowsProgressSession(string title, string description, string cancelMessage) + { + _dialog = new ShellProgressDialog + { + AutoTimeEstimation = false, + Title = title, + Description = description, + CancelMessage = cancelMessage, + HideTimeRemaining = true, + }; + _dialog.Start(AppMain.BrowserWin!); + } + + public void Report(ulong value, ulong total, string? detail = null) + { + if (detail is not null) _dialog.Detail = detail; + _dialog.UpdateProgress(value, total); + } + + public bool IsCancelled => _dialog.IsCancelled; + + public void Dispose() => _dialog.Stop(); +} +#endif diff --git a/MaiChartManager/Platform/Windows/WindowsShellService.cs b/MaiChartManager/Platform/Windows/WindowsShellService.cs new file mode 100644 index 00000000..e65ba169 --- /dev/null +++ b/MaiChartManager/Platform/Windows/WindowsShellService.cs @@ -0,0 +1,24 @@ +#if WINDOWS +using System.Diagnostics; + +namespace MaiChartManager.Platform.Windows; + +/// 通过 explorer.exe / ShellExecute 实现 Windows 系统集成。 +public class WindowsShellService : IShellService +{ + public void RevealInFileManager(string path) + { + Process.Start("explorer.exe", $"/select,\"{path}\""); + } + + public void OpenUrl(string url) + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + + public void OpenPath(string path) + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } +} +#endif diff --git a/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs b/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs new file mode 100644 index 00000000..bc64115a --- /dev/null +++ b/MaiChartManager/Platform/Windows/WindowsTaskbarProgress.cs @@ -0,0 +1,13 @@ +#if WINDOWS +using MaiChartManager.Utils; + +namespace MaiChartManager.Platform.Windows; + +/// Windows 任务栏进度,委托给基于 Vanara 的 WinUtils 辅助方法。 +public class WindowsTaskbarProgress : ITaskbarProgress +{ + public void Set(ulong value, ulong total = 100) => WinUtils.SetTaskbarProgress(value, total); + public void SetIndeterminate() => WinUtils.SetTaskbarProgressIndeterminate(); + public void Clear() => WinUtils.ClearTaskbarProgress(); +} +#endif diff --git a/MaiChartManager/Properties/AssemblyInfo.cs b/MaiChartManager/Properties/AssemblyInfo.cs index 90df6369..1e4a96b7 100644 --- a/MaiChartManager/Properties/AssemblyInfo.cs +++ b/MaiChartManager/Properties/AssemblyInfo.cs @@ -1,11 +1,15 @@ -using System.Reflection; +using System.Reflection; using MaiChartManager; [assembly: AssemblyCompany("Clansty")] -[assembly: AssemblyFileVersion(AppMain.Version)] -[assembly: AssemblyInformationalVersion(AppMain.Version)] [assembly: AssemblyProduct("MaiChartManager")] [assembly: AssemblyTitle("MaiChartManager")] +// 版本号来自 AppMain.Version(AppMain.g.cs):Windows 由 Packaging/Build.ps1 重写, +// Linux 由 Packaging/arch/PKGBUILD 重写,两端一致地从 git tag 派生。 +[assembly: AssemblyFileVersion(AppMain.Version)] +[assembly: AssemblyInformationalVersion(AppMain.Version)] [assembly: AssemblyVersion(AppMain.Version)] +#if WINDOWS [assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows10.0.17763.0")] -[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows10.0.17134.0")] \ No newline at end of file +[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows10.0.17134.0")] +#endif diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index e00338b3..705b240c 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -4,7 +4,6 @@ using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using System.Text.Json.Serialization; -using System.Windows.Forms; using idunno.Authentication.Basic; using MaiChartManager.Controllers.Charts.Services; using MaiChartManager.Controllers.Mod; @@ -13,7 +12,6 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.Extensions.FileProviders; -using Pluralsight.Crypto; using Sentry.AspNetCore; namespace MaiChartManager; @@ -35,38 +33,17 @@ public static async Task StopAsync() private static X509Certificate2 GetCert() { var path = Path.Combine(StaticSettings.appData, "cert.pfx"); - if (File.Exists(path)) - { - return new X509Certificate2(path); - } - - // ASP.NET 是不是不支持 ecc - // var ecdsa = ECDsa.Create(); - // var req = new CertificateRequest("CN=MaiChartManager", ecdsa, HashAlgorithmName.SHA256); - // req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); - // req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); - // req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], true)); - // req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); - // var builder = new SubjectAlternativeNameBuilder(); - // builder.AddDnsName("MaiChartManager"); - // req.CertificateExtensions.Add(builder.Build()); - // - // var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); - using var ctx = new CryptContext(); - ctx.Open(); - - var cert = ctx.CreateSelfSignedCertificate( - new SelfSignedCertProperties - { - IsPrivateKeyExportable = true, - KeyBitLength = 4096, - Name = new X500DistinguishedName("CN=MaiChartManager"), - ValidFrom = DateTime.Today.AddDays(-1), - ValidTo = DateTime.Today.AddYears(5), - }); - - File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx)); - return cert; + if (File.Exists(path)) return X509CertificateLoader.LoadPkcs12FromFile(path, null); + + using var rsa = System.Security.Cryptography.RSA.Create(4096); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=MaiChartManager", rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(5)); + var pfx = cert.Export(X509ContentType.Pfx); + File.WriteAllBytes(path, pfx); + return X509CertificateLoader.LoadPkcs12(pfx, null); } private static bool IsPortAvailable(int port) @@ -96,16 +73,24 @@ private static int GetAvailablePort() return port; } - public static void StartApp(bool export, Action? onStart = null) + // serveSpa:在 loopback 上伺服 wwwroot 里的 Vue SPA(用于 Photino 桌面宿主), + // 但不开放 LAN 端口。放在 onStart 之后以保持现有位置参数调用的兼容性。 + public static void StartApp(bool export, Action? onStart = null, bool serveSpa = false) { - var builder = WebApplication.CreateBuilder(); - + // ContentRoot 必须显式指定为应用自身目录:WebApplication 默认用当前工作目录(cwd), + // 而桌面宿主常从用户 HOME 启动,host 启动时会对 ContentRoot 做文件监视/扫描, + // HOME 下海量文件会让 CreateBuilder 卡上几十秒。指向 exeDir 即可(wwwroot 伺服 + // 走独立 PhysicalFileProvider,不受 ContentRoot 影响)。 + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = StaticSettings.exeDir, + }); builder.WebHost.UseSentry((SentryAspNetCoreOptions o) => { - // Tells which project in Sentry to send events to: + // 指定 Sentry 项目,将事件发送到对应的项目: o.Dsn = "https://be7a9ae3a9a88f4660737b25894b3c20@sentry.c5y.moe/3"; - // Set TracesSampleRate to 1.0 to capture 100% of transactions for tracing. - // We recommend adjusting this value in production. + // 将 TracesSampleRate 设为 1.0 可捕获 100% 的事务用于追踪。 + // 建议在生产环境中适当调低该值。 o.TracesSampleRate = 0.5; }) .ConfigureKestrel(serverOptions => @@ -145,6 +130,21 @@ public static void StartApp(bool export, Action? onStart = null) .AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); +#if WINDOWS + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +#else + // 使用 Photino 原生对话框(替换原 HeadlessDialogService 占位实现),让 OOBE 选目录可用。 + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +#endif + if (StaticSettings.Config.UseAuth) { builder.Services.AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme) @@ -206,7 +206,8 @@ public static void StartApp(bool export, Action? onStart = null) .UseSwagger() .UseSwaggerUI() .UseCors("qwq"); - if (export) + // 当 export 或 serveSpa 时都伺服 SPA:export 是导出场景,serveSpa 是 Photino 桌面宿主场景 + if (export || serveSpa) app.UseFileServer(new FileServerOptions { FileProvider = new PhysicalFileProvider(StaticSettings.wwwroot), diff --git a/MaiChartManager/StaticSettings.cs b/MaiChartManager/StaticSettings.cs index dcebacd1..8c5456c9 100644 --- a/MaiChartManager/StaticSettings.cs +++ b/MaiChartManager/StaticSettings.cs @@ -10,7 +10,7 @@ public partial class StaticSettings { public static readonly string tempPath = Path.Combine(Path.GetTempPath(), "MaiChartManager"); public static readonly string appData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MaiChartManager"); - public static readonly string exeDir = Path.GetDirectoryName(Application.ExecutablePath); + public static readonly string exeDir = Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory; #if DEBUG public static readonly string wwwroot = Path.Combine(ProjectDir, "wwwroot"); private static string ProjectDir => Path.GetDirectoryName(GetThisFilePath())!; @@ -47,8 +47,7 @@ public StaticSettings(ILogger logger, Controllers.Mod.ModConfigS { _logger.LogError(e, "初始化数据目录时出错"); SentrySdk.CaptureException(e); - MessageBox.Show(e.Message, Locale.InitDataDirError, MessageBoxButtons.OK, MessageBoxIcon.Error); - Application.Exit(); + throw new InvalidOperationException(Locale.InitDataDirError, e); } } @@ -63,19 +62,35 @@ public async Task InitializeGameData() { _logger.LogError(e, "初始化数据目录时出错"); SentrySdk.CaptureException(e); - MessageBox.Show(e.Message, Locale.InitDataDirError, MessageBoxButtons.OK, MessageBoxIcon.Error); - Application.Exit(); + throw new InvalidOperationException(Locale.InitDataDirError, e); } } [GeneratedRegex(@"^[A-Z](\d{3})$")] public static partial Regex ADirRegex(); - public static string GamePath { get; set; } + // 默认空字符串而非 null:未配置游戏目录(OOBE 阶段)时,下游 Path.Combine(GamePath, ...) + // 不会因 null 抛 ArgumentNullException(空字符串得到相对路径,后续 Directory/File.Exists 返回 false,优雅降级)。 + public static string GamePath { get; set; } = ""; public static string StreamingAssets => Path.Combine(GamePath, "Sinmai_Data", "StreamingAssets"); - public static IEnumerable AssetsDirs => Directory.EnumerateDirectories(StreamingAssets) - .Select(Path.GetFileName).Where(it => ADirRegex().IsMatch(it)); + public static IEnumerable AssetsDirs => Directory.Exists(StreamingAssets) + ? Directory.EnumerateDirectories(StreamingAssets).Select(Path.GetFileName).Where(it => ADirRegex().IsMatch(it)) + : []; + + /// + /// 在父目录下按名称大小写不敏感地解析子目录的真实路径,找不到返回 null。 + /// 用于兼容 Linux 大小写敏感文件系统:游戏目录在 Windows 下大小写随意(如 musicVersion / MusicVersion), + /// 直接 Path.Combine 固定大小写会在 Linux 上匹配不到。优先尝试精确路径以避免多数情况下的额外枚举。 + /// + public static string? ResolveSubDir(string parent, string name) + { + var exact = Path.Combine(parent, name); + if (Directory.Exists(exact)) return exact; + if (!Directory.Exists(parent)) return null; + return Directory.EnumerateDirectories(parent) + .FirstOrDefault(d => string.Equals(Path.GetFileName(d), name, StringComparison.OrdinalIgnoreCase)); + } public int gameVersion; private List _musicList = []; @@ -122,8 +137,8 @@ public void ScanMusicList() _musicList.Clear(); foreach (var a in AssetsDirs) { - var musicDir = Path.Combine(StreamingAssets, a, "music"); - if (!Directory.Exists(musicDir)) continue; + var musicDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "music"); + if (musicDir is null) continue; foreach (var subDir in Directory.EnumerateDirectories(musicDir)) { @@ -142,7 +157,7 @@ public void ScanMusicList() } } - _logger.LogInformation("Scan music list, found {0} music.", _musicList.Count); + _logger.LogInformation("扫描音乐列表,共找到 {0} 首音乐。", _musicList.Count); } public void ScanGenre() @@ -151,14 +166,17 @@ public void ScanGenre() foreach (var a in AssetsDirs) { - if (!Directory.Exists(Path.Combine(StreamingAssets, a, "musicGenre"))) continue; - foreach (var genreDir in Directory.EnumerateDirectories(Path.Combine(StreamingAssets, a, "musicGenre"), "musicgenre*")) + // 大小写不敏感解析 musicGenre 目录;枚举全部子目录后用大小写不敏感的前缀过滤(不用 glob,避免 Linux 区分大小写匹配不到)。 + var genreParent = ResolveSubDir(Path.Combine(StreamingAssets, a), "musicGenre"); + if (genreParent is null) continue; + foreach (var genreDir in Directory.EnumerateDirectories(genreParent)) { + var dirName = Path.GetFileName(genreDir); + if (!dirName.StartsWith("musicgenre", StringComparison.InvariantCultureIgnoreCase)) continue; if (!File.Exists(Path.Combine(genreDir, "MusicGenre.xml"))) continue; - if (!Path.GetFileName(genreDir).StartsWith("musicgenre", StringComparison.InvariantCultureIgnoreCase)) continue; try { - var id = int.Parse(Path.GetFileName(genreDir).Substring("musicgenre".Length)); + var id = int.Parse(dirName.Substring("musicgenre".Length)); var genreXml = new GenreXml(id, a, GamePath); var existed = GenreList.Find(it => it.Id == id); @@ -178,7 +196,7 @@ public void ScanGenre() } } - _logger.LogInformation("Scan genre list, found {0} genre.", GenreList.Count); + _logger.LogInformation("扫描流派列表,共找到 {0} 个流派。", GenreList.Count); } public void ScanVersionList() @@ -186,14 +204,17 @@ public void ScanVersionList() VersionList.Clear(); foreach (var a in AssetsDirs) { - if (!Directory.Exists(Path.Combine(StreamingAssets, a, "musicVersion"))) continue; - foreach (var versionDir in Directory.EnumerateDirectories(Path.Combine(StreamingAssets, a, "musicVersion"), "musicversion*")) + // 大小写不敏感解析 musicVersion 目录;枚举全部子目录后用大小写不敏感前缀过滤(不用 glob)。 + var versionParent = ResolveSubDir(Path.Combine(StreamingAssets, a), "musicVersion"); + if (versionParent is null) continue; + foreach (var versionDir in Directory.EnumerateDirectories(versionParent)) { + var dirName = Path.GetFileName(versionDir); + if (!dirName.StartsWith("musicversion", StringComparison.InvariantCultureIgnoreCase)) continue; if (!File.Exists(Path.Combine(versionDir, "MusicVersion.xml"))) continue; - if (!Path.GetFileName(versionDir).StartsWith("musicversion", StringComparison.InvariantCultureIgnoreCase)) continue; try { - var id = int.Parse(Path.GetFileName(versionDir).Substring("musicversion".Length)); + var id = int.Parse(dirName.Substring("musicversion".Length)); var versionXml = new VersionXml(id, a, GamePath); var existed = VersionList.Find(it => it.Id == id); @@ -213,7 +234,7 @@ public void ScanVersionList() } } - _logger.LogInformation("Scan version list, found {VersionListCount} version.", VersionList.Count); + _logger.LogInformation("扫描版本列表,共找到 {VersionListCount} 个版本。", VersionList.Count); } public void ScanAssetBundles() @@ -222,8 +243,11 @@ public void ScanAssetBundles() PseudoAssetBundleJacketMap.Clear(); foreach (var a in AssetsDirs) { - if (!Directory.Exists(Path.Combine(StreamingAssets, a, @"AssetBundleImages\jacket"))) continue; - foreach (var jacketFile in Directory.EnumerateFiles(Path.Combine(StreamingAssets, a, @"AssetBundleImages\jacket"))) + // 大小写不敏感解析 AssetBundleImages/jacket 两级目录(兼容 Linux)。 + var abImagesDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "AssetBundleImages"); + var jacketDir = abImagesDir is null ? null : ResolveSubDir(abImagesDir, "jacket"); + if (jacketDir is null) continue; + foreach (var jacketFile in Directory.EnumerateFiles(jacketDir)) { if (!Path.GetFileName(jacketFile).StartsWith("ui_jacket_", StringComparison.InvariantCultureIgnoreCase)) continue; var idStr = Path.GetFileName(jacketFile).Substring("ui_jacket_".Length, 6); @@ -235,7 +259,7 @@ public void ScanAssetBundles() } } - _logger.LogInformation($"Scan AssetBundles, found {AssetBundleJacketMap.Count} AssetBundles."); + _logger.LogInformation($"扫描 AssetBundle,共找到 {AssetBundleJacketMap.Count} 个 AssetBundle。"); } public void ScanSoundData() @@ -243,14 +267,15 @@ public void ScanSoundData() AcbAwb.Clear(); foreach (var a in AssetsDirs) { - if (!Directory.Exists(Path.Combine(StreamingAssets, a, "SoundData"))) continue; - foreach (var sound in Directory.EnumerateFiles(Path.Combine(StreamingAssets, a, @"SoundData"))) + var soundDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "SoundData"); + if (soundDir is null) continue; + foreach (var sound in Directory.EnumerateFiles(soundDir)) { AcbAwb[Path.GetFileName(sound).ToLower()] = sound; } } - _logger.LogInformation($"Scan SoundData, found {AcbAwb.Count} SoundData."); + _logger.LogInformation($"扫描 SoundData,共找到 {AcbAwb.Count} 个音频文件。"); } public void ScanMovieData() @@ -258,15 +283,16 @@ public void ScanMovieData() MovieDataMap.Clear(); foreach (var a in AssetsDirs) { - if (!Directory.Exists(Path.Combine(StreamingAssets, a, "MovieData"))) continue; - foreach (var dat in Directory.EnumerateFiles(Path.Combine(StreamingAssets, a, @"MovieData"))) + var movieDir = ResolveSubDir(Path.Combine(StreamingAssets, a), "MovieData"); + if (movieDir is null) continue; + foreach (var dat in Directory.EnumerateFiles(movieDir)) { if (!int.TryParse(Path.GetFileNameWithoutExtension(dat), out var id)) continue; MovieDataMap[id] = dat; } } - _logger.LogInformation($"Scan MovieData, found {MovieDataMap.Count} MovieData."); + _logger.LogInformation($"扫描 MovieData,共找到 {MovieDataMap.Count} 个视频文件。"); } public void GetGameVersion() @@ -277,14 +303,14 @@ public void GetGameVersion() xmlDoc.Load(Path.Combine(StreamingAssets, @"A000/DataConfig.xml")); if (!int.TryParse(xmlDoc.SelectSingleNode("/DataConfig/version/minor")?.InnerText, out gameVersion)) { - MessageBox.Show(Locale.GameVersionNotFound, Locale.GameVersionNotFoundTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning); + _logger.LogWarning("{message}", Locale.GameVersionNotFound); } } catch (Exception e) { _logger.LogError(e, @"无法获取游戏版本号,可能是因为 A000\DataConfig.xml 找不到或者有错误"); SentrySdk.CaptureException(e); - MessageBox.Show(Locale.GameVersionError, Locale.GameVersionNotFoundTitle, MessageBoxButtons.OK, MessageBoxIcon.Warning); + _logger.LogWarning(e, "{message}", Locale.GameVersionError); } } diff --git a/MaiChartManager/Utils/AssetBundleCreator.cs b/MaiChartManager/Utils/AssetBundleCreator.cs index 411b721d..b36bbd8d 100644 --- a/MaiChartManager/Utils/AssetBundleCreator.cs +++ b/MaiChartManager/Utils/AssetBundleCreator.cs @@ -181,7 +181,9 @@ public static string CreateMusicJacketAssetBundles(ReadOnlySpan pngImageDa resizeWidth: 200, resizeHeight: 200); - return Path.Combine(abiDir, $"{key}.ab"); + // 实际写出的文件名是小写(见上面 key.ToLowerInvariant()),返回路径也必须用小写, + // 否则在 Linux(大小写敏感)上这个路径匹配不到真实文件(Windows 不区分大小写所以遇不到)。 + return Path.Combine(abiDir, $"{key.ToLowerInvariant()}.ab"); } // 读取输入的ab包当中的图片,然后用指定的assetName等参数重新打包。 diff --git a/MaiChartManager/Utils/Audio.cs b/MaiChartManager/Utils/Audio.cs index 75cbaffb..e592bd27 100644 --- a/MaiChartManager/Utils/Audio.cs +++ b/MaiChartManager/Utils/Audio.cs @@ -1,6 +1,6 @@ using NAudio.Lame; using NAudio.Wave; -using Xabe.FFmpeg; +using FFMpegCore; using VGAudio; using VGAudio.Cli; using Xv2CoreLib.ACB; @@ -67,8 +67,10 @@ public static Stream ConvertToWav(Stream src, string extension, float padding = using WaveStream reader = extension switch { ".ogg" => new NAudio.Vorbis.VorbisWaveReader(src, true), - ".mp3" when !forceUseNAudio => new WaveFileReader(ConvertMp3ToWavViaFfmpeg(src)), // 默认情况下,优先使用ffmpeg - _ => new StreamMediaFoundationReader(src), // WAV, WMA, AAC, 以及 MP3+forceUseNAudio,NAudio不支持MP3 Gapless,所以作为一种“兼容模式”提供 + ".mp3" when !forceUseNAudio => new WaveFileReader(ConvertToWavViaFfmpeg(src, ".mp3")), // 默认情况下,优先使用ffmpeg + // WAV / WMA / AAC(以及 MP3+forceUseNAudio 的兼容模式)原本走 Windows-only 的 MediaFoundation, + // 跨平台改为用 ffmpeg 把任意输入解码成 16bit PCM wav,再用 NAudio WaveFileReader 读取。 + _ => new WaveFileReader(ConvertToWavViaFfmpeg(src, extension)), }; // 关于上述MP3 Gapless问题的影响等具体讨论,详见 https://github.com/MuNET-OSS/MaiChartManager/issues/40 var sample = reader.ToSampleProvider(); @@ -98,10 +100,14 @@ public static Stream ConvertToWav(Stream src, string extension, float padding = return stream; } - private static MemoryStream ConvertMp3ToWavViaFfmpeg(Stream src) + // 用 ffmpeg 把任意输入流(按 ext 写到临时文件)解码成 16bit PCM wav,返回 wav 的内存流。 + // 替代 Windows-only 的 MediaFoundation,跨平台可用(系统 ffmpeg 已配好)。 + private static MemoryStream ConvertToWavViaFfmpeg(Stream src, string ext) { var tempFileGuid = Guid.NewGuid(); - var inputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}.mp3"); + // ext 形如 ".mp3"/".wav"/".aac" 等;去掉前导点用作临时输入文件后缀 + var inputExt = string.IsNullOrEmpty(ext) ? "" : (ext.StartsWith('.') ? ext : "." + ext); + var inputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}{inputExt}"); var outputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}.wav"); try { @@ -112,15 +118,17 @@ private static MemoryStream ConvertMp3ToWavViaFfmpeg(Stream src) src.CopyTo(inputFile); } - var conversion = FFmpeg.Conversions.New() - .AddParameter("-i " + FFmpegHelper.Escape(inputPath)) - .AddParameter("-c:a pcm_s16le") // 转为16-bit little-endian PCM - .SetOutput(outputPath) - .SetOverwriteOutput(true); - conversion.Start().GetAwaiter().GetResult(); + // 用 FFMpegCore 把任意输入解码成 16bit PCM wav。 + // FFMpegCore 用参数数组传给 ffmpeg,既能正确处理带空格的路径(Windows), + // 又不引入 Xabe 在 Linux 上把引号字面传给 ffmpeg 的问题。 + // 等价命令行:ffmpeg -y -i -c:a pcm_s16le + FFMpegArguments + .FromFileInput(inputPath, verifyExists: false) + .OutputToFile(outputPath, overwrite: true, o => o.WithCustomArgument("-c:a pcm_s16le")) + .ProcessSynchronously(); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) - throw new InvalidOperationException("ffmpeg produced empty wav file from mp3 input."); + throw new InvalidOperationException("ffmpeg produced empty wav file from input."); return new MemoryStream(File.ReadAllBytes(outputPath)); } @@ -149,7 +157,20 @@ public static byte[] ConvertFile( if (options.Loop) options.LoopEnd = int.MaxValue; - byte[] track = ConvertStream.ConvertFile(options, s, encodeType, convertToType); + // AcbCore 的 ConvertStream.ConvertFile 要求传入 MemoryStream;若来源不是则拷贝一份 + MemoryStream ms; + if (s is MemoryStream existing) + { + ms = existing; + } + else + { + ms = new MemoryStream(); + s.CopyTo(ms); + ms.Position = 0; + } + + byte[] track = ConvertStream.ConvertFile(options, ms, encodeType, convertToType); //if (convertToType == FileType.Hca && loop) // track = HCA.EncodeLoop(track, loop); @@ -194,11 +215,40 @@ public static byte[] AcbToWav(string acbPath) // 从MP4视频文件中提取音频轨道并保存为WAV文件 public static void ExtractAudioFromMp4(string mp4Path, string outputWavPath) { - using (var reader = new MediaFoundationReader(mp4Path)) + // 原本用 Windows-only 的 MediaFoundationReader 解码 mp4 内的音频流, + // 跨平台改为先用 ffmpeg 把 mp4 的音频解码成 16bit PCM wav,再用 NAudio 读取写出。 + var wavPath = ConvertMp4AudioToWavViaFfmpeg(mp4Path); + try { - // MediaFoundationReader 会自动解码视频中的音频流(如AAC)为PCM + using var reader = new WaveFileReader(wavPath); WaveFileWriter.CreateWaveFile(outputWavPath, reader); } + finally + { + File.Delete(wavPath); + } + } + + // 用 ffmpeg 把 mp4(或其它视频容器)里的音频流解码成 16bit PCM wav,返回临时 wav 文件路径(调用方负责删除)。 + private static string ConvertMp4AudioToWavViaFfmpeg(string mp4Path) + { + Directory.CreateDirectory(StaticSettings.tempPath); + var outputPath = Path.Combine(StaticSettings.tempPath, $"ExtractMp4Audio_{Guid.NewGuid():N}.wav"); + + // 用 FFMpegCore 把 mp4(或其它视频容器)里的音频流解码成 16bit PCM wav。 + // 等价命令行:ffmpeg -y -i -vn -c:a pcm_s16le + FFMpegArguments + .FromFileInput(mp4Path, verifyExists: false) + .OutputToFile(outputPath, overwrite: true, o => o.WithCustomArgument("-vn -c:a pcm_s16le")) + .ProcessSynchronously(); + + if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) + { + File.Delete(outputPath); + throw new InvalidOperationException("ffmpeg produced empty wav file from mp4 input."); + } + + return outputPath; } // 将 WAV 字节数据转换为 MP3 文件 diff --git a/MaiChartManager/Utils/AudioConvert.cs b/MaiChartManager/Utils/AudioConvert.cs index bd186c9f..dbffa9d2 100644 --- a/MaiChartManager/Utils/AudioConvert.cs +++ b/MaiChartManager/Utils/AudioConvert.cs @@ -1,6 +1,6 @@ using NAudio.Lame; using Standart.Hash.xxHash; -using Xabe.FFmpeg; +using FFMpegCore; namespace MaiChartManager.Utils; @@ -84,36 +84,45 @@ public static void ConvertWavToMp3Stream(byte[] wav, Stream mp3Stream, ID3TagDat Directory.CreateDirectory(StaticSettings.tempPath); File.WriteAllBytes(inputPath, wav); - var conversion = FFmpeg.Conversions.New() - .AddParameter($"-i " + FFmpegHelper.Escape(inputPath)); + // 输出参数(PostInput):按原 Xabe 调用顺序拼装,保持 ffmpeg 命令行等价。 + // FFMpegCore 用 ArgumentList 传递,不会像 Xabe 那样产生引号问题; + // 但 -metadata 的值可能含空格,这里仍用 Escape() 把值包成带引号的单个 token, + // 与原先 FFmpegHelper.Escape 行为一致。 + var output = new List(); if (tagData != null) { - if (tagData.AlbumArt != null && tagData.AlbumArt.Length > 0) - { - // 把专辑封面写到临时文件,然后让ffmpeg把它嵌入mp3 - albumArtPath = Path.Combine(StaticSettings.tempPath, $"ConvertToMp3_{tempFileGuid:N}.png"); - File.WriteAllBytes(albumArtPath, tagData.AlbumArt); - conversion.AddParameter($"-i {FFmpegHelper.Escape(albumArtPath)}"); - } // 顺序不能换!这个必须在第一个,因为-i必须在任何其他参数之前。 - if (!string.IsNullOrEmpty(tagData.Title)) conversion.AddParameter($"-metadata title=" + FFmpegHelper.Escape(tagData.Title)); - if (!string.IsNullOrEmpty(tagData.Artist)) conversion.AddParameter($"-metadata artist=" + FFmpegHelper.Escape(tagData.Artist)); - if (!string.IsNullOrEmpty(tagData.Album)) conversion.AddParameter($"-metadata album=" + FFmpegHelper.Escape(tagData.Album)); - if (!string.IsNullOrEmpty(tagData.Year)) conversion.AddParameter($"-metadata date=" + FFmpegHelper.Escape(tagData.Year)); - if (!string.IsNullOrEmpty(tagData.Comment)) conversion.AddParameter($"-metadata comment=" + FFmpegHelper.Escape(tagData.Comment)); - if (!string.IsNullOrEmpty(tagData.Genre)) conversion.AddParameter($"-metadata genre=" + FFmpegHelper.Escape(tagData.Genre)); - if (!string.IsNullOrEmpty(tagData.Track)) conversion.AddParameter($"-metadata track=" + FFmpegHelper.Escape(tagData.Track)); + // 注意:第二个输入(专辑封面)由 AddFileInput 处理,必须在所有输出参数之前, + // 对应原注释“-i 必须在任何其他参数之前”。 + if (!string.IsNullOrEmpty(tagData.Title)) output.Add("-metadata title=" + Escape(tagData.Title)); + if (!string.IsNullOrEmpty(tagData.Artist)) output.Add("-metadata artist=" + Escape(tagData.Artist)); + if (!string.IsNullOrEmpty(tagData.Album)) output.Add("-metadata album=" + Escape(tagData.Album)); + if (!string.IsNullOrEmpty(tagData.Year)) output.Add("-metadata date=" + Escape(tagData.Year)); + if (!string.IsNullOrEmpty(tagData.Comment)) output.Add("-metadata comment=" + Escape(tagData.Comment)); + if (!string.IsNullOrEmpty(tagData.Genre)) output.Add("-metadata genre=" + Escape(tagData.Genre)); + if (!string.IsNullOrEmpty(tagData.Track)) output.Add("-metadata track=" + Escape(tagData.Track)); } - - conversion.AddParameter("-c:a libmp3lame -b:a 256k"); // 把wav编码为256kbps的LAME mp3 + output.Add("-c:a libmp3lame -b:a 256k"); // 把wav编码为256kbps的LAME mp3 + + if (tagData?.AlbumArt is { Length: > 0 }) + { + // 把专辑封面写到临时文件,然后让ffmpeg把它嵌入mp3 + albumArtPath = Path.Combine(StaticSettings.tempPath, $"ConvertToMp3_{tempFileGuid:N}.png"); + File.WriteAllBytes(albumArtPath, tagData.AlbumArt); + // 如果有专辑封面,还需要加一堆参数以写入专辑封面 + output.Add("-map 0:a -map 1:v -c:v copy -disposition:v attached_pic"); + } + + var args = FFMpegArguments.FromFileInput(inputPath, verifyExists: false); if (albumArtPath != null) - { // 如果有专辑封面,还需要加一堆参数以写入专辑封面 - conversion.AddParameter("-map 0:a -map 1:v -c:v copy -disposition:v attached_pic"); + { + args = args.AddFileInput(albumArtPath, verifyExists: false); } - - conversion.SetOutput(outputPath).SetOverwriteOutput(true); - conversion.Start().GetAwaiter().GetResult(); + + args + .OutputToFile(outputPath, overwrite: true, o => o.WithCustomArgument(string.Join(" ", output))) + .ProcessSynchronously(); if (!File.Exists(outputPath) || new FileInfo(outputPath).Length == 0) { @@ -130,4 +139,12 @@ public static void ConvertWavToMp3Stream(byte[] wav, Stream mp3Stream, ID3TagDat if (albumArtPath != null) File.Delete(albumArtPath); } } + + /// + /// 将字符串转义后用双引号包裹,作为单个 ffmpeg 参数 token。 + /// 等价于原 FFmpegHelper.Escape:正确转义内容中的反斜杠和双引号。 + /// 用于 -metadata 值,避免含空格的值被拆成多个参数。 + /// + private static string Escape(string value) => + "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; } diff --git a/MaiChartManager/Utils/FFmpegHelper.cs b/MaiChartManager/Utils/FFmpegHelper.cs deleted file mode 100644 index d6a3d60e..00000000 --- a/MaiChartManager/Utils/FFmpegHelper.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace MaiChartManager.Utils; - -public static class FFmpegHelper -{ - /// - /// 将字符串转义后用双引号包裹,用于安全地拼接 ffmpeg 参数。 - /// 与 Xabe.FFmpeg 自带的 Escape() 不同,这里会正确转义内容中的反斜杠和双引号。 - /// - public static string? Escape(string? value) - { - if (value == null) return value; - return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - } -} - diff --git a/MaiChartManager/Utils/FfmpegDiagnostics.cs b/MaiChartManager/Utils/FfmpegDiagnostics.cs index b83a439c..bc4dd760 100644 --- a/MaiChartManager/Utils/FfmpegDiagnostics.cs +++ b/MaiChartManager/Utils/FfmpegDiagnostics.cs @@ -1,24 +1,27 @@ using System.Collections.Concurrent; -using Xabe.FFmpeg; -using Xabe.FFmpeg.Exceptions; namespace MaiChartManager.Utils; +/// +/// 收集 ffmpeg 运行期间输出到 stderr 的日志行(ffmpeg 几乎所有诊断信息都走 stderr)。 +/// 迁移到 FFMpegCore 后,不再有 Xabe 的 IConversion / OnDataReceived 事件, +/// 改为把 直接挂到 FFMpegArgumentProcessor 的 NotifyOnError 上。 +/// public sealed class FfmpegLogCollector { private const int MaxLines = 400; private readonly ConcurrentQueue lines = new(); - public void Attach(IConversion conversion) + /// + /// 接收一行 ffmpeg 输出。用法:processor.NotifyOnError(collector.AddLine)。 + /// + public void AddLine(string? data) { - conversion.OnDataReceived += (_, args) => + if (string.IsNullOrWhiteSpace(data)) return; + lines.Enqueue(data); + while (lines.Count > MaxLines && lines.TryDequeue(out _)) { - if (string.IsNullOrWhiteSpace(args.Data)) return; - lines.Enqueue(args.Data); - while (lines.Count > MaxLines && lines.TryDequeue(out var _)) - { - } - }; + } } public string GetLog() => string.Join(Environment.NewLine, lines); @@ -30,13 +33,7 @@ public static string CreateDetail(Exception exception, string? ffmpegLog = null) { var parts = new List(); - if (exception is ConversionException conversionException && - !string.IsNullOrWhiteSpace(conversionException.InputParameters)) - { - parts.Add("FFmpeg parameters:"); - parts.Add(conversionException.InputParameters); - } - + // FFMpegCore 的异常不携带 Xabe 的 InputParameters,关键信息全在收集到的 ffmpeg 日志里。 if (!string.IsNullOrWhiteSpace(ffmpegLog)) { parts.Add("FFmpeg output:"); diff --git a/MaiChartManager/Utils/ImageConvert.cs b/MaiChartManager/Utils/ImageConvert.cs index 57dd41b0..223b0996 100644 --- a/MaiChartManager/Utils/ImageConvert.cs +++ b/MaiChartManager/Utils/ImageConvert.cs @@ -1,5 +1,11 @@ -using AssetStudio; +using AssetsTools.NET; +using AssetsTools.NET.Extra; +using AssetsTools.NET.Texture; using MaiChartManager.Models; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Image = SixLabors.ImageSharp.Image; namespace MaiChartManager.Utils; @@ -7,34 +13,41 @@ public static class ImageConvert { public static byte[]? GetMusicJacketPngData(this MusicXmlWithABJacket? music) { - if (music == null) - { - return null; - } - - if (System.IO.File.Exists(music.JacketPath)) - { - return File.ReadAllBytes(music.JacketPath); - } - - if (System.IO.File.Exists(music.PseudoAssetBundleJacket)) - { - return File.ReadAllBytes(music.PseudoAssetBundleJacket); - } - + if (music == null) return null; + if (File.Exists(music.JacketPath)) return File.ReadAllBytes(music.JacketPath); + if (File.Exists(music.PseudoAssetBundleJacket)) return File.ReadAllBytes(music.PseudoAssetBundleJacket); if (music.AssetBundleJacket is null) return null; return GetTextureAsPngData(music.AssetBundleJacket); } public static byte[]? GetTextureAsPngData(string inputAbPath) { - var manager = new AssetsManager(); - manager.LoadFiles(inputAbPath); - var asset = manager.assetsFileList[0].Objects.Find(it => it.type == ClassIDType.Texture2D); - if (asset is null) return null; + var am = new AssetsManager(); + var bunInst = am.LoadBundleFile(inputAbPath, true); + var afileInst = am.LoadAssetsFileFromBundle(bunInst, 0, false); + + foreach (var info in afileInst.file.Metadata.AssetInfos) + { + var baseField = am.GetBaseField(afileInst, info); + if (baseField.IsDummy || baseField.TypeName != "Texture2D") continue; - var texture = asset as Texture2D; - using var stream = texture.ConvertToStream(ImageFormat.Png, true); - return stream.ToArray(); + var tex = TextureFile.ReadTextureFile(baseField); + // 从 bundle 内解析贴图像素数据:同时覆盖 inline "image data" 与 bundle 内部的 .resS streamData。 + // 游戏原始封面的像素数据存在 bundle 内部的 .resS 里,必须用 bundle 解析, + // 不能只读文件系统目录(之前用 FillPictureData(目录) 会导致原始封面解析失败、接口返回 404)。 + tex.SetPictureDataFromBundle(bunInst); + if (tex.pictureData is null || tex.pictureData.Length == 0) return null; + + var bgra = TextureFile.DecodeManagedData( + tex.pictureData, (TextureFormat)tex.m_TextureFormat, tex.m_Width, tex.m_Height, true); + if (bgra is null || bgra.Length == 0) return null; + + using var image = Image.LoadPixelData(bgra, tex.m_Width, tex.m_Height); + image.Mutate(x => x.Flip(FlipMode.Vertical)); + using var ms = new MemoryStream(); + image.SaveAsPng(ms); + return ms.ToArray(); + } + return null; } -} \ No newline at end of file +} diff --git a/MaiChartManager/Utils/PathUtils.cs b/MaiChartManager/Utils/PathUtils.cs new file mode 100644 index 00000000..5e48b630 --- /dev/null +++ b/MaiChartManager/Utils/PathUtils.cs @@ -0,0 +1,40 @@ +namespace MaiChartManager.Utils; + +public static class PathUtils +{ + /// 判断路径是否包含某个目录段(跨平台,忽略分隔符差异,大小写不敏感) + public static bool ContainsSegment(string? path, string segment) + => path is not null && + path.Replace('\\', '/').Contains($"/{segment}/", System.StringComparison.InvariantCultureIgnoreCase); + + /// + /// 大小写不敏感地逐段解析路径,返回文件系统中实际存在的真实大小写路径。 + /// 用于兼容 Linux 大小写敏感文件系统:游戏目录/文件大小写可能与代码硬编码的不一致 + /// (如 musicVersion / musicversion、MusicVersion000001 / musicversion000001)。 + /// 某一段在文件系统中不存在时(例如写入尚不存在的新文件/目录),该段及之后按给定大小写直接拼接。 + /// + public static string ResolveIgnoreCase(string basePath, params string[] segments) + { + var current = basePath; + foreach (var seg in segments) + { + var exact = Path.Combine(current, seg); + if (File.Exists(exact) || Directory.Exists(exact)) + { + current = exact; + continue; + } + + string? match = null; + if (Directory.Exists(current)) + { + match = Directory.EnumerateFileSystemEntries(current) + .FirstOrDefault(e => string.Equals(Path.GetFileName(e), seg, System.StringComparison.OrdinalIgnoreCase)); + } + + current = match ?? exact; + } + + return current; + } +} diff --git a/MaiChartManager/Utils/VideoConvert.cs b/MaiChartManager/Utils/VideoConvert.cs index 3e8dc335..26932232 100644 --- a/MaiChartManager/Utils/VideoConvert.cs +++ b/MaiChartManager/Utils/VideoConvert.cs @@ -1,5 +1,6 @@ -using Microsoft.VisualBasic.FileIO; -using Xabe.FFmpeg; +using MaiChartManager.Platform; +using FFMpegCore; +using FFMpegCore.Enums; namespace MaiChartManager.Utils; @@ -13,59 +14,63 @@ public enum HardwareAccelerationStatus } public static HardwareAccelerationStatus HardwareAcceleration { get; private set; } = HardwareAccelerationStatus.Pending; - public static string H264Encoder { get; private set; } = "libx264"; - private static string Vp9Encoding => HardwareAcceleration == HardwareAccelerationStatus.Enabled ? "vp9_qsv" : "vp9"; + // 暴露给前端显示的选中 H264 编码器名(保持原 API 契约)。 + public static string H264Encoder => VideoEncoderProbe.H264Profile.Name; + private static readonly SemaphoreSlim UsmToMp4Semaphore = new( Math.Max(1, Environment.ProcessorCount / 4), Math.Max(1, Environment.ProcessorCount / 4)); /// - /// 检测硬件加速支持 + /// 等价于 Xabe 的 UseMultiThread(true):渲染为 "-threads {Min(ProcessorCount, 16)}"。 /// - public static async Task CheckHardwareAcceleration() + private static string MultiThreadArg => $"-threads {Math.Min(Environment.ProcessorCount, 16)}"; + + /// + /// 把 TimeSpan 格式化为 ffmpeg 时间字符串 H:MM:SS.mmm, + /// 与原 Xabe TimeExtensions.ToFFmpeg 行为一致("{0:D}:{1:D2}:{2:D2}.{3:D3}", + /// 参数为 (int)TotalHours, Minutes, Seconds, Milliseconds)。 + /// + private static string FormatFFmpegTime(TimeSpan time) => + $"{(int)time.TotalHours:D}:{time.Minutes:D2}:{time.Seconds:D2}.{time.Milliseconds:D3}"; + + /// + /// 软件专属输出参数(-cpu-used/-pix_fmt)只对软件 VP9 编码器发出;-threads 与今天一致始终发出。 + /// 顺序对齐今天:-c:v <codec> -map 0:0 [图片 -r 1 -t 2] -threads N [软件VP9: -cpu-used 5 [pix_fmt]] [profile 硬件额外] + /// + private static List BuildPostArgs(VideoEncoderProfile p, VideoConvertOptions options, bool isImage) { - var tmpDir = Directory.CreateTempSubdirectory(); - try - { - // 测试 VP9 QSV 硬件加速 - var blankPath = Path.Combine(tmpDir.FullName, "blank.ivf"); - await FFmpeg.Conversions.New() - .SetOutputTime(TimeSpan.FromSeconds(2)) - .SetInputFormat(Format.lavfi) - .AddParameter("-i color=c=black:s=720x720:r=1") - .AddParameter("-c:v vp9_qsv") - .UseMultiThread(true) - .SetOutput(blankPath) - .Start(); - HardwareAcceleration = HardwareAccelerationStatus.Enabled; - } - catch + var post = new List { $"-c:v {p.Codec}", "-map 0:0" }; + if (isImage) post.Add("-r 1 -t 2"); + post.Add(MultiThreadArg); + if (p.Kind == VideoCodecKind.Vp9 && !p.IsHardware) { - HardwareAcceleration = HardwareAccelerationStatus.Disabled; + post.Add("-cpu-used 5"); + if (options.UseYuv420p) post.Add("-pix_fmt yuv420p"); } + post.AddRange(p.ExtraOutputArgs); + return post; + } - // 检测 H264 硬件编码器 - foreach (var encoder in (string[])["h264_nvenc", "h264_qsv", "h264_vaapi", "h264_amf", "h264_mf", "h264_vulkan"]) - { - try - { - var blankPath = Path.Combine(tmpDir.FullName, $"{encoder}.mp4"); - await FFmpeg.Conversions.New() - .SetOutputTime(TimeSpan.FromSeconds(2)) - .SetInputFormat(Format.lavfi) - .AddParameter("-i color=c=black:s=720x720:r=1") - .AddParameter($"-c:v {encoder}") - .UseMultiThread(true) - .SetOutput(blankPath) - .Start(); - H264Encoder = encoder; - break; - } - catch { } - } + /// + /// 把硬件上传节点追加到软件 filter 链末尾(软件 profile 原样返回 vf)。 + /// + private static string AppendUpload(string vf, VideoEncoderProfile p) + { + if (string.IsNullOrEmpty(p.UploadFilter)) return vf; + return string.IsNullOrEmpty(vf) ? p.UploadFilter : $"{vf},{p.UploadFilter}"; + } - Console.WriteLine($"H264 encoder: {H264Encoder}"); + /// + /// 探测并选定 H264/VP9 编码器 profile。启动时调用(AppMain / LinuxProgram)。 + /// + public static async Task CheckHardwareAcceleration() + { + await VideoEncoderProbe.Probe(StaticSettings.Config.ForceSoftwareVideo); + HardwareAcceleration = (VideoEncoderProbe.H264Profile.IsHardware || VideoEncoderProbe.Vp9Profile.IsHardware) + ? HardwareAccelerationStatus.Enabled + : HardwareAccelerationStatus.Disabled; } public class VideoConvertOptions @@ -123,7 +128,9 @@ public static async Task ConvertVideo(VideoConvertOptions options) { if (options.TaskbarProgress) { +#if WINDOWS WinUtils.SetTaskbarProgressIndeterminate(); +#endif } var outputDirectory = Path.GetDirectoryName(options.OutputPath); @@ -145,13 +152,15 @@ public static async Task ConvertVideo(VideoConvertOptions options) // 第二步:VP9 直接打包到目标 USM,避免中间 USM 文件再复制。 if (options.UseH264) { - FileSystem.CopyFile(intermediateFile, options.OutputPath, true); + PlatformFile.CopyFile(intermediateFile, options.OutputPath); } else { if (options.TaskbarProgress) { +#if WINDOWS WinUtils.SetTaskbarProgressIndeterminate(); +#endif } WannaCRI.WannaCRI.CreateUsm(intermediateFile, options.OutputPath); @@ -163,7 +172,9 @@ public static async Task ConvertVideo(VideoConvertOptions options) } finally { +#if WINDOWS WinUtils.ClearTaskbarProgress(); +#endif // 清理临时目录 try { @@ -178,20 +189,19 @@ public static async Task ConvertVideo(VideoConvertOptions options) private static async Task ConvertToVp9OrH264(VideoConvertOptions options, string outputPath, string tmpDir) { - var srcMedia = await FFmpeg.GetMediaInfo(options.InputPath); - var codec = options.UseH264 ? H264Encoder : Vp9Encoding; - var firstStream = srcMedia.VideoStreams.First().SetCodec(codec); - var conversion = FFmpeg.Conversions.New() - .AddStream(firstStream); + var srcMedia = await FFProbe.AnalyseAsync(options.InputPath); + var profile = options.UseH264 ? VideoEncoderProbe.H264Profile : VideoEncoderProbe.Vp9Profile; + var srcWidth = srcMedia.PrimaryVideoStream!.Width; + var srcHeight = srcMedia.PrimaryVideoStream!.Height; + var srcDuration = srcMedia.Duration; + var logCollector = new FfmpegLogCollector(); - logCollector.Attach(conversion); // 处理图片输入 - if (options.ContentType?.StartsWith("image/") == true) + var isImage = options.ContentType?.StartsWith("image/") == true; + if (isImage) { options.Padding = 0; - conversion.AddParameter("-r 1 -t 2"); - conversion.AddParameter("-loop 1", ParameterPosition.PreInput); } // 处理极小的 padding @@ -208,69 +218,78 @@ private static async Task ConvertToVp9OrH264(VideoConvertOptions options, string vf = $"scale={scale}:-1,pad={scale}:{scale}:({scale}-iw)/2:({scale}-ih)/2:black"; } - // 处理 padding - if (options.Padding < 0) - { - // 负数:裁剪开头 - conversion.SetSeek(TimeSpan.FromSeconds(-options.Padding)); - } - else if (options.Padding > 0) + // 与 Xabe 行为一致:源视频流默认通过 -map 0:0 选取并以 -c:v 编码。 + // PreInput 参数(如 -loop 1 / -hwaccel dxva2)放到 FromFileInput 的 input options; + // PostInput 参数(-c:v / -t / -vf / -threads 等)放到 OutputToFile 的 output options, + // 顺序严格对应原 Xabe 的拼装顺序: + // [streams PostInput: -c:v -map 0:0] [user PostInput...] + + if (options.Padding > 0) { - // 正数:添加前置空白 + // 正数:添加前置空白,先生成 blank,再 concat。 + // 等价 Xabe:-t -f lavfi -i color=c=black:s=WxH:r=30 -threads N var blankPath = Path.Combine(tmpDir, "blank.mp4"); - var blank = FFmpeg.Conversions.New() - .SetOutputTime(TimeSpan.FromSeconds(options.Padding)) - .SetInputFormat(Format.lavfi) - .AddParameter($"-i color=c=black:s={srcMedia.VideoStreams.First().Width}x{srcMedia.VideoStreams.First().Height}:r=30") - .UseMultiThread(true) - .SetOutput(blankPath); - logCollector.Attach(blank); - await blank.Start(); - var blankVideoInfo = await FFmpeg.GetMediaInfo(blankPath); - conversion = Concatenate(vf, blankVideoInfo, srcMedia); - logCollector.Attach(conversion); - conversion.AddParameter($"-c:v {codec}"); + await FFMpegArguments + .FromFileInput($"color=c=black:s={srcWidth}x{srcHeight}:r=30", verifyExists: false, + opt => opt.WithCustomArgument($"-t {FormatFFmpegTime(TimeSpan.FromSeconds(options.Padding))} -f lavfi")) + .OutputToFile(blankPath, overwrite: true, + opt => opt.WithCustomArgument(MultiThreadArg)) + .NotifyOnError(logCollector.AddLine) + .ProcessAsynchronously(); + + await RunConcatenate(vf, profile, [blankPath, options.InputPath], outputPath, options, logCollector, srcDuration); + return; } - // 基本参数 - conversion - .SetOutput(outputPath) - .AddParameter("-hwaccel dxva2", ParameterPosition.PreInput) - .UseMultiThread(true); - - // VP9 特定参数 - if (!options.UseH264) + // 非 padding>0 的常规路径,消费选定的编码器 profile。 + // PreInput:图片 -loop 1 在前,随后 profile.PreInputArgs(Windows=-hwaccel dxva2,Linux VAAPI=-vaapi_device …)。 + var preArgs = new List(); + if (isImage) { - conversion.AddParameter("-cpu-used 5"); - if (options.UseYuv420p) - conversion.AddParameter("-pix_fmt yuv420p"); + preArgs.Add("-loop 1"); } + preArgs.AddRange(profile.PreInputArgs); - // 应用缩放参数 - if (!options.NoScale && options.Padding <= 0) + // PostInput:-c:v -map 0:0 [图片 -r 1 -t 2] -threads N [软件VP9: -cpu-used 5 [pix_fmt]] [硬件额外] + var postArgs = BuildPostArgs(profile, options, isImage); + + // 负数 padding:裁剪开头(-ss),位置与今天一致(用户 PostInput 段、在 -threads 之后)。 + if (options.Padding < 0) { - conversion.AddParameter($"-vf {vf}"); + postArgs.Add($"-ss {FormatFFmpegTime(TimeSpan.FromSeconds(-options.Padding))}"); } - // 进度回调 - if (options.OnProgress != null) + // 应用 filter:软件缩放 + 硬件上传节点(hwupload)。 + if (!options.NoScale && options.Padding <= 0) { - conversion.OnProgress += (sender, args) => - { - options.OnProgress((int)args.Percent); - }; + postArgs.Add($"-vf {AppendUpload(vf, profile)}"); } - if (options.TaskbarProgress) + else if (profile.UploadFilter is not null) { - conversion.OnProgress += (sender, args) => - { - WinUtils.SetTaskbarProgress((ulong)args.Percent); - }; + // NoScale 但硬件编码器仍需要把帧上传到硬件表面 + postArgs.Add($"-vf {AppendUpload("", profile)}"); } + // preArgs 在 Linux 软件编码非图片场景下可能为空,此时不附加任何 input 参数。 + var args = preArgs.Count > 0 + ? FFMpegArguments.FromFileInput(options.InputPath, verifyExists: false, + opt => opt.WithCustomArgument(string.Join(" ", preArgs))) + : FFMpegArguments.FromFileInput(options.InputPath, verifyExists: false); + +#if DEBUG + Console.WriteLine($"[ffargs] kind={(options.UseH264 ? "H264" : "VP9")} profile={profile.Name} pre=[{string.Join(" ", preArgs)}] post=[{string.Join(" ", postArgs)}]"); +#endif + + var processor = args + .OutputToFile(outputPath, overwrite: true, + opt => opt.WithCustomArgument(string.Join(" ", postArgs))) + .NotifyOnError(logCollector.AddLine); + + AttachProgress(processor, options, srcDuration); + try { - await conversion.Start(); + await processor.ProcessAsynchronously(); } catch (Exception ex) { @@ -281,23 +300,102 @@ private static async Task ConvertToVp9OrH264(VideoConvertOptions options, string } } - private static IConversion Concatenate(string vf, params IMediaInfo[] mediaInfos) + /// + /// 把进度回调挂到 FFMpegCore 的 NotifyOnProgress 上,用源时长换算百分比, + /// 语义对应原 Xabe 的 conversion.OnProgress += (s, args) => cb((int)args.Percent)。 + /// 注意:FFMpegCore 的 NotifyOnProgress 只保留最后一次注册的回调(不像 Xabe 的事件可叠加), + /// 所以必须把「前端进度」和「任务栏进度」合并到同一个回调里,否则后者会把前者覆盖掉, + /// 导致 Linux 上(任务栏回调是 #if WINDOWS 空实现)前端进度恒为 0。 + /// + private static void AttachProgress(FFMpegArgumentProcessor processor, VideoConvertOptions options, TimeSpan totalDuration) + { + if (options.OnProgress == null && !options.TaskbarProgress) return; + + processor.NotifyOnProgress(percent => + { + options.OnProgress?.Invoke((int)percent); +#if WINDOWS + if (options.TaskbarProgress) + WinUtils.SetTaskbarProgress((ulong)percent); +#endif + }, totalDuration); + } + + /// + /// 等价于原 Concatenate + 启动转换:把多个输入用 concat 滤镜拼接后输出。 + /// 原 Xabe 拼装(按输入顺序): + /// -i in0 -i in1 ... -filter_complex "[0:v]setsar=1[0s];...[0s] [1s] ...concat=n=K:v=1 [v]; [v][vout]" -map "[vout]" -aspect 1:1 + /// 然后再 AddParameter("-c:v <codec>") 与基本段的 -hwaccel(PreInput)/-threads, + /// 此处一并按相同顺序拼出。 + /// + private static async Task RunConcatenate(string vf, VideoEncoderProfile profile, string[] inputs, string outputPath, + VideoConvertOptions options, FfmpegLogCollector logCollector, TimeSpan totalDuration) { - var conversion = FFmpeg.Conversions.New(); - foreach (var inputVideo in mediaInfos) + // filter_complex 串,完全照搬原 Concatenate 的拼接逻辑;末尾把硬件上传节点接到 [vout] 前。 + var fc = ""; + for (var index = 0; index < inputs.Length; ++index) + fc += $"[{index}:v]setsar=1[{index}s];"; + for (var index = 0; index < inputs.Length; ++index) + fc += $"[{index}s] "; + var tail = AppendUpload(vf, profile); // 软件: vf;硬件: vf,format=nv12,hwupload(vf 可空) + fc += $"concat=n={inputs.Length}:v=1 [v]; [v]{tail}[vout]"; + + // 输入:保持原顺序(blank 在前,源在后)。第一个输入的 PreInput 用 profile.PreInputArgs + //(Windows=-hwaccel dxva2,Linux VAAPI=-vaapi_device …;软件为空串无害)。 + var args = profile.PreInputArgs.Count > 0 + ? FFMpegArguments.FromFileInput(inputs[0], verifyExists: false, + opt => opt.WithCustomArgument(string.Join(" ", profile.PreInputArgs))) + : FFMpegArguments.FromFileInput(inputs[0], verifyExists: false); + for (var i = 1; i < inputs.Length; i++) + { + args = args.AddFileInput(inputs[i], verifyExists: false); + } + + // PostInput 段顺序对应原代码: + // -filter_complex "..." -map "[vout]" (来自 Concatenate) + // -aspect 1:1 (来自 Concatenate) + // -c:v (Concatenate 后 AddParameter) + // -threads N (基本段 UseMultiThread) + // [软件VP9] -cpu-used 5 [+ -pix_fmt yuv420p] + // [硬件额外] + var postArgs = new List { - conversion.AddParameter("-i " + FFmpegHelper.Escape(inputVideo.Path) + " "); + $"-filter_complex \"{fc}\" -map \"[vout]\"", + "-aspect 1:1", + $"-c:v {profile.Codec}", + MultiThreadArg, + }; + + if (profile.Kind == VideoCodecKind.Vp9 && !profile.IsHardware) + { + postArgs.Add("-cpu-used 5"); + if (options.UseYuv420p) + postArgs.Add("-pix_fmt yuv420p"); } + postArgs.AddRange(profile.ExtraOutputArgs); - conversion.AddParameter("-filter_complex \""); - for (var index = 0; index < mediaInfos.Length; ++index) - conversion.AddParameter($"[{index}:v]setsar=1[{index}s];"); - for (var index = 0; index < mediaInfos.Length; ++index) - conversion.AddParameter($"[{index}s] "); - conversion.AddParameter($"concat=n={mediaInfos.Length}:v=1 [v]; [v]{vf}[vout]\" -map \"[vout]\""); +#if DEBUG + Console.WriteLine($"[ffargs] concat profile={profile.Name} pre=[{string.Join(" ", profile.PreInputArgs)}] post=[{string.Join(" ", postArgs)}]"); +#endif - conversion.AddParameter("-aspect 1:1"); - return conversion; + var processor = args + .OutputToFile(outputPath, overwrite: true, + opt => opt.WithCustomArgument(string.Join(" ", postArgs))) + .NotifyOnError(logCollector.AddLine); + + AttachProgress(processor, options, totalDuration); + + try + { + await processor.ProcessAsynchronously(); + } + catch (Exception ex) + { + throw new VideoConversionException( + FfmpegDiagnostics.CreateSummary(ex, logCollector.GetLog()), + FfmpegDiagnostics.CreateDetail(ex, logCollector.GetLog()), + ex); + } } /// @@ -334,7 +432,7 @@ public static async Task ConvertUsmToMp4(string inputPath, string outputPath, Ac var movieUsm = Path.Combine(tmpDir.FullName, "movie.usm"); onProgress?.Invoke(10); - FileSystem.CopyFile(inputPath, movieUsm, UIOption.OnlyErrorDialogs); + PlatformFile.CopyFile(inputPath, movieUsm); // 解包 USM onProgress?.Invoke(30); @@ -342,33 +440,31 @@ public static async Task ConvertUsmToMp4(string inputPath, string outputPath, Ac // 查找解包后的 IVF 文件 onProgress?.Invoke(50); - var outputIvfFile = Directory.EnumerateFiles(Path.Combine(tmpDir.FullName, @"output\movie.usm\videos")).FirstOrDefault(); + var outputIvfFile = Directory.EnumerateFiles(Path.Combine(tmpDir.FullName, "output", "movie.usm", "videos")).FirstOrDefault(); if (outputIvfFile is null) { throw new Exception("USM 解包失败:未找到视频文件"); } // 转换为 MP4 - var conversion = FFmpeg.Conversions.New() - .AddParameter("-i " + FFmpegHelper.Escape(outputIvfFile)) - .AddParameter("-c:v copy") - .SetOutput(outputPath); + // 等价 Xabe 命令行:-i -c:v copy var logCollector = new FfmpegLogCollector(); - logCollector.Attach(conversion); + var srcDuration = (await FFProbe.AnalyseAsync(outputIvfFile)).Duration; + + var processor = FFMpegArguments + .FromFileInput(outputIvfFile, verifyExists: false) + .OutputToFile(outputPath, overwrite: true, opt => opt.WithCustomArgument("-c:v copy")) + .NotifyOnError(logCollector.AddLine); if (onProgress != null) { - conversion.OnProgress += (sender, args) => - { - // FFmpeg 进度从 50% 开始,映射到 50-100% - var percent = 50 + (int)(args.Percent / 2); - onProgress(percent); - }; + // FFmpeg 进度从 50% 开始,映射到 50-100% + processor.NotifyOnProgress(percent => onProgress(50 + (int)(percent / 2)), srcDuration); } try { - await conversion.Start(); + await processor.ProcessAsynchronously(); } catch (Exception ex) { diff --git a/MaiChartManager/Utils/VideoEncoderProbe.cs b/MaiChartManager/Utils/VideoEncoderProbe.cs new file mode 100644 index 00000000..e5f0a30a --- /dev/null +++ b/MaiChartManager/Utils/VideoEncoderProbe.cs @@ -0,0 +1,126 @@ +using FFMpegCore; + +namespace MaiChartManager.Utils; + +/// 视频编码器探测:对每个候选 profile 跑「完整 recipe」的 1 帧 lavfi 试编码, +/// 输出非空且退出码 0 才算通过——这正是旧探测的缺陷所在(VAAPI/QSV 缺 device 初始化必失败)。 +/// 选第一个通过的;都不通过回退软件。结果存静态,供 VideoConvert 使用。 +public static class VideoEncoderProbe +{ + public static VideoEncoderProfile H264Profile { get; private set; } = VideoEncoderProfile.SoftwareH264; + public static VideoEncoderProfile Vp9Profile { get; private set; } = VideoEncoderProfile.SoftwareVp9; + + /// 启动时调用。forceSoftware=true 时直接用软件 profile,不探测。 + public static async Task Probe(bool forceSoftware) + { + if (forceSoftware) + { + H264Profile = VideoEncoderProfile.SoftwareH264; + Vp9Profile = VideoEncoderProfile.SoftwareVp9; + Console.WriteLine("[hwaccel] 强制软件编码:H264=libx264 VP9=vp9"); + return; + } + +#if WINDOWS + await ProbeWindows(); +#else + await ProbeLinux(); +#endif + Console.WriteLine($"[hwaccel] 选中 H264={H264Profile.Name} VP9={Vp9Profile.Name}"); + } + + // 试编一帧;成功(输出非空 + 退出码 0)返回 true。任何异常都视为该编码器不可用。 + private static async Task TryEncode(VideoEncoderProfile p) + { + var ext = p.Kind == VideoCodecKind.H264 ? "mp4" : "ivf"; + var outPath = Path.Combine(StaticSettings.tempPath, $"hwprobe_{p.Name}_{Guid.NewGuid():N}.{ext}"); + try + { + Directory.CreateDirectory(StaticSettings.tempPath); + var pre = new List(p.PreInputArgs) { "-t", "0:00:01.000", "-f", "lavfi" }; + var post = new List(); + if (!string.IsNullOrEmpty(p.UploadFilter)) { post.Add("-vf"); post.Add(p.UploadFilter); } + post.Add("-c:v"); post.Add(p.Codec); + + await FFMpegArguments + .FromFileInput("color=c=black:s=720x720:r=1", verifyExists: false, + o => o.WithCustomArgument(string.Join(" ", pre))) + .OutputToFile(outPath, overwrite: true, o => o.WithCustomArgument(string.Join(" ", post))) + .ProcessAsynchronously(); + return File.Exists(outPath) && new FileInfo(outPath).Length > 0; + } + catch + { + return false; + } + finally + { + try { File.Delete(outPath); } catch { /* ignore */ } + } + } + +#if !WINDOWS + private static async Task ProbeLinux() + { + var dev = await FindVaapiDevice(); + var vaapiPre = dev is null ? null : new[] { "-vaapi_device", dev }; + + // H264:VAAPI → NVENC → QSV → 软件 + H264Profile = await FirstWorking( + [ + dev is null ? null : new VideoEncoderProfile("h264_vaapi", VideoCodecKind.H264, true, vaapiPre!, "h264_vaapi", "format=nv12,hwupload", []), + new VideoEncoderProfile("h264_nvenc", VideoCodecKind.H264, true, [], "h264_nvenc", null, []), + dev is null ? null : new VideoEncoderProfile("h264_qsv", VideoCodecKind.H264, true, ["-qsv_device", dev], "h264_qsv", null, []), + ]) ?? VideoEncoderProfile.SoftwareH264; + + // VP9:VAAPI → QSV → 软件(NVENC 没有 VP9 编码器,不入候选) + Vp9Profile = await FirstWorking( + [ + dev is null ? null : new VideoEncoderProfile("vp9_vaapi", VideoCodecKind.Vp9, true, vaapiPre!, "vp9_vaapi", "format=nv12,hwupload", []), + dev is null ? null : new VideoEncoderProfile("vp9_qsv", VideoCodecKind.Vp9, true, ["-qsv_device", dev], "vp9_qsv", null, []), + ]) ?? VideoEncoderProfile.SoftwareVp9; + } + + // 枚举 /dev/dri/renderD128..135,取第一个能让 VAAPI 试编通过的设备 + private static async Task FindVaapiDevice() + { + for (var i = 128; i <= 135; i++) + { + var dev = $"/dev/dri/renderD{i}"; + if (!File.Exists(dev)) continue; + var test = new VideoEncoderProfile("h264_vaapi", VideoCodecKind.H264, true, + ["-vaapi_device", dev], "h264_vaapi", "format=nv12,hwupload", []); + if (await TryEncode(test)) return dev; + } + return null; + } + + private static async Task FirstWorking(IEnumerable candidates) + { + foreach (var c in candidates) + { + if (c is null) continue; + if (await TryEncode(c)) return c; + } + return null; + } +#endif + +#if WINDOWS + private static async Task ProbeWindows() + { + // 复刻今天的 Windows 语义:naive 命令(仅 -c:v,无 device)探测编码器名,全部带 -hwaccel dxva2。 + string[] h264Candidates = ["h264_nvenc", "h264_qsv", "h264_vaapi", "h264_amf", "h264_mf", "h264_vulkan"]; + var h264 = "libx264"; + foreach (var enc in h264Candidates) + if (await TryEncode(new VideoEncoderProfile(enc, VideoCodecKind.H264, true, [], enc, null, []))) { h264 = enc; break; } + H264Profile = new VideoEncoderProfile(h264, VideoCodecKind.H264, h264 != "libx264", + ["-hwaccel", "dxva2"], h264, null, []); + + var vp9Hw = await TryEncode(new VideoEncoderProfile("vp9_qsv", VideoCodecKind.Vp9, true, [], "vp9_qsv", null, [])); + var vp9 = vp9Hw ? "vp9_qsv" : "vp9"; + Vp9Profile = new VideoEncoderProfile(vp9, VideoCodecKind.Vp9, vp9Hw, + ["-hwaccel", "dxva2"], vp9, null, []); + } +#endif +} diff --git a/MaiChartManager/Utils/VideoEncoderProfile.cs b/MaiChartManager/Utils/VideoEncoderProfile.cs new file mode 100644 index 00000000..2ac99958 --- /dev/null +++ b/MaiChartManager/Utils/VideoEncoderProfile.cs @@ -0,0 +1,22 @@ +namespace MaiChartManager.Utils; + +public enum VideoCodecKind { H264, Vp9 } + +/// 描述「用某个编码器怎么编一段视频」。把今天散落、且会泄漏给硬件编码器的软件专属参数 +/// (-cpu-used/-pix_fmt)收拢:硬件 profile 携带 device/hwupload,软件参数只在软件 profile 生效。 +public sealed record VideoEncoderProfile( + string Name, // "h264_vaapi" 等,仅日志/识别 + VideoCodecKind Kind, + bool IsHardware, + IReadOnlyList PreInputArgs, // 输入前参数,如 ["-vaapi_device", "/dev/dri/renderD128"] + string Codec, // -c:v 的值,如 h264_vaapi / libx264 / vp9 + string? UploadFilter, // 追加到 filter 链末尾,如 "format=nv12,hwupload";NVENC/软件为 null + IReadOnlyList ExtraOutputArgs) // 硬件专属输出参数(初版留空,后续调码控用) +{ + // 软件兜底 profile(编码器名与今天一致:H264=libx264,VP9=vp9,保证 Windows 逐参数不变) + public static VideoEncoderProfile SoftwareH264 { get; } = + new("libx264", VideoCodecKind.H264, false, [], "libx264", null, []); + + public static VideoEncoderProfile SoftwareVp9 { get; } = + new("vp9", VideoCodecKind.Vp9, false, [], "vp9", null, []); +} diff --git a/MaiChartManager/WannaCRI/UsmCreator.cs b/MaiChartManager/WannaCRI/UsmCreator.cs index 0ec6afa0..cc80ab9f 100644 --- a/MaiChartManager/WannaCRI/UsmCreator.cs +++ b/MaiChartManager/WannaCRI/UsmCreator.cs @@ -638,42 +638,15 @@ private static VideoProbeInfo ProbeVideo(string src, bool includePackets) return probeInfo; } - private static string ResolveFfprobePath() - { - var candidates = new[] - { - Path.Combine(AppContext.BaseDirectory, "ffprobe.exe"), - Path.Combine(AppContext.BaseDirectory, "FFMpeg", "ffprobe.exe"), - Path.Combine(StaticSettings.exeDir, "ffprobe.exe"), - Path.Combine(StaticSettings.exeDir, "FFMpeg", "ffprobe.exe") - }; - foreach (var candidate in candidates) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - return "ffprobe"; - } - private static string ResolveFfmpegPath() - { - var candidates = new[] - { - Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe"), - Path.Combine(AppContext.BaseDirectory, "FFMpeg", "ffmpeg.exe"), - Path.Combine(StaticSettings.exeDir, "ffmpeg.exe"), - Path.Combine(StaticSettings.exeDir, "FFMpeg", "ffmpeg.exe") - }; - foreach (var candidate in candidates) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - return "ffmpeg"; - } + // 复用 FFMpegCore 的二进制解析(GlobalFFOptions 在启动时已配置好 BinaryFolder)。 + // 原先这里自己拼 ".exe" 路径并用 File.Exists 命中,在 Linux 上会命中输出目录里随 Windows + // 构建一起拷进来的 ffprobe.exe(PE 文件,无执行权限)→ 启动报 EACCES。改为单一来源, + // FFMpegCore 会按 OS 自动补可执行名后缀、并尊重配置的 BinaryFolder。 + private static string ResolveFfprobePath() => + FFMpegCore.GlobalFFOptions.GetFFProbeBinaryPath(FFMpegCore.GlobalFFOptions.Current); + + private static string ResolveFfmpegPath() => + FFMpegCore.GlobalFFOptions.GetFFMpegBinaryPath(FFMpegCore.GlobalFFOptions.Current); private static void TryDeleteFile(string path) { try diff --git a/MaiChartManager/x64/Texture2DDecoderNative.dll b/MaiChartManager/x64/Texture2DDecoderNative.dll deleted file mode 100644 index 8324130a..00000000 Binary files a/MaiChartManager/x64/Texture2DDecoderNative.dll and /dev/null differ diff --git a/MuConvert b/MuConvert index 47834d4d..ae6aeba2 160000 --- a/MuConvert +++ b/MuConvert @@ -1 +1 @@ -Subproject commit 47834d4dcb4b64dfc2f783821146b257074f5d99 +Subproject commit ae6aeba291c6af33cd0202e16eef3ce18f530130 diff --git a/Packaging/arch/.gitignore b/Packaging/arch/.gitignore new file mode 100644 index 00000000..feca4d2f --- /dev/null +++ b/Packaging/arch/.gitignore @@ -0,0 +1,4 @@ +# makepkg 构建产物,不纳入版本控制 +pkg/ +src/ +*.pkg.tar.* diff --git a/Packaging/arch/PKGBUILD b/Packaging/arch/PKGBUILD new file mode 100644 index 00000000..fa24b22c --- /dev/null +++ b/Packaging/arch/PKGBUILD @@ -0,0 +1,130 @@ +# Maintainer: MuNET-OSS +# 自用 Arch 打包:从本地已 checkout(含 submodule)的仓库构建。 +# 因为本仓库依赖多个 submodule(部分为 SSH/私有源),不走 makepkg 克隆, +# 直接用当前工作树构建。请在仓库内 `Packaging/arch/` 目录执行 `makepkg -si`。 + +pkgname=maichartmanager +pkgver=26.3.r49.g16f9f82 +pkgrel=1 +pkgdesc="maimai 谱面管理工具(Linux 原生,Photino + WebKitGTK)" +arch=('x86_64') +url="https://github.com/MuNET-OSS/MaiChartManager" +license=('custom') +# 运行时依赖: +# aspnet-runtime —— ASP.NET Core 10 共享框架(Kestrel 后端),含并依赖 dotnet-runtime +# webkit2gtk-4.1 —— Photino 在 Linux 的窗口宿主(libwebkit2gtk-4.1 / gtk3 / libsoup3) +# ffmpeg —— 音视频转码(系统 ffmpeg/ffprobe,已不内置) +depends=('aspnet-runtime' 'webkit2gtk-4.1' 'gtk3' 'ffmpeg') +makedepends=('dotnet-sdk' 'pnpm') +# vaapi 硬件视频编码(可选,没有则自动回退软件编码) +optdepends=('libva-mesa-driver: AMD/Intel VAAPI 硬件视频编码' + 'intel-media-driver: Intel QSV/VAAPI 硬件视频编码') +options=('!strip') # 已发布的托管程序集无需 strip +install= +source=() + +# 仓库根:PKGBUILD 在 /Packaging/arch/ 下 +_reporoot() { realpath "$startdir/../.." ; } + +pkgver() { + cd "$(_reporoot)" + # 用 git 描述生成版本号;无 tag 时退化为 r<提交数>.<短哈希> + if git describe --tags >/dev/null 2>&1; then + git describe --tags | sed 's/^v//;s/-/.r/;s/-/./g' + else + printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" + fi +} + +# 应用内显示版本号:对齐 Packaging/Build.ps1 的 Release 逻辑—— +# git describe --tags --long 形如 v26.3-46-gf68ebc1 → 取 tag 基号 26.3 作为显示版本。 +_appbasever() { + cd "$(_reporoot)" + local d + d=$(git describe --tags --long 2>/dev/null) || { echo "1.0.0"; return; } + if [[ "$d" =~ ^v?([0-9]+\.[0-9]+(\.[0-9]+)?)-([0-9]+)-g[0-9a-f]+$ ]]; then + echo "${BASH_REMATCH[1]}" + else + echo "1.0.0" + fi +} + +build() { + local root; root="$(_reporoot)" + local base; base="$(_appbasever)" + + # 写入应用内显示版本号到 AppMain.g.cs(与 Packaging/Build.ps1 的做法一致)。 + # 该常量由 Properties/AssemblyInfo.cs 生成程序集版本特性,AppVersionController 读取显示。 + cat > "$root/MaiChartManager/AppMain.g.cs" < "$pkgdir/usr/bin/maichartmanager" < "$pkgdir/usr/bin/mcm" < "$pkgdir/usr/bin/muconvert" < "$pkgdir/usr/share/applications/$pkgname.desktop" <