Skip to content

Commit 22b6f1c

Browse files
committed
视频 USM 转换工具
1 parent dfa7403 commit 22b6f1c

File tree

3 files changed

+308
-7
lines changed

3 files changed

+308
-7
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
using MaiChartManager.Controllers.Music;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.VisualBasic.FileIO;
4+
using Xabe.FFmpeg;
5+
6+
namespace MaiChartManager.Controllers.Tools;
7+
8+
[ApiController]
9+
[Route("MaiChartManagerServlet/[action]Api")]
10+
public class VideoConvertToolController(ILogger<VideoConvertToolController> logger) : ControllerBase
11+
{
12+
public enum VideoConvertEventType
13+
{
14+
Progress,
15+
Success,
16+
Error
17+
}
18+
19+
[HttpPost]
20+
public async Task VideoConvertTool([FromQuery] bool noScale, [FromQuery] bool yuv420p)
21+
{
22+
Response.Headers.Append("Content-Type", "text/event-stream");
23+
24+
if (AppMain.BrowserWin is null)
25+
{
26+
await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: 浏览器窗口未初始化\n\n");
27+
await Response.Body.FlushAsync();
28+
return;
29+
}
30+
31+
var dialog = new OpenFileDialog()
32+
{
33+
Title = "请选择要转换的 MP4 视频文件",
34+
Filter = "MP4 视频文件|*.mp4",
35+
};
36+
37+
if (AppMain.BrowserWin.Invoke(() => dialog.ShowDialog(AppMain.BrowserWin)) != DialogResult.OK)
38+
{
39+
await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: 未选择文件\n\n");
40+
await Response.Body.FlushAsync();
41+
return;
42+
}
43+
44+
var inputFile = dialog.FileName;
45+
var directory = Path.GetDirectoryName(inputFile);
46+
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(inputFile);
47+
48+
var tmpDir = Directory.CreateTempSubdirectory();
49+
logger.LogInformation("Temp dir: {tmpDir}", tmpDir.FullName);
50+
51+
try
52+
{
53+
// 输出路径
54+
var outVideoPath = Path.Combine(tmpDir.FullName, "out.ivf");
55+
var outputDatPath = Path.Combine(directory!, fileNameWithoutExt + ".dat");
56+
57+
try
58+
{
59+
// 获取源视频信息
60+
var srcMedia = await FFmpeg.GetMediaInfo(inputFile);
61+
var vp9Encoding = MovieConvertController.HardwareAcceleration ==
62+
MovieConvertController.HardwareAccelerationStatus.Enabled
63+
? "vp9_qsv"
64+
: "vp9";
65+
66+
var firstStream = srcMedia.VideoStreams.First().SetCodec(vp9Encoding);
67+
var conversion = FFmpeg.Conversions.New()
68+
.AddStream(firstStream);
69+
70+
// 处理缩放
71+
var vf = "";
72+
var scale = 1080;
73+
if (!noScale)
74+
{
75+
vf = $"scale={scale}:-1,pad={scale}:{scale}:({scale}-iw)/2:({scale}-ih)/2:black";
76+
conversion.AddParameter($"-vf {vf}");
77+
}
78+
79+
conversion
80+
.SetOutput(outVideoPath)
81+
.AddParameter("-hwaccel dxva2", ParameterPosition.PreInput)
82+
.UseMultiThread(true)
83+
.AddParameter("-cpu-used 5");
84+
85+
// 处理颜色空间
86+
if (yuv420p)
87+
conversion.AddParameter("-pix_fmt yuv420p");
88+
89+
logger.LogInformation("About to run FFMpeg with params: {params}", conversion.Build());
90+
91+
// 添加进度回调
92+
conversion.OnProgress += async (sender, args) =>
93+
{
94+
await Response.WriteAsync($"event: {VideoConvertEventType.Progress}\ndata: {args.Percent}\n\n");
95+
await Response.Body.FlushAsync();
96+
};
97+
98+
await conversion.Start();
99+
}
100+
catch (Exception e)
101+
{
102+
logger.LogError(e, "Failed to convert video");
103+
await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: 视频转换为 VP9 失败:{e.Message}\n\n");
104+
await Response.Body.FlushAsync();
105+
return;
106+
}
107+
108+
// 检查输出文件
109+
if (!System.IO.File.Exists(outVideoPath) || new FileInfo(outVideoPath).Length == 0)
110+
{
111+
await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: 视频转换为 VP9 失败:输出文件不存在\n\n");
112+
await Response.Body.FlushAsync();
113+
return;
114+
}
115+
116+
// 转换 IVF 到 USM (DAT)
117+
var outputFile = Path.Combine(tmpDir.FullName, "out.usm");
118+
try
119+
{
120+
WannaCRI.WannaCRI.CreateUsm(outVideoPath);
121+
if (!System.IO.File.Exists(outputFile) || new FileInfo(outputFile).Length == 0)
122+
{
123+
throw new Exception("Output file not found or empty");
124+
}
125+
}
126+
catch (Exception e)
127+
{
128+
logger.LogError(e, "Failed to convert ivf to usm");
129+
await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: 视频转换为 USM 失败:{e.Message}\n\n");
130+
await Response.Body.FlushAsync();
131+
return;
132+
}
133+
134+
// 复制到目标位置
135+
try
136+
{
137+
FileSystem.CopyFile(outputFile, outputDatPath, true);
138+
await Response.WriteAsync($"event: {VideoConvertEventType.Success}\ndata: {outputDatPath}\n\n");
139+
await Response.Body.FlushAsync();
140+
}
141+
catch (Exception e)
142+
{
143+
logger.LogError(e, "Failed to copy file");
144+
await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: 复制文件失败:{e.Message}\n\n");
145+
await Response.Body.FlushAsync();
146+
}
147+
}
148+
catch (Exception ex)
149+
{
150+
await Response.WriteAsync($"event: {VideoConvertEventType.Error}\ndata: 转换失败:{ex.Message}\n\n");
151+
await Response.Body.FlushAsync();
152+
}
153+
}
154+
}

