#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); } /// /// Tries to convert an absolute filepath into a relative package address. /// /// true if path could be matched to a package. 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(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 _); } } /// /// 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... /// 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(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(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? matches) => _assetsMatchingFilenames.TryGetValue(filename, out matches); public const char PathSeparator = '/'; public const char PackageSeparator = ':'; public static readonly Dictionary> ReferencesForAssetId = new(512); public static ICollection AllAssets => _assetsByAddress.Values; private static readonly ConcurrentDictionary _assetsByAddress = new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary> _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 Dependencies => []; }