Update AssetDatabase

This commit is contained in:
2026-01-27 14:39:00 +09:00
parent b505c7c1c0
commit 8a5795069f
23 changed files with 3135 additions and 53 deletions

View File

@@ -0,0 +1,363 @@
using Ghost.Core;
namespace Ghost.Editor.Core.AssetHandle;
public static partial class AssetDatabase
{
/// <summary>
/// Create a new asset at the specified path.
/// Generates metadata and adds it to the database.
/// </summary>
/// <param name="assetPath">Path to create the asset at.</param>
/// <param name="content">Content to write to the asset file.</param>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> CreateAssetAsync(string assetPath, byte[] content)
{
if (AssetsDirectory == null)
{
return Result.Failure("AssetsDirectory not initialized");
}
if (!assetPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result.Failure("Asset path must be within the Assets directory");
}
if (File.Exists(assetPath))
{
return Result.Failure("Asset already exists");
}
try
{
var directory = Path.GetDirectoryName(assetPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllBytesAsync(assetPath, content);
// GenerateMetaFileAsync will be called automatically by the file watcher
// But we'll call it directly to ensure it's created immediately
await GenerateMetaFileAsync(assetPath);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to create asset: {ex.Message}");
}
}
/// <summary>
/// Create an empty asset at the specified path.
/// Generates metadata and adds it to the database.
/// </summary>
/// <param name="assetPath">Path to create the asset at.</param>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> CreateAssetAsync(string assetPath)
{
return await CreateAssetAsync(assetPath, Array.Empty<byte>());
}
/// <summary>
/// Delete an asset and its metadata.
/// </summary>
/// <param name="guid">GUID of the asset to delete.</param>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> DeleteAssetAsync(Guid guid)
{
var pathResult = GuidToPath(guid);
if (pathResult.IsFailure)
{
return Result.Failure(pathResult.Message);
}
var fullPathResult = GetFullPath(pathResult.Value);
if (fullPathResult.IsFailure)
{
return Result.Failure(fullPathResult.Message);
}
try
{
var assetPath = fullPathResult.Value;
// Delete the asset file
if (File.Exists(assetPath))
{
File.Delete(assetPath);
}
// Delete the .gmeta file
var metaPath = assetPath + Utilities.FileExtensions.META_FILE_EXTENSION;
if (File.Exists(metaPath))
{
File.Delete(metaPath);
}
// Remove from database
await RemoveAssetFromDatabaseAsync(guid);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to delete asset: {ex.Message}");
}
}
/// <summary>
/// Delete an asset and its metadata by path.
/// </summary>
/// <param name="assetPath">Path to the asset to delete.</param>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> DeleteAssetAsync(string assetPath)
{
var guidResult = PathToGuid(assetPath);
if (guidResult.IsFailure)
{
return Result.Failure(guidResult.Message);
}
return await DeleteAssetAsync(guidResult.Value);
}
/// <summary>
/// Move an asset to a new location.
/// </summary>
/// <param name="guid">GUID of the asset to move.</param>
/// <param name="newPath">New path for the asset (relative or absolute).</param>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> MoveAssetAsync(Guid guid, string newPath)
{
var oldPathResult = GuidToPath(guid);
if (oldPathResult.IsFailure)
{
return Result.Failure(oldPathResult.Message);
}
var oldFullPathResult = GetFullPath(oldPathResult.Value);
if (oldFullPathResult.IsFailure)
{
return Result.Failure(oldFullPathResult.Message);
}
if (AssetsDirectory == null)
{
return Result.Failure("AssetsDirectory not initialized");
}
// Ensure new path is absolute and within assets directory
if (!Path.IsPathRooted(newPath))
{
newPath = Path.Combine(AssetsDirectory.FullName, newPath);
}
if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result.Failure("New path must be within the Assets directory");
}
if (File.Exists(newPath))
{
return Result.Failure("A file already exists at the new path");
}
try
{
var directory = Path.GetDirectoryName(newPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// Read metadata and calculate hash before moving
var metaResult = await ReadMetaFileAsync(oldFullPathResult.Value);
if (metaResult.IsFailure)
{
return Result.Failure(metaResult.Message);
}
var fileHash = await CalculateFileHashAsync(oldFullPathResult.Value);
// Temporarily disable file watcher to prevent race conditions
var watcherWasEnabled = s_watcher?.EnableRaisingEvents ?? false;
if (s_watcher != null)
{
s_watcher.EnableRaisingEvents = false;
}
try
{
// Move the asset file
File.Move(oldFullPathResult.Value, newPath);
// Move the .gmeta file
var oldMetaPath = oldFullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION;
var newMetaPath = newPath + Utilities.FileExtensions.META_FILE_EXTENSION;
if (File.Exists(oldMetaPath))
{
File.Move(oldMetaPath, newMetaPath);
}
// Update database with new path (hash remains the same since content didn't change)
await UpsertAssetAsync(newPath, metaResult.Value, fileHash);
}
finally
{
// Re-enable file watcher
if (s_watcher != null && watcherWasEnabled)
{
s_watcher.EnableRaisingEvents = true;
}
}
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to move asset: {ex.Message}");
}
}
/// <summary>
/// Move an asset to a new location by path.
/// </summary>
/// <param name="oldPath">Current path of the asset.</param>
/// <param name="newPath">New path for the asset (relative or absolute).</param>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> MoveAssetAsync(string oldPath, string newPath)
{
var guidResult = PathToGuid(oldPath);
if (guidResult.IsFailure)
{
return Result.Failure(guidResult.Message);
}
return await MoveAssetAsync(guidResult.Value, newPath);
}
/// <summary>
/// Copy an asset to a new location with a new GUID.
/// </summary>
/// <param name="guid">GUID of the asset to copy.</param>
/// <param name="newPath">New path for the copied asset (relative or absolute).</param>
/// <returns>Result containing the new asset's GUID.</returns>
public static async Task<Result<Guid>> CopyAssetAsync(Guid guid, string newPath)
{
var oldPathResult = GuidToPath(guid);
if (oldPathResult.IsFailure)
{
return Result<Guid>.Failure(oldPathResult.Message);
}
var oldFullPathResult = GetFullPath(oldPathResult.Value);
if (oldFullPathResult.IsFailure)
{
return Result<Guid>.Failure(oldFullPathResult.Message);
}
if (AssetsDirectory == null)
{
return Result<Guid>.Failure("AssetsDirectory not initialized");
}
// Ensure new path is absolute and within assets directory
if (!Path.IsPathRooted(newPath))
{
newPath = Path.Combine(AssetsDirectory.FullName, newPath);
}
if (!newPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result<Guid>.Failure("New path must be within the Assets directory");
}
if (File.Exists(newPath))
{
return Result<Guid>.Failure("A file already exists at the new path");
}
try
{
var directory = Path.GetDirectoryName(newPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.Copy(oldFullPathResult.Value, newPath);
// Generate new metadata with new GUID
await GenerateMetaFileAsync(newPath);
// Get the new GUID
var newGuidResult = PathToGuid(newPath);
if (newGuidResult.IsFailure)
{
return Result<Guid>.Failure(newGuidResult.Message);
}
return newGuidResult.Value;
}
catch (Exception ex)
{
return Result<Guid>.Failure($"Failed to copy asset: {ex.Message}");
}
}
/// <summary>
/// Copy an asset to a new location by path.
/// </summary>
/// <param name="sourcePath">Path of the asset to copy.</param>
/// <param name="destPath">New path for the copied asset (relative or absolute).</param>
/// <returns>Result containing the new asset's GUID.</returns>
public static async Task<Result<Guid>> CopyAssetAsync(string sourcePath, string destPath)
{
var guidResult = PathToGuid(sourcePath);
if (guidResult.IsFailure)
{
return Result<Guid>.Failure(guidResult.Message);
}
return await CopyAssetAsync(guidResult.Value, destPath);
}
/// <summary>
/// Mark an asset as dirty for re-importing.
/// </summary>
/// <param name="guid">GUID of the asset to mark dirty.</param>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> MarkDirtyAsync(Guid guid)
{
return await MarkAssetDirtyAsync(guid, true);
}
/// <summary>
/// Import all dirty assets.
/// </summary>
/// <returns>Result indicating success or failure.</returns>
public static async Task<Result> ImportDirtyAssetsAsync()
{
var dirtyAssets = await GetDirtyAssetsAsync();
foreach (var (guid, path) in dirtyAssets)
{
var fullPathResult = GetFullPath(path);
if (fullPathResult.IsFailure)
{
continue;
}
var result = await ImportAssetAsync(fullPathResult.Value);
if (result.IsSuccess)
{
await MarkAssetDirtyAsync(guid, false);
}
}
return Result.Success();
}
}

View File

@@ -0,0 +1,158 @@
using Ghost.Core;
using System.Reflection;
namespace Ghost.Editor.Core.AssetHandle;
public static partial class AssetDatabase
{
private static readonly Dictionary<Type, object> s_importerInstances = new();
/// <summary>
/// Import an asset at the specified path.
/// </summary>
/// <param name="assetPath">Full path to the asset file.</param>
/// <returns>Result indicating success or failure.</returns>
private static async Task<Result> ImportAssetAsync(string assetPath)
{
var extension = Path.GetExtension(assetPath);
if (!s_importerTypeLookup.TryGetValue(extension, out var importerType))
{
// No importer registered for this file type
return Result.Success();
}
// Get or create importer instance
if (!s_importerInstances.TryGetValue(importerType, out var importerInstance))
{
importerInstance = Activator.CreateInstance(importerType);
if (importerInstance == null)
{
return Result.Failure($"Failed to create importer instance for type {importerType.Name}");
}
s_importerInstances[importerType] = importerInstance;
}
// Read metadata
var metaResult = await ReadMetaFileAsync(assetPath);
if (metaResult.IsFailure)
{
return Result.Failure($"Failed to read asset metadata: {metaResult.Message}");
}
// Find and invoke the ImportAsync method
var importMethod = importerType.GetMethod("ImportAsync", BindingFlags.Public | BindingFlags.Instance);
if (importMethod == null)
{
return Result.Failure($"ImportAsync method not found on importer {importerType.Name}");
}
try
{
var task = importMethod.Invoke(importerInstance, new object[] { assetPath, metaResult.Value }) as Task<Result>;
if (task == null)
{
return Result.Failure("Importer did not return a valid Task<Result>");
}
var result = await task;
return result;
}
catch (Exception ex)
{
return Result.Failure($"Asset import failed: {ex.Message}");
}
}
/// <summary>
/// Get the importer type for a specific file extension.
/// </summary>
/// <param name="extension">File extension (e.g., ".png").</param>
/// <returns>The importer type if found, otherwise null.</returns>
public static Type? GetImporterType(string extension)
{
s_importerTypeLookup.TryGetValue(extension, out var importerType);
return importerType;
}
/// <summary>
/// Get all registered importer types and their supported extensions.
/// </summary>
/// <returns>Dictionary mapping extensions to importer types.</returns>
public static Dictionary<string, Type> GetAllImporters()
{
return new Dictionary<string, Type>(s_importerTypeLookup);
}
/// <summary>
/// Export in-memory asset data to disk.
/// The importer will serialize the data into a format it can later import.
/// </summary>
/// <typeparam name="T">Type of asset data to export.</typeparam>
/// <param name="assetPath">Full path where the asset should be saved.</param>
/// <param name="assetData">In-memory asset data to export.</param>
/// <returns>Result with the GUID of the exported asset.</returns>
public static async Task<Result<Guid>> ExportAssetAsync<T>(string assetPath, T assetData) where T : class
{
var extension = Path.GetExtension(assetPath);
if (!s_importerTypeLookup.TryGetValue(extension, out var importerType))
{
return Result<Guid>.Failure($"No importer registered for extension {extension}");
}
// Get or create importer instance
if (!s_importerInstances.TryGetValue(importerType, out var importerInstance))
{
importerInstance = Activator.CreateInstance(importerType);
if (importerInstance == null)
{
return Result<Guid>.Failure($"Failed to create importer instance for type {importerType.Name}");
}
s_importerInstances[importerType] = importerInstance;
}
// Find and invoke the ExportAsync method
var exportMethod = importerType.GetMethod("ExportAsync", BindingFlags.Public | BindingFlags.Instance);
if (exportMethod == null)
{
return Result<Guid>.Failure($"ExportAsync method not found on importer {importerType.Name}. This importer does not support exporting.");
}
try
{
// Generate metadata for the new asset
await GenerateMetaFileAsync(assetPath);
var metaResult = await ReadMetaFileAsync(assetPath);
if (metaResult.IsFailure)
{
return Result<Guid>.Failure($"Failed to generate metadata: {metaResult.Message}");
}
var task = exportMethod.Invoke(importerInstance, new object[] { assetPath, assetData, metaResult.Value }) as Task<Result>;
if (task == null)
{
return Result<Guid>.Failure("Exporter did not return a valid Task<Result>");
}
var result = await task;
if (result.IsFailure)
{
return Result<Guid>.Failure(result.Message);
}
// Calculate file hash and update database
var fileHash = await CalculateFileHashAsync(assetPath);
await UpsertAssetAsync(assetPath, metaResult.Value, fileHash);
return metaResult.Value.Guid;
}
catch (Exception ex)
{
return Result<Guid>.Failure($"Asset export failed: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,242 @@
using Ghost.Core;
using Ghost.Data.Services;
using System.Collections.Concurrent;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
public static partial class AssetDatabase
{
// Asset cache - stores loaded assets by GUID
private static readonly ConcurrentDictionary<Guid, Asset> s_assetCache = new();
// LRU tracking - stores access time for each cached asset
private static readonly ConcurrentDictionary<Guid, DateTime> s_assetAccessTime = new();
// Maximum number of cached assets before eviction starts
private const int MAX_CACHED_ASSETS = 1000;
// Percentage of cache to evict when limit is reached (evict oldest 20%)
private const float CACHE_EVICTION_PERCENTAGE = 0.2f;
/// <summary>
/// Get the path to the imported asset data directory.
/// </summary>
private static Result<string> GetImportedAssetsDirectory()
{
if (AssetsDirectory == null)
{
return Result<string>.Failure("AssetsDirectory not initialized");
}
var cacheDir = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "ImportedAssets");
if (!Directory.Exists(cacheDir))
{
Directory.CreateDirectory(cacheDir);
}
return cacheDir;
}
/// <summary>
/// Get the path where imported asset data is stored for a specific GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>Full path to the imported asset data file.</returns>
private static Result<string> GetImportedAssetPath(Guid guid)
{
var importedDirResult = GetImportedAssetsDirectory();
if (importedDirResult.IsFailure)
{
return Result<string>.Failure(importedDirResult.Message);
}
// Store imported assets as {GUID}.asset
var assetDataPath = Path.Combine(importedDirResult.Value, $"{guid}.asset");
return assetDataPath;
}
/// <summary>
/// Load asset by GUID with caching (internal implementation).
/// </summary>
/// <typeparam name="T">Type of asset to load.</typeparam>
/// <param name="guid">GUID of the asset.</param>
/// <returns>The loaded asset.</returns>
private static Result<T> LoadAssetInternal<T>(Guid guid) where T : Asset
{
// Check cache first
if (s_assetCache.TryGetValue(guid, out var cachedAsset))
{
// Update access time for LRU
s_assetAccessTime[guid] = DateTime.UtcNow;
if (cachedAsset is T typedAsset)
{
return typedAsset;
}
else
{
return Result<T>.Failure($"Cached asset is of type {cachedAsset.GetType().Name}, expected {typeof(T).Name}");
}
}
// Asset not in cache, load from disk
var assetPathResult = GetImportedAssetPath(guid);
if (assetPathResult.IsFailure)
{
return Result<T>.Failure(assetPathResult.Message);
}
var assetDataPath = assetPathResult.Value;
if (!File.Exists(assetDataPath))
{
return Result<T>.Failure($"Imported asset data not found at {assetDataPath}. Asset may not have been imported yet.");
}
try
{
// Read and deserialize asset data
var json = File.ReadAllText(assetDataPath);
var asset = JsonSerializer.Deserialize<T>(json);
if (asset == null)
{
return Result<T>.Failure("Failed to deserialize asset data");
}
// Add to cache
CacheAsset(guid, asset);
return asset;
}
catch (Exception ex)
{
return Result<T>.Failure($"Failed to load asset: {ex.Message}");
}
}
/// <summary>
/// Load asset by path with caching.
/// </summary>
/// <typeparam name="T">Type of asset to load.</typeparam>
/// <param name="assetPath">Full or relative path to the asset.</param>
/// <returns>The loaded asset.</returns>
public static Result<T> LoadAssetAtPath<T>(string assetPath) where T : Asset
{
var guidResult = PathToGuid(assetPath);
if (guidResult.IsFailure)
{
return Result<T>.Failure(guidResult.Message);
}
return LoadAsset<T>(guidResult.Value);
}
/// <summary>
/// Add an asset to the cache with LRU eviction if needed.
/// </summary>
private static void CacheAsset(Guid guid, Asset asset)
{
// Check if we need to evict old assets
if (s_assetCache.Count >= MAX_CACHED_ASSETS)
{
EvictOldestAssets();
}
s_assetCache[guid] = asset;
s_assetAccessTime[guid] = DateTime.UtcNow;
}
/// <summary>
/// Evict the oldest assets from cache based on LRU.
/// </summary>
private static void EvictOldestAssets()
{
var evictionCount = (int)(MAX_CACHED_ASSETS * CACHE_EVICTION_PERCENTAGE);
// Sort by access time and remove oldest entries
var oldestAssets = s_assetAccessTime
.OrderBy(kvp => kvp.Value)
.Take(evictionCount)
.Select(kvp => kvp.Key)
.ToList();
foreach (var guid in oldestAssets)
{
s_assetCache.TryRemove(guid, out _);
s_assetAccessTime.TryRemove(guid, out _);
}
}
/// <summary>
/// Unload a specific asset from cache.
/// </summary>
/// <param name="guid">GUID of the asset to unload.</param>
public static void UnloadAsset(Guid guid)
{
s_assetCache.TryRemove(guid, out _);
s_assetAccessTime.TryRemove(guid, out _);
}
/// <summary>
/// Unload all assets from cache.
/// </summary>
public static void UnloadAllAssets()
{
s_assetCache.Clear();
s_assetAccessTime.Clear();
}
/// <summary>
/// Check if an asset is currently loaded in cache.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>True if the asset is in cache.</returns>
public static bool IsAssetLoaded(Guid guid)
{
return s_assetCache.ContainsKey(guid);
}
/// <summary>
/// Get cache statistics.
/// </summary>
/// <returns>Tuple of (current cache size, max cache size).</returns>
public static (int currentSize, int maxSize) GetCacheStats()
{
return (s_assetCache.Count, MAX_CACHED_ASSETS);
}
/// <summary>
/// Save an imported asset to disk for later loading.
/// This should be called by importers after processing the source file.
/// </summary>
/// <typeparam name="T">Type of asset data.</typeparam>
/// <param name="guid">GUID of the asset.</param>
/// <param name="assetData">Processed asset data to save.</param>
/// <returns>Result indicating success or failure.</returns>
internal static Result SaveImportedAsset<T>(Guid guid, T assetData) where T : Asset
{
var assetPathResult = GetImportedAssetPath(guid);
if (assetPathResult.IsFailure)
{
return Result.Failure(assetPathResult.Message);
}
try
{
var json = JsonSerializer.Serialize(assetData, s_defaultJsonOptions);
File.WriteAllText(assetPathResult.Value, json);
// Invalidate cache for this asset so it gets reloaded next time
UnloadAsset(guid);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to save imported asset: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,203 @@
using Ghost.Core;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
public static partial class AssetDatabase
{
/// <summary>
/// Get the relative path from the assets directory.
/// </summary>
private static Result<string> GetRelativePath(string fullPath)
{
if (AssetsDirectory == null)
{
return Result<string>.Failure("AssetsDirectory not initialized");
}
if (!fullPath.StartsWith(AssetsDirectory.FullName, StringComparison.OrdinalIgnoreCase))
{
return Result<string>.Failure("Path is not within assets directory");
}
return Path.GetRelativePath(AssetsDirectory.FullName, fullPath);
}
/// <summary>
/// Get the full path from a relative path.
/// </summary>
private static Result<string> GetFullPath(string relativePath)
{
if (AssetsDirectory == null)
{
return Result<string>.Failure("AssetsDirectory not initialized");
}
return Path.Combine(AssetsDirectory.FullName, relativePath);
}
/// <summary>
/// Find GUID by asset path.
/// </summary>
/// <param name="assetPath">Full or relative path to the asset.</param>
/// <returns>The GUID of the asset if found.</returns>
public static Result<Guid> PathToGuid(string assetPath)
{
var relativePath = assetPath;
// Convert to relative path if it's a full path
if (Path.IsPathRooted(assetPath))
{
var relResult = GetRelativePath(assetPath);
if (relResult.IsFailure)
{
return Result<Guid>.Failure(relResult.Message);
}
relativePath = relResult.Value;
}
// Normalize path separators
relativePath = relativePath.Replace('\\', '/');
lock (s_dbLock)
{
if (s_pathAssetLookup.TryGetValue(relativePath, out var guid))
{
return guid;
}
}
return Result<Guid>.Failure("Asset not found in database");
}
/// <summary>
/// Find path by GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>The relative path to the asset if found.</returns>
public static Result<string> GuidToPath(Guid guid)
{
lock (s_dbLock)
{
if (s_assetPathLookup.TryGetValue(guid, out var path))
{
return path;
}
}
return Result<string>.Failure("Asset GUID not found in database");
}
/// <summary>
/// Load asset by GUID with caching.
/// </summary>
/// <typeparam name="T">Type of asset to load.</typeparam>
/// <param name="guid">GUID of the asset.</param>
/// <returns>The loaded asset.</returns>
public static Result<T> LoadAsset<T>(Guid guid) where T : Asset
{
// Implemented in AssetDatabase.Loader.cs
return LoadAssetInternal<T>(guid);
}
/// <summary>
/// Get asset tags by GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <returns>List of tags associated with the asset.</returns>
public static async ValueTask<Result<List<string>>> GetAssetTagsAsync(Guid guid, CancellationToken token = default)
{
var pathResult = GuidToPath(guid);
if (pathResult.IsFailure)
{
return Result<List<string>>.Failure(pathResult.Message);
}
var fullPathResult = GetFullPath(pathResult.Value);
if (fullPathResult.IsFailure)
{
return Result<List<string>>.Failure(fullPathResult.Message);
}
var metaResult = await ReadMetaFileAsync(fullPathResult.Value);
if (metaResult.IsFailure)
{
return Result<List<string>>.Failure(metaResult.Message);
}
return metaResult.Value.Tags;
}
/// <summary>
/// Set asset tags by GUID.
/// </summary>
/// <param name="guid">GUID of the asset.</param>
/// <param name="tags">New tags for the asset.</param>
/// <returns>Result indicating success or failure.</returns>
public static async ValueTask<Result> SetAssetTagsAsync(Guid guid, List<string> tags)
{
var pathResult = GuidToPath(guid);
if (pathResult.IsFailure)
{
return Result.Failure(pathResult.Message);
}
var fullPathResult = GetFullPath(pathResult.Value);
if (fullPathResult.IsFailure)
{
return Result.Failure(fullPathResult.Message);
}
var metaResult = await ReadMetaFileAsync(fullPathResult.Value);
if (metaResult.IsFailure)
{
return Result.Failure(metaResult.Message);
}
metaResult.Value.Tags = tags;
// Write updated metadata to .gmeta file
var writeResult = await WriteMetaFileAsync(fullPathResult.Value + Utilities.FileExtensions.META_FILE_EXTENSION, metaResult.Value);
if (writeResult.IsFailure)
{
return writeResult;
}
// Update database with new tags
var fileHash = await CalculateFileHashAsync(fullPathResult.Value);
return await UpsertAssetAsync(fullPathResult.Value, metaResult.Value, fileHash);
}
/// <summary>
/// Search assets by name pattern.
/// Supports SQL LIKE wildcards: * (any characters) and ? (single character).
/// </summary>
/// <param name="namePattern">Search pattern (e.g., "*.txt", "player?", "test*").</param>
/// <returns>List of matching asset GUIDs.</returns>
public static async Task<List<Guid>> FindAssetsByNameAsync(string namePattern)
{
return await GetAssetsByNameAsync(namePattern);
}
/// <summary>
/// Find assets by tag.
/// </summary>
/// <param name="tag">Tag to search for.</param>
/// <returns>List of asset GUIDs with the specified tag.</returns>
public static async Task<List<Guid>> FindAssetsByTagAsync(string tag)
{
return await GetAssetsByTagAsync(tag);
}
/// <summary>
/// Get all assets in the database.
/// </summary>
/// <returns>Dictionary mapping GUIDs to relative paths.</returns>
public static IReadOnlyDictionary<Guid, string> GetAllAssets()
{
lock (s_dbLock)
{
return s_assetPathLookup.AsReadOnly();
}
}
}

View File

@@ -1,6 +1,8 @@
using Ghost.Core;
using Ghost.Editor.Core.Utilities;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
@@ -8,8 +10,6 @@ namespace Ghost.Editor.Core.AssetHandle;
public static partial class AssetDatabase
{
private static readonly Dictionary<string, Type> s_importerTypeLookup = new();
private static readonly Dictionary<Guid, string> s_assetPathLookup = new();
private static readonly Dictionary<string, Guid> s_pathAssetLookup = new();
private static void InitializeMetaData()
{
@@ -31,18 +31,19 @@ public static partial class AssetDatabase
s_watcher.Created += OnAssetCreated;
s_watcher.Deleted += OnAssetDeleted;
s_watcher.Renamed += OnAssetRenamed;
s_watcher.Changed += OnAssetChanged;
}
private static Result<string, Error> GetMetaFilePath(string assetPath)
private static Result<string> GetMetaFilePath(string assetPath)
{
if (Directory.Exists(assetPath))
{
return Error.NotFound;
return Result<string>.Failure("Cannot create metadata for directories");
}
if (Path.GetExtension(assetPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return Error.InvalidState;
return Result<string>.Failure("Cannot create metadata for metadata files");
}
return assetPath + FileExtensions.META_FILE_EXTENSION;
@@ -66,105 +67,294 @@ public static partial class AssetDatabase
return null;
}
private static async Task<Result> WriteMetaFileAsync(string metaFilePath, AssetMeta metaData)
/// <summary>
/// Calculate SHA256 hash of a file for change detection.
/// </summary>
private static async Task<string> CalculateFileHashAsync(string filePath)
{
using var fileStream = File.Create(metaFilePath);
try
{
await JsonSerializer.SerializeAsync(fileStream, metaData);
await using var stream = File.OpenRead(filePath);
var hash = await SHA256.HashDataAsync(stream);
return Convert.ToHexString(hash);
}
catch
{
return string.Empty;
}
}
private static async Task<Result> WriteMetaFileAsync(string metaFilePath, AssetMeta metaData)
{
try
{
await using var fileStream = File.Create(metaFilePath);
await JsonSerializer.SerializeAsync(fileStream, metaData, s_defaultJsonOptions);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure(ex.Message);
}
return Result.Success();
}
internal static async Task<Result> GenerateMetaFileAsync(string assetPath)
/// <summary>
/// Read metadata from a .gmeta file.
/// </summary>
private static async ValueTask<Result<AssetMeta>> ReadMetaFileAsync(string assetPath, CancellationToken token = default)
{
var metaFileResult = GetMetaFilePath(assetPath);
if (metaFileResult.IsFailure)
{
return Result<AssetMeta>.Failure(metaFileResult.Message);
}
if (!File.Exists(metaFileResult.Value))
{
return Result<AssetMeta>.Failure("Metadata file does not exist");
}
try
{
await using var fileStream = File.OpenRead(metaFileResult.Value);
var meta = await JsonSerializer.DeserializeAsync<AssetMeta>(fileStream, s_defaultJsonOptions, token);
if (meta == null)
{
return Result<AssetMeta>.Failure("Failed to deserialize metadata");
}
return meta;
}
catch (Exception ex)
{
return Result<AssetMeta>.Failure($"Failed to read metadata: {ex.Message}");
}
}
internal static async ValueTask<Result> GenerateMetaFileAsync(string assetPath, CancellationToken token = default)
{
Result r;
var metaFileResult = GetMetaFilePath(assetPath);
if (metaFileResult.IsFailure)
{
return Result.Failure(metaFileResult.Error);
return Result.Failure(metaFileResult.Message);
}
if (File.Exists(metaFileResult.Value))
{
using var fileStream = File.OpenRead(metaFileResult.Value);
var existingMeta = await JsonSerializer.DeserializeAsync<AssetMeta>(fileStream);
if (existingMeta != null && s_assetPathLookup.TryGetValue(existingMeta.Guid, out var path))
var existingMetaResult = await ReadMetaFileAsync(assetPath);
if (existingMetaResult.IsSuccess)
{
if (assetPath != path)
var existingMeta = existingMetaResult.Value;
if (s_assetPathLookup.TryGetValue(existingMeta.Guid, out var path))
{
existingMeta.Guid = Guid.NewGuid();
r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta);
if (r.IsFailure)
var relResult = GetRelativePath(assetPath);
if (relResult.IsSuccess && assetPath != path)
{
return r;
// GUID conflict - regenerate
existingMeta.Guid = Guid.NewGuid();
r = await WriteMetaFileAsync(metaFileResult.Value, existingMeta);
if (r.IsFailure)
{
return r;
}
}
}
}
return Result.Success();
// Calculate file hash and update database
var fileHash = await CalculateFileHashAsync(assetPath);
await UpsertAssetAsync(assetPath, existingMeta, fileHash);
return Result.Success();
}
}
// Calculate initial file hash
var fileHash2 = await CalculateFileHashAsync(assetPath);
var defaultSettings = GetDefaultSettingsForAsset(assetPath);
var metaData = new AssetMeta
{
Guid = Guid.NewGuid(),
Settings = defaultSettings
Guid = Guid.NewGuid()
};
if (defaultSettings != null)
{
metaData.SetImporterSettings(defaultSettings.GetType().Name, defaultSettings);
}
r = await WriteMetaFileAsync(metaFileResult.Value, metaData);
if (r.IsFailure)
{
return r;
}
// Add to database
await UpsertAssetAsync(assetPath, metaData, fileHash2);
return r;
}
private static async void OnAssetCreated(object sender, FileSystemEventArgs e)
{
// Skip meta files
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return;
}
// Debounce to prevent duplicate events
if (!ShouldProcessFileOperation(e.FullPath))
{
return;
}
await GenerateMetaFileAsync(e.FullPath);
}
private static void OnAssetDeleted(object sender, FileSystemEventArgs e)
private static async void OnAssetDeleted(object sender, FileSystemEventArgs e)
{
// Skip meta files
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return;
}
// Debounce to prevent duplicate events
if (!ShouldProcessFileOperation(e.FullPath))
{
return;
}
var metaFileResult = GetMetaFilePath(e.FullPath);
if (metaFileResult.IsSuccess && File.Exists(metaFileResult.Value))
{
try
{
var meta = JsonSerializer.Deserialize<AssetMeta>(File.ReadAllText(metaFileResult.Value));
if (meta != null
&& s_assetPathLookup.TryGetValue(meta.Guid, out var path)
&& path == e.FullPath)
var metaResult = await ReadMetaFileAsync(e.FullPath);
if (metaResult.IsSuccess)
{
s_assetPathLookup.Remove(meta.Guid);
var meta = metaResult.Value;
// Remove from database
await RemoveAssetFromDatabaseAsync(meta.Guid);
// Mark dependent assets as dirty
await MarkDependentAssetsDirtyAsync(meta.Guid);
}
File.Delete(metaFileResult.Value);
}
catch (Exception ex)
{
Logger.LogError(ex);
Console.WriteLine($"Error deleting asset metadata: {ex.Message}");
}
}
}
private static async void OnAssetRenamed(object sender, RenamedEventArgs e)
{
// Skip meta files
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return;
}
// Debounce to prevent duplicate events
if (!ShouldProcessFileOperation(e.FullPath))
{
return;
}
var oldMetaPath = e.OldFullPath + FileExtensions.META_FILE_EXTENSION;
var newMetaPath = e.FullPath + FileExtensions.META_FILE_EXTENSION;
if (File.Exists(oldMetaPath))
if (File.Exists(newMetaPath))
{
// Validate and update
await GenerateMetaFileAsync(e.FullPath);
}
else if (File.Exists(oldMetaPath))
{
// Move meta file
File.Move(oldMetaPath, newMetaPath);
// Update database with new path and recalculated hash
var metaResult = await ReadMetaFileAsync(e.FullPath);
if (metaResult.IsSuccess)
{
var fileHash = await CalculateFileHashAsync(e.FullPath);
await UpsertAssetAsync(e.FullPath, metaResult.Value, fileHash);
}
}
else
{
// Generate new meta file
await GenerateMetaFileAsync(e.FullPath);
}
// Delete old meta if it still exists
if (File.Exists(oldMetaPath) && oldMetaPath != newMetaPath)
{
try
{
File.Delete(oldMetaPath);
}
catch
{
// Ignore
}
}
}
private static async void OnAssetChanged(object sender, FileSystemEventArgs e)
{
// Skip meta files
if (Path.GetExtension(e.FullPath).Equals(FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
return;
}
// Debounce to prevent duplicate events
if (!ShouldProcessFileOperation(e.FullPath))
{
return;
}
// Check if file hash changed
var metaResult = await ReadMetaFileAsync(e.FullPath);
if (metaResult.IsFailure)
{
return;
}
// Calculate new hash and compare against database
var newHash = await CalculateFileHashAsync(e.FullPath);
var oldHash = await GetFileHashAsync(metaResult.Value.Guid);
if (oldHash != newHash)
{
// File changed - update database and mark as dirty
await UpsertAssetAsync(e.FullPath, metaResult.Value, newHash);
await MarkAssetDirtyAsync(metaResult.Value.Guid, true);
}
}
/// <summary>
/// Mark all assets that depend on the specified asset as dirty.
/// </summary>
private static async Task MarkDependentAssetsDirtyAsync(Guid assetGuid)
{
// Query database for all assets and check their dependencies
var allAssets = GetAllAssets();
foreach (var kvp in allAssets)
{
var dependencies = await GetDependenciesAsync(kvp.Key);
if (dependencies.Contains(assetGuid))
{
await MarkAssetDirtyAsync(kvp.Key, true);
}
}
}
}

View File

@@ -0,0 +1,452 @@
using Ghost.Core;
using Ghost.Data.Services;
using Microsoft.Data.Sqlite;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle;
public static partial class AssetDatabase
{
private static SqliteConnection? s_dbConnection;
/// <summary>
/// Initialize the SQLite database for asset caching.
/// </summary>
private static async Task InitializeDatabaseAsync()
{
if (AssetsDirectory == null)
{
throw new InvalidOperationException("AssetsDirectory is not set. Initialize() must be called first.");
}
var dbPath = Path.Combine(AssetsDirectory.Parent!.FullName, ProjectService.CACHE_FOLDER, "AssetDatabase.db");
var cacheDir = Path.GetDirectoryName(dbPath);
if (!Directory.Exists(cacheDir))
{
Directory.CreateDirectory(cacheDir!);
}
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared
}.ToString();
s_dbConnection = new SqliteConnection(connectionString);
await s_dbConnection.OpenAsync();
// Create tables
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS Assets (
Guid TEXT PRIMARY KEY,
Path TEXT NOT NULL,
Version INTEGER NOT NULL,
Tags TEXT,
FileHash TEXT,
DependencyGuids TEXT,
IsDirty INTEGER NOT NULL DEFAULT 0,
LastModified INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_path ON Assets(Path);
CREATE INDEX IF NOT EXISTS idx_dirty ON Assets(IsDirty);
";
await cmd.ExecuteNonQueryAsync();
}
/// <summary>
/// Add or update an asset in the database.
/// </summary>
/// <param name="assetPath">Full path to the asset file.</param>
/// <param name="meta">Asset metadata from .gmeta file.</param>
/// <param name="fileHash">SHA256 hash of the asset file content.</param>
/// <param name="dependencies">List of GUIDs this asset depends on (extracted during import).</param>
private static async ValueTask<Result> UpsertAssetAsync(string assetPath, AssetMeta meta, string fileHash, List<Guid>? dependencies = null, CancellationToken token = default)
{
if (s_dbConnection == null)
{
return Result.Failure("Database not initialized");
}
var relativePath = GetRelativePath(assetPath);
if (relativePath.IsFailure)
{
return Result.Failure(relativePath.Message);
}
try
{
lock (s_dbLock)
{
// If this GUID already exists with a different path, remove the old path mapping
if (s_assetPathLookup.TryGetValue(meta.Guid, out var oldPath) && oldPath != relativePath.Value)
{
s_pathAssetLookup.Remove(oldPath);
}
// Update lookups with new path (normalize path separators for consistency)
var normalizedPath = relativePath.Value.Replace('\\', '/');
s_assetPathLookup[meta.Guid] = normalizedPath;
s_pathAssetLookup[normalizedPath] = meta.Guid;
}
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = @"
INSERT OR REPLACE INTO Assets (Guid, Path, Version, Tags, FileHash, DependencyGuids, LastModified)
VALUES (@guid, @path, @version, @tags, @fileHash, @deps, @modified)
";
cmd.Parameters.AddWithValue("@guid", meta.Guid.ToString());
cmd.Parameters.AddWithValue("@path", relativePath.Value);
cmd.Parameters.AddWithValue("@version", meta.Version);
cmd.Parameters.AddWithValue("@tags", JsonSerializer.Serialize(meta.Tags));
cmd.Parameters.AddWithValue("@fileHash", fileHash);
cmd.Parameters.AddWithValue("@deps", JsonSerializer.Serialize(dependencies ?? new List<Guid>()));
cmd.Parameters.AddWithValue("@modified", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
await cmd.ExecuteNonQueryAsync(token);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to upsert asset: {ex.Message}");
}
}
/// <summary>
/// Remove an asset from the database.
/// </summary>
private static async Task<Result> RemoveAssetFromDatabaseAsync(Guid guid)
{
if (s_dbConnection == null)
{
return Result.Failure("Database not initialized");
}
try
{
lock (s_dbLock)
{
if (s_assetPathLookup.TryGetValue(guid, out var path))
{
s_assetPathLookup.Remove(guid);
s_pathAssetLookup.Remove(path);
}
}
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "DELETE FROM Assets WHERE Guid = @guid";
cmd.Parameters.AddWithValue("@guid", guid.ToString());
await cmd.ExecuteNonQueryAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to remove asset: {ex.Message}");
}
}
/// <summary>
/// Mark an asset as dirty for re-importing.
/// </summary>
private static async Task<Result> MarkAssetDirtyAsync(Guid guid, bool isDirty = true)
{
if (s_dbConnection == null)
{
return Result.Failure("Database not initialized");
}
try
{
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "UPDATE Assets SET IsDirty = @dirty WHERE Guid = @guid";
cmd.Parameters.AddWithValue("@dirty", isDirty ? 1 : 0);
cmd.Parameters.AddWithValue("@guid", guid.ToString());
await cmd.ExecuteNonQueryAsync();
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to mark asset dirty: {ex.Message}");
}
}
/// <summary>
/// Get all dirty assets that need re-importing.
/// </summary>
private static async Task<List<(Guid guid, string path)>> GetDirtyAssetsAsync()
{
var result = new List<(Guid guid, string path)>();
if (s_dbConnection == null)
{
return result;
}
try
{
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "SELECT Guid, Path FROM Assets WHERE IsDirty = 1";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var guidStr = reader.GetString(0);
var path = reader.GetString(1);
if (Guid.TryParse(guidStr, out var guid))
{
result.Add((guid, path));
}
}
}
catch
{
// Silently fail - we'll return empty list
}
return result;
}
/// <summary>
/// Load all assets from the database into memory cache.
/// </summary>
private static async Task LoadAssetCacheFromDatabaseAsync()
{
if (s_dbConnection == null)
{
return;
}
try
{
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "SELECT Guid, Path FROM Assets";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var guidStr = reader.GetString(0);
var path = reader.GetString(1);
if (Guid.TryParse(guidStr, out var guid))
{
lock (s_dbLock)
{
s_assetPathLookup[guid] = path;
s_pathAssetLookup[path] = guid;
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to load asset cache: {ex.Message}");
}
}
/// <summary>
/// Get assets by tag.
/// </summary>
private static async Task<List<Guid>> GetAssetsByTagAsync(string tag)
{
var result = new List<Guid>();
if (s_dbConnection == null)
{
return result;
}
try
{
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "SELECT Guid, Tags FROM Assets";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var guidStr = reader.GetString(0);
var tagsJson = reader.GetString(1);
if (Guid.TryParse(guidStr, out var guid))
{
var tags = JsonSerializer.Deserialize<List<string>>(tagsJson);
if (tags != null && tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
result.Add(guid);
}
}
}
}
catch
{
// Silently fail
}
return result;
}
/// <summary>
/// Get the file hash for an asset from the database.
/// </summary>
private static async Task<string?> GetFileHashAsync(Guid guid)
{
if (s_dbConnection == null)
{
return null;
}
try
{
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "SELECT FileHash FROM Assets WHERE Guid = @guid";
cmd.Parameters.AddWithValue("@guid", guid.ToString());
var result = await cmd.ExecuteScalarAsync();
return result?.ToString();
}
catch
{
return null;
}
}
/// <summary>
/// Get the dependencies for an asset from the database.
/// </summary>
private static async Task<List<Guid>> GetDependenciesAsync(Guid guid)
{
if (s_dbConnection == null)
{
return new List<Guid>();
}
try
{
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "SELECT DependencyGuids FROM Assets WHERE Guid = @guid";
cmd.Parameters.AddWithValue("@guid", guid.ToString());
var result = await cmd.ExecuteScalarAsync();
if (result != null)
{
var json = result.ToString();
return JsonSerializer.Deserialize<List<Guid>>(json ?? "[]") ?? new List<Guid>();
}
}
catch
{
// Silently fail
}
return new List<Guid>();
}
/// <summary>
/// Find assets by name pattern using database query with wildcards.
/// </summary>
/// <param name="namePattern">Pattern supporting * (any chars) and ? (single char).</param>
private static async Task<List<Guid>> GetAssetsByNameAsync(string namePattern)
{
var results = new List<Guid>();
if (s_dbConnection == null)
{
return results;
}
try
{
// Convert wildcard pattern to SQL LIKE pattern
var sqlPattern = namePattern.Replace('*', '%').Replace('?', '_');
await using var cmd = s_dbConnection.CreateCommand();
// Extract just the filename from the path for matching
// SQLite doesn't have a built-in path manipulation, so we search in the full path
// and filter by checking if the pattern matches the filename part
cmd.CommandText = @"
SELECT Guid, Path FROM Assets
WHERE Path LIKE '%' || @pattern || '%'
";
cmd.Parameters.AddWithValue("@pattern", sqlPattern);
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var guidStr = reader.GetString(0);
var path = reader.GetString(1);
// Extract filename and check if it matches the pattern
var fileName = Path.GetFileName(path);
// Convert pattern to regex for proper matching
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(namePattern)
.Replace("\\*", ".*")
.Replace("\\?", ".") + "$";
if (System.Text.RegularExpressions.Regex.IsMatch(fileName, regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase))
{
if (Guid.TryParse(guidStr, out var guid))
{
results.Add(guid);
}
}
}
}
catch
{
// Silently fail
}
return results;
}
/// <summary>
/// Remove orphaned entries from database (assets that no longer exist on disk).
/// </summary>
private static async Task RemoveOrphanedEntriesAsync()
{
if (s_dbConnection == null || AssetsDirectory == null)
{
return;
}
try
{
var orphanedGuids = new List<Guid>();
await using var cmd = s_dbConnection.CreateCommand();
cmd.CommandText = "SELECT Guid, Path FROM Assets";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var guidStr = reader.GetString(0);
var path = reader.GetString(1);
if (Guid.TryParse(guidStr, out var guid))
{
// Check if file exists
var fullPath = Path.Combine(AssetsDirectory.FullName, path);
if (!File.Exists(fullPath))
{
orphanedGuids.Add(guid);
}
}
}
// Remove orphaned entries
foreach (var guid in orphanedGuids)
{
await RemoveAssetFromDatabaseAsync(guid);
}
}
catch
{
// Silently fail - cleanup is best effort
}
}
}

View File

@@ -1,10 +1,39 @@
using Ghost.Data.Services;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// Centralized asset database that manages all assets in the project.
/// Handles asset registration, lookup, importing, and dependency management.
/// Uses SQLite for persistent storage and efficient querying.
/// </summary>
public static partial class AssetDatabase
{
private static FileSystemWatcher? s_watcher;
private static readonly Lock s_dbLock = new();
private static readonly Dictionary<Guid, string> s_assetPathLookup = new();
private static readonly Dictionary<string, Guid> s_pathAssetLookup = new();
// Debouncing for file system watcher to prevent duplicate events
private static readonly Dictionary<string, DateTime> s_pendingFileOperations = new();
private static readonly Lock s_pendingOperationsLock = new();
private static readonly TimeSpan s_debounceDelay = TimeSpan.FromMilliseconds(100);
// Initialization guard
private static readonly Lock s_initializationLock = new();
private static bool s_initialized = false;
private static readonly JsonSerializerOptions s_defaultJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters =
{
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
}
};
public static DirectoryInfo? AssetsDirectory
{
@@ -12,22 +41,189 @@ public static partial class AssetDatabase
private set;
}
internal static void Initialize()
/// <summary>
/// Initialize the asset database.
/// Must be called after project is loaded.
/// </summary>
internal static async void Initialize()
{
lock (s_initializationLock)
{
if (s_initialized)
{
return; // Already initialized, skip
}
s_initialized = true;
}
if (ProjectService.CurrentProject.Metadata == null)
{
throw new InvalidOperationException("Project metadata is not initialized. Ensure that the project is loaded before accessing the AssetDatabase.");
}
AssetsDirectory = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(ProjectService.CurrentProject.Path)!, ProjectService.ASSETS_FOLDER));
// Initialize database
await InitializeDatabaseAsync();
// Load asset cache from database
await LoadAssetCacheFromDatabaseAsync();
// Initialize file system watcher
s_watcher = new FileSystemWatcher
{
Path = AssetsDirectory.FullName,
IncludeSubdirectories = true,
EnableRaisingEvents = true
EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
};
InitializeAssetHandle();
InitializeMetaData();
// Validate and fix database on startup
await ValidateAndFixDatabaseAsync();
}
/// <summary>
/// Validate the asset database and fix any inconsistencies.
/// Checks for missing/corrupted assets and regenerates metadata as needed.
/// </summary>
private static async Task<Ghost.Core.Result> ValidateAndFixDatabaseAsync()
{
if (AssetsDirectory == null)
{
return Ghost.Core.Result.Failure("AssetsDirectory not initialized");
}
try
{
// Scan all files in assets directory
var allFiles = Directory.GetFiles(AssetsDirectory.FullName, "*.*", SearchOption.AllDirectories)
.Where(f => !f.EndsWith(Utilities.FileExtensions.META_FILE_EXTENSION, StringComparison.OrdinalIgnoreCase))
.ToList();
// Ensure all files have metadata
foreach (var file in allFiles)
{
var metaPath = file + Utilities.FileExtensions.META_FILE_EXTENSION;
if (!File.Exists(metaPath))
{
await GenerateMetaFileAsync(file);
}
else
{
// Validate and update database
var metaResult = await ReadMetaFileAsync(file);
if (metaResult.IsSuccess)
{
var fileHash = await CalculateFileHashAsync(file);
await UpsertAssetAsync(file, metaResult.Value, fileHash);
}
else
{
// Corrupted meta file - regenerate
await GenerateMetaFileAsync(file);
}
}
}
// Remove orphaned entries from database (files that no longer exist)
await RemoveOrphanedEntriesAsync();
return Ghost.Core.Result.Success();
}
catch (Exception ex)
{
return Ghost.Core.Result.Failure($"Failed to validate database: {ex.Message}");
}
}
/// <summary>
/// Refresh the asset database manually.
/// Scans the project directory for changes.
/// </summary>
public static async Task<Ghost.Core.Result> RefreshAsync()
{
return await ValidateAndFixDatabaseAsync();
}
/// <summary>
/// Check if a file operation should be processed or debounced.
/// Returns true if the operation should proceed.
/// </summary>
private static bool ShouldProcessFileOperation(string filePath)
{
lock (s_pendingOperationsLock)
{
var now = DateTime.UtcNow;
// Clean up old entries
var toRemove = s_pendingFileOperations
.Where(kvp => now - kvp.Value > s_debounceDelay * 2)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in toRemove)
{
s_pendingFileOperations.Remove(key);
}
// Check if this operation was recently processed
if (s_pendingFileOperations.TryGetValue(filePath, out var lastTime))
{
if (now - lastTime < s_debounceDelay)
{
// Too soon, skip this event
return false;
}
}
// Update timestamp and allow processing
s_pendingFileOperations[filePath] = now;
return true;
}
}
/// <summary>
/// Register a file operation to prevent the file watcher from processing it.
/// Used by file operations (move, copy, etc.) to prevent duplicate processing.
/// </summary>
private static void RegisterFileOperation(string filePath)
{
lock (s_pendingOperationsLock)
{
s_pendingFileOperations[filePath] = DateTime.UtcNow;
}
}
/// <summary>
/// Shutdown the asset database.
/// Disposes resources and closes database connections.
/// </summary>
internal static void Shutdown()
{
lock (s_initializationLock)
{
if (!s_initialized)
{
return; // Not initialized, nothing to shutdown
}
s_watcher?.Dispose();
s_watcher = null;
s_dbConnection?.Close();
s_dbConnection?.Dispose();
s_dbConnection = null;
s_assetPathLookup.Clear();
s_pathAssetLookup.Clear();
s_importerInstances.Clear();
s_importerTypeLookup.Clear();
s_pendingFileOperations.Clear();
s_initialized = false;
}
}
}

