#nullable enable using System.Diagnostics.CodeAnalysis; using System.IO; using T3.Core.Compilation; using T3.Core.DataTypes; using T3.Core.IO; using T3.Core.Operator; using T3.Core.Operator.Slots; using T3.Core.Settings; using T3.Core.Resource; using T3.Core.Resource.Assets; using T3.Core.SystemUi; using T3.Editor.Compilation; using T3.Editor.Gui; using T3.Editor.Gui.InputUi.SimpleInputUis; using T3.Editor.Gui.Interaction.Timing; using T3.Serialization; namespace T3.Editor.UiModel.Exporting; internal static partial class PlayerExporter { public static bool TryExportInstance(Instance composition, SymbolUi.Child childUi, out string reason, out string exportDir) { T3Ui.Save(false); // Collect all ops and types var exportedInstance = composition.Children[childUi.SymbolChild.Id]; var symbol = exportedInstance.Symbol; Log.Info($"Exporting {symbol.Name}..."); var output = exportedInstance.Outputs.FirstOrDefault(); if (output == null || output.ValueType != typeof(Texture2D)) { reason = "Can only export ops with 'Texture2D' output"; exportDir = string.Empty; return false; } // Traverse starting at output and collect everything var exportData = new ExportData(); exportData.TryAddSymbol(symbol); var package = composition.Symbol.SymbolPackage; exportDir = Path.Combine(package.Folder, FileLocations.ExportSubFolder, childUi.SymbolChild.ReadableName); if (!TryRemoveExistingExportDir(out reason, exportDir)) return false; Directory.CreateDirectory(exportDir); var operatorDir = Path.Combine(exportDir, FileLocations.OperatorsSubFolder); Directory.CreateDirectory(operatorDir); // Copy assemblies into export dir. Get symbol packages directly used by the exported symbols if (!TryExportSymbolPackages(out reason, exportData, operatorDir)) return false; // Get soundtrack or show warning message if (TryFindSoundtrack(exportedInstance, symbol, out var address)) { if (AssetRegistry.TryGetAsset(address, out var soundtrackAsset)) { exportData.TryAddSharedAsset(soundtrackAsset); } } else { const string yes = "Yes"; var choice = BlockingWindow.Instance.ShowMessageBox("No defined soundtrack found. Continue with export?", "No soundtrack", yes, "No, cancel export"); if (choice != yes) { reason = $"Failed to find soundTrack for [{symbol.Name}] - export cancelled, see log for details"; return false; } } // Include implicitly shared assets foreach (var shared in (string[]) [ "Lib:shaders/dx11/resolve-multisampled-depth-buffer-cs.hlsl", "Lib:pbr/studio_small_08-prefiltered.dds", "Lib:pbr/BRDF-LookUp.dds", ]) { if (AssetRegistry.TryGetAsset(shared, out var soundtrackAsset)) { exportData.TryAddSharedAsset(soundtrackAsset); } else { Log.Warning("Can't resolved shared asset " + shared); } } // Collect used assets RecursivelyCollectExportData(output, exportData); exportData.PrintInfo(); if (!AssetExportItem.TryCopyItems(exportData.ExportItems, exportDir)) { reason = "Failed to copy resource files - see log for details"; return false; } if (!TryExportAssetsOnlyPackages(exportData, operatorDir, out reason)) return false; // Copy shared assets var editorResourcesTargetDir = Path.Combine(exportDir, FileLocations.EditorResourcesSubfolder); Directory.CreateDirectory(editorResourcesTargetDir); if (!TryCopyDirectory(SharedResources.EditorResourcesDirectory, editorResourcesTargetDir, out reason)) return false; // Exclude optional dependency files that are not required by any exported symbol. var excludedDependencyFiles = GetUnusedMappedDependencyFiles(exportData); var playerDirectory = Path.Combine(FileLocations.StartFolder, "Player"); if (!TryCopyDirectory(playerDirectory, exportDir, out reason, excludeFiles: excludedDependencyFiles)) return false; if (!TryExportSettings(exportDir, symbol, out reason)) return false; reason = "Exported successfully to " + exportDir; return true; } /// /// Compile EditableProjects and copy read only projects. /// private static bool TryExportSymbolPackages(out string reason, ExportData exportData, string operatorDir) { string[] excludeSubdirectories = [ ".git", FileLocations.SymbolUiSubFolder, FileLocations.SourceCodeSubFolder, FileLocations.ExportSubFolder, FileLocations.AssetsSubfolder, // Assets are filtered by referencing address and copied separately ]; foreach (var package in exportData.SymbolPackages) { Log.Debug($"Exporting package {package.Name}..."); var packageName = package.Name; var targetDirectory = Path.Combine(operatorDir, packageName); Directory.CreateDirectory(targetDirectory); if (package is EditableSymbolProject project) { project.SaveModifiedSymbols(); if (!project.CsProjectFile.TryCompileRelease(false, out var failureLog)) { reason = $"Failed to compile project \"{packageName}\" - \n{failureLog}"; return false; } // Copy the resulting directory into the target directory var sourceDir = project.CsProjectFile.GetBuildTargetDirectory(CsProjectFile.PlayerBuildMode); // Copy contents recursively into the target directory if (!TryCopyDirectory(sourceDir, targetDirectory, out reason, excludeSubdirectories)) return false; } else { // Copy full directory into target directory recursively, maintaining folder layout var directoryToCopy = package.AssemblyInformation.Directory; if (!TryCopyDirectory(directoryToCopy, targetDirectory, out reason, excludeSubdirectories)) return false; } } reason = string.Empty; return true; } /// /// If only Assets but no Symbols are used from a package, we still need to copy its OperatorPackage.json file, /// so the player can register these assets on startup. /// private static bool TryExportAssetsOnlyPackages(ExportData exportData, string operatorDir, out string reason) { reason = string.Empty; foreach (var assetPackage in exportData.AssetPackages) { var alreadyIncluded = exportData.SymbolPackages.Any(sp => sp.Id == assetPackage.Id); if (alreadyIncluded) continue; var sourcePath = Path.Combine(assetPackage.Folder, ReleaseInfo.FileName); var targetPath = Path.Combine(operatorDir, assetPackage.Name, ReleaseInfo.FileName); if (!TryCopyFile(sourcePath, targetPath)) { reason = $"Failed to copy {sourcePath} for asset package {assetPackage}"; return false; } } return true; } /// /// Recursively copies a directory to a target directory, excluding specified subfolders, files, and file extensions. /// private static bool TryCopyDirectory(string directoryToCopy, string targetDirectory, out string reason, string[]? excludeSubFolders = null, string[]? excludeFiles = null, string[]? excludeFileExtensions = null) { try { var rootFiles = Directory.EnumerateFiles(directoryToCopy, "*", SearchOption.TopDirectoryOnly); var subfolderFiles = Directory.EnumerateDirectories(directoryToCopy, "*", SearchOption.TopDirectoryOnly) .Where(subDir => { if (excludeSubFolders == null) return true; var dirName = Path.GetRelativePath(directoryToCopy, subDir); foreach (var excludeSubFolder in excludeSubFolders) { if (string.Equals(dirName, excludeSubFolder, StringComparison.OrdinalIgnoreCase)) { return false; } } return true; }) .SelectMany(subDir => Directory.EnumerateFiles(subDir, "*", SearchOption.AllDirectories)); var files = rootFiles.Concat(subfolderFiles); var shouldExcludeFiles = excludeFiles != null; var shouldExcludeFileExtensions = excludeFileExtensions != null; foreach (var file in files) { if (shouldExcludeFiles && excludeFiles!.Contains(Path.GetFileName(file))) continue; bool shouldSkipBasedOnExtension = false; if (shouldExcludeFileExtensions) { foreach (var extension in excludeFileExtensions!) { if (file.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) { shouldSkipBasedOnExtension = true; break; } } } if (shouldSkipBasedOnExtension) continue; var relativePath = Path.GetRelativePath(directoryToCopy, file); var targetPath = Path.Combine(targetDirectory, relativePath); var targetDir = Path.GetDirectoryName(targetPath); if (targetDir == null) { reason = $"Failed to get directory for \"{targetPath}\" - is it missing a file extension?"; return false; } Directory.CreateDirectory(targetDir); File.Copy(file, targetPath, true); } } catch (Exception e) { reason = $"Failed to copy directory {directoryToCopy} to {targetDirectory}. Exception:\n{e}"; return false; } reason = string.Empty; return true; } private static bool TryCopyFile(string sourcePath, string targetPath) { var directory = Path.GetDirectoryName(targetPath); try { Directory.CreateDirectory(directory!); File.Copy(sourcePath, targetPath, true); return true; } catch (Exception e) { Log.Error($"Failed to copy resource file for export: {sourcePath} {e.Message}"); } return false; } private static void RecursivelyCollectExportData(ISlot slot, ExportData exportData) { var gotConnection = slot.TryGetFirstConnection(out var firstConnection); if (slot is IInputSlot) { if (gotConnection) { RecursivelyCollectExportData(firstConnection, exportData); } CheckInputForResourcePath(slot, exportData); return; } if (gotConnection) { // slot is an output of an composition op RecursivelyCollectExportData(firstConnection, exportData); exportData.TryAddInstance(slot.Parent); return; } var parent = slot.Parent; if (!exportData.TryAddInstance(parent)) return; // already visited foreach (var input in parent.Inputs) { CheckInputForResourcePath(input, exportData); if (!input.HasInputConnections) continue; if (input.TryGetAsMultiInput(out var multiInput)) { foreach (var entry in multiInput.GetCollectedInputs()) { RecursivelyCollectExportData(entry, exportData); } } else if (input.TryGetFirstConnection(out var inputsFirstConnection)) { RecursivelyCollectExportData(inputsFirstConnection, exportData); } } } private static bool TryFindSoundtrack(Instance instance, Symbol symbol, [NotNullWhen(true)] out string? address) { var playbackSettings = symbol.CompositionSettings; if (playbackSettings == null) { Log.Warning($"Project {symbol} has no playback settings"); address = null; return false; } if (playbackSettings.TryGetMainSoundtrack(instance, out var soundtrack) is not true) { if (PlaybackUtils.TryFindingSoundtrack(out soundtrack, out _)) { Log.Warning($"You should define soundtracks withing the exported operators. Falling back to {soundtrack.Clip.AssetPath} set in parent..."); } else { address = null; return false; } Log.Debug("No soundtrack defined within operator."); } address = soundtrack.Clip.AssetPath; return FileResource.TryGetFileResource(soundtrack.Clip.AssetPath, instance, out _); } private static void CheckInputForResourcePath(ISlot inputSlot, ExportData exportData) { var parent = inputSlot.Parent; var inputUi = parent.GetSymbolUi().InputUis[inputSlot.Id]; if (inputUi is not StringInputUi stringInputUi) return; if (stringInputUi.Usage != StringInputUi.UsageType.FilePath && stringInputUi.Usage != StringInputUi.UsageType.DirectoryPath) return; var compositionSymbol = parent.Parent?.Symbol; if (compositionSymbol == null) return; var parentSymbolChild = compositionSymbol.Children[parent.SymbolChildId]; var value = parentSymbolChild.Inputs[inputSlot.Id].Value; if (value is not InputValue stringValue) return; var address = stringValue.Value; switch (stringInputUi.Usage) { case StringInputUi.UsageType.FilePath: { if (!AssetRegistry.TryGetAsset(address, out var asset)) { Log.Warning($" Asset not found '{address}'"); break; } exportData.TryAddSharedAsset(asset); break; } case StringInputUi.UsageType.DirectoryPath: { //var relativeDirectory = stringValue.Value; //var isFolder = relativeDirectory.EndsWith('/'); if (!AssetRegistry.TryResolveAddress(address, parent, out var absoluteDirectory, out var package, isFolder: true)) { Log.Warning($" Directory '{address}' was not found in any resource folder"); break; } // if (package == null) // { // Log.Warning($"Directory '{address}' can't be exported without a package"); // break; // } Log.Debug($"Export all files in folder {absoluteDirectory}..."); foreach (var absolutePath in Directory.EnumerateFiles(absoluteDirectory, "*", SearchOption.AllDirectories)) { var relativePathInResourceFolder = Path.GetRelativePath(package.AssetsFolder, absolutePath); exportData.TryAddExportAsset(new AssetExportItem(package.RootNamespace, relativePathInResourceFolder, absolutePath)); } break; } case StringInputUi.UsageType.Default: case StringInputUi.UsageType.Multiline: case StringInputUi.UsageType.CustomDropdown: default: break; } } private static bool TryExportSettings(string exportDir, Symbol symbol, out string reason) { reason = string.Empty; // Update project settings var exportSettings = new ExportSettings(OperatorId: symbol.Id, ApplicationTitle: symbol.Name, WindowMode: CompositionSettings.Current.Export.DefaultWindowMode, ConfigData: CoreSettings.Config, Author: symbol.SymbolPackage.AssemblyInformation?.Name ?? string.Empty, // todo - actual author name BuildId: Guid.NewGuid(), EditorVersion: Program.VersionText); const string exportSettingsFile = "exportSettings.json"; if (JsonUtils.TrySaveJson(exportSettings, Path.Combine(exportDir, exportSettingsFile))) return true; reason = $"Failed to save export settings to {exportSettingsFile}"; return false; } private static bool TryRemoveExistingExportDir(out string reason, string exportDir) { try { if (Directory.Exists(exportDir)) { Directory.Delete(exportDir, recursive: true); } } catch (Exception e) { reason = $"Failed to remove export dir: {exportDir} ({e.Message}). Please close all files and File Explorer windows."; return false; } reason = string.Empty; return true; } private static string[] GetUnusedMappedDependencyFiles(ExportData exportData) { var requiredFiles = new HashSet(StringComparer.OrdinalIgnoreCase); var allMappedFiles = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var definition in _dependencyDefinitions) { foreach (var filename in definition.Filenames) { allMappedFiles.Add(filename); } var isRequired = false; foreach (var symbolId in definition.SymbolIds) { if (!exportData.ContainsSymbolId(symbolId)) continue; isRequired = true; break; } if (!isRequired) continue; foreach (var filename in definition.Filenames) { requiredFiles.Add(filename); } } allMappedFiles.ExceptWith(requiredFiles); return allMappedFiles.ToArray(); } private sealed record OpDependencyDefinition(List SymbolIds, List Filenames); private static readonly List _dependencyDefinitions = [ new([new Guid("31ab98ec-5e79-4667-9a85-2fb168f41fa1")], [ "AbletonLink.deps.json", "AbletonLinkDLL.dll", "AbletonLink.dll", ]), // Things related to webcams and MediaPipe new([ new Guid("cd5a182e-254b-4e65-820b-ff754122614c"), // VideoDeviceInput new Guid("A1B2C3D4-E5F6-4798-89AB-CDEF12345679"), // MediaPipeFaceDetection new Guid("9b2c3d4e-5f6a-4798-89ab-cdef12345678"), // FaceLandmark new Guid("4567890a-bcde-f123-4567-890abcdef123"), // HandLandmark new Guid("23456789-0abc-def1-2345-67890abcdef1"), // Image Segmentation new Guid("12345678-90ab-cdef-1234-567890abcdef"), // Object Detection new Guid("34567890-abcd-ef12-3456-7890abcdef12"), // PoseLandmarkDetection new Guid("8b23c93b-3b45-4c9b-9c23-4d5e6f7a8b9c"), // OnvifCamera new Guid("7b4d3c2a-5b16-4b2a-8f3a-7e8c9d0b1a2b"), // CameraCalibrartor // VideoStreamInput decodes via FFmpeg (the LGPL build shipped with the Video operator package, to // which it has now moved), so it no longer needs the OpenCV DLLs. ], [ "mediapipe_c.dll", "Emgu.CV.dll", "Processing.NDI.Lib.x64.dll", "OpenGL.Net.dll", "OpenCvSharp.Extensions.dll", "OpenCvSharpExtern.dll", "opencv_videoio_ffmpeg4110_64.dll", "DirectShowLib.dll", "cvextern.dll", ]), // NDI new([ new Guid("7567c3b0-9d91-40d2-899d-3a95b481d023"), // NdiInput new Guid("9412d0f4-dab8-4145-9719-10395e154fa7"), // NdiOutput ], [ "NDILibDotNet6.dll", ]), // Unsplash new([ new Guid("89162b9f-75f5-4d32-9d28-8259cf47cf58"), ], [ "Unsplasharp.dll", ]), new([ new Guid("fc03dcd0-6f2f-4507-be06-1ed105607489"), //ArtnetInput new Guid("98efc7c8-cafd-45ee-8746-14f37e9f59f8"), //ArtnetOutput new Guid("faa3e182-96e6-45e7-b037-fb2acd88825b"), //ArtnetPixelOutput ], [ "ArtNet.dll", ]), ]; }