From 772f71aa95bf7ae3106baa4f8816ec110070ab66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=B2?= <286150633+munet-rei[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 19:57:52 +0800 Subject: [PATCH 1/2] feat: Add batch PV conversion tool A new tool in the Tools page that bulk converts every PV (Promotional Video) under StreamingAssets/A###/MovieData in both directions: - USM/DAT -> MP4: writes a sibling .mp4 next to each source, original kept untouched - MP4 -> USM/DAT: writes to a temp file, validates non-empty, atomically swaps in the final .dat, then sends the source .mp4 to the recycle bin Implementation notes: - New endpoint VideoConvertToolController.BatchConvertPvTool, SSE with events Progress / FileError / Success / Cancelled / Error. - Progress payload is JSON (camelCase) with processed / total / fileProgress / fileName / failed. - All SSE frames go through a single-writer Channel so that Xabe's synchronous OnProgress events from inside FFmpeg cannot interleave Response.WriteAsync calls. - Direct file system enumeration via StaticSettings.AssetsDirs rather than MovieDataMap, so files that share an ID across multiple asset dirs (or .mp4 + .dat siblings) are not silently skipped. - Cancellation is checked before destructive file ops; a partial output in temp is removed before throwing, so cancel cannot corrupt the source. - Sponsored feature, gated on IapManager.License == Active. - Final settings.ScanMovieData() in the finally block resynchronizes MovieDataMap with on-disk state. Frontend: src/views/Tools/BatchVideoConvertModal.tsx, a 3-step modal (Configure direction -> live Progress with overall + current-file bars and a collapsible per-file error list -> Done with summary). Uses fetchEventSource + AbortController, matching the existing single-file VideoConvertModal pattern. Surfaces 'no files' and 'needs sponsor' as friendly toasts instead of crash reports. Notes: - Locale.Designer.cs was updated by hand to expose the two new resource strings. - Front/src/client/apiGen.ts was NOT regenerated (requires a running backend on localhost:5181). The new endpoint is consumed directly via getUrl + fetchEventSource, matching the other SSE tool endpoints, so no client regen is strictly required, but a follow-up `pnpm genClient` is recommended after merging. --- .../Tools/VideoConvertToolController.cs | 290 +++++++++++++++- MaiChartManager/Front/src/locales/en.yaml | 22 ++ MaiChartManager/Front/src/locales/zh-TW.yaml | 22 ++ MaiChartManager/Front/src/locales/zh.yaml | 22 ++ .../views/Tools/BatchVideoConvertModal.tsx | 325 ++++++++++++++++++ .../Front/src/views/Tools/index.tsx | 8 + MaiChartManager/Locale.Designer.cs | 18 + MaiChartManager/Locale.resx | 6 + MaiChartManager/Locale.zh-hans.resx | 6 + MaiChartManager/Locale.zh-hant.resx | 6 + 10 files changed, 723 insertions(+), 2 deletions(-) create mode 100644 MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index 9909d106..c3d6b5d4 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -1,11 +1,14 @@ +using System.Text.Json; +using System.Threading.Channels; 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, StaticSettings settings) : ControllerBase { public enum VideoConvertEventType { @@ -83,4 +86,287 @@ await VideoConvert.ConvertVideoToUsm( await Response.Body.FlushAsync(); } } -} \ No newline at end of file + + public enum BatchConvertPvDirection + { + /// USM/DAT → MP4 + UsmToMp4, + /// MP4 → USM/DAT + Mp4ToUsm + } + + public enum BatchConvertPvEventType + { + /// 整体 + 当前文件进度,data 为 JSON + Progress, + /// 单文件失败,仍然继续处理后续文件 + FileError, + /// 全部完成,data 为 "processed/total|failedCount" + Success, + /// 致命错误,停止 + Error, + /// 被取消,data 为 "processed/total" + Cancelled + } + + private static readonly JsonSerializerOptions BatchJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private record BatchProgressPayload(int Processed, int Total, int FileProgress, string FileName, int Failed); + + /// + /// 批量转换 StreamingAssets 内所有 PV:USM/DAT ↔ MP4。 + /// 使用 SSE 实时推送整体进度(已处理/总数)+ 当前文件进度。 + /// 客户端断开连接时通过 RequestAborted 触发取消,循环在下一个文件之间退出。 + /// 所有 SSE 写入通过单写者 Channel 串行化,避免 Xabe 同步进度事件触发的 async-void 写入交错。 + /// + [HttpPost] + public async Task BatchConvertPvTool([FromQuery] BatchConvertPvDirection direction) + { + Response.Headers.Append("Content-Type", "text/event-stream"); + + // PV 转换属于赞助功能 + if (IapManager.License != IapManager.LicenseStatus.Active) + { + await Response.WriteAsync($"event: {BatchConvertPvEventType.Error}\ndata: {SanitizeSseLine(Locale.BatchConvertPvNeedLicense)}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + // 直接枚举 MovieData 目录,绕过 MovieDataMap 的 ID 去重,覆盖所有源文件 + var sourceExtensions = direction == BatchConvertPvDirection.UsmToMp4 + ? new HashSet(StringComparer.OrdinalIgnoreCase) { ".dat", ".usm" } + : new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }; + + var files = EnumerateMoviePvs(sourceExtensions); + + if (files.Count == 0) + { + await Response.WriteAsync($"event: {BatchConvertPvEventType.Error}\ndata: {SanitizeSseLine(Locale.BatchConvertPvNoFiles)}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + var total = files.Count; + var processed = 0; + var failedCount = 0; + var cancellationToken = HttpContext.RequestAborted; + + // 单写者 Channel:所有 SSE 帧(不论来自循环还是 OnProgress)都进入这条队列 + var sseChannel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + + var writer = WriteSseFrames(sseChannel.Reader, cancellationToken); + + try + { + foreach (var inputPath in files) + { + if (cancellationToken.IsCancellationRequested) break; + + var fileName = Path.GetFileName(inputPath); + await EnqueueProgress(sseChannel.Writer, processed, total, 0, fileName, failedCount); + + try + { + var directory = Path.GetDirectoryName(inputPath)!; + var nameWithoutExt = Path.GetFileNameWithoutExtension(inputPath); + + if (direction == BatchConvertPvDirection.UsmToMp4) + { + var outputPath = Path.Combine(directory, nameWithoutExt + ".mp4"); + var snapshot = (Processed: processed, Failed: failedCount); + await VideoConvert.ConvertUsmToMp4( + inputPath, + outputPath, + percent => EnqueueProgressFireAndForget(sseChannel.Writer, snapshot.Processed, total, percent, fileName, snapshot.Failed)); + } + else + { + // MP4 → USM(VP9):先输出到临时文件,验证后再覆盖目标 + var finalPath = Path.Combine(directory, nameWithoutExt + ".dat"); + var tempPath = finalPath + ".tmp"; + var snapshot = (Processed: processed, Failed: failedCount); + try + { + await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions + { + InputPath = inputPath, + OutputPath = tempPath, + NoScale = StaticSettings.Config.NoScale, + UseH264 = false, + UseYuv420p = StaticSettings.Config.Yuv420p, + Padding = 0, + TaskbarProgress = false, + OnProgress = percent => EnqueueProgressFireAndForget(sseChannel.Writer, snapshot.Processed, total, percent, fileName, snapshot.Failed) + }); + + if (!System.IO.File.Exists(tempPath) || new FileInfo(tempPath).Length == 0) + { + throw new Exception("Converted DAT is missing or empty"); + } + + // 取消检查必须在覆盖/删除前,避免取消时仍然损毁源文件 + cancellationToken.ThrowIfCancellationRequested(); + + if (System.IO.File.Exists(finalPath)) + { + System.IO.File.Delete(finalPath); + } + System.IO.File.Move(tempPath, finalPath); + + // 源 MP4 送进回收站,而非永久删除,最大程度避免用户数据丢失 + try + { + FileSystem.DeleteFile(inputPath, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + } + catch (Exception delEx) + { + logger.LogWarning(delEx, "Failed to move source MP4 to recycle bin after batch convert: {Path}", inputPath); + await EnqueueEvent(sseChannel.Writer, BatchConvertPvEventType.FileError, $"{fileName}: moved to .dat but failed to remove source MP4 ({delEx.Message})"); + } + } + catch + { + try { if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); } + catch { /* ignored */ } + throw; + } + } + + processed++; + await EnqueueProgress(sseChannel.Writer, processed, total, 100, fileName, failedCount); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception fileEx) + { + logger.LogError(fileEx, "Failed to convert PV file {File}", inputPath); + failedCount++; + processed++; + await EnqueueEvent(sseChannel.Writer, BatchConvertPvEventType.FileError, $"{fileName}: {fileEx.Message}"); + await EnqueueProgress(sseChannel.Writer, processed, total, 100, fileName, failedCount); + } + } + + if (cancellationToken.IsCancellationRequested) + { + await EnqueueEvent(sseChannel.Writer, BatchConvertPvEventType.Cancelled, $"{processed}/{total}"); + } + else + { + await EnqueueEvent(sseChannel.Writer, BatchConvertPvEventType.Success, $"{processed}/{total}|{failedCount}"); + } + } + catch (OperationCanceledException) + { + await EnqueueEvent(sseChannel.Writer, BatchConvertPvEventType.Cancelled, $"{processed}/{total}"); + } + catch (Exception ex) + { + logger.LogError(ex, "Batch PV conversion failed"); + SentrySdk.CaptureException(ex); + try + { + await EnqueueEvent(sseChannel.Writer, BatchConvertPvEventType.Error, string.Format(Locale.ConvertFailed, ex.Message)); + } + catch + { + // 客户端可能已断开 + } + } + finally + { + sseChannel.Writer.TryComplete(); + try + { + await writer; + } + catch + { + // writer 自己负责吞掉客户端断开异常 + } + + // 最终再扫描一遍,让 MovieDataMap 与磁盘状态一致 + try + { + settings.ScanMovieData(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Final ScanMovieData after batch convert failed"); + } + } + } + + private static List EnumerateMoviePvs(HashSet sourceExtensions) + { + var result = new List(); + foreach (var assetDir in StaticSettings.AssetsDirs) + { + var movieDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "MovieData"); + if (!Directory.Exists(movieDir)) continue; + foreach (var file in Directory.EnumerateFiles(movieDir)) + { + if (!sourceExtensions.Contains(Path.GetExtension(file))) continue; + if (!int.TryParse(Path.GetFileNameWithoutExtension(file), out _)) continue; + result.Add(file); + } + } + return result; + } + + private static string SanitizeSseLine(string data) => + data.Replace("\r", " ").Replace("\n", " "); + + private static ValueTask EnqueueEvent(ChannelWriter writer, BatchConvertPvEventType eventType, string data) => + writer.WriteAsync($"event: {eventType}\ndata: {SanitizeSseLine(data)}\n\n"); + + private static ValueTask EnqueueProgress(ChannelWriter writer, int processed, int total, int fileProgress, string fileName, int failed) + { + var payload = JsonSerializer.Serialize(new BatchProgressPayload(processed, total, fileProgress, fileName, failed), BatchJsonOptions); + return writer.WriteAsync($"event: {BatchConvertPvEventType.Progress}\ndata: {payload}\n\n"); + } + + private static void EnqueueProgressFireAndForget(ChannelWriter writer, int processed, int total, int fileProgress, string fileName, int failed) + { + var payload = JsonSerializer.Serialize(new BatchProgressPayload(processed, total, fileProgress, fileName, failed), BatchJsonOptions); + // Channel 是无界的,TryWrite 同步入队,避免在 Xabe 的同步进度事件里 await + writer.TryWrite($"event: {BatchConvertPvEventType.Progress}\ndata: {payload}\n\n"); + } + + private async Task WriteSseFrames(ChannelReader reader, CancellationToken cancellationToken) + { + try + { + await foreach (var frame in reader.ReadAllAsync(cancellationToken)) + { + try + { + await Response.WriteAsync(frame, cancellationToken); + await Response.Body.FlushAsync(cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + logger.LogDebug(ex, "SSE frame write failed (client disconnected?)"); + return; + } + } + } + catch (OperationCanceledException) + { + // ignore + } + } +} diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index f0fdcfe1..39282741 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -501,6 +501,28 @@ tools: imageToAbError: Image to AB conversion error videoOptions: processing: Still processing, please wait... + batchPv: + label: Batch Convert PVs + title: Batch Convert All PVs + direction: Conversion direction + directionUsmToMp4: USM / DAT → MP4 + directionMp4ToUsm: MP4 → USM / DAT + directionUsmToMp4Hint: Writes a sibling .mp4 file next to each source; originals are kept + directionMp4ToUsmHint: Writes a sibling .dat file next to each source; source .mp4 files are moved to the recycle bin + start: Start + cancel: Cancel + cancelling: Cancelling... + cancelHint: Cancel takes effect after the current file finishes + close: Close + overall: Overall + currentFile: Current file + currentFileProgress: Current file progress + completedSummary: 'Completed: {success} / {total} succeeded ({failed} failed)' + cancelledSummary: 'Cancelled: {completed} / {total} done' + noFiles: No PV files found in the matching format + needLicense: This feature requires sponsor activation + error: Batch PV conversion error + fileErrors: 'File errors encountered:' error: title: Error unknown: Unknown error occurred diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index a952d37c..590533e4 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -464,6 +464,28 @@ tools: imageToAbError: 圖片轉 AB 出錯 videoOptions: processing: 還在處理,別急… + batchPv: + label: 批次轉換 PV + title: 批次轉換 PV + direction: 轉換方向 + directionUsmToMp4: USM / DAT → MP4 + directionMp4ToUsm: MP4 → USM / DAT + directionUsmToMp4Hint: 在同目錄下產生對應的 MP4 檔案,原檔保留 + directionMp4ToUsmHint: 在同目錄下產生對應的 DAT 檔案,原 MP4 會被移入資源回收筒 + start: 開始轉換 + cancel: 取消 + cancelling: 正在取消… + cancelHint: 取消會在目前檔案轉換完成後生效 + close: 關閉 + overall: 總進度 + currentFile: 目前檔案 + currentFileProgress: 目前檔案進度 + completedSummary: 轉換完成:成功 {success} / {total}(失敗 {failed}) + cancelledSummary: 已取消:已完成 {completed} / {total} + noFiles: 找不到符合格式的 PV 檔案 + needLicense: 此功能需要贊助啟用 + error: 批次轉換 PV 出錯 + fileErrors: 處理過程中的檔案錯誤: error: title: 錯誤 unknown: 發生未知錯誤 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 47d750e9..0cffcb8c 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -458,6 +458,28 @@ tools: imageToAbError: 图片转 AB 出错 videoOptions: processing: 还在处理,别急… + batchPv: + label: 批量转换 PV + title: 批量转换 PV + direction: 转换方向 + directionUsmToMp4: USM / DAT → MP4 + directionMp4ToUsm: MP4 → USM / DAT + directionUsmToMp4Hint: 在同目录下生成对应的 MP4 文件,原文件保留 + directionMp4ToUsmHint: 在同目录下生成对应的 DAT 文件,原 MP4 会被移入回收站 + start: 开始转换 + cancel: 取消 + cancelling: 正在取消… + cancelHint: 取消会在当前文件转换完成后生效 + close: 关闭 + overall: 总进度 + currentFile: 当前文件 + currentFileProgress: 当前文件进度 + completedSummary: 转换完成:成功 {success} / {total}(失败 {failed}) + cancelledSummary: 已取消:已完成 {completed} / {total} + noFiles: 没有找到匹配格式的 PV 文件 + needLicense: 此功能需要赞助激活 + error: 批量转换 PV 出错 + fileErrors: 处理过程中的文件错误: error: title: 错误 unknown: 发生未知错误 diff --git a/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx b/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx new file mode 100644 index 00000000..b57dcfa3 --- /dev/null +++ b/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx @@ -0,0 +1,325 @@ +import { getUrl } from '@/client/api'; +import { Button, Modal, Progress, Radio, addToast } from '@munet/ui'; +import { computed, defineComponent, reactive, ref } from 'vue'; +import { LicenseStatus } from '@/client/apiGen'; +import { globalCapture, showNeedPurchaseDialog, version } from '@/store/refs'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { handleSseOpen } from '@/utils/sseOpen'; +import { useI18n } from 'vue-i18n'; + +enum STEP { + None, + Configure, + Progress, + Done, +} + +enum Direction { + UsmToMp4 = 'UsmToMp4', + Mp4ToUsm = 'Mp4ToUsm', +} + +enum FinishKind { + Success, + Cancelled, +} + +interface ProgressPayload { + processed: number; + total: number; + fileProgress: number; + fileName: string; + failed: number; +} + +export default defineComponent({ + setup(_, { expose }) { + const { t } = useI18n(); + const step = ref(STEP.None); + const direction = ref(Direction.UsmToMp4); + + const state = reactive({ + completed: 0, + total: 0, + fileProgress: 0, + fileName: '', + failed: 0, + }); + + const fileErrors = ref([]); + const finishKind = ref(FinishKind.Success); + const finishSummary = ref(''); + const cancelling = ref(false); + + let controller: AbortController | null = null; + + const overallPercent = computed(() => + state.total === 0 ? 0 : Math.floor((state.completed / state.total) * 100), + ); + + const show = computed({ + get: () => step.value !== STEP.None, + set(v: boolean) { + if (!v && step.value !== STEP.Progress) { + step.value = STEP.None; + } + }, + }); + + const resetProgressState = () => { + state.completed = 0; + state.total = 0; + state.fileProgress = 0; + state.fileName = ''; + state.failed = 0; + fileErrors.value = []; + finishSummary.value = ''; + cancelling.value = false; + }; + + const start = async () => { + resetProgressState(); + step.value = STEP.Progress; + + controller = new AbortController(); + const url = `${getUrl('BatchConvertPvToolApi')}?direction=${direction.value}`; + + let succeeded = false; + let cancelled = false; + try { + await new Promise((resolve, reject) => { + fetchEventSource(url, { + signal: controller!.signal, + method: 'POST', + onopen: handleSseOpen, + onerror(e) { + reject(e); + controller?.abort(); + throw new Error('disable retry onerror'); + }, + onclose() { + if (succeeded || cancelled) { + resolve(); + } else { + reject(new Error('EventSource Close')); + } + throw new Error('disable retry onclose'); + }, + openWhenHidden: true, + onmessage: (e) => { + switch (e.event) { + case 'Progress': { + try { + const payload = JSON.parse(e.data) as ProgressPayload; + state.completed = payload.processed; + state.total = payload.total; + state.fileProgress = payload.fileProgress; + state.fileName = payload.fileName; + state.failed = payload.failed; + } catch { + // ignore malformed payload + } + break; + } + case 'FileError': { + fileErrors.value.push(e.data); + break; + } + case 'Success': { + succeeded = true; + finishKind.value = FinishKind.Success; + finishSummary.value = e.data; + controller?.abort(); + resolve(); + break; + } + case 'Cancelled': { + cancelled = true; + finishKind.value = FinishKind.Cancelled; + finishSummary.value = e.data; + controller?.abort(); + resolve(); + break; + } + case 'Error': { + controller?.abort(); + reject(new Error(e.data)); + break; + } + } + }, + }); + }); + + step.value = STEP.Done; + } catch (e: any) { + if (e?.name === 'AbortError') { + // 用户点了取消:HTTP 已断开,后端的 Cancelled 帧大概率收不到,所以前端自己进入 Done + step.value = STEP.Done; + if (!finishSummary.value) { + finishKind.value = FinishKind.Cancelled; + finishSummary.value = `${state.completed}/${state.total}`; + } + return; + } + // 已知的友好错误(无文件 / 需要赞助):toast 提示并回到 Configure,不上报 + const message: string = e?.message ?? ''; + const friendlyMessages = [ + t('tools.batchPv.noFiles'), + t('tools.batchPv.needLicense'), + ]; + if (friendlyMessages.includes(message)) { + addToast({ message, type: 'warning' }); + step.value = STEP.Configure; + return; + } + console.log(e); + globalCapture(e, t('tools.batchPv.error')); + step.value = STEP.None; + } finally { + controller = null; + } + }; + + const cancel = () => { + if (cancelling.value) return; + cancelling.value = true; + controller?.abort(); + }; + + const closeDone = () => { + if (finishKind.value === FinishKind.Success) { + const [doneStr, failedStr] = finishSummary.value.split('|'); + const [doneVal, totalVal] = doneStr.split('/').map(v => parseInt(v, 10)); + const failedVal = parseInt(failedStr ?? '0', 10); + const succeeded = doneVal - failedVal; + addToast({ + type: failedVal > 0 ? 'warning' : 'success', + message: t('tools.batchPv.completedSummary', { + success: succeeded, + total: totalVal, + failed: failedVal, + }), + }); + } + step.value = STEP.None; + }; + + const trigger = () => { + if (version.value?.license !== LicenseStatus.Active) { + showNeedPurchaseDialog.value = true; + return; + } + resetProgressState(); + direction.value = Direction.UsmToMp4; + step.value = STEP.Configure; + }; + + expose({ trigger }); + + const renderConfigure = () => ( +
+
{t('tools.batchPv.direction')}
+ +
+ {t('tools.batchPv.directionUsmToMp4')} + {t('tools.batchPv.directionUsmToMp4Hint')} +
+
+ +
+ {t('tools.batchPv.directionMp4ToUsm')} + {t('tools.batchPv.directionMp4ToUsmHint')} +
+
+
+ + +
+
+ ); + + const renderProgress = () => ( +
+
+
+ {t('tools.batchPv.overall')} + {state.completed}/{state.total}{state.failed > 0 ? ` (${state.failed} ✗)` : ''} +
+ +
+
+
+ {t('tools.batchPv.currentFile')} + {state.fileName} +
+ +
+ {fileErrors.value.length > 0 && ( +
+ {t('tools.batchPv.fileErrors')} ({fileErrors.value.length}) +
    + {fileErrors.value.map((err, i) =>
  • {err}
  • )} +
+
+ )} +
+ {t('tools.batchPv.cancelHint')} + +
+
+ ); + + const renderDone = () => { + const summary = (() => { + if (finishKind.value === FinishKind.Cancelled) { + const [doneStr, totalStr] = finishSummary.value.split('/'); + return t('tools.batchPv.cancelledSummary', { + completed: parseInt(doneStr ?? '0', 10), + total: parseInt(totalStr ?? '0', 10), + }); + } + const [doneStr, failedStr] = finishSummary.value.split('|'); + const [doneVal, totalVal] = doneStr.split('/').map(v => parseInt(v, 10)); + const failedVal = parseInt(failedStr ?? '0', 10); + return t('tools.batchPv.completedSummary', { + success: doneVal - failedVal, + total: totalVal, + failed: failedVal, + }); + })(); + + return ( +
+
{summary}
+ {fileErrors.value.length > 0 && ( +
+ {t('tools.batchPv.fileErrors')} ({fileErrors.value.length}) +
    + {fileErrors.value.map((err, i) =>
  • {err}
  • )} +
+
+ )} +
+ +
+
+ ); + }; + + return () => ( + + {step.value === STEP.Configure && renderConfigure()} + {step.value === STEP.Progress && renderProgress()} + {step.value === STEP.Done && renderDone()} + + ); + }, +}); diff --git a/MaiChartManager/Front/src/views/Tools/index.tsx b/MaiChartManager/Front/src/views/Tools/index.tsx index 052184c9..cf47debd 100644 --- a/MaiChartManager/Front/src/views/Tools/index.tsx +++ b/MaiChartManager/Front/src/views/Tools/index.tsx @@ -3,6 +3,7 @@ import { addToast } from '@munet/ui'; import { defineComponent, ref } from 'vue'; import VideoConvertButton from '@/views/Tools/VideoConvertModal'; import ImageToAbModal from '@/views/Tools/ImageToAbModal'; +import BatchVideoConvertModal from '@/views/Tools/BatchVideoConvertModal'; import { useI18n } from 'vue-i18n'; interface ToolCard { @@ -16,6 +17,7 @@ export default defineComponent({ setup() { const videoConvertRef = ref<{ trigger: () => void }>(); const imageToAbRef = ref<{ trigger: () => void }>(); + const batchVideoConvertRef = ref<{ trigger: () => void }>(); const { t } = useI18n(); const handleAudioConvert = async () => { @@ -47,6 +49,11 @@ export default defineComponent({ labelKey: 'tools.imageToAb', action: () => imageToAbRef.value?.trigger(), }, + { + icon: 'i-mdi-video-box', + labelKey: 'tools.batchPv.label', + action: () => batchVideoConvertRef.value?.trigger(), + }, ]; return () => ( @@ -72,6 +79,7 @@ export default defineComponent({ + ); }, diff --git a/MaiChartManager/Locale.Designer.cs b/MaiChartManager/Locale.Designer.cs index 4d050541..a8ab9304 100644 --- a/MaiChartManager/Locale.Designer.cs +++ b/MaiChartManager/Locale.Designer.cs @@ -792,6 +792,24 @@ internal static string SelectVideoToConvert { } } + /// + /// Looks up a localized string similar to Batch PV conversion requires sponsor activation. + /// + internal static string BatchConvertPvNeedLicense { + get { + return ResourceManager.GetString("BatchConvertPvNeedLicense", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No PV files found in the matching format. + /// + internal static string BatchConvertPvNoFiles { + get { + return ResourceManager.GetString("BatchConvertPvNoFiles", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unknown error. /// diff --git a/MaiChartManager/Locale.resx b/MaiChartManager/Locale.resx index e2c2e0d1..f79e80f0 100644 --- a/MaiChartManager/Locale.resx +++ b/MaiChartManager/Locale.resx @@ -279,6 +279,12 @@ If you notice any issues with the conversion result, you can try testing it in A Video or Image|*.mp4;*.mov;*.avi;*.mkv;*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.webp;*.svg;*.dat;*.usm + + Batch PV conversion requires sponsor activation + + + No PV files found in the matching format + diff --git a/MaiChartManager/Locale.zh-hans.resx b/MaiChartManager/Locale.zh-hans.resx index 721d3a65..0552889f 100644 --- a/MaiChartManager/Locale.zh-hans.resx +++ b/MaiChartManager/Locale.zh-hans.resx @@ -271,6 +271,12 @@ 视频或者图片|*.mp4;*.mov;*.avi;*.mkv;*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.webp;*.svg;*.dat;*.usm + + 批量转换 PV 需要赞助激活 + + + 没有找到匹配格式的 PV 文件 + diff --git a/MaiChartManager/Locale.zh-hant.resx b/MaiChartManager/Locale.zh-hant.resx index 5f3b3bbe..a3ed4721 100644 --- a/MaiChartManager/Locale.zh-hant.resx +++ b/MaiChartManager/Locale.zh-hant.resx @@ -271,6 +271,12 @@ 影片或者圖片|*.mp4;*.mov;*.avi;*.mkv;*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.webp;*.svg;*.dat;*.usm + + 批次轉換 PV 需要贊助啟用 + + + 未找到符合格式的 PV 檔案 + From 8ad1c726271ee7b96295621854127ddb2923eac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=B2?= <286150633+munet-rei[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:15:47 +0800 Subject: [PATCH 2/2] refactor: batch PV scans a user-picked folder instead of the game dir Per review feedback, batch PV conversion no longer scans the game's StreamingAssets/A###/MovieData. The user picks an arbitrary folder via the existing OpenFolderDialog endpoint and we convert every matching file in that folder in place. Backend (VideoConvertToolController.cs): - BatchConvertPvTool now takes folderPath in addition to direction. - Direct Directory.EnumerateFiles on the chosen folder, filtered by direction-derived source extensions. Removed the numeric-filename filter and EnumerateMoviePvs helper since arbitrary user folders contain arbitrarily named files. - Removed StaticSettings dependency: no AssetsDirs scan, no MovieDataMap mutation, no settings.ScanMovieData() call in finally. Constructor no longer takes StaticSettings. - Returns the new BatchConvertPvFolderNotFound error when the path is missing or does not exist. - All preserved: license gate, single-writer Channel SSE serialization, cancellation-before-delete, temp->validate->atomic-move, source MP4 to recycle bin, JSON camelCase payload. Frontend (BatchVideoConvertModal.tsx): - trigger() now opens the native folder picker via api.OpenFolderDialog (reusing the OobeController endpoint) before showing the modal. If the user cancels the picker, no modal is opened. - Configure step displays the selected path with a Change folder button that re-opens the picker. - start() includes folderPath as a query param when opening the SSE. - folderNotFound joins the friendly-error allowlist that surfaces as a toast instead of going through globalCapture. i18n: drop the 'same directory' wording from the direction hints, add selectFolder / changeFolder / selectedFolder / folderNotFound for zh, zh-TW, en. Backend Locale resx (and Designer.cs) gets the matching BatchConvertPvFolderNotFound entry. --- .../Tools/VideoConvertToolController.cs | 47 ++++++------------- MaiChartManager/Front/src/locales/en.yaml | 12 +++-- MaiChartManager/Front/src/locales/zh-TW.yaml | 10 ++-- MaiChartManager/Front/src/locales/zh.yaml | 10 ++-- .../views/Tools/BatchVideoConvertModal.tsx | 41 +++++++++++++--- MaiChartManager/Locale.Designer.cs | 9 ++++ MaiChartManager/Locale.resx | 3 ++ MaiChartManager/Locale.zh-hans.resx | 3 ++ MaiChartManager/Locale.zh-hant.resx | 3 ++ 9 files changed, 90 insertions(+), 48 deletions(-) diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index c3d6b5d4..63fed317 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -8,7 +8,7 @@ namespace MaiChartManager.Controllers.Tools; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public class VideoConvertToolController(ILogger logger, StaticSettings settings) : ControllerBase +public class VideoConvertToolController(ILogger logger) : ControllerBase { public enum VideoConvertEventType { @@ -117,13 +117,13 @@ public enum BatchConvertPvEventType private record BatchProgressPayload(int Processed, int Total, int FileProgress, string FileName, int Failed); /// - /// 批量转换 StreamingAssets 内所有 PV:USM/DAT ↔ MP4。 + /// 批量转换用户选择的文件夹内所有 PV:USM/DAT ↔ MP4。 /// 使用 SSE 实时推送整体进度(已处理/总数)+ 当前文件进度。 /// 客户端断开连接时通过 RequestAborted 触发取消,循环在下一个文件之间退出。 /// 所有 SSE 写入通过单写者 Channel 串行化,避免 Xabe 同步进度事件触发的 async-void 写入交错。 /// [HttpPost] - public async Task BatchConvertPvTool([FromQuery] BatchConvertPvDirection direction) + public async Task BatchConvertPvTool([FromQuery] string folderPath, [FromQuery] BatchConvertPvDirection direction) { Response.Headers.Append("Content-Type", "text/event-stream"); @@ -135,12 +135,22 @@ public async Task BatchConvertPvTool([FromQuery] BatchConvertPvDirection directi return; } - // 直接枚举 MovieData 目录,绕过 MovieDataMap 的 ID 去重,覆盖所有源文件 + if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath)) + { + await Response.WriteAsync($"event: {BatchConvertPvEventType.Error}\ndata: {SanitizeSseLine(Locale.BatchConvertPvFolderNotFound)}\n\n"); + await Response.Body.FlushAsync(); + return; + } + + // 直接枚举用户选择的文件夹(不递归),按方向筛选源扩展名 var sourceExtensions = direction == BatchConvertPvDirection.UsmToMp4 ? new HashSet(StringComparer.OrdinalIgnoreCase) { ".dat", ".usm" } : new HashSet(StringComparer.OrdinalIgnoreCase) { ".mp4" }; - var files = EnumerateMoviePvs(sourceExtensions); + var files = Directory.EnumerateFiles(folderPath) + .Where(f => sourceExtensions.Contains(Path.GetExtension(f))) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); if (files.Count == 0) { @@ -293,34 +303,7 @@ await VideoConvert.ConvertVideo(new VideoConvert.VideoConvertOptions { // writer 自己负责吞掉客户端断开异常 } - - // 最终再扫描一遍,让 MovieDataMap 与磁盘状态一致 - try - { - settings.ScanMovieData(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Final ScanMovieData after batch convert failed"); - } - } - } - - private static List EnumerateMoviePvs(HashSet sourceExtensions) - { - var result = new List(); - foreach (var assetDir in StaticSettings.AssetsDirs) - { - var movieDir = Path.Combine(StaticSettings.StreamingAssets, assetDir, "MovieData"); - if (!Directory.Exists(movieDir)) continue; - foreach (var file in Directory.EnumerateFiles(movieDir)) - { - if (!sourceExtensions.Contains(Path.GetExtension(file))) continue; - if (!int.TryParse(Path.GetFileNameWithoutExtension(file), out _)) continue; - result.Add(file); - } } - return result; } private static string SanitizeSseLine(string data) => diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 39282741..0b00c55e 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -503,12 +503,16 @@ tools: processing: Still processing, please wait... batchPv: label: Batch Convert PVs - title: Batch Convert All PVs + title: Batch Convert PVs + selectFolder: Pick folder + changeFolder: Change folder + selectedFolder: Selected folder + folderNotFound: Selected folder does not exist direction: Conversion direction directionUsmToMp4: USM / DAT → MP4 directionMp4ToUsm: MP4 → USM / DAT - directionUsmToMp4Hint: Writes a sibling .mp4 file next to each source; originals are kept - directionMp4ToUsmHint: Writes a sibling .dat file next to each source; source .mp4 files are moved to the recycle bin + directionUsmToMp4Hint: Writes a sibling .mp4 next to each DAT/USM in the chosen folder; originals are kept + directionMp4ToUsmHint: Writes a sibling .dat next to each MP4 in the chosen folder; source .mp4 files are moved to the recycle bin start: Start cancel: Cancel cancelling: Cancelling... @@ -519,7 +523,7 @@ tools: currentFileProgress: Current file progress completedSummary: 'Completed: {success} / {total} succeeded ({failed} failed)' cancelledSummary: 'Cancelled: {completed} / {total} done' - noFiles: No PV files found in the matching format + noFiles: No PV files in that folder match the selected direction needLicense: This feature requires sponsor activation error: Batch PV conversion error fileErrors: 'File errors encountered:' diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index 590533e4..742742c1 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -467,11 +467,15 @@ tools: batchPv: label: 批次轉換 PV title: 批次轉換 PV + selectFolder: 選擇資料夾 + changeFolder: 重新選擇 + selectedFolder: 已選擇的資料夾 + folderNotFound: 選擇的資料夾不存在 direction: 轉換方向 directionUsmToMp4: USM / DAT → MP4 directionMp4ToUsm: MP4 → USM / DAT - directionUsmToMp4Hint: 在同目錄下產生對應的 MP4 檔案,原檔保留 - directionMp4ToUsmHint: 在同目錄下產生對應的 DAT 檔案,原 MP4 會被移入資源回收筒 + directionUsmToMp4Hint: 在該資料夾內為每個 DAT/USM 產生對應的 MP4,原檔案保留 + directionMp4ToUsmHint: 在該資料夾內為每個 MP4 產生對應的 DAT,原 MP4 會被移入資源回收筒 start: 開始轉換 cancel: 取消 cancelling: 正在取消… @@ -482,7 +486,7 @@ tools: currentFileProgress: 目前檔案進度 completedSummary: 轉換完成:成功 {success} / {total}(失敗 {failed}) cancelledSummary: 已取消:已完成 {completed} / {total} - noFiles: 找不到符合格式的 PV 檔案 + noFiles: 該資料夾下找不到符合方向的 PV 檔案 needLicense: 此功能需要贊助啟用 error: 批次轉換 PV 出錯 fileErrors: 處理過程中的檔案錯誤: diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index 0cffcb8c..a38f1ce9 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -461,11 +461,15 @@ tools: batchPv: label: 批量转换 PV title: 批量转换 PV + selectFolder: 选择文件夹 + changeFolder: 重新选择 + selectedFolder: 已选择的文件夹 + folderNotFound: 选择的文件夹不存在 direction: 转换方向 directionUsmToMp4: USM / DAT → MP4 directionMp4ToUsm: MP4 → USM / DAT - directionUsmToMp4Hint: 在同目录下生成对应的 MP4 文件,原文件保留 - directionMp4ToUsmHint: 在同目录下生成对应的 DAT 文件,原 MP4 会被移入回收站 + directionUsmToMp4Hint: 在该文件夹内为每个 DAT/USM 生成对应的 MP4,原文件保留 + directionMp4ToUsmHint: 在该文件夹内为每个 MP4 生成对应的 DAT,原 MP4 会被移入回收站 start: 开始转换 cancel: 取消 cancelling: 正在取消… @@ -476,7 +480,7 @@ tools: currentFileProgress: 当前文件进度 completedSummary: 转换完成:成功 {success} / {total}(失败 {failed}) cancelledSummary: 已取消:已完成 {completed} / {total} - noFiles: 没有找到匹配格式的 PV 文件 + noFiles: 该文件夹下没有找到匹配方向的 PV 文件 needLicense: 此功能需要赞助激活 error: 批量转换 PV 出错 fileErrors: 处理过程中的文件错误: diff --git a/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx b/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx index b57dcfa3..a62bcd17 100644 --- a/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx +++ b/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx @@ -1,4 +1,4 @@ -import { getUrl } from '@/client/api'; +import api, { getUrl } from '@/client/api'; import { Button, Modal, Progress, Radio, addToast } from '@munet/ui'; import { computed, defineComponent, reactive, ref } from 'vue'; import { LicenseStatus } from '@/client/apiGen'; @@ -37,6 +37,8 @@ export default defineComponent({ const { t } = useI18n(); const step = ref(STEP.None); const direction = ref(Direction.UsmToMp4); + const folderPath = ref(''); + const picking = ref(false); const state = reactive({ completed: 0, @@ -78,11 +80,12 @@ export default defineComponent({ }; const start = async () => { + if (!folderPath.value) return; resetProgressState(); step.value = STEP.Progress; controller = new AbortController(); - const url = `${getUrl('BatchConvertPvToolApi')}?direction=${direction.value}`; + const url = `${getUrl('BatchConvertPvToolApi')}?folderPath=${encodeURIComponent(folderPath.value)}&direction=${direction.value}`; let succeeded = false; let cancelled = false; @@ -162,11 +165,12 @@ export default defineComponent({ } return; } - // 已知的友好错误(无文件 / 需要赞助):toast 提示并回到 Configure,不上报 + // 已知的友好错误(无文件 / 需要赞助 / 文件夹不存在):toast 提示并回到 Configure,不上报 const message: string = e?.message ?? ''; const friendlyMessages = [ t('tools.batchPv.noFiles'), t('tools.batchPv.needLicense'), + t('tools.batchPv.folderNotFound'), ]; if (friendlyMessages.includes(message)) { addToast({ message, type: 'warning' }); @@ -205,11 +209,29 @@ export default defineComponent({ step.value = STEP.None; }; - const trigger = () => { + const pickFolder = async () => { + if (picking.value) return; + picking.value = true; + try { + const res = await api.OpenFolderDialog(); + if (res.data) { + folderPath.value = res.data; + } + } catch (e) { + console.log(e); + globalCapture(e, t('tools.batchPv.error')); + } finally { + picking.value = false; + } + }; + + const trigger = async () => { if (version.value?.license !== LicenseStatus.Active) { showNeedPurchaseDialog.value = true; return; } + await pickFolder(); + if (!folderPath.value) return; resetProgressState(); direction.value = Direction.UsmToMp4; step.value = STEP.Configure; @@ -219,7 +241,14 @@ export default defineComponent({ const renderConfigure = () => (
-
{t('tools.batchPv.direction')}
+
{t('tools.batchPv.selectedFolder')}
+
+ {folderPath.value} + +
+
{t('tools.batchPv.direction')}
{t('tools.batchPv.directionUsmToMp4')} @@ -234,7 +263,7 @@ export default defineComponent({
- +
); diff --git a/MaiChartManager/Locale.Designer.cs b/MaiChartManager/Locale.Designer.cs index a8ab9304..ff788bd9 100644 --- a/MaiChartManager/Locale.Designer.cs +++ b/MaiChartManager/Locale.Designer.cs @@ -810,6 +810,15 @@ internal static string BatchConvertPvNoFiles { } } + /// + /// Looks up a localized string similar to Selected folder does not exist. + /// + internal static string BatchConvertPvFolderNotFound { + get { + return ResourceManager.GetString("BatchConvertPvFolderNotFound", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unknown error. /// diff --git a/MaiChartManager/Locale.resx b/MaiChartManager/Locale.resx index f79e80f0..352a9ade 100644 --- a/MaiChartManager/Locale.resx +++ b/MaiChartManager/Locale.resx @@ -285,6 +285,9 @@ If you notice any issues with the conversion result, you can try testing it in A No PV files found in the matching format + + Selected folder does not exist + diff --git a/MaiChartManager/Locale.zh-hans.resx b/MaiChartManager/Locale.zh-hans.resx index 0552889f..29bb963d 100644 --- a/MaiChartManager/Locale.zh-hans.resx +++ b/MaiChartManager/Locale.zh-hans.resx @@ -277,6 +277,9 @@ 没有找到匹配格式的 PV 文件 + + 选择的文件夹不存在 + diff --git a/MaiChartManager/Locale.zh-hant.resx b/MaiChartManager/Locale.zh-hant.resx index a3ed4721..7eeae65c 100644 --- a/MaiChartManager/Locale.zh-hant.resx +++ b/MaiChartManager/Locale.zh-hant.resx @@ -277,6 +277,9 @@ 未找到符合格式的 PV 檔案 + + 選擇的資料夾不存在 +