Asynchronously load textures in the UI asset preview

* Related: #1347
This commit is contained in:
ds5678 2024-05-20 16:50:58 -04:00
parent 2c32c98013
commit 297106d0ea
7 changed files with 299 additions and 50 deletions

View File

@ -1,4 +1,6 @@
namespace AssetRipper.Assets.Generics
using System.Runtime.CompilerServices;
namespace AssetRipper.Assets.Generics
{
public sealed class AssetList<T> : AccessListBase<T>
where T : notnull, new()
@ -9,12 +11,12 @@
public AssetList()
{
items = Array.Empty<T>();
items = [];
}
public AssetList(int capacity)
{
items = capacity == 0 ? Array.Empty<T>() : new T[capacity];
items = capacity == 0 ? [] : new T[capacity];
}
/// <inheritdoc/>
@ -26,10 +28,7 @@
get => items.Length;
set
{
if (value < count)
{
throw new ArgumentOutOfRangeException(nameof(value));
}
ArgumentOutOfRangeException.ThrowIfLessThan(value, count);
if (value != items.Length)
{
@ -44,7 +43,7 @@
}
else
{
items = Array.Empty<T>();
items = [];
}
}
}
@ -154,10 +153,7 @@
/// <inheritdoc/>
public override void CopyTo(T[] array, int arrayIndex)
{
if (array == null)
{
throw new ArgumentNullException(nameof(array));
}
ArgumentNullException.ThrowIfNull(array);
if (arrayIndex < 0 || arrayIndex > array.Length - count)
{
@ -172,6 +168,25 @@
new ReadOnlySpan<T>(items, 0, count).CopyTo(destination);
}
/// <summary>
/// Get a span for this list.
/// </summary>
/// <remarks>
/// <typeparamref name="T"/> must be blittable.
/// </remarks>
/// <returns>A span for the underlying array, with length equal to <see cref="Count"/>.</returns>
public Span<T> GetSpan()
{
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
return new(items, 0, count);
}
else
{
throw new NotSupportedException("Type must be blittable.");
}
}
/// <inheritdoc/>
public override int IndexOf(T item) => Array.IndexOf(items, item, 0, count);

View File

@ -49,5 +49,22 @@
_ => throw new ArgumentOutOfRangeException(nameof(_this)),
};
}
//When extension types come in C# 13, this will be more convenient to use.
public static bool TryGetFromExtension(string extension, out ImageExportFormat format)
{
format = extension switch
{
"bmp" => ImageExportFormat.Bmp,
"exr" => ImageExportFormat.Exr,
"hdr" => ImageExportFormat.Hdr,
"jpeg" => ImageExportFormat.Jpeg,
"jpg" => ImageExportFormat.Jpeg,
"png" => ImageExportFormat.Png,
"tga" => ImageExportFormat.Tga,
_ => (ImageExportFormat)(-1),
};
return format >= 0;
}
}
}

View File

