Editor/Import/UassetThumbnail.cs

Utility for extracting embedded editor thumbnails from Unreal .uasset files. Scans the asset bytes for PNG or JPEG magic, validates a preceding 12-byte FObjectThumbnail header, caches extracted images on disk under .sbox/unrealimporter/thumbnails and keeps an in-memory Pixmap cache with in-flight task deduping.

File AccessNative Interop
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Sandbox;

namespace Editor.UnrealImporter;

/// <summary>
/// Extracts the editor thumbnail Unreal embeds in every saved (uncooked) .uasset.
///
/// The package stores an FObjectThumbnail: 12 bytes of header (int32 width, height,
/// compressedSize) followed by the compressed image - PNG normally, JPEG in newer
/// packs (flagged by a negative height). Rather than parsing the version-dependent
/// package summary to find the thumbnail table, we scan for the PNG/JPEG magic and
/// validate the header that precedes it - verified against UE 5.x Fab packs.
///
/// Extracted images are cached on disk under the project's .sbox/ folder (keyed on
/// file mtime+size, so re-saved assets re-extract), plus an in-memory Pixmap cache
/// because the import window rebuilds its list on every keystroke.
/// </summary>
public static class UassetThumbnail
{
	// path -> pixmap (null = scanned, no thumbnail found)
	static readonly Dictionary<string, Pixmap> memoryCache = new();
	static readonly Dictionary<string, Task<Pixmap>> inFlight = new();

	static string CacheDir => Sandbox.Project.Current is not null
		? Path.Combine( Sandbox.Project.Current.GetRootPath(), ".sbox", "unrealimporter", "thumbnails" )
		: Path.Combine( Path.GetTempPath(), "unrealimporter", "thumbnails" );

	/// <summary>Memory-cache lookup. True if this path has been resolved (pixmap may still be null).</summary>
	public static bool TryGetCached( string absPath, out Pixmap pixmap )
		=> memoryCache.TryGetValue( absPath, out pixmap );

	/// <summary>
	/// Resolve the thumbnail for a .uasset: memory cache, then disk cache, then a scan of the
	/// file itself. Returns null if the asset has no embedded thumbnail. Safe to call
	/// repeatedly - concurrent requests for the same path share one task.
	/// </summary>
	public static Task<Pixmap> LoadAsync( string absPath )
	{
		if ( memoryCache.TryGetValue( absPath, out var cached ) )
			return Task.FromResult( cached );

		if ( inFlight.TryGetValue( absPath, out var running ) )
			return running;

		var task = Load( absPath );
		inFlight[absPath] = task;
		return task;
	}

	static async Task<Pixmap> Load( string absPath )
	{
		string imagePath = null;
		try
		{
			// File IO + scanning off the main thread; only the Pixmap itself is created back on it.
			imagePath = await Task.Run( () => ResolveCacheFile( absPath ) );
		}
		catch ( Exception e )
		{
			Log.Warning( $"Thumbnail extraction failed for {absPath}: {e.Message}" );
		}

		var pixmap = imagePath is not null ? Pixmap.FromFile( imagePath ) : null;
		memoryCache[absPath] = pixmap;
		inFlight.Remove( absPath );
		return pixmap;
	}

	/// <summary>
	/// Path to a cached thumbnail image for this uasset, extracting it if needed.
	/// Null if the asset has no embedded thumbnail (recorded with a .none marker).
	/// </summary>
	static string ResolveCacheFile( string absPath )
	{
		var fi = new FileInfo( absPath );
		if ( !fi.Exists )
			return null;

		var pathHash = ShortHash( absPath.ToLowerInvariant() );
		var statHash = ShortHash( $"{fi.LastWriteTimeUtc.Ticks}|{fi.Length}" );
		var dir = CacheDir;
		var baseName = Path.Combine( dir, $"{pathHash}_{statHash}" );

		if ( File.Exists( baseName + ".png" ) ) return baseName + ".png";
		if ( File.Exists( baseName + ".jpg" ) ) return baseName + ".jpg";
		if ( File.Exists( baseName + ".none" ) ) return null;

		Directory.CreateDirectory( dir );

		// The asset changed since it was last cached - drop the stale entries for this path.
		foreach ( var stale in Directory.EnumerateFiles( dir, pathHash + "_*" ) )
			File.Delete( stale );

		var (image, ext) = Extract( File.ReadAllBytes( absPath ) );
		if ( image is null )
		{
			File.WriteAllBytes( baseName + ".none", Array.Empty<byte>() );
			return null;
		}

		var target = baseName + ext;
		File.WriteAllBytes( target, image );
		return target;
	}

	static string ShortHash( string input )
		=> Convert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( input ) ) )[..16].ToLowerInvariant();

	static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };

	/// <summary>Find the embedded thumbnail in raw .uasset bytes, or (null, null).</summary>
	internal static (byte[] Image, string Extension) Extract( byte[] data )
	{
		if ( TryFindImage( data, PngMagic, out var png ) )
			return (png, ".png");

		// JPEG: SOI (FF D8 FF) followed by an APP0/APP1/DQT segment.
		if ( TryFindJpeg( data, out var jpg ) )
			return (jpg, ".jpg");

		return (null, null);
	}

	static bool TryFindImage( byte[] data, byte[] magic, out byte[] image )
	{
		int pos = 12;
		while ( (pos = IndexOf( data, magic, pos )) >= 0 )
		{
			if ( TrySlice( data, pos, out image ) )
				return true;
			pos += 1;
		}

		image = null;
		return false;
	}

	static bool TryFindJpeg( byte[] data, out byte[] image )
	{
		for ( int pos = 12; pos < data.Length - 4; pos++ )
		{
			if ( data[pos] != 0xFF || data[pos + 1] != 0xD8 || data[pos + 2] != 0xFF )
				continue;
			var seg = data[pos + 3];
			if ( seg != 0xE0 && seg != 0xE1 && seg != 0xDB )
				continue;
			if ( TrySlice( data, pos, out image ) )
				return true;
		}

		image = null;
		return false;
	}

	/// <summary>
	/// Validate the FObjectThumbnail header in the 12 bytes before the image magic and
	/// slice out the image. Rejects magic hits that aren't preceded by a sane header.
	/// </summary>
	static bool TrySlice( byte[] data, int magicPos, out byte[] image )
	{
		image = null;
		if ( magicPos < 12 )
			return false;

		int width = BitConverter.ToInt32( data, magicPos - 12 );
		int height = Math.Abs( BitConverter.ToInt32( data, magicPos - 8 ) );   // negative = JPEG flag
		int size = BitConverter.ToInt32( data, magicPos - 4 );

		if ( width < 4 || width > 8192 || height < 4 || height > 8192 )
			return false;
		if ( size < 16 || (long)magicPos + size > data.Length )
			return false;

		image = data[magicPos..(magicPos + size)];
		return true;
	}

	static int IndexOf( byte[] haystack, byte[] needle, int start )
	{
		var idx = haystack.AsSpan( start ).IndexOf( needle );
		return idx < 0 ? -1 : start + idx;
	}
}