Files
2026-07-13 13:13:17 +08:00

544 lines
20 KiB
C#

#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using T3.Core.Logging;
using T3.Core.Model;
using T3.Core.Operator;
using T3.Core.Operator.Slots;
using T3.Core.Resource.ShaderCompiling;
using T3.Core.Settings;
using T3.Core.Utils;
namespace T3.Core.Resource.Assets;
public static class AssetRegistry
{
public static bool TryGetAsset(string? address, [NotNullWhen(true)] out Asset? asset)
{
if (address != null)
return _assetsByAddress.TryGetValue(address, out asset);
asset = null;
return false;
}
public static bool TryResolveAddress(string? address,
IResourceConsumer? consumer,
out string absolutePath,
[NotNullWhen(true)] out IResourcePackage? resourcePackage,
bool isFolder = false,
bool logWarnings = false)
{
resourcePackage = null;
absolutePath = string.Empty;
if (string.IsNullOrWhiteSpace(address))
return false;
// 1. High-performance registry lookup
if (TryGetAsset(address, out var asset))
{
if (asset.FileSystemInfo != null && asset.IsDirectory == isFolder)
{
absolutePath = asset.FullPath;
resourcePackage = null;
foreach (var c in ResourcePackageManager.SharedResourcePackages)
{
if (c.Id != asset.PackageId) continue;
resourcePackage = c;
break;
}
return resourcePackage != null;
}
}
address.ToForwardSlashesUnsafe();
var span = address.AsSpan();
// 2. Fallback for internal editor resources
if (span.StartsWith("./"))
{
absolutePath = Path.GetFullPath(address);
if (!logWarnings)
return false;
if (consumer is Instance instance)
{
Log.Warning($"Can't resolve relative asset '{address}'", instance);
}
else
Log.Warning($"Can't relative resolve asset '{address}'");
return false;
}
var projectSeparator = address.IndexOf(PackageSeparator);
// 3. Legacy windows absolute paths (e.g. C:/...)
if (projectSeparator == 1)
{
absolutePath = address;
return isFolder
? Directory.Exists(absolutePath)
: File.Exists(absolutePath);
}
if (projectSeparator == -1)
{
if (logWarnings)
Log.Warning($"Can't resolve asset '{address}'");
return false;
}
// 4. Fallback search through packages
var packageName = span[..projectSeparator];
var localPath = span[(projectSeparator + 1)..];
var packages = consumer?.AvailableResourcePackages ?? ResourcePackageManager.SharedResourcePackages;
if (packages.Count == 0)
{
if (logWarnings)
Log.Warning($"Can't resolve asset '{address}' (no packages found)");
return false;
}
foreach (var package in packages)
{
if (!package.Name.AsSpan().Equals(packageName, StringComparison.Ordinal))
continue;
resourcePackage = package;
absolutePath = $"{package.AssetsFolder}/{localPath}";
return isFolder
? Directory.Exists(absolutePath)
: File.Exists(absolutePath);
}
return false;
}
public static bool TryToGetAssetFromFilepath(string absolutePath, bool isFolder, [NotNullWhen(true)] out Asset? asset)
{
asset = null;
return TryConvertFilepathToAddress(absolutePath, isFolder, out var address, out var package)
&& _assetsByAddress.TryGetValue(address, out asset);
}
/// <summary>
/// Tries to convert an absolute filepath into a relative package address.
/// </summary>
/// <returns>true if path could be matched to a package. </returns>
internal static bool TryConvertFilepathToAddress(string absolutePath, bool isFolder,
[NotNullWhen(true)] out string? relativeAddress,
[NotNullWhen(true)] out IResourcePackage? matchingPackage)
{
absolutePath.ToForwardSlashesUnsafe();
foreach (var package in SymbolPackage.AllPackages)
{
var folder = package.AssetsFolder;
if (absolutePath.StartsWith(folder, StringComparison.OrdinalIgnoreCase))
{
var dirSuffix = isFolder ? "/" : string.Empty;
// Trim the folder length AND the following slash if it exists
var relativePart = absolutePath[folder.Length..].TrimStart('/');
relativeAddress = $"{package.Name}{PackageSeparator}{relativePart}{dirSuffix}";
matchingPackage = package;
return true;
}
}
relativeAddress = null;
matchingPackage = null;
return false;
}
public static void RegisterAssetsFromPackage(SymbolPackage package)
{
var root = package.AssetsFolder;
if (!Directory.Exists(root)) return;
var di = new DirectoryInfo(root);
RegisterPackageEntry(di, package, isDirectory: true);
// Register all files
foreach (var fileInfo in di.EnumerateFiles("*.*", SearchOption.AllDirectories))
{
if (FileLocations.IgnoredFiles.Contains(fileInfo.Name))
continue;
var asset = RegisterPackageEntry(fileInfo, package, false);
// Collect all possible addresses for this filename
var list = _assetsMatchingFilenames.GetOrAdd(fileInfo.Name, _ => []);
lock (list)
{
if (!list.Contains(asset)) list.Add(asset);
}
}
// Register all directories
foreach (var dirInfo in di.EnumerateDirectories("*", SearchOption.AllDirectories))
{
if (FileLocations.IgnoredFiles.Contains(dirInfo.Name))
continue;
RegisterPackageEntry(new FileInfo(dirInfo.FullName), package, true);
}
//Log.Debug($"{packageAlias}: Registered {_assetsByAddress.Count(a => a.Value.PackageId == packageId)} assets (including directories).");
}
public static Asset RegisterPackageEntry(FileSystemInfo info, IResourcePackage package, bool isDirectory)
{
info.Refresh();
// If the info is the root itself, relative path is empty string
var relativePath = Path.GetRelativePath(package.AssetsFolder, info.FullName).Replace("\\", "/");
var isPackageFolder = relativePath == ".";
if (isPackageFolder)
relativePath = string.Empty;
var dirSuffix = (isDirectory && !isPackageFolder) ? "/" : string.Empty;
var address = $"{package.Name}{PackageSeparator}{relativePath}{dirSuffix}";
// Pre-calculate path parts
var parts = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
var pathParts = new List<string>(parts.Length + 1) { package.Name };
// Logic for folder structure
var partCount = isDirectory ? parts.Length : parts.Length - 1;
for (var i = 0; i < partCount; i++)
{
pathParts.Add(parts[i]);
}
AssetType.TryGetForFilePath(info.Name, out var assetType, out var extensionId);
var asset = new Asset(address)
{
PackageId = package.Id,
FileSystemInfo = info,
AssetType = assetType,
IsDirectory = isDirectory,
FullPath = info.FullName.ToForwardSlashes(),
PathParts = pathParts.ToArray(),
ExtensionId = extensionId,
Package = package,
};
_assetsByAddress[address] = asset;
return asset;
}
public static Asset GetOrRegisterExternalFileAsset(string absolutePath)
{
// 1. Normalize the path to ensure consistent IDs
absolutePath.ToForwardSlashesUnsafe();
// 2. If it's already registered (e.g. by another address), return the existing one
if (TryToGetAssetFromFilepath(absolutePath, false, out var existingAsset))
{
return existingAsset;
}
// 3. Create a new Ad-hoc Asset
// For external files, the Address IS the absolute path
var asset = new Asset(absolutePath)
{
PackageId = ExternalAssetsPackage.Id, // Use Empty if no package owns it
FileSystemInfo = new FileInfo(absolutePath),
FullPath = absolutePath,
IsDirectory = false,
Package = ExternalAssetsPackage,
};
// 4. Determine AssetType for the UI/Compiler
AssetType.TryGetForFilePath(asset.FileSystemInfo.Name, out asset.AssetType, out asset.ExtensionId);
// 5. Register in the lookup dictionary
_assetsByAddress[absolutePath] = asset;
// Also add to filename lookup for quick searches
var list = _assetsMatchingFilenames.GetOrAdd(asset.FileSystemInfo.Name, _ => []);
lock (list)
{
if (!list.Contains(asset)) list.Add(asset);
}
return asset;
}
internal static void UnregisterPackage(Guid packageId)
{
var addressesToRemove = _assetsByAddress.Values
.Where(a => a.PackageId == packageId)
.ToList();
foreach (var asset in addressesToRemove)
{
_assetsByAddress.TryRemove(asset.Address, out _);
ReferencesForAssetId.Remove(asset.Id, out _);
}
}
/// <summary>
/// This will try to first create a localUrl, then a packageUrl,
/// and finally fall back to an absolute path.
///
/// This method is useful to test if path would be valid before before dropping and external file
/// into the editor the asset is being registered...
/// </summary>
public static bool TryConstructAddressFromFilePath(string absolutePath,
Instance composition,
[NotNullWhen(true)] out string? address,
[NotNullWhen(true)] out IResourcePackage? package)
{
address = null;
package = null;
if (string.IsNullOrWhiteSpace(absolutePath)) return false;
var normalizedPath = absolutePath.Replace("\\", "/");
var localPackage = composition.Symbol.SymbolPackage;
// Disable localUris for now
var localRoot = localPackage.AssetsFolder.TrimEnd('/') + "/";
if (normalizedPath.StartsWith(localRoot, StringComparison.OrdinalIgnoreCase))
{
// Dropping the root folder gives us the local relative path
address = localPackage.Name + ":" + normalizedPath[localRoot.Length..];
package = localPackage;
return true;
}
// 3. Check other packages
foreach (var p in composition.AvailableResourcePackages)
{
if (p == localPackage) continue;
var packageRoot = p.AssetsFolder.TrimEnd('/') + "/";
if (normalizedPath.StartsWith(packageRoot, StringComparison.OrdinalIgnoreCase))
{
address = $"{p.Name}:{normalizedPath[packageRoot.Length..]}";
package = p;
return true;
}
}
// 4. Fallback to Absolute
address = normalizedPath;
return false;
}
public static Asset? UpdateMovedAsset(string oldPath, string newPath)
{
var isDir = Directory.Exists(newPath);
// Remove old address
if (!TryConvertFilepathToAddress(oldPath, isDir, out var oldAddress, out _))
{
Log.Warning("Can't resolve old path");
return null;
}
if (!_assetsByAddress.TryRemove(oldAddress, out var oldAsset))
{
Log.Warning("Can't resolve old path");
return null;
}
var package = ResourcePackageManager.SharedResourcePackages.FirstOrDefault(p => p.Id == oldAsset.PackageId);
if (package == null)
{
Log.Warning("Can't resolve old path package");
return null;
}
// Register new address
FileSystemInfo info = isDir ? new DirectoryInfo(newPath) : new FileInfo(newPath);
var newAsset = RegisterPackageEntry(info, package, isDir);
ResourceFileWatcher.FileStateChangeCounter++;
// Update references...
if (!ReferencesForAssetId.Remove(oldAsset.Id, out var references))
return newAsset;
foreach (var r in references)
{
if (!UpdateAddressForReference(r, newAsset))
Log.Warning("Failed to update asset reference: " + r);
}
return newAsset;
}
private static bool UpdateAddressForReference(AssetReference reference, Asset newAsset)
{
if (!SymbolRegistry.TryGetSymbol(reference.SymbolId, out var symbol))
{
Log.Debug("Symbol for asset reference not found? " + reference.SymbolId);
return false;
}
if (reference.IsDefaultValueReference)
{
var inputDefinition = symbol.InputDefinitions.FirstOrDefault(i => i.Id == reference.InputId);
if (inputDefinition == null)
return false;
if (inputDefinition.DefaultValue.ValueType != typeof(string))
return false;
inputDefinition.DefaultValue.Assign(new InputValue<string>(newAsset.Address));
AddAssetReference(newAsset, symbol.Id, Guid.Empty, inputDefinition.Id);
}
else
{
if (!symbol.Children.TryGetValue(reference.SymbolChildId, out var symbolChild))
{
return false;
}
if (!symbolChild.Inputs.TryGetValue(reference.InputId, out var input))
{
return false;
}
input.Value.Assign(new InputValue<string>(newAsset.Address));
AddAssetReference(newAsset, symbol.Id, symbolChild.Id, input.Id);
}
return true;
}
public static void UnregisterAbsoluteFilePath(string absolutePath, SymbolPackage package)
{
// Convert the absolute disk path back to our conformed "Alias:Path"
var root = package.AssetsFolder;
if (!absolutePath.StartsWith(root, StringComparison.OrdinalIgnoreCase))
return;
var relativePath = Path.GetRelativePath(root, absolutePath).Replace("\\", "/");
if (relativePath.Equals(".", StringComparison.Ordinal))
relativePath = string.Empty;
var address = $"{package.Name}{PackageSeparator}{relativePath}";
var wasDirectory = false;
if (_assetsByAddress.TryRemove(address, out var asset))
{
Log.Debug($"Removed {address} from registry.");
}
else if (_assetsByAddress.TryRemove(address + "/", out asset))
{
Log.Debug($"Removed {address}/ from registry.");
wasDirectory = true;
}
if (!wasDirectory)
{
var lastSlash = relativePath.LastIndexOf('/');
var filename = lastSlash == -1
? relativePath
: relativePath[lastSlash..];
if (_assetsMatchingFilenames.TryRemove(filename, out _))
{
Log.Debug($"Removed {address} from file matches.");
}
}
if (asset != null && ReferencesForAssetId.Remove(asset.Id))
{
Log.Debug($"Removed {address} from file matches.");
}
}
public static void RemoveObsoleteAsset(Asset? asset)
{
if (asset == null)
return;
Log.Debug("Remove obsolete asset definition " + asset);
_assetsByAddress.Remove(asset.Address, out _);
if (!asset.IsDirectory && asset.TryGetFileName(out var filename))
{
_assetsMatchingFilenames.TryRemove(filename.ToString(), out _);
}
ReferencesForAssetId.Remove(asset.Id);
ResourceFileWatcher.FileStateChangeCounter++;
}
public static void AddAssetReference(Asset asset, Guid symbolId, Guid symbolChildId, Guid stringUiId)
{
if (!ReferencesForAssetId.TryGetValue(asset.Id, out var list))
{
list = [];
ReferencesForAssetId[asset.Id] = list;
}
foreach (var reference in list)
{
var alreadyExists = reference.SymbolId == symbolId
&& reference.SymbolChildId == symbolChildId
&& reference.InputId == stringUiId;
if (alreadyExists)
return;
}
list.Add(new AssetReference
{
Asset = asset,
SymbolId = symbolId,
SymbolChildId = symbolChildId,
InputId = stringUiId
});
}
public static bool TryGetAssetsForFilename(string filename, [NotNullWhen(true)] out List<Asset>? matches)
=> _assetsMatchingFilenames.TryGetValue(filename, out matches);
public const char PathSeparator = '/';
public const char PackageSeparator = ':';
public static readonly Dictionary<Guid, List<AssetReference>> ReferencesForAssetId = new(512);
public static ICollection<Asset> AllAssets => _assetsByAddress.Values;
private static readonly ConcurrentDictionary<string, Asset> _assetsByAddress = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, List<Asset>> _assetsMatchingFilenames = new(StringComparer.OrdinalIgnoreCase);
public static readonly ExternalAssetsPackage ExternalAssetsPackage = new();
}
public sealed class ExternalAssetsPackage : IResourcePackage
{
public string DisplayName => "ExternalFiles";
public string Name => "ExternalFiles";
public Guid Id { get; } = new Guid("00000000-e283-436e-ba85-2f3a1de76a9d");
public string AssetsFolder => ".";
public string Folder => ".";
public string? RootNamespace => string.Empty;
public ResourceFileWatcher? FileWatcher => null;
public bool IsReadOnly => true;
public IReadOnlyCollection<DependencyCounter> Dependencies => [];
}