Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions MaiChartManager/Controllers/App/AppVersionController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,24 @@ namespace MaiChartManager.Controllers.App;
public class AppVersionController(StaticSettings settings, ILogger<AppVersionController> logger) : ControllerBase
{
#if WINDOWS
public record AppVersionResult(string Version, int GameVersion, IapManager.LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale);
public record AppVersionResult(string Version, int GameVersion, IapManager.LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale, string Platform, bool Export);

[HttpGet]
public AppVersionResult GetAppVersion()
{
return new AppVersionResult(Application.ProductVersion, settings.gameVersion, IapManager.License, VideoConvert.HardwareAcceleration, VideoConvert.H264Encoder, StaticSettings.CurrentLocale);
return new AppVersionResult(
Application.ProductVersion,
settings.gameVersion,
IapManager.License,
VideoConvert.HardwareAcceleration,
VideoConvert.H264Encoder,
StaticSettings.CurrentLocale,
OperatingSystem.IsWindows() ? "Windows" : "Linux",
StaticSettings.Config.Export);
}
#else
public enum LicenseStatus { Pending, Active, Inactive }
public record AppVersionResult(string Version, int GameVersion, LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale);
public record AppVersionResult(string Version, int GameVersion, LicenseStatus License, VideoConvert.HardwareAccelerationStatus HardwareAcceleration, string H264Encoder, string Locale, string Platform, bool Export);

[HttpGet]
public AppVersionResult GetAppVersion()
Expand All @@ -29,7 +37,15 @@ public AppVersionResult GetAppVersion()
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);
return new AppVersionResult(
version,
settings.gameVersion,
LicenseStatus.Active,
VideoConvert.HardwareAcceleration,
VideoConvert.H264Encoder,
StaticSettings.CurrentLocale,
OperatingSystem.IsWindows() ? "Windows" : "Linux",
StaticSettings.Config.Export);
}
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public record ImportDirEntry(string Name, string Path, bool IsDirectory);
public ActionResult<string?> PickImportFolder()
{
// 仅本地桌面场景,export / 远程模式下禁止
if (StaticSettings.Config.Export) return Forbid();
if (StaticSettings.Config.Export) return StatusCode(StatusCodes.Status403Forbidden); // 如果直接 return Forbid(); 的话,会由于框架内部的某些authentication scheme机制造成抛异常,导致500
var path = dialogService.PickFolder();
logger.LogInformation("PickImportFolder: {path}", path);
// 取消时 PickFolder 返回 null,这里原样返回(前端按取消处理)
Expand All @@ -35,7 +35,7 @@ public record ImportDirEntry(string Name, string Path, bool IsDirectory);
public ActionResult<IEnumerable<ImportDirEntry>> ListImportDir([FromQuery] string path)
{
// 仅本地桌面场景,export / 远程模式下禁止
if (StaticSettings.Config.Export) return Forbid();
if (StaticSettings.Config.Export) return StatusCode(StatusCodes.Status403Forbidden);
if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
{
return Ok(Array.Empty<ImportDirEntry>());
Expand Down Expand Up @@ -64,7 +64,7 @@ public ActionResult<IEnumerable<ImportDirEntry>> ListImportDir([FromQuery] strin
public IActionResult ReadImportFile([FromQuery] string path, [FromQuery] string? name = null)
{
// 仅本地桌面场景,export / 远程模式下禁止
if (StaticSettings.Config.Export) return Forbid();
if (StaticSettings.Config.Export) return StatusCode(StatusCodes.Status403Forbidden);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

诶,那这样的话,export+应用内打开是不是会炸
(至少 Windows 是有 export+应用内打开的操作的,我不记得 Linux 做没做了)

@Starrah Starrah Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

首先,原本的代码return Forbid();26530b1 中引入的,并不是我写的;我推测这么做的目的可能是,远程情况下浏览器和后端不运行在一台机器上,很多情况下后端是游戏专机之类的、可能连完整的鼠标键盘都没,因此从后端的机器调用本机的文件选择API、弹出文件选择窗口,前端那边也是看不到,没有意义的。但实际上,在(修改后的)正常的逻辑之下,理论上Export模式应该一定调用的是浏览器侧的文件选择API(因为isPhotino必定工作在非export模式下?见下面讨论),所以这个代码其实只是个Guard;

// 通用选目录:
// - 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<ImportDirectory> {
// 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<ImportDirectory>;
}
// 其它本地桌面宿主(无 showDirectoryPicker):后端原生选目录
if (isLocalHost) {
return pickViaBackend();
}
// 远程浏览器且无 File System Access API:不支持,按取消处理
abort('当前环境不支持选择目录');
}

其次,我这里的修改只是把return Forbid();改成了return StatusCode(StatusCodes.Status403Forbidden);,因为测试发现,return Forbid();由于一些配置上的原因,无法返回403,而是抛异常导致500。(AI分析如下图)
{4DFF3F16-2036-4121-9D0C-B297CC15A4A9}

最后,直接回答您的”export+应用内打开是不是会炸“的问题。分两个方面:

  • Windows而言不会炸(我已实际测试)。这是因为即便是export+应用内打开,应用内打开调的也是WebView2,而Windows上的WebView2的实现是完整的,带有正常的浏览器侧的文件选择API。因此文件选择窗口最终实际上还是由前端WebView打开的,根本不会调用到您引用的这个后端函数。
  • Linux上。其实最简单的办法当然也是前端打开,但是根据pickDirectory.ts中的注释,之所以不这么做是因为WebKitGTK 即使暴露了 window.showDirectoryPicker,其实现也有问题(调用后访问 handle 会抛 "The string did not match the expected pattern"),所以只好退而求其次用后端文件选择作为workaround。所以,问题的关键就是isPhotino是不是保证一定非export模式。 如果是的话则没问题;不然的话,则这里确实是个问题(但也是从 26530b1 就引入了的)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

好的 我觉得我应该检查一下,就不让 Linux 在 export 模式下再在应用内打开好了

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

好呀,我觉得可以,因为「选择」这个行为本身也一定是在本机上完成的,因为弹窗只能在本机上弹出来
不过我是希望,如果不是 localhost 的访问,或者不是 photino 而是本机 chromium 之类的,还是用像原先一样的浏览器端直接选择文件夹,而不需要走这个绕一圈(

@Starrah Starrah Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

好的 我觉得我应该检查一下,就不让 Linux 在 export 模式下再在应用内打开好了

讲一点我的观察,供您参考:

  • 在完全不考虑安全性的情况下:这个“export模式下返回403”的逻辑其实没有意义、不会命中,干脆可以去掉。因为在正常的程序逻辑下,后端文件选择函数PickImportFolderAPI一定是前端通过pickViaBackend触发的。阅读相关代码容易知道,它一定是在localhost访问的情况下才会触发的(isPhotino内也隐含了isLocalhost的判断的),所以正常情况下并不可能出现“用户在其他机器上远程访问,却在后端机器上弹出了个对话框”的情况。
    // 通用选目录:
    // - 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<ImportDirectory> {
    // 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<ImportDirectory>;
    }
    // 其它本地桌面宿主(无 showDirectoryPicker):后端原生选目录
    if (isLocalHost) {
    return pickViaBackend();
    }
    // 远程浏览器且无 File System Access API:不支持,按取消处理
    abort('当前环境不支持选择目录');
    }
  • 所以AI原本的顾虑其实是安全性问题。也就是恶意用户,并不通过我们的前端,而是直接强行请求API的情况。
    // maidata 导入用的后端目录浏览接口。
    // 背景:WebKitGTK(Linux/Photino)既没有 showDirectoryPicker,<input webkitdirectory> 也只能选单文件,
    // 所以前端没法在浏览器侧拿到目录内容。改为:后端弹原生选文件夹对话框,再通过下面 3 个接口
    // 把所选目录的内容提供给前端的 ImportDirectory 适配器(见 Front/src/utils/httpImportDirectory.ts)。
    //
    // 安全说明:这些接口可以读取任意本地路径,仅限「本地桌面 + 仅 loopback」场景使用。
    // 切勿在 export / 远程模式下启用——否则等于把整个文件系统暴露给局域网。
    // 因此每个接口都先校验 !StaticSettings.Config.Export。
    [ApiController]
    [Route("MaiChartManagerServlet/[action]Api")]
    public class ImportBrowseController(IDesktopDialogService dialogService, ILogger<ImportBrowseController> logger) : ControllerBase
    • 后端提供的文件操作API中除了有PickImportFolder这种只是弹对话框、本身无害的,但也有ListImportDirReadImportFile这种可以实现任意文件读取的。 确实可能有明显的安全风险。

综上以上大概是我对情况的分析和总结,具体怎么改看您

@Starrah Starrah Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

好呀,我觉得可以,因为「选择」这个行为本身也一定是在本机上完成的,因为弹窗只能在本机上弹出来 不过我是希望,如果不是 localhost 的访问,或者不是 photino 而是本机 chromium 之类的,还是用像原先一样的浏览器端直接选择文件夹,而不需要走这个绕一圈(

呜呜我一开始分析和判断的有问题,我删了重发了,您以我后面发的为准

就,弹窗本身确实没有什么危害,但麻烦的点在于这套文件操作API里还提供了ListImportDirReadImportFile这种可以实现任意文件读取的大雷。

@Starrah Starrah Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

如果不是 localhost 的访问,或者不是 photino 而是本机 chromium 之类的,还是用像原先一样的浏览器端直接选择文件夹,而不需要走这个绕一圈(

对啊,现在就已经是这样的了,只有isPhotinopickViaBackend,否则还是前端文件选择

export async function pickDirectory(
options?: { id?: string; startIn?: string },
): Promise<ImportDirectory> {
// 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<ImportDirectory>;
}
// 其它本地桌面宿主(无 showDirectoryPicker):后端原生选目录
if (isLocalHost) {
return pickViaBackend();
}
// 远程浏览器且无 File System Access API:不支持,按取消处理
abort('当前环境不支持选择目录');
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

或者把”打开界面“的按钮改成拉起系统浏览器打开网页,而不是photino 我其实推荐这个

说真的,我最早的 Linux 想法就是纯后端,然后拉起 {google-chrome,chromium,microsoft-edge} --app=http://localhost。其实这样似乎是体验更好的,因为现在 webkit-git 甚至还有一些显示 bug,而 --app 的话,甚至不会有浏览器自带的 chrome(指地址栏、工具栏这些,不是谷歌浏览器)就和 Windows 上体验差不多。

但是我觉得可能不是所有人电脑上都有 chromium 系浏览器,而且后来调查发现了 Photino 这个东西,但是我也不知道 webkit-gtk 有这么多不兼容的东西,我就拿这个做了,后来才发现一堆不兼容的坑然后开始填

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

或者把”打开界面“的按钮改成拉起系统浏览器打开网页,而不是photino 我其实推荐这个

说真的,我最早的 Linux 想法就是纯后端,然后拉起 {google-chrome,chromium,microsoft-edge} --app=http://localhost。其实这样似乎是体验更好的,因为现在 webkit-git 甚至还有一些显示 bug,而 --app 的话,甚至不会有浏览器自带的 chrome(指地址栏、工具栏这些,不是谷歌浏览器)就和 Windows 上体验差不多。

但是我觉得可能不是所有人电脑上都有 chromium 系浏览器,而且后来调查发现了 Photino 这个东西,但是我也不知道 webkit-gtk 有这么多不兼容的东西,我就拿这个做了,后来才发现一堆不兼容的坑然后开始填

所以变动性比较小的改动是不是,本地模式还是photino不变,远程模式的话直接xdg-open一个http://,这样应该会调用系统默认浏览器。尽管不是所有人电脑上都有 chromium 系浏览器,但应该所有人电脑上都至少有个浏览器吧,而且大概率会是个支持window.showDirectoryPicker的浏览器

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

而且大概率会是个支持window.showDirectoryPicker的浏览器

似乎,支持这个的浏览器也就是 chromium 系的浏览器了

不过不支持的他们也有办法自行解决吧,毕竟用 Linux 了应该默认是有能力的了

var fullPath = string.IsNullOrEmpty(name) ? path : Path.Combine(path, name);
if (string.IsNullOrEmpty(fullPath) || !System.IO.File.Exists(fullPath))
{
Expand Down
9 changes: 6 additions & 3 deletions MaiChartManager/Front/src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ export const getUrl = (suffix: string) => {
return `${base}/MaiChartManagerServlet/${suffix}`;
}

// 是否运行在 Photino(WebKitGTK) 宿主:本地宿主但不是 Windows WebView2。
// WebKitGTK 不支持 window.open 弹新窗口,需要走后端用系统浏览器打开。
export const isPhotino = isLocalHost && !isWebView;
// 是否运行在 Photino(WebKitGTK) 宿主。
// 不能仅用 isLocalHost && !isWebView:因为Export 模式下用本机浏览器访问 localhost 也满足该条件。
// Photino 暴露 window.external.sendMessage(见 PreviewChartButton),普通浏览器没有。
export const isPhotino =
isLocalHost && !isWebView &&
typeof (window as any).external?.sendMessage === 'function';

// 用系统浏览器打开一个 http/https URL(后端 xdg-open 等)。
// 给 Photino 用:WebKitGTK 弹不出 window.open 的新窗口,预览谱面等改为外部浏览器打开。
Expand Down
13 changes: 11 additions & 2 deletions MaiChartManager/Front/src/client/apiGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export enum VerifyStatus {
Valid = "Valid",
}

export enum StorePurchaseStatus {
Succeeded = "Succeeded",
AlreadyPurchased = "AlreadyPurchased",
NotPurchased = "NotPurchased",
NetworkError = "NetworkError",
ServerError = "ServerError",
}

export enum ShiftMethod {
Legacy = "Legacy",
Bar = "Bar",
Expand Down Expand Up @@ -70,6 +78,8 @@ export interface AppVersionResult {
hardwareAcceleration?: HardwareAccelerationStatus;
h264Encoder?: string | null;
locale?: string | null;
platform?: string | null;
export?: boolean;
}

export interface AudioPreviewTime {
Expand Down Expand Up @@ -385,8 +395,7 @@ export interface RequestExportMaidataRequest {

export interface RequestPurchaseResult {
errorMessage?: string | null;
/** @format int32 */
status?: number;
status?: StorePurchaseStatus;
}

export interface Section {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { computed, defineComponent, PropType, useId, watch } from "vue";
import { Button, CheckBox, Modal, NumberInput, Popover, Section, Select } from "@munet/ui";
import type { ImportChartMessageEx, ImportMeta, SavedOptions, TempOptions } from "./types";
import noJacket from '@/assets/noJacket.webp';
import { addVersionList, genreList, showNeedPurchaseDialog } from "@/store/refs";
import { addVersionList, genreList, showNeedPurchaseDialog, version } from "@/store/refs";
import GenreInput from "@/components/GenreInput";
import VersionInput from "@/components/VersionInput";
import { UTAGE_GENRE } from "@/consts";
Expand Down Expand Up @@ -76,15 +76,15 @@ export default defineComponent({
</CheckBox>
<Section title={t('chart.import.option.advancedOptions')}>
<ShiftModeSelector tempOptions={props.tempOptions}></ShiftModeSelector>
<div class="flex items-center gap-1" style="margin-top: 0.25rem">
{version.value?.platform === 'Windows' && <div class="flex items-center gap-1" style="margin-top: 0.25rem">
<CheckBox v-model:value={props.tempOptions.ignoreGapless}>{t('chart.import.option.ignoreGapless')}</CheckBox>
<Popover trigger="hover">
{{
trigger: () => <div class="i-material-symbols:info-outline-rounded op-50"/>,
default: () => <div class="max-w-60">{t('chart.import.option.ignoreGaplessTip')}</div>
}}
</Popover>
</div>
</div>}
</Section>
</>}
</div>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,4 @@ export const getCaptureTarget = (errorValue: unknown): unknown => {
return errorValue.error;
};

export const isAbortError = (errorValue: unknown): boolean =>
errorValue instanceof DOMException && errorValue.name === "AbortError" ||
errorValue instanceof Error && errorValue.name === "AbortError";
export const isAbortError = (errorValue: any): boolean => errorValue?.name === "AbortError"
56 changes: 28 additions & 28 deletions MaiChartManager/Locale.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion MaiChartManager/Locale.resx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ If you notice any issues with the conversion result, you can try testing it in A
<value>A chart with difficulty {0} will be ignored</value>
</data>
<data name="MusicNoCharts" xml:space="preserve">
<value>Music has no charts</value>
<value>Music has no valid charts</value>
</data>
<data name="ChartInvalidMeasure" xml:space="preserve">
<value>Chart difficulty {0} contains {1}-note division, this value cannot exceed 384. Most cases can be fixed by modifying the chart</value>
Expand Down
2 changes: 1 addition & 1 deletion MaiChartManager/Locale.zh-Hans.resx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@
<value>有一个难度为 {0} 的谱面将被忽略</value>
</data>
<data name="MusicNoCharts" xml:space="preserve">
<value>乐曲没有谱面</value>
<value>乐曲中没有有效的谱面</value>
</data>
<data name="ChartInvalidMeasure" xml:space="preserve">
<value>谱面难度 {0} 存在 {1} 分音符,这个数值不能大于 384。绝大多数这样的情况都是可以修改谱面解决的</value>
Expand Down
2 changes: 1 addition & 1 deletion MaiChartManager/Locale.zh-Hant.resx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@
<value>有一個難度為 {0} 的譜面將被忽略</value>
</data>
<data name="MusicNoCharts" xml:space="preserve">
<value>樂曲沒有譜面</value>
<value>樂曲中沒有有效的譜面</value>
</data>
<data name="ChartInvalidMeasure" xml:space="preserve">
<value>譜面難度 {0} 存在 {1} 分音符,這個數值不能大於 384。絕大多數這樣的情況都是可以修改譜面解決的</value>
Expand Down
17 changes: 12 additions & 5 deletions MaiChartManager/Utils/Audio.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ 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(ConvertToWavViaFfmpeg(src, ".mp3")), // 默认情况下,优先使用ffmpeg
// WAV / WMA / AAC(以及 MP3+forceUseNAudio 的兼容模式)原本走 Windows-only 的 MediaFoundation,
// 跨平台改为用 ffmpeg 把任意输入解码成 16bit PCM wav,再用 NAudio WaveFileReader 读取。
// MP3 兼容模式(ignoreGapless):仅在 Windows 上用 MediaFoundation 解码,
// 这会忽略 MP3 Gapless 元数据,从而表现与 Visual Maimai 等软件一致的行为。
// Linux 上无 MediaFoundation,因此兼容模式不可用(不管开不开启兼容模式,都一定会落到下面的ffmpeg逻辑中)
".mp3" when forceUseNAudio && SupportsMp3CompatibilityMode => new StreamMediaFoundationReader(src),
// 一般情况(MP3 默认、WAV、WMA、AAC 等):走 ffmpeg 解码为 16bit PCM wav。
_ => new WaveFileReader(ConvertToWavViaFfmpeg(src, extension)),
};
// 关于上述MP3 Gapless问题的影响等具体讨论,详见 https://github.com/MuNET-OSS/MaiChartManager/issues/40
Expand Down Expand Up @@ -99,16 +101,21 @@ public static Stream ConvertToWav(Stream src, string extension, float padding =
stream.Position = 0;
return stream;
}

/// <summary>MP3 兼容模式(ignoreGapless)依赖 Windows MediaFoundation,Linux 上不可用。</summary>
public static bool SupportsMp3CompatibilityMode => OperatingSystem.IsWindows();

// 用 ffmpeg 把任意输入流(按 ext 写到临时文件)解码成 16bit PCM wav,返回 wav 的内存流。
// 替代 Windows-only 的 MediaFoundation,跨平台可用(系统 ffmpeg 已配好)。
private static MemoryStream ConvertToWavViaFfmpeg(Stream src, string ext)
{
var tempFileGuid = Guid.NewGuid();
// ext 形如 ".mp3"/".wav"/".aac" 等;去掉前导点用作临时输入文件后缀
var inputExt = string.IsNullOrEmpty(ext) ? "" : (ext.StartsWith('.') ? ext : "." + ext);

var tempFileGuid = Guid.NewGuid();
// 输入/输出须用不同文件名,以防输入也是 .wav时,输入输出相同文件名导致报错。
var inputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}{inputExt}");
var outputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}.wav");
var outputPath = Path.Combine(StaticSettings.tempPath, $"ConvertToWav_{tempFileGuid:N}_out.wav");
try
{
Directory.CreateDirectory(StaticSettings.tempPath);
Expand Down
51 changes: 30 additions & 21 deletions MaiChartManager/Utils/ImageConvert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,40 @@ public static class ImageConvert
public static byte[]? GetTextureAsPngData(string inputAbPath)
{
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)
BundleFileInstance? bunInst = null;
try
{
var baseField = am.GetBaseField(afileInst, info);
if (baseField.IsDummy || baseField.TypeName != "Texture2D") continue;
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 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 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;
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<Bgra32>(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();
using var image = Image.LoadPixelData<Bgra32>(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;
}
finally
{
if (bunInst is not null)
am.UnloadBundleFile(bunInst);
}
return null;
}
}
1 change: 0 additions & 1 deletion MaiLib
Submodule MaiLib deleted from c76e94
1 change: 0 additions & 1 deletion SimaiSharp
Submodule SimaiSharp deleted from bcaf93
5 changes: 1 addition & 4 deletions Sitreamai.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@
<Project Path="SonicAudioTools/Source/SonicAudioLib/SonicAudioLib.csproj">
<BuildType Solution="Crack|*" Project="Release" />
</Project>
<Project Path="XV2-Tools/LB_Common/LB_Common.csproj">
<BuildType Solution="Crack|*" Project="Release" />
</Project>
<Project Path="XV2-Tools/Xv2CoreLib/Xv2CoreLib.csproj">
<Project Path="XV2-Tools/AcbCore/AcbCore.csproj">
<BuildType Solution="Crack|*" Project="Release" />
</Project>
</Solution>