@ -0,0 +1,214 @@
using AssetRipper.Assets;
using AssetRipper.Export.UnityProjects.Configuration;
using AssetRipper.Export.UnityProjects.Terrains;
using AssetRipper.Export.UnityProjects.Textures;
using AssetRipper.GUI.Web.Paths;
using AssetRipper.Import.AssetCreation;
using AssetRipper.Processing.Textures;
using AssetRipper.SourceGenerated.Classes.ClassID_156;
using AssetRipper.SourceGenerated.Classes.ClassID_213;
using AssetRipper.SourceGenerated.Classes.ClassID_28;
using AssetRipper.SourceGenerated.Extensions;
using AssetRipper.Web.Extensions;
using Microsoft.AspNetCore.Http;
using System.Runtime.InteropServices;
using DirectBitmap = AssetRipper.Export.UnityProjects.Utils.DirectBitmap<AssetRipper.TextureDecoder.Rgb.Formats.ColorBGRA32, byte>;
namespace AssetRipper.GUI.Web.Pages.Assets;
internal static class AssetAPI
{
public const string Extension = "Extension";
public const string Path = "Path";
#region Image
public static Task GetImageData(HttpContext context)
{
context.Response.DisableCaching();
if (!TryGetAssetFromQuery(context, out IUnityObjectBase? asset, out Task? failureTask))
{
return failureTask;
}
if (TryGetImageExtensionFromQuery(context, out string? extension, out ImageExportFormat format))
{
MemoryStream stream = new();
GetImageBitmap(asset).Save(stream, format);
return Results.Bytes(stream.ToArray(), $"image/{extension}").ExecuteAsync(context);
}
else
{
return Results.Bytes(GetRawImageData(asset), "application/octet-stream").ExecuteAsync(context);
}
}
public static bool HasImageData(IUnityObjectBase asset) => asset switch
{
ITexture2D texture => texture.CheckAssetIntegrity(),
SpriteInformationObject spriteInformationObject => spriteInformationObject.Texture.CheckAssetIntegrity(),
ISprite sprite => sprite.TryGetTexture()?.CheckAssetIntegrity() ?? false,
ITerrainData terrainData => terrainData.Heightmap.Heights.Count > 0,
_ => false,
};
private static DirectBitmap GetImageBitmap(IUnityObjectBase asset)
{
return asset switch
{
ITexture2D texture => TextureToBitmap(texture),
SpriteInformationObject spriteInformationObject => TextureToBitmap(spriteInformationObject.Texture),
ISprite sprite => SpriteToBitmap(sprite),
ITerrainData terrainData => TerrainHeatmapExporter.GetBitmap(terrainData),
_ => default,
};
static DirectBitmap TextureToBitmap(ITexture2D texture)
{
return TextureConverter.TryConvertToBitmap(texture, out DirectBitmap bitmap) ? bitmap : default;
}
static DirectBitmap SpriteToBitmap(ISprite sprite)
{
return sprite.TryGetTexture() is { } spriteTexture ? TextureToBitmap(spriteTexture) : default;
}
}
private static byte[] GetRawImageData(IUnityObjectBase asset)
{
return asset switch
{
ITexture2D texture => texture.GetImageData(),
SpriteInformationObject spriteInformationObject => spriteInformationObject.Texture.GetImageData(),
ISprite sprite => sprite.TryGetTexture()?.GetImageData() ?? [],
ITerrainData terrainData => MemoryMarshal.AsBytes(terrainData.Heightmap.Heights.GetSpan()).ToArray(),
_ => [],
};
}
private static bool TryGetImageExtensionFromQuery(HttpContext context, [NotNullWhen(true)] out string? extension, out ImageExportFormat format)
{
if (context.Request.Query.TryGetValue(Extension, out extension))
{
return ImageExportFormatExtensions.TryGetFromExtension(extension, out format);
}
else
{
format = default;
return false;
}
}
#endregion
#region Audio
public static Task GetAudioData(HttpContext context)
{
//Accept Path and Extension in the query.
throw new NotImplementedException();
}
public static bool HasAudioData(IUnityObjectBase asset)
{
throw new NotImplementedException();
}
#endregion
#region Model
public static Task GetModelData(HttpContext context)
{
//Only accept Path in the query.
throw new NotImplementedException();
}
public static bool HasModelData(IUnityObjectBase asset)
{
throw new NotImplementedException();
}
#endregion
#region Font
public static Task GetFontData(HttpContext context)
{
//Only accept Path in the query.
throw new NotImplementedException();
}
public static bool HasFontData(IUnityObjectBase asset)
{
throw new NotImplementedException();
}
#endregion
#region Json
public static Task GetJson(HttpContext context)
{
throw new NotImplementedException();
}
#endregion
#region Yaml
public static Task GetYaml(HttpContext context)
{
throw new NotImplementedException();
}
#endregion
#region Text
public static Task GetText(HttpContext context)
{
//Only accept Path in the query. It sensibly determines the file extension.
throw new NotImplementedException();
}
public static bool HasText(IUnityObjectBase asset)
{
throw new NotImplementedException();
}
#endregion
#region Binary Data
public static Task GetBinaryData(HttpContext context)
{
//Only for RawDataObject. This should not call any of the IUnityAssetBase Write methods.
throw new NotImplementedException();
}
public static bool HasBinaryData(IUnityObjectBase asset)
{
return asset is RawDataObject { RawData.Length: > 0 };
}
#endregion
private static bool TryGetAssetFromQuery(HttpContext context, [NotNullWhen(true)] out IUnityObjectBase? asset, [NotNullWhen(false)] out Task? failureTask)
{
if (!context.Request.Query.TryGetValue(Path, out string? json) || string.IsNullOrEmpty(json))
{
asset = null;
failureTask = context.Response.NotFound("The path must be included in the request.");
return false;
}
AssetPath path;
try
{
path = AssetPath.FromJson(json);
}
catch (Exception ex)
{
asset = null;
failureTask = context.Response.NotFound(ex.ToString());
return false;
}
if (!GameFileLoader.IsLoaded)
{
asset = null;
failureTask = context.Response.NotFound("No files loaded.");
return false;
}
else if (!GameFileLoader.GameBundle.TryGetAsset(path, out asset))
{
failureTask = context.Response.NotFound($"Asset could not be resolved: {path}");
return false;
}
else
{
failureTask = null;
return true;
}
}
}

View File

