diff --git a/drs2dd.py b/drs2dd.py index 1d9169bf..56b3906b 100644 --- a/drs2dd.py +++ b/drs2dd.py @@ -35,7 +35,7 @@ from model.dancerush import DRSSongDifficulty from model.dancerush import DRSTrackPoint from model.dancerush import OUTPUT_ZIP_NAME from util import create_valid_filename -from util import get_ogg_and_duration +from util import get_drs_ogg_and_duration from util import get_song_cover_path from util import yyyymmdd_to_ticks from util import zipdir @@ -165,7 +165,7 @@ def create_dd_tracks_from_DRSSongData(drs_song_data: DRSSongData, target_dir: st dd_bps = dd_bmp / 60 folder_path = TRACK_ID_TO_PATH.get(drs_song_data.song_id) - song_path, song_length = get_ogg_and_duration(folder_path) + song_path, song_length = get_drs_ogg_and_duration(folder_path) if not song_path or not song_length: print( f'No song found for {drs_song_data.info.title_name} ({drs_song_data.song_id})', @@ -205,7 +205,7 @@ def create_dd_tracks_from_DRSSongData(drs_song_data: DRSSongData, target_dir: st ) total_note_count = len(sphere_notes + line_notes + road_block_notes) # noqa - drs_beat_map = DDBeatMap( + dd_beat_map = DDBeatMap( data=DDBeatMapData( name=f'{drs_song_data.info.title_name} {attr}', sphereNodes=sphere_notes, @@ -217,19 +217,19 @@ def create_dd_tracks_from_DRSSongData(drs_song_data: DRSSongData, target_dir: st ) song_paths.append( - drs_beat_map.save_to_file( + dd_beat_map.save_to_file( target_dir, f'{attr}.json', ), ) song_paths.append( - drs_beat_map.block_less.save_to_file( + dd_beat_map.block_less.save_to_file( target_dir, f'{attr}_blockless.json', ), ) normal, normal_no_blocks, easy, easy_no_blocks = song_paths create_ticks = yyyymmdd_to_ticks(str(drs_song_data.info.distribution_date)) - drs_song_info_json = DDBeatMapInfoFile( + drs_beat_map_info = DDBeatMapInfoFile( CreateTicks=create_ticks, CreateTime=str(create_ticks), BeatMapId=drs_song_data.song_id, @@ -244,15 +244,15 @@ def create_dd_tracks_from_DRSSongData(drs_song_data: DRSSongData, target_dir: st DRS_Hard=os.path.basename(easy), DRS_Expert=os.path.basename(normal), ) - drs_song_info_json.save_to_file(target_dir) + drs_beat_map_info.save_to_file(target_dir) print(f'Created {drs_song_data.info.title_name} ({drs_song_data.song_id})') - return drs_song_info_json + return drs_beat_map_info if __name__ == '__main__': parser = argparse.ArgumentParser( - description='Create DD tracks from DRS Song Data', + description='Create Dance Dash tracks from DANCERUSH STARDOM Song Data', ) parser.add_argument( '--song-id', diff --git a/fs2dd.py b/fs2dd.py new file mode 100644 index 00000000..cd71356c --- /dev/null +++ b/fs2dd.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +import os +import shutil +from datetime import datetime + +from model.dancedash import DDBeatMap +from model.dancedash import DDBeatMapData +from model.dancedash import DDBeatMapInfoFile +from model.dancedash import DDLineNode +from model.dancedash import DDRoadBlockNode +from model.dancedash import DDSphereNode +from model.feetsaber import FSBeatMapFile +from model.feetsaber import FSInfoDat +from util import convert_egg_to_ogg_and_get_length +from util import get_first_image_file_in_folder +from util import random_10_digit_int +from util import yyyymmdd_to_ticks + + +def map_sphere_nodes(fs_beat_map: FSBeatMapFile) -> list[DDSphereNode]: + spheres = [] + ... + return spheres + + +def map_line_nodes(fs_beat_map: FSBeatMapFile) -> list[DDLineNode]: + lines = [] + ... + return lines + + +def map_down_and_jump_notes(fs_beat_map: FSBeatMapFile) -> list[DDRoadBlockNode]: + road_blocks = [] + ... + return road_blocks + + +def create_dd_tracks_from_fs(fs_map_dir: str) -> DDBeatMapInfoFile: + info_file_path = os.path.join(fs_map_dir, 'Info.dat') + fs_info = FSInfoDat.from_json_file(info_file_path) + + egg_song_path = [ + os.path.join(fs_map_dir, f) for f in os.listdir( + fs_map_dir, + ) if f.endswith('.egg') + ][0] + target_dir = f'{fs_info.songName} - {fs_info.levelAuthorName}' + if not os.path.exists(target_dir): + os.makedirs(target_dir) + + ogg_song_name, song_length = convert_egg_to_ogg_and_get_length( + egg_song_path, + ) + + new_song_path = os.path.join(target_dir, ogg_song_name) + shutil.copy(egg_song_path, new_song_path) + song_path = new_song_path + if song_cover_path := get_first_image_file_in_folder(fs_map_dir): + new_song_cover_path = os.path.join( + target_dir, os.path.basename(song_cover_path), + ) + shutil.copy(song_cover_path, new_song_cover_path) + song_cover_path = new_song_cover_path + + song_paths = [] + for difficulty_set in fs_info.difficultyBeatmapSets: + for difficulty_set in difficulty_set.difficultyBeatmaps: + beat_map: FSBeatMapFile = difficulty_set.get_beatmap(fs_map_dir) + + sphere_notes = map_sphere_nodes(beat_map) + line_notes = map_line_nodes(beat_map) + road_block_notes = map_down_and_jump_notes(beat_map) + + total_note_count = len(sphere_notes + line_notes + road_block_notes) # noqa + dd_beat_map = DDBeatMap( + data=DDBeatMapData( + name=f'{fs_info.songName}', + sphereNodes=sphere_notes, + lineNodes=line_notes, + roadBlockNodes=road_block_notes, + ), + BPM=fs_info.beatsPerMinute, + NPS=str(round(total_note_count / song_length, 2)), + ) + + song_paths.append( + dd_beat_map.save_to_file( + target_dir, 'a.json', + ), + ) + + available_difficulties = [ + 'Easy', 'Normal', + 'Hard', 'Expert', 'Master', 'ACE', + ] + song_path_difficulties = zip(song_paths, available_difficulties) + difficulties = { + f'DRS_{d}': os.path.basename( + sp, + ) for sp, d in song_path_difficulties + } + + create_ticks = yyyymmdd_to_ticks(datetime.now().strftime('%Y%m%d')) + dd_beat_map_info = DDBeatMapInfoFile( + CreateTicks=create_ticks, + CreateTime=str(create_ticks), + BeatMapId=random_10_digit_int(), + SongName=fs_info.songName, + SongLength=str(song_length), + SongAuthorName=fs_info.songAuthorName, + LevelAuthorName=fs_info.levelAuthorName, + Bpm=str(fs_info.beatsPerMinute), + SongPath=os.path.basename(song_path), + CoverPath=os.path.basename(song_cover_path or '') or None, + **difficulties, + ) + + dd_beat_map_info.save_to_file(target_dir) + + print( + f'Created {dd_beat_map_info.SongName} ({dd_beat_map_info.BeatMapId})', + ) + return dd_beat_map_info + + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Create Dance Dash tracks from Feet Saber maps', + ) + parser.add_argument( + '--fs-map-dir', + type=str, + help='The directory containing the Feet Saber map files', + required=True, + ) + + args = parser.parse_args() + if not os.path.exists(args.fs_map_dir): + print(f'Invalid Feet Saber map directory: {args.fs_map_dir}') + raise SystemExit(1) + + create_dd_tracks_from_fs(args.fs_map_dir) + raise SystemExit(0) diff --git a/model/feetsaber.py b/model/feetsaber.py new file mode 100644 index 00000000..11e3a194 --- /dev/null +++ b/model/feetsaber.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass + + +@dataclass +class FSEditor: + version: str + + +@dataclass +class FSEditorMetadata: + lastEditedBy: str + editors: dict[str, FSEditor] + + +@dataclass +class FSContributor: + role: str + name: str + iconPath: str + + +@dataclass +class FSDatCustomData: + contributors: list[FSContributor] + editors: FSEditorMetadata + + +@dataclass +class FSBeatMapCustomData: + difficultyLabel: str + editorOffset: int + editorOldOffset: int + suggestions: list[str] + requirements: list[str] + + +@dataclass +class FSBeatmap: + difficulty: str + difficultyRank: int + beatmapFilename: str + noteJumpMovementSpeed: int + noteJumpStartBeatOffset: float + customData: FSBeatMapCustomData + + def get_beatmap(self, map_dir: str) -> FSBeatMapFile: + file_name = os.path.join(map_dir, self.beatmapFilename) + beat_map_dict = json.load(open(file_name, encoding='utf-8')) + return FSBeatMapFile.from_json_dict(beat_map_dict) + + +@dataclass +class FSDifficultyBeatMapSets: + beatmapCharacteristicName: str + difficultyBeatmaps: list[FSBeatmap] + + +@dataclass +class FSInfoDat: + version: str + songName: str + songSubName: str + songAuthorName: str + levelAuthorName: str + beatsPerMinute: int + shuffle: int + shufflePeriod: float + previewStartTime: int + previewDuration: int + songFilename: str + coverImageFilename: str + environmentName: str + allDirectionsEnvironmentName: str + songTimeOffset: int + customData: FSDatCustomData + difficultyBeatmapSets: list[FSDifficultyBeatMapSets] + + @classmethod + def from_json_dict(cls, json_dict: dict) -> FSInfoDat: + custom_data_raw = json_dict['_customData'] + + def remove_underscore_from_keys(data): + return {key.lstrip('_'): value for key, value in data.items()} + + contributors = [ + FSContributor(**remove_underscore_from_keys(contributor)) for contributor in + custom_data_raw['_contributors'] + ] + + editors = { + editor: FSEditor(version=data['version']) for editor, data in custom_data_raw['_editors'].items() if + editor != '_lastEditedBy' + } + + custom_data = FSDatCustomData( + contributors=contributors, + editors=FSEditorMetadata( + lastEditedBy=custom_data_raw['_editors']['_lastEditedBy'], + editors=editors, + ), + ) + + difficulty_beatmap_sets_raw = json_dict['_difficultyBeatmapSets'] + + difficulty_beatmaps_list = [] + for dbset in difficulty_beatmap_sets_raw: + beatmaps = [ + FSBeatmap( + difficulty=beatmap['_difficulty'], + difficultyRank=beatmap['_difficultyRank'], + beatmapFilename=beatmap['_beatmapFilename'], + noteJumpMovementSpeed=beatmap['_noteJumpMovementSpeed'], + noteJumpStartBeatOffset=beatmap['_noteJumpStartBeatOffset'], + customData=FSBeatMapCustomData( + difficultyLabel=beatmap['_customData']['_difficultyLabel'], + editorOffset=beatmap['_customData']['_editorOffset'], + editorOldOffset=beatmap['_customData']['_editorOldOffset'], + suggestions=beatmap['_customData']['_suggestions'], + requirements=beatmap['_customData']['_requirements'], + ), + ) for beatmap in dbset['_difficultyBeatmaps'] + ] + + difficulty_beatmaps_list.append( + FSDifficultyBeatMapSets( + beatmapCharacteristicName=dbset['_beatmapCharacteristicName'], + difficultyBeatmaps=beatmaps, + ), + ) + + return cls( + version=json_dict['_version'], + songName=json_dict['_songName'], + songSubName=json_dict['_songSubName'], + songAuthorName=json_dict['_songAuthorName'], + levelAuthorName=json_dict['_levelAuthorName'], + beatsPerMinute=json_dict['_beatsPerMinute'], + shuffle=json_dict['_shuffle'], + shufflePeriod=json_dict['_shufflePeriod'], + previewStartTime=json_dict['_previewStartTime'], + previewDuration=json_dict['_previewDuration'], + songFilename=json_dict['_songFilename'], + coverImageFilename=json_dict['_coverImageFilename'], + environmentName=json_dict['_environmentName'], + allDirectionsEnvironmentName=json_dict['_allDirectionsEnvironmentName'], + songTimeOffset=json_dict['_songTimeOffset'], + customData=custom_data, + difficultyBeatmapSets=difficulty_beatmaps_list, + ) + + @classmethod + def from_json_file(cls, json_file: str) -> FSInfoDat: + import json + with open(json_file) as f: + json_dict = json.load(f) + return cls.from_json_dict(json_dict) + + +@dataclass +class FSBeatMapFileBookmark: + time: int + name: str + color: list[float] + + +@dataclass +class FSBeatMapFileCustomData: + time: float + bookmarks: list[FSBeatMapFileBookmark] + + +@dataclass +class FSBeatMapFileEvent: + time: int + type: int + value: int + + +@dataclass +class FSBeatMapFileNoteCustomData: + position: tuple[float, float] + + +@dataclass +class FSBeatMapFileNote: + time: float + lineIndex: int + lineLayer: int + type: int + cutDirection: int + customData: FSBeatMapFileNoteCustomData + + +@dataclass +class FSBeatMapFileObstacleCustomData: + interactable: bool + fake: bool + position: tuple[float, float] + scale: tuple[float, float] + color: tuple[float, float, float, float] + localRotation: tuple[float, float, float] | None = None + + +@dataclass +class FSBeatMapFileObstacle: + time: float + lineIndex: int + type: int + duration: int + width: int + customData: FSBeatMapFileObstacleCustomData + + +@dataclass +class FSBeatMapFile: + version: str + customData: FSBeatMapFileCustomData + events: list + notes: list + obstacles: list + waypoints: list + + @classmethod + def from_json_dict(cls, json_dict: dict) -> FSBeatMapFile: + custom_data = FSBeatMapFileCustomData( + time=json_dict['_customData']['_time'], + bookmarks=[ + FSBeatMapFileBookmark( + time=bm['_time'], + name=bm['_name'], + color=bm['_color'], + ) for bm in json_dict['_customData']['_bookmarks'] + ], + ) + + events = [ + FSBeatMapFileEvent( + time=event['_time'], + type=event['_type'], + value=event['_value'], + ) for event in json_dict['_events'] + ] + + notes = [ + FSBeatMapFileNote( + time=note['_time'], + lineIndex=note['_lineIndex'], + lineLayer=note['_lineLayer'], + type=note['_type'], + cutDirection=note['_cutDirection'], + customData=FSBeatMapFileNoteCustomData( + position=tuple(note['_customData']['_position']), + ), + ) for note in json_dict['_notes'] + ] + + obstacles = [ + FSBeatMapFileObstacle( + time=obstacle['_time'], + lineIndex=obstacle['_lineIndex'], + type=obstacle['_type'], + duration=obstacle['_duration'], + width=obstacle['_width'], + customData=FSBeatMapFileObstacleCustomData( + interactable=obstacle['_customData']['_interactable'], + fake=obstacle['_customData']['_fake'], + position=tuple(obstacle['_customData']['_position']), + localRotation=tuple(obstacle['_customData']['_localRotation']) if '_localRotation' in obstacle[ + '_customData' + ] else None, + scale=tuple(obstacle['_customData']['_scale']), + color=tuple(obstacle['_customData']['_color']), + ), + ) for obstacle in json_dict['_obstacles'] + ] + + return cls( + version=json_dict['_version'], + customData=custom_data, + events=events, + notes=notes, + obstacles=obstacles, + waypoints=json_dict['_waypoints'], + ) + + @classmethod + def from_json_file(cls, json_file: str) -> FSBeatMapFile: + import json + with open(json_file) as f: + json_dict = json.load(f) + return cls.from_json_dict(json_dict) diff --git a/tests/test_util.py b/tests/test_util.py index df114597..ecb8abf8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -8,7 +8,7 @@ from datetime import datetime import pytest from util import create_valid_filename -from util import get_ogg_and_duration +from util import get_drs_ogg_and_duration from util import get_song_cover_path @@ -27,7 +27,7 @@ def test_get_ogg_and_duration(file_names, expected_output, tmpdir, mocker): mocker.patch('subprocess.check_output', return_value='10') - ogg_file, duration_str = get_ogg_and_duration(str(folder_path)) + ogg_file, duration_str = get_drs_ogg_and_duration(str(folder_path)) if ogg_file: ogg_file = os.path.basename(ogg_file) @@ -44,7 +44,7 @@ def test_get_ogg_and_duration_subprocess_error(tmpdir, mocker): side_effect=subprocess.CalledProcessError(1, 'cmd'), ) - ogg_file, duration_str = get_ogg_and_duration(str(folder_path)) + ogg_file, duration_str = get_drs_ogg_and_duration(str(folder_path)) assert (ogg_file, duration_str) == (None, None) diff --git a/util.py b/util.py index 3ed0b786..98d9b59e 100644 --- a/util.py +++ b/util.py @@ -2,11 +2,12 @@ from __future__ import annotations import contextlib import os +import random import subprocess from datetime import datetime -def get_ogg_and_duration(folder_path) -> tuple[str, str] | tuple[None, None]: +def get_drs_ogg_and_duration(folder_path) -> tuple[str, str] | tuple[None, None]: ogg_files = [f for f in os.listdir(folder_path) if f.endswith('.ogg')] matching_files = [f for f in ogg_files if f.endswith('clip1.ogg')] if matching_files: @@ -37,6 +38,16 @@ def get_song_cover_path(folder_path) -> str | None: return os.path.join(folder_path, cover_files[0]) +def get_first_image_file_in_folder(folder_path) -> str | None: + image_files = [ + f for f in os.listdir(folder_path) if f.endswith('.png') or f.endswith('.jpg') + ] + if not image_files: + return None + + return os.path.join(folder_path, image_files[0]) + + def create_valid_filename(input_string: str): valid_chars = f'-_() {os.sep}{os.sep}' sanitized_string = ''.join( @@ -67,3 +78,26 @@ def zipdir(path, ziph, archiveroot): archiveroot, os.path.relpath(actual_file_path, path), ) ziph.write(actual_file_path, archive_file_path) + + +def random_10_digit_int(): + return random.randint(10 ** 9, 10 ** 10 - 1) + + +def convert_egg_to_ogg_and_get_length(egg_path: str): + ogg_path = egg_path.replace('.egg', '.ogg') + subprocess.run( + ['ffmpeg', '-loglevel', 'quiet', '-y', '-i', egg_path, ogg_path], + check=True, + ) + ffprobe_cmd = [ + 'ffprobe', + '-i', ogg_path, + '-show_entries', 'format=duration', + '-v', 'error', + '-of', 'default=noprint_wrappers=1:nokey=1', + ] + duration_str = subprocess.check_output( + ffprobe_cmd, text=True, + ).strip() + return os.path.basename(ogg_path), float(duration_str)