// Copyright Epic Games, Inc. All Rights Reserved. #include "ThumbnailExternalCache.h" #include "ThumbnailRendering/ThumbnailManager.h" #include "HAL/FileManager.h" #include "Misc/Paths.h" #include "AssetThumbnail.h" #include "Misc/ObjectThumbnail.h" #include "ObjectTools.h" #include "Serialization/Archive.h" #include "AssetRegistry/AssetRegistryModule.h" #include "Misc/ScopedSlowTask.h" #include "Interfaces/IPluginManager.h" #include "ImageUtils.h" #include "Hash/CityHash.h" #include "Async/Async.h" #include "Async/ParallelFor.h" #define LOCTEXT_NAMESPACE "ThumbnailExternalCache" DEFINE_LOG_CATEGORY_STATIC(LogThumbnailExternalCache, Log, All); namespace ThumbnailExternalCache { const int64 LatestVersion = 0; const uint64 ExpectedHeaderId = 0x424d5548545f4555; // "UE_THUMB" const FString ThumbnailImageFormatName(TEXT("")); void ResizeThumbnailImage(FObjectThumbnail& Thumbnail, const int32 NewWidth, const int32 NewHeight) { FImageView SrcImage = Thumbnail.GetImage(); FImage DestImage; FImageCore::ResizeImageAllocDest(SrcImage,DestImage,NewWidth,NewHeight); Thumbnail.SetImage( MoveTemp(DestImage) ); } // Return true if was resized bool ResizeThumbnailIfNeeded(FObjectThumbnail& Thumbnail, const int32 MaxImageSize) { const int32 Width = Thumbnail.GetImageWidth(); const int32 Height = Thumbnail.GetImageHeight(); // Resize if larger than maximum size if (Width > MaxImageSize || Height > MaxImageSize) { const double ShrinkModifier = (double)FMath::Max(Width, Height) / (double)MaxImageSize; const int32 NewWidth = FMath::RoundToInt((double)Width / ShrinkModifier); const int32 NewHeight = FMath::RoundToInt((double)Height / ShrinkModifier); ResizeThumbnailImage(Thumbnail, NewWidth, NewHeight); return true; } return false; } } struct FPackageThumbnailRecord { FName Name; int64 Offset = 0; }; class FSaveThumbnailCache { public: FSaveThumbnailCache(); ~FSaveThumbnailCache(); void Save(FArchive& Ar, const TArrayView InAssetDatas, const FThumbnailExternalCacheSettings& InSettings); void Save(FArchive& Ar, FCombinedThumbnailCacheToSave& CombinedCache, bool bSort); }; FSaveThumbnailCache::FSaveThumbnailCache() { } FSaveThumbnailCache::~FSaveThumbnailCache() { } void FThumbnailExternalCache::LoadCompressAndAppend(const TArrayView InAssetDatas, FCombinedThumbnailCacheToSave& CombinedCache) { const double StartTime = FPlatformTime::Seconds(); struct FAssetToProcess { const FAssetData* AssetData = nullptr; TSharedPtr Task; }; TArray AssetsToProcess; AssetsToProcess.Reserve(InAssetDatas.Num()); CombinedCache.Tasks.Reserve(CombinedCache.Tasks.Num() + InAssetDatas.Num()); for (const FAssetData& AssetData : InAssetDatas) { FNameBuilder ObjectFullNameBuilder; AssetData.GetFullName(ObjectFullNameBuilder); FName ObjectFullName(ObjectFullNameBuilder); if (!CombinedCache.Tasks.Contains(ObjectFullName)) { FAssetToProcess& AssetToProcess = AssetsToProcess.AddDefaulted_GetRef(); AssetToProcess.AssetData = &AssetData; AssetToProcess.Task = MakeShared(); AssetToProcess.Task->Name = ObjectFullName; CombinedCache.Tasks.Add(ObjectFullName, AssetToProcess.Task); } } // Load and compress ParallelFor(AssetsToProcess.Num(), [&AssetsToProcess, &CombinedCache](int32 Index) { FAssetToProcess& AssetToProcess = AssetsToProcess[Index]; if (ThumbnailTools::LoadThumbnailFromPackage(*AssetToProcess.AssetData, AssetToProcess.Task->ObjectThumbnail) && !AssetToProcess.Task->ObjectThumbnail.IsEmpty()) { AssetToProcess.Task->Compress(CombinedCache.Settings); } else { AssetToProcess.Task.Reset(); } }); // Deduplicate to free up memory // Assumes compression method is deterministic (refactor if not deterministic to hash decompressed thumbnails) CombinedCache.DeduplicateMap.Reserve(CombinedCache.DeduplicateMap.Num() + AssetsToProcess.Num()); for (FAssetToProcess& AssetToProcess : AssetsToProcess) { if (AssetToProcess.Task.IsValid() && !AssetToProcess.Task->ObjectThumbnail.IsEmpty()) { FSaveThumbnailCacheDeduplicateKey DeduplicateKey(AssetToProcess.Task->CompressedBytesHash, AssetToProcess.Task->ObjectThumbnail.GetCompressedDataSize()); if (TSharedPtr* ExistingData = CombinedCache.DeduplicateMap.Find(DeduplicateKey)) { // Point to the other object's thumbnail CombinedCache.Tasks[AssetToProcess.Task->Name] = *ExistingData; } else { CombinedCache.DeduplicateMap.Add(DeduplicateKey, AssetToProcess.Task); } } } CombinedCache.AccumlatedLoadTime += FPlatformTime::Seconds() - StartTime; } void FSaveThumbnailCache::Save(FArchive& Ar, FCombinedThumbnailCacheToSave& CombinedCache, bool bSort) { const double TimeStart = FPlatformTime::Seconds(); // Sorting is often done to reduce size of patches if (bSort) { CombinedCache.Tasks.KeyStableSort(FNameLexicalLess()); } // Only write information about assets that contain thumbnails TArray>> AssetsToWrite; AssetsToWrite.Reserve(CombinedCache.Tasks.Num()); for (TPair>& It : CombinedCache.Tasks) { if (It.Value.IsValid() && !It.Value->ObjectThumbnail.IsEmpty()) { AssetsToWrite.Add(It); } } const int32 NumAssetDatas = AssetsToWrite.Num(); UE_LOG(LogThumbnailExternalCache, Log, TEXT("Saving thumbnails for %d/%d assets (%d unique thumbnails) to %s"), NumAssetDatas, CombinedCache.Tasks.Num(), CombinedCache.DeduplicateMap.Num(), *Ar.GetArchiveName()); FText StatusText = LOCTEXT("SaveStatus", "Saving Thumbnails: {0}"); FScopedSlowTask SlowTask((float)NumAssetDatas, FText::Format(StatusText, FText::AsNumber(NumAssetDatas))); SlowTask.MakeDialog(/*bShowCancelButton*/ false); const double SaveTimeStart = FPlatformTime::Seconds(); TArray PackageThumbnailRecords; PackageThumbnailRecords.Reset(); PackageThumbnailRecords.Reserve(NumAssetDatas); TMap DeduplicateMap; DeduplicateMap.Reset(); DeduplicateMap.Reserve(CombinedCache.DeduplicateMap.Num()); int32 NumDuplicates = 0; int64 DuplicateBytesSaved = 0; int64 TotalCompressedBytes = 0; // Write Header { FThumbnailExternalCache::FThumbnailExternalCacheHeader Header; Header.HeaderId = ThumbnailExternalCache::ExpectedHeaderId; Header.Version = ThumbnailExternalCache::LatestVersion; Header.Flags = 0; Header.ImageFormatName = ThumbnailExternalCache::ThumbnailImageFormatName; Header.Serialize(Ar); } const int64 ThumbnailTableOffsetPos = Ar.Tell() - sizeof(int64); // Write compressed image data for (TPair>& It : AssetsToWrite) { // Add table of contents entry FPackageThumbnailRecord& PackageThumbnailRecord = PackageThumbnailRecords.AddDefaulted_GetRef(); PackageThumbnailRecord.Name = It.Key; // Image data FSaveThumbnailCacheTask& Task = *It.Value; FSaveThumbnailCacheDeduplicateKey DeduplicateKey(Task.CompressedBytesHash, Task.ObjectThumbnail.GetCompressedDataSize()); if (const int64* ExistingOffset = DeduplicateMap.Find(DeduplicateKey)) { // Reference existing compressed image data PackageThumbnailRecord.Offset = *ExistingOffset; DuplicateBytesSaved += DeduplicateKey.NumBytes; ++NumDuplicates; } else { // Save compressed image data PackageThumbnailRecord.Offset = Ar.Tell(); Task.ObjectThumbnail.Serialize(Ar); DeduplicateMap.Add(DeduplicateKey, PackageThumbnailRecord.Offset); TotalCompressedBytes += DeduplicateKey.NumBytes; } // Free memory Task.ObjectThumbnail.AccessCompressedImageData().Empty(); } // Save table of contents int64 NewThumbnailTableOffset = Ar.Tell(); int64 NumThumbnails = PackageThumbnailRecords.Num(); Ar << NumThumbnails; { FString ThumbnailNameString; int64 Index = 0; for (FPackageThumbnailRecord& PackageThumbnailRecord : PackageThumbnailRecords) { ThumbnailNameString.Reset(); PackageThumbnailRecord.Name.AppendString(ThumbnailNameString); UE_LOG(LogThumbnailExternalCache, Verbose, TEXT("\t[%d] %s"), Index++, *ThumbnailNameString); Ar << ThumbnailNameString; Ar << PackageThumbnailRecord.Offset; } } // Modify top of archive to know where table of contents is located Ar.Seek(ThumbnailTableOffsetPos); Ar << NewThumbnailTableOffset; const double SaveTime = FPlatformTime::Seconds() - SaveTimeStart; UE_LOG(LogThumbnailExternalCache, Log, TEXT("Load Time: %f secs, Save Time: %f secs, Total Time: %f secs"), CombinedCache.AccumlatedLoadTime, SaveTime, (FPlatformTime::Seconds() - TimeStart) + CombinedCache.AccumlatedLoadTime); UE_LOG(LogThumbnailExternalCache, Log, TEXT("Thumbnails: %d, %f MB"), PackageThumbnailRecords.Num(), (TotalCompressedBytes / (1024.0 * 1024.0))); UE_LOG(LogThumbnailExternalCache, Log, TEXT("Duplicates: %d, %f MB"), NumDuplicates, (DuplicateBytesSaved / (1024.0 * 1024.0))); } void FSaveThumbnailCache::Save(FArchive& Ar, const TArrayView InAssetDatas, const FThumbnailExternalCacheSettings& InSettings) { FCombinedThumbnailCacheToSave CombinedCache; CombinedCache.Settings = InSettings; FThumbnailExternalCache::Get().LoadCompressAndAppend(InAssetDatas, CombinedCache); Save(Ar, CombinedCache, true); } void FSaveThumbnailCacheTask::Compress(const FThumbnailExternalCacheSettings& InSettings) { ThumbnailExternalCache::ResizeThumbnailIfNeeded(ObjectThumbnail, InSettings.MaxImageSize); if (ObjectThumbnail.GetCompressedDataSize() > 0) { if (InSettings.bRecompressLossless) { // See if compressor would change FThumbnailCompressionInterface* SourceCompressor = ObjectThumbnail.GetCompressor(); FThumbnailCompressionInterface* DestCompressor = ObjectThumbnail.ChooseNewCompressor(); if (SourceCompressor != DestCompressor && SourceCompressor && DestCompressor) { // Do not recompress lossy images because they are already likely small and artifacts in the image would increase if (SourceCompressor->IsLosslessCompression()) { // Force decompress if needed so we can compress again ObjectThumbnail.GetUncompressedImageData(); // Delete existing compressed image data and compress again ObjectThumbnail.CompressImageData(); } } } } else { ObjectThumbnail.CompressImageData(); } CompressedBytesHash = CityHash64(reinterpret_cast(ObjectThumbnail.AccessCompressedImageData().GetData()), ObjectThumbnail.GetCompressedDataSize()); // Release uncompressed image memory ObjectThumbnail.AccessImageData().Empty(); } FThumbnailExternalCache::FThumbnailExternalCache() { } FThumbnailExternalCache::~FThumbnailExternalCache() { Cleanup(); } FThumbnailExternalCache& FThumbnailExternalCache::Get() { static FThumbnailExternalCache ThumbnailExternalCache; return ThumbnailExternalCache; } const FString& FThumbnailExternalCache::GetCachedEditorThumbnailsFilename() { static const FString Filename = TEXT("CachedEditorThumbnails.bin"); return Filename; } void FThumbnailExternalCache::Init() { if (!bHasInit) { bHasInit = true; // Load file for project LoadCacheFileIndex(FPaths::ProjectDir() / FThumbnailExternalCache::GetCachedEditorThumbnailsFilename()); // Load any thumbnail files for content plugins TArray> ContentPlugins = IPluginManager::Get().GetEnabledPluginsWithContent(); for (const TSharedRef& ContentPlugin : ContentPlugins) { LoadCacheFileIndexForPlugin(ContentPlugin); } // Look for cache file when a new path is mounted FPackageName::OnContentPathMounted().AddRaw(this, &FThumbnailExternalCache::OnContentPathMounted); // Unload cache file when path is unmounted FPackageName::OnContentPathDismounted().AddRaw(this, &FThumbnailExternalCache::OnContentPathDismounted); } } void FThumbnailExternalCache::Cleanup() { if (bHasInit) { FPackageName::OnContentPathMounted().RemoveAll(this); FPackageName::OnContentPathDismounted().RemoveAll(this); } } bool FThumbnailExternalCache::LoadThumbnailsFromExternalCache(const TSet& InObjectFullNames, FThumbnailMap& InOutThumbnails) { if (bIsSavingCache) { return false; } Init(); if (CacheFiles.Num() == 0) { return false; } static const FString BlueprintGeneratedClassPrefix = TEXT("/Script/Engine.BlueprintGeneratedClass "); int32 NumLoaded = 0; for (const FName ObjectFullName : InObjectFullNames) { FName ThumbnailName = ObjectFullName; FNameBuilder NameBuilder(ObjectFullName); FStringView NameView(NameBuilder); // BlueprintGeneratedClass assets can be displayed in content browser but thumbnails are usually not saved to package file for them if (NameView.StartsWith(BlueprintGeneratedClassPrefix) && NameView.EndsWith(TEXT("_C"))) { // Look for the thumbnail of the Blueprint version of this object instead FNameBuilder ModifiedNameBuilder; ModifiedNameBuilder.Append(TEXT("/Script/Engine.Blueprint ")); FStringView ViewToAppend = NameView; ViewToAppend.RightChopInline(BlueprintGeneratedClassPrefix.Len()); ViewToAppend.LeftChopInline(2); ModifiedNameBuilder.Append(ViewToAppend); ThumbnailName = FName(ModifiedNameBuilder.ToView()); } for (TPair>& It : CacheFiles) { TSharedPtr& ThumbnailCacheFile = It.Value; if (FThumbnailEntry* Found = ThumbnailCacheFile->NameToEntry.Find(ThumbnailName)) { if (ThumbnailCacheFile->bUnableToOpenFile == false) { if (TUniquePtr FileReader = TUniquePtr(IFileManager::Get().CreateFileReader(*ThumbnailCacheFile->Filename))) { FileReader->Seek(Found->Offset); if (ensure(!FileReader->IsError())) { FObjectThumbnail ObjectThumbnail; (*FileReader) << ObjectThumbnail; InOutThumbnails.Add(ObjectFullName, ObjectThumbnail); ++NumLoaded; } } else { // Avoid retrying if file no longer exists ThumbnailCacheFile->bUnableToOpenFile = true; } } } } } return NumLoaded > 0; } void FThumbnailExternalCache::SortAssetDatas(TArray& AssetDatas) { Algo::SortBy(AssetDatas, [](const FAssetData& Data) { return Data.PackageName; }, FNameLexicalLess()); } bool FThumbnailExternalCache::SaveExternalCache(const FString& InFilename, FCombinedThumbnailCacheToSave& InCache, const bool bSort) { bIsSavingCache = true; if (TUniquePtr FileWriter = TUniquePtr(IFileManager::Get().CreateFileWriter(*InFilename))) { FSaveThumbnailCache SaveJob; SaveJob.Save(*FileWriter, InCache, bSort); bIsSavingCache = false; return true; } bIsSavingCache = false; return false; } bool FThumbnailExternalCache::SaveExternalCache(const FString& InFilename, const TArrayView InAssetDatas, const FThumbnailExternalCacheSettings& InSettings) { bIsSavingCache = true; if (TUniquePtr FileWriter = TUniquePtr(IFileManager::Get().CreateFileWriter(*InFilename))) { SaveExternalCache(*FileWriter, InAssetDatas, InSettings); bIsSavingCache = false; return true; } bIsSavingCache = false; return false; } void FThumbnailExternalCache::SaveExternalCache(FArchive& Ar, const TArrayView InAssetDatas, const FThumbnailExternalCacheSettings& InSettings) { bIsSavingCache = true; FSaveThumbnailCache SaveJob; SaveJob.Save(Ar, InAssetDatas, InSettings); bIsSavingCache = false; } void FThumbnailExternalCache::OnContentPathMounted(const FString& InAssetPath, const FString& InFilesystemPath) { if (TSharedPtr FoundPlugin = IPluginManager::Get().FindPluginFromPath(InAssetPath)) { LoadCacheFileIndexForPlugin(FoundPlugin); } } void FThumbnailExternalCache::OnContentPathDismounted(const FString& InAssetPath, const FString& InFilesystemPath) { if (TSharedPtr FoundPlugin = IPluginManager::Get().FindPluginFromPath(InAssetPath)) { if (FoundPlugin->CanContainContent()) { const FString Filename = FoundPlugin->GetBaseDir() / FThumbnailExternalCache::GetCachedEditorThumbnailsFilename(); CacheFiles.Remove(Filename); } } } void FThumbnailExternalCache::LoadCacheFileIndexForPlugin(const TSharedPtr InPlugin) { if (InPlugin && InPlugin->CanContainContent()) { const FString Filename = InPlugin->GetBaseDir() / FThumbnailExternalCache::GetCachedEditorThumbnailsFilename(); if (IFileManager::Get().FileExists(*Filename)) { LoadCacheFileIndex(Filename); } } } bool FThumbnailExternalCache::LoadCacheFileIndex(const FString& Filename) { // Stop if attempt to load already made if (CacheFiles.Contains(Filename)) { return true; } // Track file TSharedPtr ThumbnailCacheFile = MakeShared(); ThumbnailCacheFile->Filename = Filename; ThumbnailCacheFile->bUnableToOpenFile = true; CacheFiles.Add(Filename, ThumbnailCacheFile); // Attempt load index of file if (TUniquePtr FileReader = TUniquePtr(IFileManager::Get().CreateFileReader(*Filename))) { if (LoadCacheFileIndex(*FileReader, ThumbnailCacheFile)) { ThumbnailCacheFile->bUnableToOpenFile = false; return true; } } return false; } bool FThumbnailExternalCache::LoadCacheFileIndex(FArchive& Ar, const TSharedPtr& CacheFile) { FThumbnailExternalCacheHeader& Header = CacheFile->Header; Header.Serialize(Ar); if (Header.HeaderId != ThumbnailExternalCache::ExpectedHeaderId) { return false; } if (Header.Version != 0) { return false; } Ar.Seek(Header.ThumbnailTableOffset); int64 NumPackages = 0; Ar << NumPackages; CacheFile->NameToEntry.Reserve(IntCastChecked(NumPackages)); FString PackageNameString; for (int64 i=0; i < NumPackages; ++i) { PackageNameString.Reset(); Ar << PackageNameString; FThumbnailEntry NewEntry; Ar << NewEntry.Offset; CacheFile->NameToEntry.Add(FName(PackageNameString), NewEntry); } return true; } #undef LOCTEXT_NAMESPACE