diff --git a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs index 9909d10..63fed31 100644 --- a/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs +++ b/MaiChartManager/Controllers/Tools/VideoConvertToolController.cs @@ -1,5 +1,8 @@ +using System.Text.Json; +using System.Threading.Channels; using MaiChartManager.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualBasic.FileIO; namespace MaiChartManager.Controllers.Tools; @@ -83,4 +86,270 @@ 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); + + /// + /// 批量转换用户选择的文件夹内所有 PV:USM/DAT ↔ MP4。 + /// 使用 SSE 实时推送整体进度(已处理/总数)+ 当前文件进度。 + /// 客户端断开连接时通过 RequestAborted 触发取消,循环在下一个文件之间退出。 + /// 所有 SSE 写入通过单写者 Channel 串行化,避免 Xabe 同步进度事件触发的 async-void 写入交错。 + /// + [HttpPost] + public async Task BatchConvertPvTool([FromQuery] string folderPath, [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; + } + + 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 = Directory.EnumerateFiles(folderPath) + .Where(f => sourceExtensions.Contains(Path.GetExtension(f))) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + + 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 自己负责吞掉客户端断开异常 + } + } + } + + 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 f0fdcfe..0b00c55 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -501,6 +501,32 @@ tools: imageToAbError: Image to AB conversion error videoOptions: processing: Still processing, please wait... + batchPv: + label: Batch Convert 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 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... + 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 in that folder match the selected direction + 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 a952d37..742742c 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -464,6 +464,32 @@ tools: imageToAbError: 圖片轉 AB 出錯 videoOptions: processing: 還在處理,別急… + batchPv: + label: 批次轉換 PV + title: 批次轉換 PV + selectFolder: 選擇資料夾 + changeFolder: 重新選擇 + selectedFolder: 已選擇的資料夾 + folderNotFound: 選擇的資料夾不存在 + direction: 轉換方向 + directionUsmToMp4: USM / DAT → MP4 + directionMp4ToUsm: MP4 → USM / DAT + directionUsmToMp4Hint: 在該資料夾內為每個 DAT/USM 產生對應的 MP4,原檔案保留 + directionMp4ToUsmHint: 在該資料夾內為每個 MP4 產生對應的 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 47d750e..a38f1ce 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -458,6 +458,32 @@ tools: imageToAbError: 图片转 AB 出错 videoOptions: processing: 还在处理,别急… + batchPv: + label: 批量转换 PV + title: 批量转换 PV + selectFolder: 选择文件夹 + changeFolder: 重新选择 + selectedFolder: 已选择的文件夹 + folderNotFound: 选择的文件夹不存在 + direction: 转换方向 + directionUsmToMp4: USM / DAT → MP4 + directionMp4ToUsm: MP4 → USM / DAT + directionUsmToMp4Hint: 在该文件夹内为每个 DAT/USM 生成对应的 MP4,原文件保留 + directionMp4ToUsmHint: 在该文件夹内为每个 MP4 生成对应的 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 0000000..a62bcd1 --- /dev/null +++ b/MaiChartManager/Front/src/views/Tools/BatchVideoConvertModal.tsx @@ -0,0 +1,354 @@ +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'; +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 folderPath = ref(''); + const picking = ref(false); + + 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 () => { + if (!folderPath.value) return; + resetProgressState(); + step.value = STEP.Progress; + + controller = new AbortController(); + const url = `${getUrl('BatchConvertPvToolApi')}?folderPath=${encodeURIComponent(folderPath.value)}&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'), + t('tools.batchPv.folderNotFound'), + ]; + 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 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; + }; + + expose({ trigger }); + + const renderConfigure = () => ( +
+
{t('tools.batchPv.selectedFolder')}
+
+ {folderPath.value} + +
+
{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 052184c..cf47deb 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 4d05054..ff788bd 100644 --- a/MaiChartManager/Locale.Designer.cs +++ b/MaiChartManager/Locale.Designer.cs @@ -792,6 +792,33 @@ 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 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 e2c2e0d..352a9ad 100644 --- a/MaiChartManager/Locale.resx +++ b/MaiChartManager/Locale.resx @@ -279,6 +279,15 @@ 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 + + + Selected folder does not exist + diff --git a/MaiChartManager/Locale.zh-hans.resx b/MaiChartManager/Locale.zh-hans.resx index 721d3a6..29bb963 100644 --- a/MaiChartManager/Locale.zh-hans.resx +++ b/MaiChartManager/Locale.zh-hans.resx @@ -271,6 +271,15 @@ 视频或者图片|*.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 5f3b3bb..7eeae65 100644 --- a/MaiChartManager/Locale.zh-hant.resx +++ b/MaiChartManager/Locale.zh-hant.resx @@ -271,6 +271,15 @@ 影片或者圖片|*.mp4;*.mov;*.avi;*.mkv;*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.webp;*.svg;*.dat;*.usm + + 批次轉換 PV 需要贊助啟用 + + + 未找到符合格式的 PV 檔案 + + + 選擇的資料夾不存在 +