feat: SongCreator 씬 완성 — Beat Sage URL 지원, info.dat 메타데이터 자동 추출
- BeatSageUploader: audio_url 지원(UploadFromUrl), PollAndDownload 공통화, ZIP 500 오류 3회 재시도 - BeatSageConverter: info.dat 파싱(SongMetadata), BPM 자동 감지 → 노트 타이밍 변환에 적용 - SongCreatorManager: title/BPM 필수 입력 제거, 난이도 4개 자동 선택, GenerateFlowFromUrl 버그 수정 - NasPublisher: audioPath null 허용(URL 흐름에서 로컬 파일 없는 경우 스킵) - .gitignore/.gitattributes 초기 설정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class SongCreatorManager : MonoBehaviour
|
||||
{
|
||||
[Header("Audio Source")]
|
||||
[SerializeField] private TMP_Dropdown audioDropdown;
|
||||
[SerializeField] private Button refreshBtn;
|
||||
[SerializeField] private TMP_Text inputPathHint;
|
||||
|
||||
[Header("Audio — Local File")]
|
||||
[SerializeField] private Button filePickerBtn;
|
||||
[SerializeField] private TMP_Text addStatusText;
|
||||
|
||||
[Header("Audio — URL")]
|
||||
[SerializeField] private TMP_InputField urlInput;
|
||||
[SerializeField] private Button urlDownloadBtn;
|
||||
|
||||
[Header("Metadata")]
|
||||
[SerializeField] private TMP_InputField titleInput;
|
||||
[SerializeField] private TMP_InputField artistInput;
|
||||
[SerializeField] private TMP_InputField bpmInput;
|
||||
|
||||
[Header("Difficulty")]
|
||||
[SerializeField] private Toggle toggleNormal;
|
||||
[SerializeField] private Toggle toggleHard;
|
||||
[SerializeField] private Toggle toggleExpert;
|
||||
[SerializeField] private Toggle toggleExpertPlus;
|
||||
|
||||
[Header("Actions")]
|
||||
[SerializeField] private Button generateButton;
|
||||
[SerializeField] private Button manualEditorButton;
|
||||
[SerializeField] private Button backButton;
|
||||
[SerializeField] private string menuSceneName = "Menu";
|
||||
|
||||
[Header("Progress")]
|
||||
[SerializeField] private GameObject progressGroup;
|
||||
[SerializeField] private TMP_Text statusText;
|
||||
[SerializeField] private Slider progressSlider;
|
||||
|
||||
[Header("References")]
|
||||
[SerializeField] private BeatSageUploader beatSageUploader;
|
||||
[SerializeField] private NasPublisher nasPublisher;
|
||||
|
||||
private static string InputPath =>
|
||||
Path.Combine(Application.persistentDataPath, "input");
|
||||
|
||||
private readonly List<string> audioFiles = new();
|
||||
private string _pendingFilePath;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
Directory.CreateDirectory(InputPath);
|
||||
|
||||
if (inputPathHint != null)
|
||||
inputPathHint.text = $"Path: {InputPath}";
|
||||
|
||||
refreshBtn?.onClick.AddListener(RefreshAudioList);
|
||||
generateButton?.onClick.AddListener(OnGenerateClicked);
|
||||
backButton?.onClick.AddListener(() => SceneManager.LoadScene(menuSceneName));
|
||||
filePickerBtn?.onClick.AddListener(OnFilePickerClicked);
|
||||
urlDownloadBtn?.onClick.AddListener(OnUrlDownloadClicked);
|
||||
|
||||
if (progressGroup != null) progressGroup.SetActive(false);
|
||||
RefreshAudioList();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_pendingFilePath != null)
|
||||
{
|
||||
CopyToInput(_pendingFilePath);
|
||||
_pendingFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshAudioList()
|
||||
{
|
||||
audioFiles.Clear();
|
||||
audioDropdown?.ClearOptions();
|
||||
|
||||
var options = new List<string>();
|
||||
foreach (string f in Directory.GetFiles(InputPath, "*.mp3"))
|
||||
{
|
||||
audioFiles.Add(f);
|
||||
options.Add(Path.GetFileNameWithoutExtension(f));
|
||||
}
|
||||
|
||||
if (options.Count == 0) options.Add("-- no .mp3 files --");
|
||||
audioDropdown?.AddOptions(options);
|
||||
}
|
||||
|
||||
private void OnGenerateClicked()
|
||||
{
|
||||
string directUrl = urlInput != null ? urlInput.text.Trim() : "";
|
||||
bool hasUrl = !string.IsNullOrEmpty(directUrl);
|
||||
bool hasFile = audioFiles.Count > 0;
|
||||
|
||||
if (!hasUrl && !hasFile) { SetStatus("No audio source. Add a file or enter a URL."); return; }
|
||||
|
||||
// BPM input is optional — Beat Sage auto-detects from audio; use as fallback only
|
||||
float.TryParse(bpmInput?.text, out float bpmHint);
|
||||
|
||||
var diffs = new List<string> { "normal", "hard", "expert", "expertplus" };
|
||||
|
||||
if (hasUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(directUrl, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{ SetStatus("Invalid URL."); return; }
|
||||
|
||||
StartCoroutine(GenerateFlowFromUrl(uri.AbsoluteUri, bpmHint, diffs));
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(GenerateFlow(audioFiles[audioDropdown.value], bpmHint, diffs));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator GenerateFlowFromUrl(string audioUrl, float bpm, List<string> diffs)
|
||||
{
|
||||
SetInteractable(false);
|
||||
if (progressGroup != null) progressGroup.SetActive(true);
|
||||
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
bool failed = false;
|
||||
|
||||
yield return beatSageUploader.UploadFromUrl(
|
||||
audioUrl, diffs, bpm,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = p * 0.8f;
|
||||
SetStatus($"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)");
|
||||
},
|
||||
onComplete: result => maps = result,
|
||||
onError: err => { SetStatus($"Error: {err}"); failed = true; });
|
||||
|
||||
if (failed) { SetInteractable(true); yield break; }
|
||||
|
||||
SongInfo song = BuildSongInfo(audioUrl, bpm, maps);
|
||||
|
||||
yield return nasPublisher.Publish(
|
||||
song, null, maps,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 0.8f + p * 0.2f;
|
||||
SetStatus($"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)");
|
||||
},
|
||||
onComplete: () =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 1f;
|
||||
SetStatus($"Done! '{song.title}' created successfully.");
|
||||
},
|
||||
onError: err => { SetStatus($"NAS upload failed: {err}"); failed = true; });
|
||||
|
||||
SetInteractable(true);
|
||||
}
|
||||
|
||||
private IEnumerator GenerateFlow(string audioPath, float bpm, List<string> diffs)
|
||||
{
|
||||
SetInteractable(false);
|
||||
if (progressGroup != null) progressGroup.SetActive(true);
|
||||
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
bool failed = false;
|
||||
|
||||
yield return beatSageUploader.Upload(
|
||||
audioPath, diffs, bpm,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = p * 0.8f;
|
||||
SetStatus($"{beatSageUploader.CurrentStatus} ({(int)(p * 80)}%)");
|
||||
},
|
||||
onComplete: result => maps = result,
|
||||
onError: err => { SetStatus($"Error: {err}"); failed = true; });
|
||||
|
||||
if (failed) { SetInteractable(true); yield break; }
|
||||
|
||||
SongInfo song = BuildSongInfo(audioPath, bpm, maps);
|
||||
|
||||
yield return nasPublisher.Publish(
|
||||
song, audioPath, maps,
|
||||
onProgress: p =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 0.8f + p * 0.2f;
|
||||
SetStatus($"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)");
|
||||
},
|
||||
onComplete: () =>
|
||||
{
|
||||
if (progressSlider != null) progressSlider.value = 1f;
|
||||
SetStatus($"Done! '{song.title}' created successfully.");
|
||||
},
|
||||
onError: err => { SetStatus($"NAS upload failed: {err}"); failed = true; });
|
||||
|
||||
SetInteractable(true);
|
||||
}
|
||||
|
||||
private SongInfo BuildSongInfo(string audioPath, float fallbackBpm,
|
||||
Dictionary<string, List<NoteData>> maps)
|
||||
{
|
||||
// Prefer values from info.dat (auto-detected by Beat Sage); UI inputs override if non-empty
|
||||
var meta = beatSageUploader != null ? beatSageUploader.LastMetadata : null;
|
||||
string uiTitle = titleInput?.text.Trim() ?? "";
|
||||
string uiArtist = artistInput?.text.Trim() ?? "";
|
||||
float.TryParse(bpmInput?.text, out float uiBpm);
|
||||
|
||||
string title = !string.IsNullOrEmpty(uiTitle) ? uiTitle : (meta?.title ?? "");
|
||||
string artist = !string.IsNullOrEmpty(uiArtist) ? uiArtist : (meta?.artist ?? "");
|
||||
float bpm = (meta != null && meta.bpm > 0) ? meta.bpm : (uiBpm > 0 ? uiBpm : fallbackBpm);
|
||||
|
||||
// Fallback id from filename if title is still empty
|
||||
if (string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(audioPath))
|
||||
title = Path.GetFileNameWithoutExtension(audioPath);
|
||||
if (string.IsNullOrEmpty(title))
|
||||
title = $"song_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
|
||||
string id = title.ToLower().Replace(" ", "_");
|
||||
|
||||
var diffMap = new DifficultyMap();
|
||||
foreach (var kv in maps)
|
||||
{
|
||||
var info = new DifficultyInfo { noteCount = kv.Value.Count };
|
||||
switch (kv.Key)
|
||||
{
|
||||
case "normal": diffMap.normal = info; break;
|
||||
case "hard": diffMap.hard = info; break;
|
||||
case "expert": diffMap.expert = info; break;
|
||||
case "expertplus": diffMap.expertplus = info; break;
|
||||
}
|
||||
}
|
||||
|
||||
return new SongInfo
|
||||
{
|
||||
id = id,
|
||||
title = title,
|
||||
artist = artist,
|
||||
bpm = bpm,
|
||||
audioFile = $"music/{id}.mp3",
|
||||
difficulties = diffMap,
|
||||
addedAt = DateTime.Now.ToString("yyyy-MM-dd"),
|
||||
};
|
||||
}
|
||||
|
||||
private void SetStatus(string msg) { if (statusText != null) statusText.text = msg; }
|
||||
|
||||
private void SetInteractable(bool value)
|
||||
{
|
||||
if (generateButton != null) generateButton.interactable = value;
|
||||
if (audioDropdown != null) audioDropdown.interactable = value;
|
||||
if (refreshBtn != null) refreshBtn.interactable = value;
|
||||
if (filePickerBtn != null) filePickerBtn.interactable = value;
|
||||
if (urlDownloadBtn != null) urlDownloadBtn.interactable = value;
|
||||
}
|
||||
|
||||
private void OnFilePickerClicked()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
string path = UnityEditor.EditorUtility.OpenFilePanel("Select audio file", "", "mp3");
|
||||
if (!string.IsNullOrEmpty(path)) CopyToInput(path);
|
||||
#elif UNITY_STANDALONE_WIN
|
||||
var t = new Thread(() =>
|
||||
{
|
||||
var dlg = new System.Windows.Forms.OpenFileDialog { Filter = "MP3|*.mp3" };
|
||||
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
|
||||
_pendingFilePath = dlg.FileName;
|
||||
});
|
||||
t.SetApartmentState(ApartmentState.STA);
|
||||
t.Start();
|
||||
#else
|
||||
SetAddStatus($"Copy file via ADB:\n{InputPath}");
|
||||
#endif
|
||||
}
|
||||
|
||||
private void CopyToInput(string srcPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
string dest = Path.Combine(InputPath, Path.GetFileName(srcPath));
|
||||
File.Copy(srcPath, dest, overwrite: true);
|
||||
RefreshAudioList();
|
||||
string nameNoExt = Path.GetFileNameWithoutExtension(srcPath);
|
||||
int idx = audioFiles.FindIndex(f => Path.GetFileNameWithoutExtension(f) == nameNoExt);
|
||||
if (idx >= 0 && audioDropdown != null) audioDropdown.value = idx;
|
||||
SetAddStatus($"Added: {Path.GetFileName(srcPath)}");
|
||||
}
|
||||
catch (Exception e) { SetAddStatus($"File copy failed: {e.Message}"); }
|
||||
}
|
||||
|
||||
private void OnUrlDownloadClicked()
|
||||
{
|
||||
string url = urlInput != null ? urlInput.text.Trim() : "";
|
||||
if (string.IsNullOrEmpty(url)) { SetAddStatus("Please enter a URL."); return; }
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
SetAddStatus($"Invalid URL: must start with http:// or https://");
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming service URLs cannot be downloaded directly
|
||||
string host = uri.Host.ToLower();
|
||||
if (host.Contains("youtube.com") || host.Contains("youtu.be") ||
|
||||
host.Contains("spotify.com") || host.Contains("soundcloud.com") ||
|
||||
host.Contains("music.apple.com"))
|
||||
{
|
||||
SetAddStatus("Streaming URLs not supported.\nUse a direct .mp3 download link or Browse File.");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(DownloadFromUrl(uri.AbsoluteUri));
|
||||
}
|
||||
|
||||
private IEnumerator DownloadFromUrl(string url)
|
||||
{
|
||||
SetAddStatus("Downloading...");
|
||||
if (urlDownloadBtn != null) urlDownloadBtn.interactable = false;
|
||||
|
||||
string fileName;
|
||||
try
|
||||
{
|
||||
string uriPath = new Uri(url).AbsolutePath;
|
||||
fileName = Path.GetFileName(uriPath);
|
||||
if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
|
||||
fileName = "download.mp3";
|
||||
}
|
||||
catch { fileName = "download.mp3"; }
|
||||
|
||||
string savePath = Path.GetFullPath(Path.Combine(InputPath, fileName));
|
||||
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
req.downloadHandler = new DownloadHandlerFile(savePath);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (urlDownloadBtn != null) urlDownloadBtn.interactable = true;
|
||||
|
||||
if (req.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
RefreshAudioList();
|
||||
int idx = audioFiles.FindIndex(
|
||||
f => Path.GetFileNameWithoutExtension(f) == Path.GetFileNameWithoutExtension(fileName));
|
||||
if (idx >= 0 && audioDropdown != null) audioDropdown.value = idx;
|
||||
SetAddStatus($"Downloaded: {fileName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (File.Exists(savePath)) File.Delete(savePath);
|
||||
SetAddStatus($"Download failed: {req.error}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SetAddStatus(string msg) { if (addStatusText != null) addStatusText.text = msg; }
|
||||
}
|
||||
Reference in New Issue
Block a user