Skip to content

Commit ae55cfa

Browse files
committed
feat: 全面重构谱面管理界面
后端: - ExportCustom API (format=ugc/sus) 替代 ExportUgc - ChangeId / DeleteMusic / SetJacket / SetAudio / ReplaceChart API - FumenData: NoteCount 从 C2S 统计, NotesDesigner 从 C2S CREATOR fallback - HCA 编码加密钥修复 (EncryptionKey), 解码兼容明文+加密 - ImportAudioToMusic: WAV/MP3→HCA→AWB 完整流程 - SetAudio 支持 AWB 直接复制 前端: - toolbar 重构: A000 显示提示, 非 A000 显示 删除/保存/导入谱面 - DropMenu 复制与导出: 复制到.../导出 ZIP/UGC/SUS/修改 ID/打开目录/编辑 XML - 复制到改为 showDirectoryPicker + zip.js 解压 - 难度面板: 作者/显示等级/定数/音符数量, 右上角替换谱面 - 播放器: 单行胶囊风格, 主题色跟随, 选曲即显示, 导出 MP3 按钮 - 封面点击替换 (showOpenFilePicker → SetJacket) - 替换音频按钮 (showOpenFilePicker → SetAudio) - 替换谱面改为前端选文件 (showOpenFilePicker → ReplaceChart) - BottomOverlay + FileTypeIcon: 文件选择时底部提示支持格式 - ImportMusicModal 改用 MuNET-UI Modal + BottomOverlay 依赖: - 新增 @zip.js/zip.js 组件: - 新增 BottomOverlay.vue, FileTypeIcon.vue, getSubDirFile.ts
1 parent b294f8b commit ae55cfa

18 files changed

Lines changed: 1052 additions & 556 deletions

File tree

.gitignore

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,5 @@ node_modules/
1616
ChuChartManager/wwwroot/
1717
pnpm-lock.yaml
1818

19-
# 临时文件
20-
*.psb
21-
*.pure.psb
22-
*.emtbytes
23-
19+
# 其他
20+
.sisyphus

ChuChartManager/AudioHelper.cs

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,26 @@ public float Volume
6262

6363
public static byte[]? DecodeHcaToWav(byte[] hcaData)
6464
{
65+
try
66+
{
67+
var reader = new HcaReader();
68+
var audio = reader.Read(hcaData);
69+
return new WaveWriter().GetFile(audio);
70+
}
71+
catch { }
72+
6573
foreach (var key in Keys)
6674
{
6775
try
6876
{
6977
var hcaReader = new HcaReader { EncryptionKey = key };
7078
var audioData = hcaReader.Read(hcaData);
71-
var waveWriter = new WaveWriter();
72-
return waveWriter.GetFile(audioData);
79+
return new WaveWriter().GetFile(audioData);
7380
}
7481
catch (InvalidDataException) { }
7582
}
7683

77-
var fallback = new HcaReader();
78-
var audio = fallback.Read(hcaData);
79-
return new WaveWriter().GetFile(audio);
84+
return null;
8085
}
8186

8287
public static byte[]? GetWavFromMusic(MusicXml music)
@@ -171,9 +176,56 @@ public static void ExportMp3(byte[] wavData, string outputPath)
171176
var audioData = waveReader.Read(wavData);
172177

173178
var hcaWriter = new HcaWriter();
179+
hcaWriter.Configuration.EncryptionKey = Keys[0];
174180
return hcaWriter.GetFile(audioData);
175181
}
176182

