Files
UnrealEngine/Engine/Source/Editor/UnrealEd/Private/Commandlets/ExportDialogueScriptCommandlet.cpp
2025-05-18 13:04:45 +08:00

482 lines
18 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "Commandlets/ExportDialogueScriptCommandlet.h"
#include "UObject/UnrealType.h"
#include "Modules/ModuleManager.h"
#include "Misc/PackageName.h"
#include "AssetRegistry/AssetData.h"
#include "Sound/SoundWave.h"
#include "Sound/DialogueWave.h"
#include "Misc/FileHelper.h"
#include "Misc/App.h"
#include "UObject/PropertyPortFlags.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Sound/DialogueVoice.h"
DEFINE_LOG_CATEGORY_STATIC(LogExportDialogueScriptCommandlet, Log, All);
namespace
{
struct FCollapsedDialogueContextKey
{
FCollapsedDialogueContextKey(const UDialogueWave* InDialogueWave, const FDialogueContextMapping* InContext, FString InLocalizedSpokenText)
: DialogueWave(InDialogueWave)
, Context(InContext)
, LocalizedSpokenText(MoveTemp(InLocalizedSpokenText))
{
}
bool operator==(const FCollapsedDialogueContextKey& InOther) const
{
// We only care about the text that is spoken, and the voice that is speaking it
return LocalizedSpokenText.Equals(InOther.LocalizedSpokenText, ESearchCase::CaseSensitive)
&& Context->Context.Speaker == InOther.Context->Context.Speaker;
}
bool operator!=(const FCollapsedDialogueContextKey& InOther) const
{
return !(*this == InOther);
}
friend inline uint32 GetTypeHash(const FCollapsedDialogueContextKey& InKey)
{
// We only care about the text that is spoken, and the voice that is speaking it
uint32 KeyHash = 0;
KeyHash = HashCombine(KeyHash, FCrc::StrCrc32(*InKey.LocalizedSpokenText)); // Need case-sensitive hash
KeyHash = HashCombine(KeyHash, GetTypeHash(InKey.Context->Context.Speaker));
return KeyHash;
}
const UDialogueWave* DialogueWave;
const FDialogueContextMapping* Context;
FString LocalizedSpokenText;
};
}
UExportDialogueScriptCommandlet::UExportDialogueScriptCommandlet(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
}
int32 UExportDialogueScriptCommandlet::Main(const FString& Params)
{
// Parse command line
TArray<FString> Tokens;
TArray<FString> Switches;
TMap<FString, FString> ParamVals;
UCommandlet::ParseCommandLine(*Params, Tokens, Switches, ParamVals);
// Set config path
FString ConfigPath;
{
const FString* ConfigPathParamVal = ParamVals.Find(FString(TEXT("Config")));
if (!ConfigPathParamVal)
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No config specified."));
return -1;
}
ConfigPath = *ConfigPathParamVal;
}
// Set config section
FString SectionName;
{
const FString* SectionNameParamVal = ParamVals.Find(FString(TEXT("Section")));
if (!SectionNameParamVal)
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No config section specified."));
return -1;
}
SectionName = *SectionNameParamVal;
}
// Source path to the root folder that manifest/archive files live in
FString SourcePath;
if (!GetPathFromConfig(*SectionName, TEXT("SourcePath"), SourcePath, ConfigPath))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No source path specified."));
return -1;
}
// Destination path to the root folder that dialogue script CSV files live in
FString DestinationPath;
if (!GetPathFromConfig(*SectionName, TEXT("DestinationPath"), DestinationPath, ConfigPath))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No destination path specified."));
return -1;
}
// Get culture directory setting, default to true if not specified (used to allow picking of export directory with windows file dialog from Translation Editor)
bool bUseCultureDirectory = true;
if (!GetBoolFromConfig(*SectionName, TEXT("bUseCultureDirectory"), bUseCultureDirectory, ConfigPath))
{
bUseCultureDirectory = true;
}
// Get the native culture
FString NativeCulture;
if (!GetStringFromConfig(*SectionName, TEXT("NativeCulture"), NativeCulture, ConfigPath))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No native culture specified."));
return -1;
}
// Get cultures to generate
TArray<FString> CulturesToGenerate;
if (GetStringArrayFromConfig(*SectionName, TEXT("CulturesToGenerate"), CulturesToGenerate, ConfigPath) == 0)
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No cultures specified for import."));
return -1;
}
// Get the manifest name
FString ManifestName;
if (!GetStringFromConfig(*SectionName, TEXT("ManifestName"), ManifestName, ConfigPath))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No manifest name specified."));
return -1;
}
// Get the archive name
FString ArchiveName;
if (!GetStringFromConfig(*SectionName, TEXT("ArchiveName"), ArchiveName, ConfigPath))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No archive name specified."));
return -1;
}
// Get the dialogue script name
FString DialogueScriptName;
if (!GetStringFromConfig(*SectionName, TEXT("DialogueScriptName"), DialogueScriptName, ConfigPath))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("No dialogue script name specified."));
return -1;
}
// We may only have a single culture if using this setting
if (!bUseCultureDirectory && CulturesToGenerate.Num() > 1)
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("bUseCultureDirectory may only be used with a single culture."));
return false;
}
// Load the manifest and all archives
FLocTextHelper LocTextHelper(SourcePath, ManifestName, ArchiveName, NativeCulture, CulturesToGenerate, GatherManifestHelper->GetLocFileNotifies(), GatherManifestHelper->GetPlatformSplitMode());
LocTextHelper.SetCopyrightNotice(GatherManifestHelper->GetCopyrightNotice());
{
FText LoadError;
if (!LocTextHelper.LoadAll(ELocTextHelperLoadFlags::LoadOrCreate, &LoadError))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("%s"), *LoadError.ToString());
return false;
}
}
const FString RootAssetPath = FApp::HasProjectName() ? TEXT("/Game") : TEXT("/Engine");
// Prepare the asset registry
FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& AssetRegistry = AssetRegistryModule.Get();
AssetRegistry.SearchAllAssets(true);
// We want all the non-localized project specific dialogue waves
TArray<FAssetData> AssetDataArrayForDialogueWaves;
if (!FLocalizedAssetUtil::GetAssetsByPathAndClass(AssetRegistry, *RootAssetPath, UDialogueWave::StaticClass()->GetClassPathName(), /*bIncludeLocalizedAssets*/false, AssetDataArrayForDialogueWaves))
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("Unable to get dialogue wave asset data from asset registry."));
return -1;
}
for (const FString& CultureName : CulturesToGenerate)
{
const bool bIsNativeCulture = CultureName == NativeCulture;
const FString CultureSourcePath = SourcePath / CultureName;
const FString CultureDestinationPath = DestinationPath / (bUseCultureDirectory ? CultureName : TEXT(""));
TArray<TSharedPtr<FDialogueScriptEntry>> ExportedDialogueLines;
for (const FAssetData& AssetData : AssetDataArrayForDialogueWaves)
{
// Verify that the found asset is a dialogue wave
if (AssetData.GetClass() != UDialogueWave::StaticClass())
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("Asset registry found asset '%s', but the asset with this name is not actually a dialogue wave."), *AssetData.AssetName.ToString());
continue;
}
// Get the dialogue wave
UDialogueWave* const DialogueWave = Cast<UDialogueWave>(AssetData.GetAsset());
// Verify that the dialogue wave was loaded
if (!DialogueWave)
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("Asset registry found asset '%s', but the dialogue wave could not be accessed."), *AssetData.AssetName.ToString());
continue;
}
// This maps collapsed context keys to additional contexts that were collapsed into the primary context (the context within the key) - all contexts belong to the dialogue wave in the key
// If multiple contexts have the same speaking voice and use the same dialogue (because it translates to the same text), then only one of those contexts needs to be exported
// The resultant audio file will create a shared asset automatically when the dialogue is imported
TMap<FCollapsedDialogueContextKey, TArray<const FDialogueContextMapping*>> CollapsedDialogueContexts;
// Iterate over each context to build the list of unique entries
for (FDialogueContextMapping& ContextMapping : DialogueWave->ContextMappings)
{
const FString ContextLocalizationKey = DialogueWave->GetContextLocalizationKey(ContextMapping);
// Check that this entry exists in the manifest file, as we want to skip over dialogue that we aren't gathering
TSharedPtr<FManifestEntry> ContextManifestEntry = LocTextHelper.FindSourceText(FDialogueConstants::DialogueNamespace, ContextLocalizationKey, &DialogueWave->SpokenText);
if (!ContextManifestEntry.IsValid())
{
UE_LOG(LogExportDialogueScriptCommandlet, Log, TEXT("No internationalization manifest entry was found for context '%s' in culture '%s'. This context will be skipped."), *ContextLocalizationKey, *CultureName);
continue;
}
// Find the correct entry for our context
const FManifestContext* ContextManifestEntryContext = ContextManifestEntry->FindContextByKey(ContextLocalizationKey);
check(ContextManifestEntryContext); // This should never fail as we pass in the key to FindSourceText
// Get the localized text to export
FLocItem ExportedSource;
FLocItem ExportedTranslation;
LocTextHelper.GetExportText(CultureName, FDialogueConstants::DialogueNamespace, ContextManifestEntryContext->Key, ContextManifestEntryContext->KeyMetadataObj, ELocTextExportSourceMethod::NativeText, ContextManifestEntry->Source, ExportedSource, ExportedTranslation);
if (ExportedTranslation.Text.IsEmpty())
{
UE_LOG(LogExportDialogueScriptCommandlet, Log, TEXT("Empty translation found for context '%s' in culture '%s'. This context will be skipped."), *ContextLocalizationKey, *CultureName);
continue;
}
const auto CollapsedDialogueContextKey = FCollapsedDialogueContextKey(DialogueWave, &ContextMapping, ExportedTranslation.Text);
TArray<const FDialogueContextMapping*>* MergedContextsPtr = CollapsedDialogueContexts.Find(CollapsedDialogueContextKey);
if (MergedContextsPtr)
{
MergedContextsPtr->Add(&ContextMapping);
}
else
{
CollapsedDialogueContexts.Add(CollapsedDialogueContextKey);
}
}
// Get the localized voice actor direction
FLocItem ExportedVoiceActorDirectionSource;
FLocItem ExportedVoiceActorDirectionTranslation;
LocTextHelper.GetExportText(CultureName, FDialogueConstants::DialogueNamespace, DialogueWave->LocalizationGUID.ToString() + FDialogueConstants::ActingDirectionKeySuffix, nullptr, ELocTextExportSourceMethod::NativeText, FLocItem(DialogueWave->VoiceActorDirection), ExportedVoiceActorDirectionSource, ExportedVoiceActorDirectionTranslation);
// Get the localized version of the dialogue wave for the current culture
UDialogueWave* LocalizedDialogueWave = nullptr;
{
const FString LocalizedDialogueWavePackagePath = FPackageName::GetLocalizedPackagePath(AssetData.PackageName.ToString(), CultureName);
const FAssetData LocalizedDialogueWaveAssetData = AssetRegistry.GetAssetByObjectPath(FSoftObjectPath(FTopLevelAssetPath(*LocalizedDialogueWavePackagePath, AssetData.AssetName)));
LocalizedDialogueWave = Cast<UDialogueWave>(LocalizedDialogueWaveAssetData.GetAsset());
if (LocalizedDialogueWave == DialogueWave)
{
LocalizedDialogueWave = nullptr;
}
}
// Iterate over the unique contexts and generate exported data for them
for (const auto& CollapsedDialogueContextPair : CollapsedDialogueContexts)
{
TSharedRef<FDialogueScriptEntry> ExportedDialogueLine = MakeShareable(new FDialogueScriptEntry());
PopulateDialogueScriptEntry(DialogueWave, LocalizedDialogueWave, *CollapsedDialogueContextPair.Key.Context, CollapsedDialogueContextPair.Value, CollapsedDialogueContextPair.Key.LocalizedSpokenText, ExportedVoiceActorDirectionTranslation.Text, *ExportedDialogueLine);
ExportedDialogueLines.Add(ExportedDialogueLine);
}
}
// Sort the exported lines to maintain a consistent order between exports
// Sort order is speaking voice name, then localized dialogue
ExportedDialogueLines.Sort([](const TSharedPtr<FDialogueScriptEntry>& InFirstEntry, const TSharedPtr<FDialogueScriptEntry>& InSecondEntry) -> bool
{
const int32 SpeakingVoiceResult = InFirstEntry->SpeakingVoice.Compare(InSecondEntry->SpeakingVoice, ESearchCase::CaseSensitive);
if (SpeakingVoiceResult < 0)
{
return true;
}
if (SpeakingVoiceResult == 0 && InFirstEntry->SpokenDialogue.Compare(InSecondEntry->SpokenDialogue, ESearchCase::CaseSensitive) < 0)
{
return true;
}
return false;
});
{
FString CSVFileData;
CSVFileData += GenerateCSVHeader() + TEXT("\n");
for (const auto& DialogueScriptEntry : ExportedDialogueLines)
{
CSVFileData += GenerateCSVRow(*DialogueScriptEntry) + TEXT("\n");
}
const FString CSVFileName = CultureDestinationPath / DialogueScriptName;
const bool bCSVFileSaved = FLocalizedAssetSCCUtil::SaveFileWithSCC(SourceControlInfo, CSVFileName, [&](const FString& InSaveFileName) -> bool
{
return FFileHelper::SaveStringToFile(CSVFileData, *InSaveFileName, FFileHelper::EEncodingOptions::ForceUTF8);
});
if (!bCSVFileSaved)
{
UE_LOG(LogExportDialogueScriptCommandlet, Error, TEXT("Failed to write CSV file for culture '%s' to '%s'."), *CultureName, *CSVFileName);
continue;
}
}
}
return 0;
}
FString UExportDialogueScriptCommandlet::GenerateCSVHeader()
{
FString CSVHeader;
for (TFieldIterator<const FProperty> PropertyIt(FDialogueScriptEntry::StaticStruct(), EFieldIteratorFlags::IncludeSuper, EFieldIteratorFlags::ExcludeDeprecated, EFieldIteratorFlags::IncludeInterfaces); PropertyIt; ++PropertyIt)
{
if (!CSVHeader.IsEmpty())
{
CSVHeader += TEXT(",");
}
const FString PropertyName = PropertyIt->GetName();
CSVHeader += TEXT("\"");
CSVHeader += PropertyName.Replace(TEXT("\""), TEXT("\"\""));
CSVHeader += TEXT("\"");
}
return CSVHeader;
}
FString UExportDialogueScriptCommandlet::GenerateCSVRow(const FDialogueScriptEntry& InDialogueScriptEntry)
{
FString CSVRow;
for (TFieldIterator<const FProperty> PropertyIt(FDialogueScriptEntry::StaticStruct(), EFieldIteratorFlags::IncludeSuper, EFieldIteratorFlags::ExcludeDeprecated, EFieldIteratorFlags::IncludeInterfaces); PropertyIt; ++PropertyIt)
{
if (!CSVRow.IsEmpty())
{
CSVRow += TEXT(",");
}
FString PropertyValue;
PropertyIt->ExportTextItem_InContainer(PropertyValue, &InDialogueScriptEntry, nullptr, nullptr, PPF_None);
CSVRow += TEXT("\"");
CSVRow += PropertyValue.Replace(TEXT("\""), TEXT("\"\""));
CSVRow += TEXT("\"");
}
return CSVRow;
}
void UExportDialogueScriptCommandlet::PopulateDialogueScriptEntry(const UDialogueWave* InDialogueWave, const UDialogueWave* InLocalizedDialogueWave, const FDialogueContextMapping& InPrimaryContext, const TArray<const FDialogueContextMapping*>& InAdditionalContexts, const FString& InLocalizedDialogue, const FString& InLocalizedVoiceActorDirection, FDialogueScriptEntry& OutDialogueScriptEntry)
{
auto AppendTargetVoices = [&](const FDialogueContext& InContext)
{
if (InContext.Targets.Num() > 0)
{
FString TargetVoicesText;
const bool bIsArray = InContext.Targets.Num() > 1;
if (bIsArray)
{
TargetVoicesText += TEXT("[");
}
bool bIsFirst = true;
for (const UDialogueVoice* TargetVoice : InContext.Targets)
{
if (!bIsFirst)
{
TargetVoicesText += TEXT(",");
}
bIsFirst = false;
TargetVoicesText += TargetVoice->GetName();
}
if (bIsArray)
{
TargetVoicesText += TEXT("]");
}
OutDialogueScriptEntry.TargetVoices.Add(MoveTemp(TargetVoicesText));
}
};
auto AppendTargetVoiceGUIDs = [&](const FDialogueContext& InContext)
{
if (InContext.Targets.Num() > 0)
{
FString TargetVoiceGUIDsText;
const bool bIsArray = InContext.Targets.Num() > 1;
if (bIsArray)
{
TargetVoiceGUIDsText += TEXT("[");
}
bool bIsFirst = true;
for (const UDialogueVoice* TargetVoice : InContext.Targets)
{
if (!bIsFirst)
{
TargetVoiceGUIDsText += TEXT(",");
}
bIsFirst = false;
TargetVoiceGUIDsText += TargetVoice->LocalizationGUID.ToString();
}
if (bIsArray)
{
TargetVoiceGUIDsText += TEXT("]");
}
OutDialogueScriptEntry.TargetVoiceGUIDs.Add(MoveTemp(TargetVoiceGUIDsText));
}
};
auto HasLocalizedSoundWave = [&](const FDialogueContext& InContext) -> bool
{
if (InLocalizedDialogueWave)
{
for (const FDialogueContextMapping& LocalizedContextMapping : InLocalizedDialogueWave->ContextMappings)
{
if (LocalizedContextMapping.Context == InContext)
{
return LocalizedContextMapping.SoundWave && LocalizedContextMapping.SoundWave->IsLocalizedResource();
}
}
}
return false;
};
OutDialogueScriptEntry.SpokenDialogue = InLocalizedDialogue;
OutDialogueScriptEntry.VoiceActorDirection = InLocalizedVoiceActorDirection;
OutDialogueScriptEntry.AudioFileName = InDialogueWave->GetContextRecordedAudioFilename(InPrimaryContext);
OutDialogueScriptEntry.DialogueAsset = InDialogueWave->GetPathName();
OutDialogueScriptEntry.IsRecorded = HasLocalizedSoundWave(InPrimaryContext.Context);
OutDialogueScriptEntry.SpeakingVoice = InPrimaryContext.Context.Speaker->GetName();
OutDialogueScriptEntry.SpeakingVoiceGUID = InPrimaryContext.Context.Speaker->LocalizationGUID.ToString();
OutDialogueScriptEntry.DialogueAssetGUID = InDialogueWave->LocalizationGUID.ToString();
OutDialogueScriptEntry.LocalizationKeys.Add(InDialogueWave->GetContextLocalizationKey(InPrimaryContext));
AppendTargetVoices(InPrimaryContext.Context);
AppendTargetVoiceGUIDs(InPrimaryContext.Context);
for (const FDialogueContextMapping* AdditionalContext : InAdditionalContexts)
{
if (!OutDialogueScriptEntry.IsRecorded)
{
OutDialogueScriptEntry.IsRecorded = HasLocalizedSoundWave(AdditionalContext->Context);
}
OutDialogueScriptEntry.LocalizationKeys.Add(InDialogueWave->GetContextLocalizationKey(*AdditionalContext));
AppendTargetVoices(AdditionalContext->Context);
AppendTargetVoiceGUIDs(AdditionalContext->Context);
}
}