diff --git a/README.md b/README.md index 8fe463a3..a9d1dbdd 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ pre-commit install Usage: +Convert a DRS track video to a DD beat map + ```bash # grab video of DRS track from youtube yt-dlp -f bv[ext=webm] https://youtu.be/o7I0scmptmo -o "drs_video.webm" @@ -48,7 +50,14 @@ yt-dlp -f bv[ext=webm] https://youtu.be/o7I0scmptmo -o "drs_video.webm" .venv/Scripts/python drsvideo2dd.py "drs_video.webm" --song-id "BOOMBAYAH-JP Ver.-" ``` +Generate json files from xml files (needs xml files and a brave soul) + ```bash -# you will need relevant xml files (not from DRS, that's illegal...) -.venv/Scripts/python drsxml2drsjson.py +HAS_XML=1 .venv/Scripts/python drsxml2drsjson.py +``` + +Generate full DD Beat Map from json files in repository (does not map notes atm, todo) + +```bash +.venv/Scripts/python drs2dd.py --song-id 187 ``` diff --git a/drs2dd.py b/drs2dd.py index 3e17154a..8dcdd919 100644 --- a/drs2dd.py +++ b/drs2dd.py @@ -1,2 +1,128 @@ -# todo, parse xml or json drs data to dd json :) from __future__ import annotations + +import argparse +import json +import os +import shutil +from dataclasses import asdict + +from drsxml2drsjson import get_songdata_from_track_id +from drsxml2drsjson import TRACK_ID_TO_PATH +from model.dancedash import DDBeatMap +from model.dancedash import DDBeatMapData +from model.dancedash import DDBeatMapInfoFile +from model.dancedash import X_Y +from model.dancerush import DRSSongData +from util import get_m4a_and_duration +from util import get_song_cover_path +from util import ORDER_COUNT_PER_BEAT + + +def create_dd_tracks_from_DRSSongData( + drs_song_data: DRSSongData, target_dir: str, +) -> bool: + if not os.path.exists(target_dir): + os.makedirs(target_dir) + print(f'Created directory: {target_dir}') + + dd_bmp = int(drs_song_data.info.bpm_max / 100) + + song_paths = [] + for attr, difficulty in drs_song_data.difficulties.with_attrs_as_str.items(): + target_difficulty_path = os.path.join( + target_dir, f'{drs_song_data.song_id}_{attr}.json', + ) + song_paths.append(target_difficulty_path) + drs_beat_map = DDBeatMap( + data=DDBeatMapData( + name=f'{drs_song_data.info.title_name} {attr}', + intervalPerSecond=0.0, + gridSize=X_Y(x=0, y=0), + planeSize=X_Y(x=0, y=0), + orderCountPerBeat=ORDER_COUNT_PER_BEAT, + sphereNodes=[], # TODO: DO THE MAPPING LOL. + lineNodes=[], # TODO: DO THE MAPPING LOL. + effectNodes=[], + roadBlockNodes=[], + trapNodes=[], + ), + beatSubs=1, + BPM=dd_bmp, + songStartOffset=0.0, + NPS='0.0', + developerMode=False, + noteSpeed=1.0, + noteJumpOffset=0.0, + interval=1.0, + info='', + ) + with open(target_difficulty_path, 'w') as f: + json.dump(asdict(drs_beat_map), f, indent=4) + print(f'Created file: {target_difficulty_path}') + + folder_path = TRACK_ID_TO_PATH.get(drs_song_data.song_id) + song_path, song_length = get_m4a_and_duration(folder_path) + if song_path and song_length: + shutil.copy(song_path, target_dir) + + if song_cover_path := get_song_cover_path(folder_path): + shutil.copy(song_cover_path, target_dir) + + drs_song_info_json = DDBeatMapInfoFile( + EditorVersion='1.3.2', + BeatMapId=drs_song_data.song_id, + OstId=0, + CreateTicks=0, + CreateTime='', + SongName=drs_song_data.info.title_name, + SongLength=song_length or '-1', + SongAuthorName=drs_song_data.info.artist_name, + LevelAuthorName='https://github.com/thomasasfk/drs2dd', + SongPreviewSection=0, + Bpm=str(dd_bmp), + SongPath=os.path.basename(song_path) if song_path else None, + OstName=None, + CoverPath=os.path.basename( + song_cover_path, + ) if song_cover_path else None, + DRS_Easy=os.path.basename(song_paths[0]) if len( + song_paths, + ) > 0 else None, + DRS_Normal=os.path.basename(song_paths[1]) if len( + song_paths, + ) > 1 else None, + DRS_Hard=os.path.basename(song_paths[2]) if len( + song_paths, + ) > 2 else None, + DRS_Expert=os.path.basename(song_paths[3]) if len( + song_paths, + ) > 3 else None, + ) + + info_file_path = os.path.join( + target_dir, f'{drs_song_data.song_id}_info.json', + ) + with open(info_file_path, 'w') as f: + json.dump(asdict(drs_song_info_json), f, indent=4) + print(f'Created file: {info_file_path}') + + return True + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Create DD tracks from DRS Song Data', + ) + parser.add_argument( + '--song-id', type=int, + help='ID of the song to process (number of folder)', required=True, + ) + + args = parser.parse_args() + + song_data = get_songdata_from_track_id(args.song_id) + created = create_dd_tracks_from_DRSSongData( + song_data, song_data.info.title_name, + ) + + print(f'Song {args.song_id} created?: {created}') diff --git a/drsxml2drsjson.py b/drsxml2drsjson.py index f5866aa9..7cead278 100644 --- a/drsxml2drsjson.py +++ b/drsxml2drsjson.py @@ -14,62 +14,73 @@ from model.dancerush import DRSTrack TRACK_LIST = r'resources\drs\datax\music\music-info-base.xml' TRACKS_BASE_DIR = r'resources\drs\datax\music' - -ALL_SONG_METADATA = open(TRACK_LIST, encoding='utf-8').read() -ALL_SONG_METADATA_DICT = xmltodict.parse(ALL_SONG_METADATA) - -_ALL_TRACK_PATHS = [x for x in os.listdir(TRACKS_BASE_DIR) if x.isdigit()] +ALL_TRACK_PATHS = [x for x in os.listdir(TRACKS_BASE_DIR) if x.isdigit()] TRACK_ID_TO_PATH = { int(x): os.path.join(TRACKS_BASE_DIR, x) - for x in _ALL_TRACK_PATHS + for x in ALL_TRACK_PATHS } -TRACK_ID_TO_METADATA = {} -TRACK_ID_TO_METADATA_DICT = {} -for song in ALL_SONG_METADATA_DICT['mdb']['music']: - song_id = int(song['@id']) - path = TRACK_ID_TO_PATH.get(song_id) - files = os.listdir(path) - track_id_to_path_dict = { - re.search(r'_(\d[a-zA-Z])\.xml$', f).group(1): os.path.join(path, f) - for f in files - if re.search(r'_(\d[a-zA-Z])\.xml$', f) - } - if not track_id_to_path_dict: - continue - track_id_to_track = { - key: DRSTrack.from_xml( - value, - ) for key, value in track_id_to_path_dict.items() - } +def get_songdata_from_track_id(track_id: int) -> DRSSongData: + if os.getenv('HAS_XML'): + return TRACK_ID_TO_SONGDATA.get(track_id) + track_path = TRACK_ID_TO_PATH.get(track_id) + songs_json_path = os.path.join(track_path, 'songs.json') + songs_dict = json.load(open(songs_json_path, encoding='utf-8')) + song_data = DRSSongData.from_json_dict(songs_dict) + return song_data - difficulties = DRSSongDifficulties( - **{ - f'difficulty_{key}': DRSSongDifficulty( - difnum=int( - song['difficulty'] - [f'fumen_{key}']['difnum']['#text'], - ), - track=track_id_to_track[key], + +if os.getenv('HAS_XML'): + ALL_SONG_METADATA = open(TRACK_LIST, encoding='utf-8').read() + ALL_SONG_METADATA_DICT = xmltodict.parse(ALL_SONG_METADATA) + + TRACK_ID_TO_SONGDATA = {} + TRACK_ID_TO_SONGDATA_DICT = {} + for song in ALL_SONG_METADATA_DICT['mdb']['music']: + song_id = int(song['@id']) + path = TRACK_ID_TO_PATH.get(song_id) + + files = os.listdir(path) + track_id_to_path_dict = { + re.search(r'_(\d[a-zA-Z])\.xml$', f).group(1): os.path.join(path, f) + for f in files + if re.search(r'_(\d[a-zA-Z])\.xml$', f) + } + if not track_id_to_path_dict: + continue + track_id_to_track = { + key: DRSTrack.from_xml( + value, ) for key, value in track_id_to_path_dict.items() - }, - ) + } - song_data = DRSSongData.from_dict(song, difficulties) - song_data_dict = asdict(song_data) - TRACK_ID_TO_METADATA[song_id] = song_data - TRACK_ID_TO_METADATA_DICT[song_id] = song_data_dict + difficulties = DRSSongDifficulties( + **{ + f'difficulty_{key}': DRSSongDifficulty( + difnum=int( + song['difficulty'] + [f'fumen_{key}']['difnum']['#text'], + ), + track=track_id_to_track[key], + ) for key, value in track_id_to_path_dict.items() + }, + ) + song_data = DRSSongData.from_xml_dict(song, difficulties) + song_data_dict = asdict(song_data) + TRACK_ID_TO_SONGDATA[song_id] = song_data + TRACK_ID_TO_SONGDATA_DICT[song_id] = song_data_dict -if __name__ == '__main__': + if __name__ == '__main__': + for song_id, song_data_dict in TRACK_ID_TO_SONGDATA_DICT.items(): + SONG_JSON_PATH = os.path.join( + TRACK_ID_TO_PATH[song_id], 'songs.json', + ) + with open(SONG_JSON_PATH, 'w', encoding='utf-8') as f: + json.dump(song_data_dict, f, ensure_ascii=False, indent=4) - for song_id, song_data_dict in TRACK_ID_TO_METADATA_DICT.items(): - SONG_JSON_PATH = os.path.join(TRACK_ID_TO_PATH[song_id], 'songs.json') - with open(SONG_JSON_PATH, 'w', encoding='utf-8') as f: - json.dump(song_data_dict, f, ensure_ascii=False, indent=4) - - for key, data in song_data_dict['difficulties'].items(): - path = os.path.join(TRACK_ID_TO_PATH[song_id], f'{key}.json') - with open(path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) + for key, data in song_data_dict['difficulties'].items(): + path = os.path.join(TRACK_ID_TO_PATH[song_id], f'{key}.json') + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=4) diff --git a/model/dancedash.py b/model/dancedash.py index 5bf265db..5474e5d3 100644 --- a/model/dancedash.py +++ b/model/dancedash.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from dataclasses import field + LEFT_NOTE = 8 RIGHT_NOTE = 9 @@ -126,3 +127,32 @@ class DDBeatMap: interval=1.0, info=info, ) + + +@dataclass +class DDBeatMapInfoFile: + EditorVersion: str + BeatMapId: int + OstId: int + CreateTicks: int + CreateTime: str + SongName: str + SongLength: str + SongAuthorName: str + LevelAuthorName: str + SongPreviewSection: int + Bpm: str + SongPath: str + OstName: str | None = None + CoverPath: str | None = None + DDVR_Easy: str | None = None + DDVR_Normal: str | None = None + DDVR_Hard: str | None = None + DRS_Easy: str | None = None + DRS_Normal: str | None = None + DRS_Hard: str | None = None + DRS_Expert: str | None = None + DRS_Master: str | None = None + DRS_ACE: str | None = None + DDVR_Env: str | None = None + DRS_Env: str | None = None diff --git a/model/dancerush.py b/model/dancerush.py index 860c6ce2..1d071ca8 100644 --- a/model/dancerush.py +++ b/model/dancerush.py @@ -63,6 +63,15 @@ class DRSSongDifficulties: difficulty_2a: DRSSongDifficulty | None = None difficulty_2b: DRSSongDifficulty | None = None + @property + def with_attrs_as_str(self) -> dict[str, DRSSongDifficulty]: + return { + 'difficulty_1a': self.difficulty_1a, + 'difficulty_1b': self.difficulty_1b, + 'difficulty_2a': self.difficulty_2a, + 'difficulty_2b': self.difficulty_2b, + } + @dataclass class DRSSongData: @@ -71,7 +80,7 @@ class DRSSongData: info: DRSSongInfo | None = None @classmethod - def from_dict(cls, data: dict, difficulties: DRSSongDifficulties) -> DRSSongData: + def from_xml_dict(cls, data: dict, difficulties: DRSSongDifficulties) -> DRSSongData: return cls( song_id=int(data['@id']), difficulties=difficulties, @@ -92,6 +101,53 @@ class DRSSongData: ), ) + @classmethod + def from_json_dict(cls, data: dict): + difficulties = data['difficulties'] + info = data['info'] + return cls( + song_id=int(data['song_id']), + difficulties=DRSSongDifficulties( + difficulty_1a=DRSSongDifficulty( + track=DRSTrack.from_json_dict( + difficulties['difficulty_1a']['track'], + ), + difnum=difficulties['difficulty_1a']['difnum'], + ) if difficulties['difficulty_1a'] else None, + difficulty_1b=DRSSongDifficulty( + track=DRSTrack.from_json_dict( + difficulties['difficulty_1b']['track'], + ), + difnum=difficulties['difficulty_1b']['difnum'], + ) if difficulties['difficulty_1b'] else None, + difficulty_2a=DRSSongDifficulty( + track=DRSTrack.from_json_dict( + difficulties['difficulty_2a']['track'], + ), + difnum=difficulties['difficulty_2a']['difnum'], + ) if difficulties['difficulty_2a'] else None, + difficulty_2b=DRSSongDifficulty( + track=DRSTrack.from_json_dict( + difficulties['difficulty_2b']['track'], + ), + difnum=difficulties['difficulty_2b']['difnum'], + ) if difficulties['difficulty_2b'] else None, + ), + info=DRSSongInfo( + artist_name=info['artist_name'], + artist_yomigana=info['artist_yomigana'], + genre=info['genre'], + title_name=info['title_name'], + title_yomigana=info['title_yomigana'], + bpm_max=info['bpm_max'], + bpm_min=info['bpm_min'], + distribution_date=info['distribution_date'], + license=info['license'], + region=info['region'], + volume=info['volume'], + ), + ) + @dataclass class DRSTrackBPMInfo: @@ -119,7 +175,7 @@ class DRSTrackInfo: measure_info: list[DRSTrackMeasureInfo] = field(default_factory=list) @classmethod - def from_dict(cls, data: dict): + def from_xml_dict(cls, data: dict): if type(data['measure_info']['measure']) is list: drs_track_measure_info = [ DRSTrackMeasureInfo( @@ -159,6 +215,26 @@ class DRSTrackInfo: measure_info=drs_track_measure_info, ) + @classmethod + def from_json_dict(cls, data: dict): + return cls( + end_tick=int(data['end_tick']), + time_unit=DRSTrackTimeInfo(int(data['time_unit']['time_unit'])), + bpm_info=[ + DRSTrackBPMInfo( + bpm=int(bpm['bpm']), + tick=int(bpm['tick']), + ) for bpm in data['bpm_info'] + ], + measure_info=[ + DRSTrackMeasureInfo( + denomi=int(measure['denomi']), + num=int(measure['num']), + tick=int(measure['tick']), + ) for measure in data['measure_info'] + ], + ) + @dataclass class DRSTrackStepTickInfo: @@ -186,7 +262,7 @@ class DRSTrackStep: player_info: DRSTrackStepPlayerInfo @classmethod - def from_dict(cls, data: dict): + def from_xml_dict(cls, data: dict): return cls( DRSTrackStepTickInfo( int(data['start_tick']['#text']), int( @@ -203,6 +279,22 @@ class DRSTrackStep: DRSTrackStepPlayerInfo(int(data['player_id']['#text'])), ) + @classmethod + def from_json_dict(cls, data: dict): + return cls( + DRSTrackStepTickInfo( + int(data['tick_info']['start_tick']), + int(data['tick_info']['end_tick']), + ), + int(data['kind']), + DRSTrackStepPositionInfo( + int(data['position_info']['left_pos']), + int(data['position_info']['right_pos']), + ), + bool(data['long_point']), + DRSTrackStepPlayerInfo(int(data['player_info']['player_id'])), + ) + @dataclass class DRSTrack: @@ -213,13 +305,13 @@ class DRSTrack: rec_data = None # TODO: Implement this ??? @classmethod - def from_dict(cls, data: dict): + def from_xml_dict(cls, data: dict): data = data['data'] return cls( int(data['seq_version']['#text']), - DRSTrackInfo.from_dict(data['info']), + DRSTrackInfo.from_xml_dict(data['info']), [ - DRSTrackStep.from_dict(step) + DRSTrackStep.from_xml_dict(step) for step in data['sequence_data']['step'] ], ) @@ -227,4 +319,15 @@ class DRSTrack: @classmethod def from_xml(cls, path: str): data = open(path, encoding='utf-8').read() - return cls.from_dict(xmltodict.parse(data)) + return cls.from_xml_dict(xmltodict.parse(data)) + + @classmethod + def from_json_dict(cls, data: dict): + return cls( + int(data['seq_version']), + DRSTrackInfo.from_json_dict(data['info']), + [ + DRSTrackStep.from_json_dict(step) + for step in data['sequence_data'] + ], + ) diff --git a/tests/test_drsxml2drsjson.py b/tests/test_drsxml2drsjson.py new file mode 100644 index 00000000..c0dd4268 --- /dev/null +++ b/tests/test_drsxml2drsjson.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from drsxml2drsjson import get_songdata_from_track_id +from model.dancerush import DRSSongData + + +def test_get_songdata_from_track_id(): + song_data = get_songdata_from_track_id(187) + assert isinstance(song_data, DRSSongData) diff --git a/util.py b/util.py index 00640861..a86ce13d 100644 --- a/util.py +++ b/util.py @@ -1,7 +1,9 @@ from __future__ import annotations +import contextlib import json import os +import subprocess from functools import partial import cv2 @@ -78,3 +80,33 @@ SIGN_TEMPLATE = Image.open( SIGN_SEARCH_AREA = 850, 143, 223, 49 find_stage = partial(match_template, template=SIGN_TEMPLATE) + + +def get_m4a_and_duration(folder_path) -> tuple[str, str] | tuple[None, None]: + m4a_files = [f for f in os.listdir(folder_path) if f.endswith('.m4a')] + if not m4a_files: + return None, None + + m4a_file = os.path.join(folder_path, m4a_files[0]) + with contextlib.suppress(subprocess.CalledProcessError): + ffprobe_cmd = [ + 'ffprobe', + '-i', m4a_file, + '-show_entries', 'format=duration', + '-v', 'error', + '-of', 'default=noprint_wrappers=1:nokey=1', + ] + duration_str = subprocess.check_output(ffprobe_cmd, text=True).strip() + return m4a_file, duration_str + + return None, None + + +def get_song_cover_path(folder_path) -> str | None: + cover_files = [ + f for f in os.listdir(folder_path) if f.startswith('jk_') and f.endswith('_b.png') + ] + if not cover_files: + return None + + return os.path.join(folder_path, cover_files[0])