183+
public static void ImportAudioToMusic(MusicXml music, string audioPath)
184+
{
185+
byte[] wavBytes;
186+
var ext = Path.GetExtension(audioPath).ToLowerInvariant();
187+
if (ext == ".wav")
188+
{
189+
wavBytes = File.ReadAllBytes(audioPath);
190+
}
191+
else
192+
{
193+
using var reader = new AudioFileReader(audioPath);
194+
using var wavMs = new MemoryStream();
195+
var pcm16 = reader.ToWaveProvider16();
196+
WaveFileWriter.WriteWavFileToStream(wavMs, pcm16);
197+
wavBytes = wavMs.ToArray();
198+
}
199+
200+
var hcaBytes = EncodeWavToHca(wavBytes);
201+
if (hcaBytes == null || hcaBytes.Length == 0)
202+
throw new InvalidOperationException("Failed to encode audio to HCA");
203+
204+
var hcaTempPath = Path.Combine(Path.GetTempPath(), $"ccm_hca_{Guid.NewGuid():N}.hca");
205+
try
206+
{
207+
File.WriteAllBytes(hcaTempPath, hcaBytes);
208+
209+
var sourceRoot = Path.GetDirectoryName(Path.GetDirectoryName(music.MusicDirectory));
210+
if (sourceRoot == null) throw new InvalidOperationException("Cannot determine option root");
211+
212+
var cueFileName = $"music{music.Id:D4}";
213+
var cueFileDir = Path.Combine(sourceRoot, "cueFile", $"cueFile{music.Id:D6}");
214+
Directory.CreateDirectory(cueFileDir);
215+
var awbPath = Path.Combine(cueFileDir, $"{cueFileName}.awb");
216+
217+
var archive = new CriAfs2Archive();
218+
archive.Add(new CriAfs2Entry { Id = 0, FilePath = new FileInfo(hcaTempPath) });
219+
220+
using var awbStream = File.Create(awbPath);
221+
archive.Write(awbStream);
222+
}
223+
finally
224+
{
225+
try { File.Delete(hcaTempPath); } catch { }
226+
}
227+
}
228+
177229
public void Dispose()
178230
{
179231
Stop();

ChuChartManager/Controllers/MusicController.cs

Lines changed: 214 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ public ActionResult<List<MusicListItem>> GetMusicList([FromQuery] string? source
5555
Level = f.Level,
5656
LevelDecimal = f.LevelDecimal,
5757
LevelDisplay = f.LevelDisplay,
58-
NotesDesigner = f.NotesDesigner
58+
NotesDesigner = f.NotesDesigner,
59+
NoteCount = f.NoteCount
5960
}).ToArray()
6061
}).ToList();
6162

@@ -334,6 +335,77 @@ public ActionResult ImportJacket([FromQuery] int id, [FromQuery] string assetDir
334335
return Ok(new { imported = true });
335336
}
336337