View File

@@ -0,0 +1,80 @@
using Ghost.Core;
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// Base class for all asset importers.
/// Asset importers process source files and convert them into engine-ready formats.
/// </summary>
/// <typeparam name="TSettings">The type of importer settings this importer uses.</typeparam>
internal abstract class AssetImporter<TSettings>
where TSettings : ImporterSettings, new()
{
/// <summary>
/// Import the asset at the specified path with the given settings.
/// </summary>
/// <param name="assetPath">Full path to the source asset file.</param>
/// <param name="meta">Metadata for the asset.</param>
/// <returns>Result indicating success or failure.</returns>
public abstract Task<Result> ImportAsync(string assetPath, AssetMeta meta);
/// <summary>
/// Export in-memory asset data to disk.
/// Override this method to support creating assets from code.
/// </summary>
/// <typeparam name="T">Type of asset data to export.</typeparam>
/// <param name="assetPath">Full path where the asset should be saved.</param>
/// <param name="assetData">In-memory asset data to serialize.</param>
/// <param name="meta">Metadata for the asset.</param>
/// <returns>Result indicating success or failure.</returns>
public virtual Task<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta) where T : class
{
return Task.FromResult(Result.Failure("This importer does not support exporting assets."));
}
/// <summary>
/// Get the settings for this importer from the metadata.
/// Creates default settings if none exist.
/// </summary>
/// <param name="meta">Asset metadata.</param>
/// <returns>The importer settings.</returns>
protected TSettings GetSettings(AssetMeta meta)
{
var typeName = GetType().Name;
var settings = meta.GetImporterSettings<TSettings>(typeName);
if (settings != null)
{
return settings;
}
var defaultSettings = new TSettings();
meta.SetImporterSettings(typeName, defaultSettings);
return defaultSettings;
}
/// <summary>
/// Validate dependencies referenced by this asset.
/// Dependencies are extracted from asset content during import and stored in the database.
/// </summary>
/// <param name="dependencies">List of dependency GUIDs extracted from the asset.</param>
/// <returns>Result indicating if all dependencies are valid.</returns>
protected virtual ValueTask<Result> ValidateDependenciesAsync(List<Guid> dependencies)
{
foreach (var dependencyGuid in dependencies)
{
var path = AssetDatabase.GuidToPath(dependencyGuid);
if (path.IsFailure)
{
return ValueTask.FromResult(Result.Failure($"Missing dependency: {dependencyGuid}"));
}
if (!File.Exists(path.Value))
{
return ValueTask.FromResult(Result.Failure($"Dependency file does not exist: {path.Value}"));
}
}
return ValueTask.FromResult(Result.Success());
}
}

