// Copyright Epic Games, Inc. All Rights Reserved. #include "AutoReimport/ContentDirectoryMonitor.h" #include "HAL/FileManager.h" #include "Modules/ModuleManager.h" #include "EditorReimportHandler.h" #include "Settings/EditorLoadingSavingSettings.h" #include "Factories/Factory.h" #include "Factories/SceneImportFactory.h" #include "EditorFramework/AssetImportData.h" #include "Editor.h" #include "AutoReimport/AutoReimportUtilities.h" #include "AssetRegistryModule.h" #include "PackageTools.h" #include "ObjectTools.h" #include "IAssetTools.h" #include "AssetToolsModule.h" #include "AutoReimport/ReimportFeedbackContext.h" #include "AutoReimport/AssetSourceFilenameCache.h" #define LOCTEXT_NAMESPACE "ContentDirectoryMonitor" bool IsAssetDirty(UObject* Asset) { UPackage* Package = Asset ? Asset->GetOutermost() : nullptr; return Package ? Package->IsDirty() : false; } /** Generate a config from the specified options, to pass to FFileCache on construction */ DirectoryWatcher::FFileCacheConfig GenerateFileCacheConfig(const FString& InPath, const DirectoryWatcher::FMatchRules& InMatchRules, const FString& InMountedContentPath) { FString Directory = FPaths::ConvertRelativePathToFull(InPath); const FString& HashString = InMountedContentPath.IsEmpty() ? Directory : InMountedContentPath; const uint32 CRC = FCrc::MemCrc32(*HashString, HashString.Len() * sizeof(TCHAR)); FString CacheFilename = FPaths::ConvertRelativePathToFull(FPaths::ProjectIntermediateDir()) / TEXT("ReimportCache") / FString::Printf(TEXT("%u.bin"), CRC); DirectoryWatcher::FFileCacheConfig Config(Directory, MoveTemp(CacheFilename)); Config.Rules = InMatchRules; // We always store paths inside content folders relative to the folder Config.PathType = DirectoryWatcher::EPathType::Relative; Config.bDetectChangesSinceLastRun = GetDefault()->bDetectChangesOnStartup; // It's safe to assume the asset registry is not re-loadable IAssetRegistry* Registry = &FModuleManager::LoadModuleChecked(AssetRegistryConstants::ModuleName).Get(); Config.CustomChangeLogic = [Directory, Registry](const DirectoryWatcher::FImmutableString& InRelativePath, const DirectoryWatcher::FFileData& FileData) -> TOptional { int32 TotalNumReferencingAssets = 0; TArray Assets = FAssetSourceFilenameCache::Get().GetAssetsPertainingToFile(*Registry, Directory / InRelativePath.Get()); if (Assets.Num() == 0) { return TOptional(); } // We need to consider this as a changed file if the hash doesn't match any asset imported from that file for (FAssetData& Asset: Assets) { TOptional Info = FAssetSourceFilenameCache::ExtractAssetImportInfo(Asset); // Check if the source file that this asset last imported was the same as the one we're going to reimport. // If it is, there's no reason to auto-reimport it if (Info.IsSet() && Info->SourceFiles.Num() == 1) { if (Info->SourceFiles[0].FileHash != FileData.FileHash) { return true; } } } return TOptional(); }; // We only detect changes for when the file *contents* have changed (not its timestamp) Config .DetectMoves(true) .DetectChangesFor(DirectoryWatcher::FFileCacheConfig::Timestamp, false) .DetectChangesFor(DirectoryWatcher::FFileCacheConfig::FileHash, true); return Config; } FContentDirectoryMonitor::FContentDirectoryMonitor(const FString& InDirectory, const DirectoryWatcher::FMatchRules& InMatchRules, const FString& InMountedContentPath) : Cache(GenerateFileCacheConfig(InDirectory, InMatchRules, InMountedContentPath)), MountedContentPath(InMountedContentPath), LastSaveTime(0) { Registry = &FModuleManager::LoadModuleChecked(AssetRegistryConstants::ModuleName).Get(); } void FContentDirectoryMonitor::Destroy() { Cache.Destroy(); } void FContentDirectoryMonitor::IgnoreNewFile(const FString& Filename) { Cache.IgnoreNewFile(Filename); } void FContentDirectoryMonitor::IgnoreFileModification(const FString& Filename) { Cache.IgnoreFileModification(Filename); } void FContentDirectoryMonitor::IgnoreMovedFile(const FString& SrcFilename, const FString& DstFilename) { Cache.IgnoreMovedFile(SrcFilename, DstFilename); } void FContentDirectoryMonitor::IgnoreDeletedFile(const FString& Filename) { Cache.IgnoreDeletedFile(Filename); } void FContentDirectoryMonitor::Tick() { Cache.Tick(); // Immediately resolve any changes that we should not consider const FDateTime Threshold = FDateTime::UtcNow() - FTimespan::FromSeconds(GetDefault()->AutoReimportThreshold); TArray InsignificantTransactions = Cache.FilterOutstandingChanges([=](const DirectoryWatcher::FUpdateCacheTransaction& Transaction, const FDateTime& TimeOfChange) { return TimeOfChange <= Threshold && !ShouldConsiderChange(Transaction); }); for (DirectoryWatcher::FUpdateCacheTransaction& Transaction: InsignificantTransactions) { Cache.CompleteTransaction(MoveTemp(Transaction)); } const double Now = FPlatformTime::Seconds(); if (Now - LastSaveTime > ResaveIntervalS) { LastSaveTime = Now; Cache.WriteCache(); } } bool FContentDirectoryMonitor::ShouldConsiderChange(const DirectoryWatcher::FUpdateCacheTransaction& Transaction) const { // If the file was removed, and nothing references it, there's nothing else to do if (Transaction.Action == DirectoryWatcher::EFileAction::Removed && FAssetSourceFilenameCache::Get().GetAssetsPertainingToFile(*Registry, Cache.GetDirectory() / Transaction.Filename.Get()).Num() == 0) { return false; } return true; } int32 FContentDirectoryMonitor::GetNumUnprocessedChanges() const { const FDateTime Threshold = FDateTime::UtcNow() - FTimespan::FromSeconds(GetDefault()->AutoReimportThreshold); int32 Total = 0; // Get all the changes that have happend beyond our import threshold Cache.IterateOutstandingChanges([=, &Total](const DirectoryWatcher::FUpdateCacheTransaction& Transaction, const FDateTime& TimeOfChange) { if (TimeOfChange <= Threshold && ShouldConsiderChange(Transaction)) { ++Total; } return true; }); return Total; } void FContentDirectoryMonitor::IterateUnprocessedChanges(TFunctionRef InIter) const { Cache.IterateOutstandingChanges(InIter); } int32 FContentDirectoryMonitor::StartProcessing() { // We only process things that haven't changed for a given threshold auto& FileManager = IFileManager::Get(); const FDateTime Threshold = FDateTime::UtcNow() - FTimespan::FromSeconds(GetDefault()->AutoReimportThreshold); // Get all the changes that have happend beyond our import threshold auto OutstandingChanges = Cache.FilterOutstandingChanges([=](const DirectoryWatcher::FUpdateCacheTransaction& Transaction, const FDateTime& TimeOfChange) { return TimeOfChange <= Threshold && ShouldConsiderChange(Transaction); }); if (OutstandingChanges.Num() == 0) { return 0; } const auto* Settings = GetDefault(); for (auto& Transaction: OutstandingChanges) { switch (Transaction.Action) { case DirectoryWatcher::EFileAction::Added: if (Settings->bAutoCreateAssets && !MountedContentPath.IsEmpty()) { AddedFiles.Emplace(MoveTemp(Transaction)); } else { Cache.CompleteTransaction(MoveTemp(Transaction)); } break; case DirectoryWatcher::EFileAction::Moved: case DirectoryWatcher::EFileAction::Modified: ModifiedFiles.Emplace(MoveTemp(Transaction)); break; case DirectoryWatcher::EFileAction::Removed: if (Settings->bAutoDeleteAssets && !MountedContentPath.IsEmpty()) { DeletedFiles.Emplace(MoveTemp(Transaction)); } else { Cache.CompleteTransaction(MoveTemp(Transaction)); } break; } } return AddedFiles.Num() + ModifiedFiles.Num() + DeletedFiles.Num(); } UObject* AttemptImport(UClass* InFactoryType, UPackage* Package, FName InName, bool& bCancelled, const FString& FullFilename) { UObject* Asset = nullptr; if (UFactory* Factory = NewObject(GetTransientPackage(), InFactoryType)) { Factory->AddToRoot(); if (Factory->ConfigureProperties()) { if (auto* SupportedClass = Factory->ResolveSupportedClass()) { Asset = Factory->ImportObject(SupportedClass, Package, InName, RF_Public | RF_Standalone, FullFilename, nullptr, bCancelled); } } Factory->RemoveFromRoot(); } return Asset; } void FContentDirectoryMonitor::ProcessAdditions(const DirectoryWatcher::FTimeLimit& TimeLimit, TArray& OutPackagesToSave, const TMap>& InFactoriesByExtension, FReimportFeedbackContext& Context) { bool bCancelled = false; for (int32 Index = 0; Index < AddedFiles.Num(); ++Index) { auto& Addition = AddedFiles[Index]; if (bCancelled) { // Just update the cache immediately if the user cancelled Cache.CompleteTransaction(MoveTemp(Addition)); Context.MainTask->EnterProgressFrame(); continue; } const FString FullFilename = Cache.GetDirectory() + Addition.Filename.Get(); FString NewAssetName = ObjectTools::SanitizeObjectName(FPaths::GetBaseFilename(FullFilename)); FString PackagePath = UPackageTools::SanitizePackageName(MountedContentPath / FPaths::GetPath(Addition.Filename.Get()) / NewAssetName); // Don't create assets for new files if assets already exist for the filename auto ExistingReferences = Utils::FindAssetsPertainingToFile(*Registry, FullFilename); if (ExistingReferences.Num() != 0) { // Treat this as a modified file that will attempt to reimport it (if applicable). We don't update the progress for this item until it is processed by ProcessModifications ModifiedFiles.Add(MoveTemp(Addition)); continue; } // Move the progress on now that we know we're going to process the file Context.MainTask->EnterProgressFrame(); if (FPackageName::DoesPackageExist(*PackagePath)) { // Package already exists, so try and import over the top of it, if it doesn't already have a source file path TArray Assets; if (Registry->GetAssetsByPackageName(*PackagePath, Assets) && Assets.Num() == 1) { if (UObject* ExistingAsset = Assets[0].GetAsset()) { // We're only eligible for reimport if the existing asset doesn't reference a source file already const bool bEligibleForReimport = !Utils::ExtractSourceFilePaths(ExistingAsset).ContainsByPredicate([&](const FString& In) { return !In.IsEmpty() && In == FullFilename; }); if (bEligibleForReimport) { ReimportAssetWithNewSource(ExistingAsset, FullFilename, OutPackagesToSave, Context); } } } } else { UPackage* NewPackage = CreatePackage(*PackagePath); if (!ensure(NewPackage)) { Context.AddMessage(EMessageSeverity::Error, FText::Format(LOCTEXT("Error_FailedToCreateAsset", "Failed to create new asset ({0}) for file ({1})."), FText::FromString(NewAssetName), FText::FromString(FullFilename))); } else { Context.AddMessage(EMessageSeverity::Info, FText::Format(LOCTEXT("Info_CreatingNewAsset", "Importing new asset {0}."), FText::FromString(PackagePath))); // Make sure the destination package is loaded NewPackage->FullyLoad(); UObject* NewAsset = nullptr; // Find a relevant factory for this file // @todo import: gmp: show dialog in case of multiple matching factories const FString Ext = FPaths::GetExtension(Addition.Filename.Get(), false); auto* Factories = InFactoriesByExtension.Find(Ext); if (Factories && Factories->Num() != 0) { // Make sure all the scene factory are put at the end of the array. We give priority to asset factory before scene factory TArray SortFactories; TArray SceneFactories; for (UFactory* Factory: *Factories) { if (Factory->IsA(USceneImportFactory::StaticClass())) { SceneFactories.Add(Factory); } else { SortFactories.Add(Factory); } } if (SceneFactories.Num() > 0) { SortFactories.Append(SceneFactories); } // Prefer a factory if it explicitly can import. UFactory::FactoryCanImport returns false by default, even if the factory supports the extension, so we can't use it directly. UFactory* const* PreferredFactory = SortFactories.FindByPredicate([&](UFactory* F) { return F->FactoryCanImport(FullFilename); }); if (PreferredFactory) { NewAsset = AttemptImport((*PreferredFactory)->GetClass(), NewPackage, *NewAssetName, bCancelled, FullFilename); } // If there was no preferred factory, just try them all until one succeeds else for (UFactory* Factory: SortFactories) { NewAsset = AttemptImport(Factory->GetClass(), NewPackage, *NewAssetName, bCancelled, FullFilename); if (bCancelled || NewAsset) { break; } } } // Verify if the package still exists after the import (it may have been cleaned up by the factory if the import was canceled). NewPackage = FindObject(nullptr, *PackagePath); if (NewPackage) { // If we didn't create an asset and the package was not cleaned up, unload and delete the package we just created if (!NewAsset) { TArray Packages; Packages.Add(NewPackage); TGuardValue SuppressSlowTaskMessages(Context.bSuppressSlowTaskMessages, true); FText ErrorMessage; if (!UPackageTools::UnloadPackages(Packages, ErrorMessage)) { Context.AddMessage(EMessageSeverity::Error, FText::Format(LOCTEXT("Error_UnloadingPackage", "There was an error unloading a package: {0}."), ErrorMessage)); } // Just add the message to the message log rather than add it to the UI // Factories may opt not to import the file, so we let them report errors if they do Context.GetMessageLog().Message(EMessageSeverity::Info, FText::Format(LOCTEXT("Info_FailedToImportAsset", "Failed to import file {0}."), FText::FromString(FullFilename))); } else if (!bCancelled) { FAssetRegistryModule::AssetCreated(NewAsset); GEditor->BroadcastObjectReimported(NewAsset); OutPackagesToSave.Add(NewPackage); } } // Refresh the supported class. Some factories (e.g. FBX) only resolve their type after reading the file // ImportAssetType = Factory->ResolveSupportedClass(); // @todo: analytics? } } // Let the cache know that we've dealt with this change (it will be imported immediately) Cache.CompleteTransaction(MoveTemp(Addition)); if (!bCancelled && TimeLimit.Exceeded()) { // Remove the ones we've processed AddedFiles.RemoveAt(0, Index + 1); return; } } AddedFiles.Empty(); } void FContentDirectoryMonitor::ProcessModifications(const DirectoryWatcher::FTimeLimit& TimeLimit, TArray& OutPackagesToSave, FReimportFeedbackContext& Context) { auto* ReimportManager = FReimportManager::Instance(); for (int32 Index = 0; Index < ModifiedFiles.Num(); ++Index) { Context.MainTask->EnterProgressFrame(); auto& Change = ModifiedFiles[Index]; const FString FullFilename = Cache.GetDirectory() + Change.Filename.Get(); // Move the asset before reimporting it. We always reimport moved assets to ensure that their import path is up to date if (Change.Action == DirectoryWatcher::EFileAction::Moved) { const FString OldFilename = Cache.GetDirectory() + Change.MovedFromFilename.Get(); const auto Assets = Utils::FindAssetsPertainingToFile(*Registry, OldFilename); if (Assets.Num() == 1) { UObject* Asset = Assets[0].GetAsset(); if (Asset && Utils::ExtractSourceFilePaths(Asset).Num() == 1) { UPackage* ExistingPackage = Asset->GetOutermost(); const bool bAssetWasDirty = IsAssetDirty(Asset); const FString NewAssetName = ObjectTools::SanitizeObjectName(FPaths::GetBaseFilename(Change.Filename.Get())); const FString PackagePath = UPackageTools::SanitizePackageName(MountedContentPath / FPaths::GetPath(Change.Filename.Get())); const FString FullDestPath = PackagePath / NewAssetName; if (ExistingPackage && ExistingPackage->FileName.ToString() == FullDestPath) { // No need to process this asset - it's already been moved to the right location Cache.CompleteTransaction(MoveTemp(Change)); continue; } const FText SrcPathText = FText::FromString(Assets[0].PackageName.ToString()), DstPathText = FText::FromString(FullDestPath); if (FPackageName::DoesPackageExist(*FullDestPath)) { Context.AddMessage(EMessageSeverity::Warning, FText::Format(LOCTEXT("MoveWarning_ExistingAsset", "Can't move {0} to {1} - one already exists."), SrcPathText, DstPathText)); } else { TArray RenameData; RenameData.Emplace(Asset, PackagePath, NewAssetName); Context.AddMessage(EMessageSeverity::Info, FText::Format(LOCTEXT("Success_MovedAsset", "Moving asset {0} to {1}."), SrcPathText, DstPathText)); FModuleManager::LoadModuleChecked("AssetTools").Get().RenameAssetsWithDialog(RenameData); TArray Filenames; Filenames.Add(FullFilename); // Update the reimport file names FReimportManager::Instance()->UpdateReimportPaths(Asset, Filenames); Asset->MarkPackageDirty(); if (!bAssetWasDirty) { if (UPackage* NewPackage = Asset->GetOutermost()) { OutPackagesToSave.Add(NewPackage); } } } } } } else { // Modifications or additions are treated the same by this point for (const auto& AssetData: Utils::FindAssetsPertainingToFile(*Registry, FullFilename)) { if (UObject* Asset = AssetData.GetAsset()) { ReimportAsset(Asset, FullFilename, OutPackagesToSave, Context); } } } // Let the cache know that we've dealt with this change Cache.CompleteTransaction(MoveTemp(Change)); if (TimeLimit.Exceeded()) { ModifiedFiles.RemoveAt(0, Index + 1); return; } } ModifiedFiles.Empty(); } void FContentDirectoryMonitor::ReimportAssetWithNewSource(UObject* InAsset, const FString& FullFilename, TArray& OutPackagesToSave, FReimportFeedbackContext& Context) { TArray Filenames; Filenames.Add(FullFilename); FReimportManager::Instance()->UpdateReimportPaths(InAsset, Filenames); ReimportAsset(InAsset, FullFilename, OutPackagesToSave, Context); } void FContentDirectoryMonitor::ReimportAsset(UObject* Asset, const FString& FullFilename, TArray& OutPackagesToSave, FReimportFeedbackContext& Context) { const bool bAssetWasDirty = IsAssetDirty(Asset); if (!FReimportManager::Instance()->Reimport(Asset, false /* Ask for new file */, false /* Show notification */)) { Context.AddMessage(EMessageSeverity::Error, FText::Format(LOCTEXT("Error_FailedToReimportAsset", "Failed to reimport asset {0}."), FText::FromString(Asset->GetName()))); } else { Context.AddMessage(EMessageSeverity::Info, FText::Format(LOCTEXT("Success_CreatedNewAsset", "Reimported asset {0} from {1}."), FText::FromString(Asset->GetName()), FText::FromString(FullFilename))); if (!bAssetWasDirty) { if (UPackage* NewPackage = Asset->GetOutermost()) { OutPackagesToSave.Add(NewPackage); } } } } void FContentDirectoryMonitor::ExtractAssetsToDelete(TArray& OutAssetsToDelete) { for (auto& Deletion: DeletedFiles) { for (const auto& AssetData: Utils::FindAssetsPertainingToFile(*Registry, Cache.GetDirectory() + Deletion.Filename.Get())) { OutAssetsToDelete.Add(AssetData); } // Let the cache know that we've dealt with this change (it will be imported in due course) Cache.CompleteTransaction(MoveTemp(Deletion)); } DeletedFiles.Empty(); } void FContentDirectoryMonitor::Abort() { for (auto& Add: AddedFiles) { Cache.CompleteTransaction(MoveTemp(Add)); } AddedFiles.Empty(); for (auto& Mod: ModifiedFiles) { Cache.CompleteTransaction(MoveTemp(Mod)); } ModifiedFiles.Empty(); for (auto& Del: DeletedFiles) { Cache.CompleteTransaction(MoveTemp(Del)); } DeletedFiles.Empty(); for (auto& Change: Cache.GetOutstandingChanges()) { Cache.CompleteTransaction(MoveTemp(Change)); } } #undef LOCTEXT_NAMESPACE