@ -1,64 +1,34 @@
using AssetRipper.Assets;
using AssetRipper.Export.UnityProjects.Terrains;
using AssetRipper.Export.UnityProjects.Textures;
using AssetRipper.Export.UnityProjects.Utils;
using AssetRipper.Processing.Textures;
using AssetRipper.SourceGenerated.Classes.ClassID_156;
using AssetRipper.SourceGenerated.Classes.ClassID_213;
using AssetRipper.SourceGenerated.Classes.ClassID_28;
using AssetRipper.SourceGenerated.Extensions;
using AssetRipper.TextureDecoder.Rgb.Formats;
using DirectBitmap = AssetRipper.Export.UnityProjects.Utils.DirectBitmap<AssetRipper.TextureDecoder.Rgb.Formats.ColorBGRA32, byte>;
using AssetRipper.GUI.Web.Paths;
namespace AssetRipper.GUI.Web.Pages.Assets;
internal sealed class ImageTab : HtmlTab
{
private readonly DirectBitmap<ColorBGRA32, byte> bitmap;
public override string DisplayName => Localization.AssetTabImage;
public override string HtmlName => "image";
public override bool Enabled => bitmap != default;
public override bool Enabled => AssetAPI.HasImageData(Asset);
public IUnityObjectBase Asset { get; }
public ImageTab(IUnityObjectBase asset)
{
bitmap = GetImageBitmap(asset);
Asset = asset;
}
public override void Write(TextWriter writer)
{
MemoryStream stream = new();
bitmap.SaveAsPng(stream);
string sourcePath = $"data:image/png;base64,{stream.ToArray().ToBase64String()}";
string sourcePath = $"/Assets/Image?{AssetAPI.Path}={Asset.GetPath().ToJson().ToUrl()}&{AssetAPI.Extension}=png";
// Click on image to save
using (new A(writer).WithHref(sourcePath).WithDownload("extracted_image").End())
{
new Img(writer).WithSrc(sourcePath).WithStyle("object-fit:contain; width:100%; height:100%").Close();
}
}
private static DirectBitmap GetImageBitmap(IUnityObjectBase asset)
{
return asset switch
{
ITexture2D texture => TextureToBitmap(texture),
SpriteInformationObject spriteInformationObject => TextureToBitmap(spriteInformationObject.Texture),
ISprite sprite => SpriteToBitmap(sprite),
ITerrainData terrainData => TerrainHeatmapExporter.GetBitmap(terrainData),
_ => default,
};
static DirectBitmap TextureToBitmap(ITexture2D texture)
{
return TextureConverter.TryConvertToBitmap(texture, out DirectBitmap bitmap) ? bitmap : default;
}
static DirectBitmap SpriteToBitmap(ISprite sprite)
{
return sprite.TryGetTexture() is { } spriteTexture ? TextureToBitmap(spriteTexture) : default;
}
// Todo: add a button beneath the image to download its raw data
// https://github.com/AssetRipper/AssetRipper/issues/1298
}
}

View File

@ -6,4 +6,6 @@ internal static class StringExtensions
{
[return: NotNullIfNotNull(nameof(value))]
public static string? ToHtml(this string? value) => WebUtility.HtmlEncode(value);
[return: NotNullIfNotNull(nameof(value))]
public static string? ToUrl(this string? value) => WebUtility.UrlEncode(value);
}

View File

@ -145,6 +145,16 @@ public static class WebApplicationLauncher
app.MapPost("/Resources/View", Pages.Resources.ViewPage.HandlePostRequest);
app.MapPost("/Scenes/View", Pages.Scenes.ViewPage.HandlePostRequest);
//Asset GET API
app.MapGet("/Assets/Image", Pages.Assets.AssetAPI.GetImageData);
app.MapGet("/Assets/Audio", Pages.Assets.AssetAPI.GetAudioData);
app.MapGet("/Assets/Model", Pages.Assets.AssetAPI.GetModelData);
app.MapGet("/Assets/Font", Pages.Assets.AssetAPI.GetFontData);
app.MapGet("/Assets/Json", Pages.Assets.AssetAPI.GetJson);
app.MapGet("/Assets/Yaml", Pages.Assets.AssetAPI.GetYaml);
app.MapGet("/Assets/Text", Pages.Assets.AssetAPI.GetText);
app.MapGet("/Assets/Binary", Pages.Assets.AssetAPI.GetBinaryData);
app.MapPost("/Localization", (context) =>
{
context.Response.DisableCaching();

View File

@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
namespace AssetRipper.Web.Extensions;
public static class QueryCollectionExtensions
{
public static bool TryGetValue(this IQueryCollection query, string key, [NotNullWhen(true)] out string? value)
{
if (query.TryGetValue(key, out StringValues values))
{
value = values.ToString();
return true;
}
else
{
value = null;
return false;
}
}
}