mirror of
https://github.com/FunkinCrew/Funkin.git
synced 2025-02-25 08:04:27 -05:00
387 lines
15 KiB
Haxe
387 lines
15 KiB
Haxe
package funkin.ui.debug.charting;
|
|
|
|
import funkin.util.VersionUtil;
|
|
import haxe.ui.notifications.NotificationType;
|
|
import funkin.util.DateUtil;
|
|
import haxe.io.Path;
|
|
import funkin.util.SerializerUtil;
|
|
import haxe.ui.notifications.NotificationManager;
|
|
import funkin.util.FileUtil;
|
|
import funkin.util.FileUtil;
|
|
import haxe.io.Bytes;
|
|
import funkin.play.song.Song;
|
|
import funkin.data.song.SongData.SongChartData;
|
|
import funkin.data.song.SongData.SongMetadata;
|
|
import funkin.data.song.SongRegistry;
|
|
import funkin.data.song.importer.ChartManifestData;
|
|
|
|
/**
|
|
* Contains functions for importing, loading, saving, and exporting charts.
|
|
*/
|
|
@:nullSafety
|
|
@:allow(funkin.ui.debug.charting.ChartEditorState)
|
|
class ChartEditorImportExportHandler
|
|
{
|
|
/**
|
|
* Fetch's a song's existing chart and audio and loads it, replacing the current song.
|
|
*/
|
|
public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void
|
|
{
|
|
var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
|
|
|
|
if (song == null) return;
|
|
|
|
// Load the song metadata.
|
|
var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata();
|
|
var songMetadata:Map<String, SongMetadata> = [];
|
|
var songChartData:Map<String, SongChartData> = [];
|
|
|
|
for (metadata in rawSongMetadata)
|
|
{
|
|
if (metadata == null) continue;
|
|
var variation = (metadata.variation == null || metadata.variation == '') ? Constants.DEFAULT_VARIATION : metadata.variation;
|
|
|
|
// Clone to prevent modifying the original.
|
|
var metadataClone:SongMetadata = metadata.clone(variation);
|
|
if (metadataClone != null) songMetadata.set(variation, metadataClone);
|
|
|
|
var chartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartData(songId, metadata.variation);
|
|
if (chartData != null) songChartData.set(variation, chartData);
|
|
}
|
|
|
|
loadSong(state, songMetadata, songChartData);
|
|
|
|
state.sortChartData();
|
|
|
|
state.clearVocals();
|
|
|
|
var variations:Array<String> = state.availableVariations;
|
|
for (variation in variations)
|
|
{
|
|
if (variation == Constants.DEFAULT_VARIATION)
|
|
{
|
|
ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId));
|
|
}
|
|
else
|
|
{
|
|
ChartEditorAudioHandler.loadInstFromAsset(state, Paths.inst(songId, '-$variation'), variation);
|
|
}
|
|
}
|
|
|
|
for (difficultyId in song.listDifficulties())
|
|
{
|
|
var diff:Null<SongDifficulty> = song.getDifficulty(difficultyId);
|
|
if (diff == null) continue;
|
|
|
|
var instId:String = diff.variation == Constants.DEFAULT_VARIATION ? '' : diff.variation;
|
|
var voiceList:Array<String> = diff.buildVoiceList(); // SongDifficulty accounts for variation already.
|
|
|
|
if (voiceList.length == 2)
|
|
{
|
|
ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId);
|
|
ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], diff.characters.opponent, instId);
|
|
}
|
|
else if (voiceList.length == 1)
|
|
{
|
|
ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], diff.characters.player, instId);
|
|
}
|
|
else
|
|
{
|
|
trace('[WARN] Strange quantity of voice paths for difficulty ${difficultyId}: ${voiceList.length}');
|
|
}
|
|
}
|
|
|
|
state.switchToCurrentInstrumental();
|
|
|
|
state.refreshMetadataToolbox();
|
|
|
|
#if !mac
|
|
NotificationManager.instance.addNotification(
|
|
{
|
|
title: 'Success',
|
|
body: 'Loaded song (${rawSongMetadata[0].songName})',
|
|
type: NotificationType.Success,
|
|
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
|
|
});
|
|
#end
|
|
}
|
|
|
|
/**
|
|
* Loads a chart from parsed song metadata and chart data into the editor.
|
|
* @param newSongMetadata The song metadata to load.
|
|
* @param newSongChartData The song chart data to load.
|
|
*/
|
|
public static function loadSong(state:ChartEditorState, newSongMetadata:Map<String, SongMetadata>, newSongChartData:Map<String, SongChartData>):Void
|
|
{
|
|
state.songMetadata = newSongMetadata;
|
|
state.songChartData = newSongChartData;
|
|
|
|
Conductor.forceBPM(null); // Disable the forced BPM.
|
|
Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
|
|
|
|
state.notePreviewDirty = true;
|
|
state.notePreviewViewportBoundsDirty = true;
|
|
state.difficultySelectDirty = true;
|
|
state.opponentPreviewDirty = true;
|
|
state.playerPreviewDirty = true;
|
|
|
|
// Remove instrumental and vocal tracks, they will be loaded next.
|
|
if (state.audioInstTrack != null)
|
|
{
|
|
state.audioInstTrack.stop();
|
|
state.audioInstTrack = null;
|
|
}
|
|
if (state.audioVocalTrackGroup != null)
|
|
{
|
|
state.audioVocalTrackGroup.stop();
|
|
state.audioVocalTrackGroup.clear();
|
|
}
|
|
}
|
|
|
|
public static function loadFromFNFCPath(state:ChartEditorState, path:String):Bool
|
|
{
|
|
var bytes:Null<Bytes> = FileUtil.readBytesFromPath(path);
|
|
if (bytes == null) return false;
|
|
|
|
trace('Loaded ${bytes.length} bytes from $path');
|
|
|
|
var result:Bool = loadFromFNFC(state, bytes);
|
|
if (result)
|
|
{
|
|
state.currentWorkingFilePath = path;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Load a chart's metadata, chart data, and audio from an FNFC archive..
|
|
* @param state
|
|
* @param bytes
|
|
* @param instId
|
|
* @return Bool
|
|
*/
|
|
public static function loadFromFNFC(state:ChartEditorState, bytes:Bytes):Bool
|
|
{
|
|
var songMetadatas:Map<String, SongMetadata> = [];
|
|
var songChartDatas:Map<String, SongChartData> = [];
|
|
|
|
var fileEntries:Array<haxe.zip.Entry> = FileUtil.readZIPFromBytes(bytes);
|
|
var mappedFileEntries:Map<String, haxe.zip.Entry> = FileUtil.mapZIPEntriesByName(fileEntries);
|
|
|
|
var manifestBytes:Null<Bytes> = mappedFileEntries.get('manifest.json')?.data;
|
|
if (manifestBytes == null) throw 'Could not locate manifest.';
|
|
var manifestString = manifestBytes.toString();
|
|
var manifest:Null<ChartManifestData> = ChartManifestData.deserialize(manifestString);
|
|
if (manifest == null) throw 'Could not read manifest.';
|
|
|
|
// Get the song ID.
|
|
var songId:String = manifest.songId;
|
|
|
|
var baseMetadataPath:String = manifest.getMetadataFileName();
|
|
var baseChartDataPath:String = manifest.getChartDataFileName();
|
|
|
|
var baseMetadataBytes:Null<Bytes> = mappedFileEntries.get(baseMetadataPath)?.data;
|
|
if (baseMetadataBytes == null) throw 'Could not locate metadata (default).';
|
|
var baseMetadataString:String = baseMetadataBytes.toString();
|
|
var baseMetadataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(baseMetadataString);
|
|
if (baseMetadataVersion == null) throw 'Could not read metadata version (default).';
|
|
|
|
var baseMetadata:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(baseMetadataString, baseMetadataPath, baseMetadataVersion);
|
|
if (baseMetadata == null) throw 'Could not read metadata (default).';
|
|
songMetadatas.set(Constants.DEFAULT_VARIATION, baseMetadata);
|
|
|
|
var baseChartDataBytes:Null<Bytes> = mappedFileEntries.get(baseChartDataPath)?.data;
|
|
if (baseChartDataBytes == null) throw 'Could not locate chart data (default).';
|
|
var baseChartDataString:String = baseChartDataBytes.toString();
|
|
var baseChartDataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(baseChartDataString);
|
|
if (baseChartDataVersion == null) throw 'Could not read chart data (default) version.';
|
|
|
|
var baseChartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(baseChartDataString, baseChartDataPath,
|
|
baseChartDataVersion);
|
|
if (baseChartData == null) throw 'Could not read chart data (default).';
|
|
songChartDatas.set(Constants.DEFAULT_VARIATION, baseChartData);
|
|
|
|
var variationList:Array<String> = baseMetadata.playData.songVariations;
|
|
|
|
for (variation in variationList)
|
|
{
|
|
var variMetadataPath:String = manifest.getMetadataFileName(variation);
|
|
var variChartDataPath:String = manifest.getChartDataFileName(variation);
|
|
|
|
var variMetadataBytes:Null<Bytes> = mappedFileEntries.get(variMetadataPath)?.data;
|
|
if (variMetadataBytes == null) throw 'Could not locate metadata ($variation).';
|
|
var variMetadataString:String = variMetadataBytes.toString();
|
|
var variMetadataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(variMetadataString);
|
|
if (variMetadataVersion == null) throw 'Could not read metadata ($variation) version.';
|
|
|
|
var variMetadata:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(baseMetadataString, variMetadataPath, variMetadataVersion);
|
|
if (variMetadata == null) throw 'Could not read metadata ($variation).';
|
|
songMetadatas.set(variation, variMetadata);
|
|
|
|
var variChartDataBytes:Null<Bytes> = mappedFileEntries.get(variChartDataPath)?.data;
|
|
if (variChartDataBytes == null) throw 'Could not locate chart data ($variation).';
|
|
var variChartDataString:String = variChartDataBytes.toString();
|
|
var variChartDataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(variChartDataString);
|
|
if (variChartDataVersion == null) throw 'Could not read chart data version ($variation).';
|
|
|
|
var variChartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(variChartDataString, variChartDataPath,
|
|
variChartDataVersion);
|
|
if (variChartData == null) throw 'Could not read chart data ($variation).';
|
|
songChartDatas.set(variation, variChartData);
|
|
}
|
|
|
|
ChartEditorAudioHandler.stopExistingInstrumental(state);
|
|
ChartEditorAudioHandler.stopExistingVocals(state);
|
|
|
|
// Load instrumentals
|
|
for (variation in [Constants.DEFAULT_VARIATION].concat(variationList))
|
|
{
|
|
var variMetadata:Null<SongMetadata> = songMetadatas.get(variation);
|
|
if (variMetadata == null) continue;
|
|
|
|
var instId:String = variMetadata?.playData?.characters?.instrumental ?? '';
|
|
var playerCharId:String = variMetadata?.playData?.characters?.player ?? Constants.DEFAULT_CHARACTER;
|
|
var opponentCharId:Null<String> = variMetadata?.playData?.characters?.opponent;
|
|
|
|
var instFileName:String = manifest.getInstFileName(instId);
|
|
var instFileBytes:Null<Bytes> = mappedFileEntries.get(instFileName)?.data;
|
|
if (instFileBytes != null)
|
|
{
|
|
if (!ChartEditorAudioHandler.loadInstFromBytes(state, instFileBytes, instId))
|
|
{
|
|
throw 'Could not load instrumental ($instFileName).';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw 'Could not find instrumental ($instFileName).';
|
|
}
|
|
|
|
var playerVocalsFileName:String = manifest.getVocalsFileName(playerCharId);
|
|
var playerVocalsFileBytes:Null<Bytes> = mappedFileEntries.get(playerVocalsFileName)?.data;
|
|
if (playerVocalsFileBytes != null)
|
|
{
|
|
if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, playerVocalsFileBytes, playerCharId, instId))
|
|
{
|
|
throw 'Could not load vocals ($playerCharId).';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw 'Could not find vocals ($playerVocalsFileName).';
|
|
}
|
|
|
|
if (opponentCharId != null)
|
|
{
|
|
var opponentVocalsFileName:String = manifest.getVocalsFileName(opponentCharId);
|
|
var opponentVocalsFileBytes:Null<Bytes> = mappedFileEntries.get(opponentVocalsFileName)?.data;
|
|
if (opponentVocalsFileBytes != null)
|
|
{
|
|
if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, opponentVocalsFileBytes, opponentCharId, instId))
|
|
{
|
|
throw 'Could not load vocals ($opponentCharId).';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw 'Could not load vocals ($playerCharId-$instId).';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply chart data.
|
|
trace(songMetadatas);
|
|
trace(songChartDatas);
|
|
loadSong(state, songMetadatas, songChartDatas);
|
|
|
|
state.switchToCurrentInstrumental();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param force Whether to export without prompting. `false` will prompt the user for a location.
|
|
* @param targetPath where to export if `force` is `true`. If `null`, will export to the `backups` folder.
|
|
*/
|
|
public static function exportAllSongData(state:ChartEditorState, force:Bool = false, ?targetPath:String):Void
|
|
{
|
|
var zipEntries:Array<haxe.zip.Entry> = [];
|
|
|
|
var variations = state.availableVariations;
|
|
|
|
for (variation in variations)
|
|
{
|
|
var variationId:String = variation;
|
|
if (variation == '' || variation == 'default' || variation == 'normal')
|
|
{
|
|
variationId = '';
|
|
}
|
|
|
|
if (variationId == '')
|
|
{
|
|
var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
|
|
if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata.json', variationMetadata.serialize()));
|
|
var variationChart:Null<SongChartData> = state.songChartData.get(variation);
|
|
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', variationChart.serialize()));
|
|
}
|
|
else
|
|
{
|
|
var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
|
|
if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json',
|
|
variationMetadata.serialize()));
|
|
var variationChart:Null<SongChartData> = state.songChartData.get(variation);
|
|
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json', variationChart.serialize()));
|
|
}
|
|
}
|
|
|
|
if (state.audioInstTrackData != null) zipEntries = zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state));
|
|
if (state.audioVocalTrackData != null) zipEntries = zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state));
|
|
|
|
var manifest:ChartManifestData = new ChartManifestData(state.currentSongId);
|
|
zipEntries.push(FileUtil.makeZIPEntry('manifest.json', manifest.serialize()));
|
|
|
|
trace('Exporting ${zipEntries.length} files to ZIP...');
|
|
|
|
if (force)
|
|
{
|
|
if (targetPath == null)
|
|
{
|
|
targetPath = Path.join([
|
|
'./backups/',
|
|
'chart-editor-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
|
|
]);
|
|
}
|
|
|
|
// We have to force write because the program will die before the save dialog is closed.
|
|
trace('Force exporting to $targetPath...');
|
|
FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
|
|
}
|
|
else
|
|
{
|
|
// Prompt and save.
|
|
var onSave:Array<String>->Void = function(paths:Array<String>) {
|
|
if (paths.length != 1)
|
|
{
|
|
trace('[WARN] Could not get save path.');
|
|
}
|
|
else
|
|
{
|
|
state.currentWorkingFilePath = paths[0];
|
|
state.applyWindowTitle();
|
|
}
|
|
};
|
|
|
|
var onCancel:Void->Void = function() {
|
|
trace('Export cancelled.');
|
|
};
|
|
|
|
trace('Exporting to user-defined location...');
|
|
try
|
|
{
|
|
FileUtil.saveChartAsFNFC(zipEntries, onSave, onCancel, '${state.currentSongId}.${Constants.EXT_CHART}');
|
|
}
|
|
catch (e) {}
|
|
}
|
|
}
|
|
}
|