View File

@@ -1,7 +1,7 @@
namespace Ghost.Editor.Core.AssetHandle;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class AssetImporterAttribute : Attribute
internal class AssetImporterAttribute : Attribute
{
public string[] SupportedExtensions
{

View File

@@ -1,16 +1,85 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// Metadata for an asset, stored in .gmeta files.
/// Contains GUID, version, tags, and importer settings.
/// FileHash and Dependencies are stored in the database only, not in .gmeta files.
/// </summary>
internal class AssetMeta
{
/// <summary>
/// Unique identifier for the asset.
/// </summary>
[JsonPropertyName("Guid")]
public Guid Guid
{
get;
set;
}
public ImporterSettings? Settings
/// <summary>
/// Version of the asset pipeline (not the asset itself).
/// Used for migration when the asset pipeline is redesigned.
/// </summary>
[JsonPropertyName("Version")]
public int Version
{
get;
set;
} = 1;
/// <summary>
/// Tags for categorizing and searching assets.
/// </summary>
[JsonPropertyName("Tags")]
public List<string> Tags
{
get;
set;
} = new();
/// <summary>
/// Importer settings specific to this asset.
/// The key is the importer type name, and the value is a JSON element containing the settings.
/// Use GetImporterSettings&lt;T&gt;() and SetImporterSettings&lt;T&gt;() to work with strongly-typed settings.
/// </summary>
[JsonPropertyName("ImporterSettings")]
public Dictionary<string, JsonElement> ImporterSettings
{
get;
set;
} = new();
/// <summary>
/// Get importer settings of a specific type.
/// </summary>
public T? GetImporterSettings<T>(string importerName) where T : ImporterSettings
{
if (ImporterSettings.TryGetValue(importerName, out var element))
{
return element.Deserialize<T>();
}
return null;
}
/// <summary>
/// Set importer settings.
/// </summary>
public void SetImporterSettings<T>(string importerName, T settings) where T : ImporterSettings
{
var element = JsonSerializer.SerializeToElement(settings);
ImporterSettings[importerName] = element;
}
/// <summary>
/// Set importer settings (non-generic overload).
/// </summary>
internal void SetImporterSettings(string importerName, ImporterSettings settings)
{
var element = JsonSerializer.SerializeToElement(settings, settings.GetType());
ImporterSettings[importerName] = element;
}
}

View File

@@ -1,5 +1,5 @@
namespace Ghost.Editor.Core.AssetHandle;
public abstract class ImporterSettings
internal abstract class ImporterSettings
{
}

View File

@@ -0,0 +1,70 @@
using Ghost.Core;
namespace Ghost.Editor.Core.AssetHandle.Importers;
/// <summary>
/// Example importer settings for text assets.
/// </summary>
internal class TextImporterSettings : ImporterSettings
{
public string Encoding
{
get;
set;
} = "UTF-8";
public bool TrimWhitespace
{
get;
set;
} = false;
}
/// <summary>
/// Example importer for text files (.txt, .md).
/// This is a simple test importer to demonstrate the asset import system.
/// </summary>
[AssetImporter(".txt", ".md")]
internal class TextImporter : AssetImporter<TextImporterSettings>
{
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta)
{
var settings = GetSettings(meta);
// Text files typically don't have dependencies
// If they did, you would extract them from the content here
var dependencies = new List<Guid>();
// Validate dependencies
var depResult = await ValidateDependenciesAsync(dependencies);
if (depResult.IsFailure)
{
return depResult;
}
try
{
// Read the file
var content = await File.ReadAllTextAsync(assetPath);
if (settings.TrimWhitespace)
{
content = content.Trim();
}
// TODO: Process the text content
// For example:
// - Convert to a specific format
// - Extract metadata
// - Generate assets
// - Save to output folder
// For now, just report success
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to import text asset: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,279 @@
using Ghost.Core;
using System.Text.Json;
namespace Ghost.Editor.Core.AssetHandle.Importers;
/// <summary>
/// Importer settings for texture assets.
/// </summary>
internal class TextureImporterSettings : ImporterSettings
{
/// <summary>
/// Whether to generate mipmaps for the texture.
/// </summary>
public bool GenerateMipmaps
{
get;
set;
} = true;
/// <summary>
/// Whether the texture uses sRGB color space.
/// </summary>
public bool SRGB
{
get;
set;
} = true;
/// <summary>
/// Maximum texture size. Images larger than this will be downscaled.
/// </summary>
public uint MaxSize
{
get;
set;
} = 2048;
/// <summary>
/// Texture compression format.
/// Options: "None", "BC1", "BC3", "BC7"
/// </summary>
public string CompressionFormat
{
get;
set;
} = "None";
/// <summary>
/// Texture filter mode.
/// Options: "Point", "Bilinear", "Trilinear"
/// </summary>
public string FilterMode
{
get;
set;
} = "Bilinear";
/// <summary>
/// Texture wrap mode.
/// Options: "Repeat", "Clamp", "Mirror"
/// </summary>
public string WrapMode
{
get;
set;
} = "Repeat";
}
/// <summary>
/// Importer for texture files (.png, .jpg, .jpeg, .dds, .tga, .bmp).
/// Processes image files and converts them into engine-ready texture assets.
/// </summary>
[AssetImporter(".png", ".jpg", ".jpeg", ".dds", ".tga", ".bmp")]
internal class TextureImporter : AssetImporter<TextureImporterSettings>
{
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta)
{
var settings = GetSettings(meta);
// Textures typically don't reference other assets as dependencies
// If they did (e.g., normal maps referencing base textures), extract here
var dependencies = new List<Guid>();
// Validate dependencies
var depResult = await ValidateDependenciesAsync(dependencies);
if (depResult.IsFailure)
{
return depResult;
}
try
{
// Check if file exists
if (!File.Exists(assetPath))
{
return Result.Failure($"Source texture file not found: {assetPath}");
}
// Get image dimensions (simplified - in real implementation would use image library)
var (width, height) = await GetImageDimensionsAsync(assetPath);
if (width == 0 || height == 0)
{
return Result.Failure("Failed to read image dimensions");
}
// Apply max size constraint
if (width > settings.MaxSize || height > settings.MaxSize)
{
var scale = Math.Min(settings.MaxSize / (float)width, settings.MaxSize / (float)height);
width = (uint)(width * scale);
height = (uint)(height * scale);
}
// Calculate mipmap count
uint mipLevels = 1;
if (settings.GenerateMipmaps)
{
mipLevels = CalculateMipLevels(width, height);
}
// Determine format
var format = settings.CompressionFormat == "None" ? "RGBA8" : settings.CompressionFormat;
// Create texture asset
var textureAsset = new TextureAsset(meta.Guid, Path.GetFileNameWithoutExtension(assetPath))
{
Width = width,
Height = height,
MipLevels = mipLevels,
Format = format,
IsSRGB = settings.SRGB,
SourcePath = assetPath
};
// Save the imported asset data
var saveResult = AssetDatabase.SaveImportedAsset(meta.Guid, textureAsset);
if (saveResult.IsFailure)
{
return Result.Failure($"Failed to save texture asset: {saveResult.Message}");
}
// In a real implementation, you would:
// 1. Load the image using a library like ImageSharp or StbImageSharp
// 2. Resize if needed
// 3. Generate mipmaps
// 4. Compress if needed
// 5. Save the processed texture data to the ImportedAssets folder
// 6. Update the hash in database
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to import texture: {ex.Message}");
}
}
/// <summary>
/// Get image dimensions from file.
/// Simplified implementation - in production, use an image library.
/// </summary>
private async Task<(uint width, uint height)> GetImageDimensionsAsync(string imagePath)
{
// This is a placeholder implementation
// In a real implementation, you would use a library like:
// - ImageSharp
// - StbImageSharp
// - DirectXTex (for DDS files)
var extension = Path.GetExtension(imagePath).ToLowerInvariant();
if (extension == ".dds")
{
// For DDS files, read the header
// DDS header format: https://docs.microsoft.com/en-us/windows/win32/direct3ddds/dds-header
return await ReadDDSHeaderAsync(imagePath);
}
else
{
// For PNG/JPG/etc, we would use an image library
// For now, return placeholder values
return await Task.FromResult<(uint, uint)>((1024, 1024));
}
}
/// <summary>
/// Read DDS file header to get dimensions.
/// </summary>
private async Task<(uint width, uint height)> ReadDDSHeaderAsync(string ddsPath)
{
try
{
await using var stream = File.OpenRead(ddsPath);
using var reader = new BinaryReader(stream);
// Read magic number (should be "DDS ")
var magic = reader.ReadUInt32();
if (magic != 0x20534444) // "DDS " in little-endian
{
return (0, 0);
}
// Read header size (should be 124)
var headerSize = reader.ReadUInt32();
if (headerSize != 124)
{
return (0, 0);
}
// Skip flags
reader.ReadUInt32();
// Read height and width
var height = reader.ReadUInt32();
var width = reader.ReadUInt32();
return (width, height);
}
catch
{
return (0, 0);
}
}
/// <summary>
/// Export a texture asset from memory to disk.
/// </summary>
public override async Task<Result> ExportAsync<T>(string assetPath, T assetData, AssetMeta meta)
{
if (assetData is not TextureAsset textureAsset)
{
return Result.Failure($"Asset data is not a TextureAsset, got {typeof(T).Name}");
}
try
{
// In a real implementation, you would:
// 1. Convert the texture data to the appropriate format
// 2. Write the image file (PNG, DDS, etc.)
// 3. Save metadata
// For now, just save metadata as JSON
var json = JsonSerializer.Serialize(textureAsset, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(assetPath, json);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to export texture: {ex.Message}");
}
}
/// <summary>
/// Calculate number of mipmap levels for a given texture size.
/// </summary>
private static uint CalculateMipLevels(uint width, uint height)
{
if (width == 0 || height == 0)
{
return 0;
}
uint count = 1;
while (width > 1 || height > 1)
{
width >>= 1;
height >>= 1;
count++;
}
return count;
}
}

View File

@@ -0,0 +1,250 @@
# Asset Database Implementation
This is the complete implementation of the GhostEngine Asset Database system based on the plan in `AssetDBPlan.md`.
## Structure
The asset database is implemented as a partial class split across multiple files:
### Core Files
- **AssetDatabase.cs** - Main entry point with initialization and shutdown logic
- **AssetDatabase.Meta.cs** - Metadata file management and file system watching
- **AssetDatabase.SQLite.cs** - SQLite database operations for caching
- **AssetDatabase.Lookup.cs** - GUID/Path lookup and search operations
- **AssetDatabase.FileOps.cs** - File operations (create, delete, move, copy)
- **AssetDatabase.Importer.cs** - Asset importing framework
- **AssetDatabase.Open.cs** - Asset opening handlers (existing file)
### Supporting Files
- **Asset.cs** - Base class for all assets
- **AssetMeta.cs** - Metadata structure (stored in .gmeta files)
- **AssetImporter.cs** - Base class for all asset importers
- **AssetImporterAttribute.cs** - Attribute to mark importer classes
- **AssetOpenHandlerAttribute.cs** - Attribute for custom open handlers
- **ImporterSettings.cs** - Base class for importer settings
### Example Importer
- **Importers/TextImporter.cs** - Example importer for .txt and .md files
## Features Implemented
### Core API (AssetDatabase.Lookup.cs)
-`PathToGuid(string assetPath)` - Find GUID by path
-`GuidToPath(Guid guid)` - Find path by GUID
-`LoadAsset<T>(Guid guid)` - Load asset by GUID (TODO: needs asset loader)
-`GetAssetTagsAsync(Guid guid)` - Get asset tags
-`SetAssetTagsAsync(Guid guid, List<string> tags)` - Set asset tags
-`FindAssetsByName(string namePattern)` - Search by name
-`FindAssetsByTagAsync(string tag)` - Search by tag
-`GetAllAssets()` - Get all assets in database
### File Operations (AssetDatabase.FileOps.cs)
-`CreateAssetAsync(string assetPath, byte[] content)` - Create new asset
-`DeleteAssetAsync(Guid guid)` - Delete asset
-`MoveAssetAsync(Guid guid, string newPath)` - Move/rename asset
-`CopyAssetAsync(Guid guid, string newPath)` - Copy asset with new GUID
-`RefreshAsync()` - Refresh database manually
-`MarkDirtyAsync(Guid guid)` - Mark asset for re-import
-`ImportDirtyAssetsAsync()` - Import all dirty assets
### Background Services (AssetDatabase.Meta.cs)
- ✅ File system watcher for automatic change detection
- ✅ Automatic metadata generation on file creation
- ✅ Automatic metadata cleanup on file deletion
- ✅ Automatic metadata movement on file rename
- ✅ File hash comparison for change detection
- ✅ Automatic dirty marking on file modification
- ✅ Dependent asset tracking and dirty propagation
### Database (AssetDatabase.SQLite.cs)
- ✅ SQLite for persistent storage and efficient querying
- ✅ In-memory cache for fast lookups
- ✅ Automatic database creation and schema management
- ✅ Asset indexing by GUID and path
- ✅ Dirty flag tracking for re-import
- ✅ Tag-based search support
### Validation (AssetDatabase.cs)
- ✅ Validate and fix database on project load
- ✅ Check for missing/corrupted metadata files
- ✅ Regenerate metadata when necessary
- ✅ Database consistency checks
## Metadata File Format
Assets have associated `.gmeta` files stored alongside them:
```json
{
"Guid": "123e4567-e89b-12d3-a456-426614174000",
"Version": 1,
"Tags": ["Environment", "Texture"],
"FileHash": "ABC123...",
"Dependencies": [
"456e7890-e89b-12d3-a456-426614174001"
],
"ImporterSettings": {
"TextureImporter": {
"MaxSize": 2048,
"MipLevels": 1
}
}
}
```
## Usage Examples
### Finding Assets
```csharp
// Find by path
var guidResult = AssetDatabase.PathToGuid("Assets/Textures/logo.png");
if (guidResult.IsSuccess)
{
var guid = guidResult.Value;
// Use guid...
}
// Find by GUID
var pathResult = AssetDatabase.GuidToPath(myGuid);
if (pathResult.IsSuccess)
{
var path = pathResult.Value;
// Use path...
}
// Search by name
var results = AssetDatabase.FindAssetsByName("logo");
// Search by tag
var textureAssets = await AssetDatabase.FindAssetsByTagAsync("Texture");
```
### Creating and Managing Assets
```csharp
// Create new asset
var content = Encoding.UTF8.GetBytes("Hello, World!");
await AssetDatabase.CreateAssetAsync("Assets/test.txt", content);
// Move asset
await AssetDatabase.MoveAssetAsync(guid, "Assets/NewFolder/test.txt");
// Copy asset
var newGuid = await AssetDatabase.CopyAssetAsync(guid, "Assets/test_copy.txt");
// Delete asset
await AssetDatabase.DeleteAssetAsync(guid);
```
### Working with Tags
```csharp
// Get tags
var tagsResult = await AssetDatabase.GetAssetTagsAsync(guid);
if (tagsResult.IsSuccess)
{
var tags = tagsResult.Value;
}
// Set tags
await AssetDatabase.SetAssetTagsAsync(guid, new List<string> { "UI", "Icon" });
```
### Asset Importing
```csharp
// Mark asset dirty for re-import
await AssetDatabase.MarkDirtyAsync(guid);
// Import all dirty assets
await AssetDatabase.ImportDirtyAssetsAsync();
```
## Creating Custom Importers
To create a custom asset importer:
1. Create a settings class inheriting from `ImporterSettings`
2. Create an importer class inheriting from `AssetImporter<TSettings>`
3. Add the `[AssetImporter]` attribute with supported extensions
Example:
```csharp
public class MyImporterSettings : ImporterSettings
{
public bool SomeOption { get; set; } = true;
}
[AssetImporter(".myext")]
public class MyImporter : AssetImporter<MyImporterSettings>
{
public override async Task<Result> ImportAsync(string assetPath, AssetMeta meta)
{
var settings = GetSettings(meta);
// Validate dependencies
var depResult = await ValidateDependenciesAsync(meta);
if (depResult.IsFailure)
{
return depResult;
}
// Import logic here...
return Result.Success();
}
}
```
## Architecture Notes
### Source of Truth
The `.gmeta` files are the **source of truth** for asset information. The SQLite database is used only for:
- Caching for fast lookups
- Efficient querying and search operations
- Tracking dirty state
If the database becomes inconsistent, it can be regenerated from the `.gmeta` files by calling `RefreshAsync()`.
### Thread Safety
All database operations use locks to ensure thread safety. File system watcher events are handled asynchronously to avoid blocking the main thread.
### Error Handling
The system uses the `Result` pattern for railway-oriented programming. All operations return `Result` or `Result<T>` to indicate success or failure without throwing exceptions for expected failures.
## Testing
Unit tests should be added to verify:
- Metadata file generation and parsing
- Database consistency
- File operations (create, delete, move, copy)
- Asset importing
- Dependency tracking
- Tag management
- Search functionality
## Future Improvements
- Asset loader implementation (`LoadAsset<T>`)
- Asset browser UI
- More sophisticated dependency resolution
- Asset preview generation
- Asset versioning and migration
- Orphaned entry cleanup in database
- Better error reporting and logging
- Asset import progress tracking
- Parallel asset importing
- Asset thumbnail generation

View File

@@ -0,0 +1,75 @@
namespace Ghost.Editor.Core.AssetHandle;
/// <summary>
/// Represents a texture asset.
/// </summary>
public class TextureAsset : Asset
{
public override string Name
{
get;
set;
}
/// <summary>
/// Width of the texture in pixels.
/// </summary>
public uint Width
{
get;
set;
}
/// <summary>
/// Height of the texture in pixels.
/// </summary>
public uint Height
{
get;
set;
}
/// <summary>
/// Number of mipmap levels.
/// </summary>
public uint MipLevels
{
get;
set;
}
/// <summary>
/// Texture format (e.g., "RGBA8", "BC1", "BC7").
/// </summary>
public string Format
{
get;
set;
}
/// <summary>
/// Whether the texture uses sRGB color space.
/// </summary>
public bool IsSRGB
{
get;
set;
}
/// <summary>
/// Relative path to the source image file.
/// </summary>
public string SourcePath
{
get;
set;
}
public TextureAsset(Guid id, string name) : base(id)
{
Name = name;
Format = "RGBA8";
IsSRGB = true;
SourcePath = string.Empty;
}
}