338+
[HttpPut]
339+
public ActionResult SetJacket([FromQuery] int id, [FromQuery] string assetDir, IFormFile file)
340+
{
341+
var scanner = scannerService.Scanner;
342+
if (scanner == null) return NotFound();
343+
344+
var music = FindMusic(scanner, id, assetDir);
345+
if (music == null) return NotFound();
346+
347+
var ddsFileName = $"CHU_UI_Jacket_{id:D8}.dds";
348+
var tempPath = Path.Combine(Path.GetTempPath(), $"ccm_jacket_{Guid.NewGuid()}{Path.GetExtension(file.FileName)}");
349+
try
350+
{
351+
using (var fs = System.IO.File.Create(tempPath))
352+
file.CopyTo(fs);
353+
DdsHelper.ConvertPngToDds(tempPath, Path.Combine(music.MusicDirectory, ddsFileName));
354+
}
355+
finally
356+
{
357+
try { System.IO.File.Delete(tempPath); } catch { }
358+
}
359+
360+
var root = music.XmlDoc.SelectSingleNode("/MusicData/jaketFile/path");
361+
if (root != null) root.InnerText = ddsFileName;
362+
music.JacketFileName = ddsFileName;
363+
music.Save();
364+
365+
JacketCache.TryRemove(music.GetJacketFullPath() ?? "", out _);
366+
367+
return Ok();
368+
}
369+
370+
[HttpPut]
371+
[DisableRequestSizeLimit]
372+
public ActionResult SetAudio([FromQuery] int id, [FromQuery] string assetDir, IFormFile file)
373+
{
374+
var scanner = scannerService.Scanner;
375+
if (scanner == null) return NotFound();
376+
377+
var music = FindMusic(scanner, id, assetDir);
378+
if (music == null) return NotFound();
379+
380+
var tempPath = Path.Combine(Path.GetTempPath(), $"ccm_audio_{Guid.NewGuid()}{Path.GetExtension(file.FileName)}");
381+
try
382+
{
383+
using (var fs = System.IO.File.Create(tempPath))
384+
file.CopyTo(fs);
385+
386+
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
387+
if (ext == ".awb")
388+
{
389+
var sourceRoot = Path.GetDirectoryName(Path.GetDirectoryName(music.MusicDirectory));
390+
if (sourceRoot == null) return BadRequest("Cannot determine option root");
391+
var cueFileDir = Path.Combine(sourceRoot, "cueFile", $"cueFile{id:D6}");
392+
Directory.CreateDirectory(cueFileDir);
393+
var awbPath = Path.Combine(cueFileDir, $"music{id:D4}.awb");
394+
System.IO.File.Copy(tempPath, awbPath, true);
395+
}
396+
else
397+
{
398+
AudioHelper.ImportAudioToMusic(music, tempPath);
399+
}
400+
}
401+
finally
402+
{
403+
try { System.IO.File.Delete(tempPath); } catch { }
404+
}
405+
406+
return Ok();
407+
}
408+
337409
[HttpPost]
338410
public ActionResult ImportChart([FromQuery] int id, [FromQuery] string assetDir, [FromQuery] int diffIndex)
339411
{
@@ -406,6 +478,63 @@ public ActionResult ImportChart([FromQuery] int id, [FromQuery] string assetDir,
406478
return Ok(new { imported = true, convertedFrom = ext != "c2s" ? ext : (string?)null, alerts });
407479
}
408480

481+
[HttpPut]
482+
public ActionResult ReplaceChart([FromQuery] int id, [FromQuery] string assetDir, [FromQuery] int diffIndex, IFormFile file)
483+
{
484+
var scanner = scannerService.Scanner;
485+
if (scanner == null) return NotFound();
486+
487+
var music = FindMusic(scanner, id, assetDir);
488+
if (music == null) return NotFound();
489+
490+
var ext = Path.GetExtension(file.FileName).TrimStart('.').ToLowerInvariant();
491+
var destFileName = $"{id:D4}_0{diffIndex}.c2s";
492+
var destPath = Path.Combine(music.MusicDirectory, destFileName);
493+
var alerts = new List<string>();
494+
495+
using var ms = new MemoryStream();
496+
file.CopyTo(ms);
497+
var sourceContent = Encoding.UTF8.GetString(ms.ToArray());
498+
499+
if (ext is "ugc" or "sus")
500+
{
501+
try
502+
{
503+
var (chart, parseAlerts) = ext == "ugc"
504+
? new ChuUgcParser().Parse(sourceContent)
505+
: new SusParser().Parse(sourceContent);
506+
alerts.AddRange(parseAlerts.Select(a => a.ToString()));
507+
508+
var (c2sContent, genAlerts) = new C2sGenerator().Generate(chart);
509+
alerts.AddRange(genAlerts.Select(a => a.ToString()));
510+
511+
System.IO.File.WriteAllText(destPath, c2sContent, Encoding.UTF8);
512+
}
513+
catch (MuConvert.utils.ConversionException ex)
514+
{
515+
alerts.AddRange(ex.Alerts.Select(a => a.ToString()));
516+
return BadRequest(new { error = ex.Message, alerts });
517+
}
518+
}
519+
else
520+
{
521+
System.IO.File.WriteAllText(destPath, sourceContent, Encoding.UTF8);
522+
}
523+
524+
var fumenNodes = music.XmlDoc.SelectNodes("/MusicData/fumens/MusicFumenData");
525+
if (fumenNodes != null && diffIndex < fumenNodes.Count)
526+
{
527+
var node = fumenNodes[diffIndex]!;
528+
var enableNode = node.SelectSingleNode("enable");
529+
if (enableNode != null) enableNode.InnerText = "true";
530+
var fileNode = node.SelectSingleNode("file/path");
531+
if (fileNode != null) fileNode.InnerText = destFileName;
532+
}
533+
music.Save();
534+
535+
return Ok(new { imported = true, convertedFrom = ext != "c2s" ? ext : (string?)null, alerts });
536+
}
537+
409538
[HttpGet]
410539
public ActionResult ExportChart([FromQuery] int id, [FromQuery] string assetDir, [FromQuery] int diffIndex, [FromQuery] string format = "ugc")
411540
{
@@ -940,7 +1069,7 @@ public ActionResult ExportOpt([FromQuery] int id, [FromQuery] string assetDir)
9401069
}
9411070

9421071
[HttpGet]
943-
public ActionResult ExportUgc([FromQuery] int id, [FromQuery] string assetDir)
1072+
public ActionResult ExportCustom([FromQuery] int id, [FromQuery] string assetDir, [FromQuery] string format = "ugc")
9441073
{
9451074
var scanner = scannerService.Scanner;
9461075
if (scanner == null) return NotFound();
@@ -962,10 +1091,13 @@ public ActionResult ExportUgc([FromQuery] int id, [FromQuery] string assetDir)
9621091
try
9631092
{
9641093
var (chart, _) = new C2sParser().Parse(c2sContent);
965-
var (ugcContent, _) = new UgcGenerator().Generate(chart);
966-
var entry = zip.CreateEntry($"{safeName}.ugc");
1094+
var ext = format.ToLowerInvariant() == "sus" ? "sus" : "ugc";
1095+
var content = ext == "sus"
1096+
? new SusGenerator().Generate(chart).Item1
1097+
: new UgcGenerator().Generate(chart).Item1;
1098+
var entry = zip.CreateEntry($"{safeName}.{ext}");
9671099
using var w = new StreamWriter(entry.Open(), Encoding.UTF8);
968-
w.Write(ugcContent);
1100+
w.Write(content);
9691101
}
9701102
catch
9711103
{
@@ -1003,6 +1135,82 @@ public ActionResult ExportUgc([FromQuery] int id, [FromQuery] string assetDir)
10031135
return File(ms, "application/zip", $"{safeName}.zip");
10041136
}
10051137

1138+
[HttpPost]
1139+
public ActionResult ChangeId([FromQuery] int id, [FromQuery] string assetDir, [FromBody] int newId)
1140+
{
1141+
var scanner = scannerService.Scanner;
1142+
if (scanner == null) return NotFound();
1143+
1144+
var music = FindMusic(scanner, id, assetDir);
1145+
if (music == null) return NotFound("曲目不存在");
1146+
1147+
if (assetDir == "A000") return BadRequest("不能修改 A000 的曲目 ID");
1148+
1149+
var optRoot = ResolveOptRoot(assetDir);
1150+
if (optRoot == null) return BadRequest("目录无效");
1151+
1152+
var newMusicDirName = $"music{newId:D4}";
1153+
var newMusicDir = Path.Combine(optRoot, "music", newMusicDirName);
1154+
if (Directory.Exists(newMusicDir) && newId != id)
1155+
return BadRequest($"ID {newId} 的曲目目录已存在");
1156+
1157+
var oldMusicDir = music.MusicDirectory;
1158+
1159+
// Music.xml: 更新 ID
1160+
var root = music.XmlDoc.SelectSingleNode("/MusicData");
1161+
var idNode = root?.SelectSingleNode("name/id");
1162+
if (idNode != null) idNode.InnerText = newId.ToString();
1163+
var dataNameNode = root?.SelectSingleNode("dataName");
1164+
if (dataNameNode != null) dataNameNode.InnerText = newMusicDirName;
1165+
music.Save();
1166+
1167+
// 重命名 music 目录
1168+
if (newId != id && oldMusicDir != newMusicDir)
1169+
Directory.Move(oldMusicDir, newMusicDir);
1170+
1171+
// 重命名 cueFile 目录
1172+
var oldCueDir = Path.Combine(optRoot, "cueFile", $"cueFile{id:D6}");
1173+
var newCueDir = Path.Combine(optRoot, "cueFile", $"cueFile{newId:D6}");
1174+
if (newId != id && Directory.Exists(oldCueDir) && !Directory.Exists(newCueDir))
1175+
Directory.Move(oldCueDir, newCueDir);
1176+
1177+
// 重新扫描
1178+
var newScanner = new MusicScanner(StaticSettings.GamePath);
1179+
newScanner.ScanAll();
1180+
StaticSettings.Scanner = newScanner;
1181+
1182+
return Ok();
1183+
}
1184+
1185+
[HttpPost]
1186+
public ActionResult DeleteMusic([FromQuery] int id, [FromQuery] string assetDir)
1187+
{
1188+
var scanner = scannerService.Scanner;
1189+
if (scanner == null) return NotFound();
1190+
1191+
var music = FindMusic(scanner, id, assetDir);
1192+
if (music == null) return NotFound("曲目不存在");
1193+
1194+
if (assetDir == "A000") return BadRequest("不能删除 A000 的曲目");
1195+
1196+
if (Directory.Exists(music.MusicDirectory))
1197+
Directory.Delete(music.MusicDirectory, true);
1198+
1199+
var optRoot = ResolveOptRoot(assetDir);
1200+
if (optRoot != null)
1201+
{
1202+
var cueDir = Path.Combine(optRoot, "cueFile", $"cueFile{id:D6}");
1203+
if (Directory.Exists(cueDir))
1204+
Directory.Delete(cueDir, true);
1205+
}
1206+
1207+
var newScanner = new MusicScanner(StaticSettings.GamePath);
1208+
newScanner.ScanAll();
1209+
StaticSettings.Scanner = newScanner;
1210+
1211+
return Ok();
1212+
}
1213+
10061214
[HttpPost]
10071215
public ActionResult OpenExplorer([FromQuery] int id, [FromQuery] string assetDir)
10081216
{
@@ -1057,6 +1265,7 @@ public class FumenSummary
10571265
public int LevelDecimal { get; set; }
10581266
public string LevelDisplay { get; set; } = "";
10591267
public string NotesDesigner { get; set; } = "";
1268+
public int NoteCount { get; set; }
10601269
}
10611270

10621271
public class MusicEditDto

ChuChartManager/Front/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@fontsource/noto-sans-sc": "^5.2.9",
1212
"@fontsource/quicksand": "^5.2.10",
1313
"@munet/ui": "workspace:*",
14+
"@zip.js/zip.js": "^2.8.26",
1415
"axios": "^1.7.0",
1516
"naive-ui": "^2.44.1",
1617
"virtua": "^0.49.1",

ChuChartManager/Front/src/App.vue

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { ref, onMounted } from 'vue'
33
import { modalShowing, GlobalElementsContainer } from '@munet/ui'
44
import Sidebar from '@/components/Sidebar.vue'
5-
import PlayerBar from '@/components/PlayerBar.vue'
65
import StatusBar from '@/components/StatusBar.vue'
76
import MusicList from '@/views/MusicList.vue'
87
import Course from '@/views/Course/index'
@@ -58,7 +57,6 @@ const handleRefresh = () => {
5857
<Settings v-if="sidebarActive === 'settings'" />
5958
</div>
6059
</div>
61-
<PlayerBar />
6260
<StatusBar />
6361
</div>
6462
</template>

0 commit comments

Comments
 (0)