MaiChartManager/Front/src/client/apiGen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,27 @@ export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDa
18351835
...params,
18361836
}),
18371837

1838+
/**
1839+
* No description
1840+
*
1841+
* @tags VideoConvertTool
1842+
* @name VideoConvertTool
1843+
* @request POST:/MaiChartManagerServlet/VideoConvertToolApi
1844+
*/
1845+
VideoConvertTool: (
1846+
query?: {
1847+
noScale?: boolean;
1848+
yuv420p?: boolean;
1849+
},
1850+
params: RequestParams = {},
1851+
) =>
1852+
this.request<void, any>({
1853+
path: `/MaiChartManagerServlet/VideoConvertToolApi`,
1854+
method: "POST",
1855+
query: query,
1856+
...params,
1857+
}),
1858+
18381859
/**
18391860
* No description
18401861
*
Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,92 @@
1-
import api from '@/client/api';
2-
import { NDropdown, NButton, useMessage } from 'naive-ui';
1+
import api, { getUrl } from '@/client/api';
2+
import { NDropdown, NButton, useMessage, NModal, NFlex, NCheckbox, NProgress } from 'naive-ui';
33
import { defineComponent, PropType, ref, computed, watch } from 'vue';
4+
import { useStorage } from '@vueuse/core';
5+
import { LicenseStatus } from '@/client/apiGen';
6+
import { globalCapture, showNeedPurchaseDialog, version } from '@/store/refs';
7+
import { fetchEventSource } from '@microsoft/fetch-event-source';
48

59
enum DROPDOWN_OPTIONS {
610
AudioConvert,
11+
VideoConvert,
12+
}
13+
14+
enum STEP {
15+
None,
16+
Options,
17+
Progress,
18+
}
19+
20+
// 视频转换选项的默认值
21+
const defaultVideoConvertOptions = {
22+
noScale: false,
23+
yuv420p: true,
724
}
825

926
export default defineComponent({
1027
// props: {
1128
// },
1229
setup(props, { emit }) {
1330
const message = useMessage();
31+
const step = ref(STEP.None);
32+
const progress = ref(0);
33+
const videoConvertOptions = useStorage('videoConvertOptions', defaultVideoConvertOptions, undefined, { mergeDefaults: true });
34+
1435
const options = [
1536
{ label: "音频转换", key: DROPDOWN_OPTIONS.AudioConvert },
37+
{ label: "视频转换(MP4 转 DAT)", key: DROPDOWN_OPTIONS.VideoConvert },
1638
]
1739

40+
const handleVideoConvert = async () => {
41+
step.value = STEP.Progress;
42+
progress.value = 0;
43+
44+
const controller = new AbortController();
45+
46+
try {
47+
await new Promise<void>((resolve, reject) => {
48+
fetchEventSource(getUrl(`VideoConvertToolApi?noScale=${videoConvertOptions.value.noScale}&yuv420p=${videoConvertOptions.value.yuv420p}`), {
49+
signal: controller.signal,
50+
method: 'POST',
51+
onerror(e) {
52+
reject(e);
53+
controller.abort();
54+
throw new Error("disable retry onerror");
55+
},
56+
onclose() {
57+
reject(new Error("EventSource Close"));
58+
controller.abort();
59+
throw new Error("disable retry onclose");
60+
},
61+
openWhenHidden: true,
62+
onmessage: (e) => {
63+
switch (e.event) {
64+
case 'Progress':
65+
progress.value = parseInt(e.data);
66+
break;
67+
case 'Success':
68+
console.log("success", e.data);
69+
controller.abort();
70+
message.success("转换完成!");
71+
resolve();
72+
break;
73+
case 'Error':
74+
controller.abort();
75+
reject(new Error(e.data));
76+
break;
77+
}
78+
}
79+
});
80+
});
81+
} catch (e: any) {
82+
if (e?.name === 'AbortError') return;
83+
console.log(e);
84+
globalCapture(e, "视频转换出错");
85+
} finally {
86+
step.value = STEP.None;
87+
}
88+
};
89+
1890
const handleOptionClick = async (key: DROPDOWN_OPTIONS) => {
1991
switch (key) {
2092
case DROPDOWN_OPTIONS.AudioConvert: {
@@ -26,13 +98,67 @@ export default defineComponent({
2698
}
2799
break;
28100
}
101+
case DROPDOWN_OPTIONS.VideoConvert: {
102+
// 检查是否为赞助版
103+
if (version.value?.license !== LicenseStatus.Active) {
104+
showNeedPurchaseDialog.value = true;
105+
return;
106+
}
107+
// 显示选项对话框
108+
step.value = STEP.Options;
109+
break;
110+
}
29111
}
30112
}
31113

32-
return () => (location.hostname === 'mcm.invalid' || import.meta.env.DEV) && <NDropdown options={options} trigger="click" onSelect={handleOptionClick}>
33-
<NButton secondary>
34-
工具
35-
</NButton>
36-
</NDropdown>;
114+
return () => (location.hostname === 'mcm.invalid' || import.meta.env.DEV) && <>
115+
<NDropdown options={options} trigger="click" onSelect={handleOptionClick}>
116+
<NButton secondary>
117+
工具
118+
</NButton>
119+
</NDropdown>
120+
121+
<NModal
122+
preset="card"
123+
class="w-[min(30vw,25em)]"
124+
title="视频转换选项"
125+
show={step.value === STEP.Options}
126+
onUpdateShow={() => step.value = STEP.None}
127+
>{{
128+
default: () => <NFlex vertical size="large">
129+
<div>将 MP4 视频转换为 DAT 格式(USM 容器)</div>
130+
<NCheckbox v-model:checked={videoConvertOptions.value.noScale}>
131+
不要缩放视频到 1080 宽度
132+
</NCheckbox>
133+
<NCheckbox v-model:checked={videoConvertOptions.value.yuv420p}>
134+
使用 YUV420P 颜色空间
135+
</NCheckbox>
136+
</NFlex>,
137+
footer: () => <NFlex justify="end">
138+
<NButton onClick={() => step.value = STEP.None}>取消</NButton>
139+
<NButton type="primary" onClick={handleVideoConvert}>确定</NButton>
140+
</NFlex>
141+
}}</NModal>
142+
143+
<NModal
144+
preset="card"
145+
class="w-[min(40vw,40em)]"
146+
title="正在转换…"
147+
show={step.value === STEP.Progress}
148+
closable={false}
149+
maskClosable={false}
150+
closeOnEsc={false}
151+
>
152+
<NProgress
153+
type="line"
154+
status="success"
155+
percentage={progress.value}
156+
indicator-placement="inside"
157+
processing
158+
>
159+
{progress.value === 100 ? '还在处理,别急…' : `${progress.value}%`}
160+
</NProgress>
161+
</NModal>
162+
</>;
37163
},
38164
});

0 commit comments

Comments
 (0)