using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using Unity.Cinemachine; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.Timeline; namespace Streamingle.Editor { public static class TimelineCameraBatchExporter { private const string AuthoredSceneRoot = "Assets/Resourcedata/Character"; private const string ReportFileName = "yamo_batch_report.json"; private const string DatasetSchemaVersion = "1.3"; public static string WriteAuthoredSceneInventoryForCli(string outputPath) { EnsureEditMode(); var candidates = DiscoverCandidateScenes(); var report = new InventoryReport { schemaVersion = "1.0", createdUtc = DateTime.UtcNow.ToString("O"), unityVersion = Application.unityVersion, assetRoot = AuthoredSceneRoot, candidateSceneCount = candidates.Count, uniqueTimelineCount = candidates .SelectMany(candidate => candidate.timelines) .Select(timeline => timeline.assetPath) .Distinct(StringComparer.Ordinal) .Count(), candidates = candidates }; var fullPath = Path.GetFullPath(outputPath); WriteJsonAtomic(fullPath, report); return fullPath; } public static string WriteActiveSceneDiagnosticsForCli(string outputPath) { EnsureEditMode(); var scene = SceneManager.GetActiveScene(); var report = new ActiveSceneDiagnosticReport { schemaVersion = "1.0", createdUtc = DateTime.UtcNow.ToString("O"), scenePath = scene.path, directors = Resources.FindObjectsOfTypeAll() .Where(director => director.gameObject.scene == scene && director.playableAsset is TimelineAsset) .OrderBy(director => GetHierarchyPath(director.transform)) .Select(BuildDirectorDiagnostic) .ToList() }; var fullPath = Path.GetFullPath(outputPath); WriteJsonAtomic(fullPath, report); return fullPath; } public static string ExportAuthoredScenesForCli( string outputRoot, int startIndex = 0, int maxScenes = 0, bool requireAudio = false) { EnsureEditMode(); EnsureNoDirtyScenes(); var fullOutputRoot = Path.GetFullPath(outputRoot) .TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); EnsureSafeOutputRoot(fullOutputRoot); Directory.CreateDirectory(fullOutputRoot); var reportPath = Path.Combine(fullOutputRoot, ReportFileName); var candidates = DiscoverCandidateScenes(); if (candidates.Count == 0) { throw new InvalidOperationException( "No authored camera scene candidates were discovered."); } if (startIndex < 0 || startIndex >= candidates.Count) { throw new ArgumentOutOfRangeException( nameof(startIndex), startIndex, $"Expected 0 through {candidates.Count - 1}."); } var firstIndex = startIndex; var endExclusive = maxScenes > 0 ? Math.Min(candidates.Count, firstIndex + maxScenes) : candidates.Count; if (endExclusive <= firstIndex) { throw new ArgumentOutOfRangeException( nameof(maxScenes), maxScenes, "The requested range contains no scenes."); } var report = new BatchReport { schemaVersion = "1.1", startedUtc = DateTime.UtcNow.ToString("O"), lastUpdatedUtc = DateTime.UtcNow.ToString("O"), state = "running", unityVersion = Application.unityVersion, assetRoot = AuthoredSceneRoot, outputRoot = fullOutputRoot, candidateSceneCount = candidates.Count, candidateInventoryFingerprint = StableHash(string.Join( "\n", candidates.Select(candidate => candidate.scenePath + "|" + string.Join( ",", candidate.timelines.Select(timeline => timeline.assetPath))))), startIndex = firstIndex, endExclusive = endExclusive, requireAudio = requireAudio, results = new List() }; WriteJsonAtomic(reportPath, report); var originalSetup = EditorSceneManager.GetSceneManagerSetup(); try { for (var index = firstIndex; index < endExclusive; index++) { var candidate = candidates[index]; var result = ExportCandidate( candidate, index, candidates.Count, fullOutputRoot, requireAudio); report.results.Add(result); report.processedSceneCount = report.results.Count; report.lastUpdatedUtc = DateTime.UtcNow.ToString("O"); WriteJsonAtomic(reportPath, report); } } catch (Exception exception) { report.state = "failed"; report.fatalError = exception.ToString(); throw; } finally { EditorUtility.ClearProgressBar(); try { RestoreSceneSetup(originalSetup); } catch (Exception exception) { report.restoreError = exception.ToString(); } if (!string.Equals( report.state, "failed", StringComparison.Ordinal)) { var hasSceneErrors = report.results.Any(result => result.status != "exported" && result.status != "already_complete" && result.status != "exported_with_skips" && result.status != "already_complete_with_skips"); var hasSkippedTracks = report.results.Any(result => result.skippedCameraTrackCount > 0); report.state = string.IsNullOrWhiteSpace(report.restoreError) ? hasSceneErrors ? "completed_with_errors" : hasSkippedTracks ? "completed_with_skips" : "completed" : "completed_with_restore_error"; } report.completedUtc = DateTime.UtcNow.ToString("O"); report.lastUpdatedUtc = report.completedUtc; WriteJsonAtomic(reportPath, report); } if (!string.Equals( report.state, "completed", StringComparison.Ordinal)) { throw new InvalidOperationException( $"Batch finished with state '{report.state}'. " + $"See '{reportPath}'."); } return reportPath; } private static void EnsureSafeOutputRoot(string outputRoot) { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? throw new InvalidOperationException( "Unable to resolve the Unity project root."); foreach (var protectedPath in new[] { projectRoot, Path.Combine(projectRoot, "Assets"), Path.Combine(projectRoot, "Packages"), Path.Combine(projectRoot, "Library") }) { var normalizedProtected = Path.GetFullPath(protectedPath) .TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (string.Equals( outputRoot.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), normalizedProtected, StringComparison.OrdinalIgnoreCase) || outputRoot.StartsWith( normalizedProtected + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"Output root must be outside the Unity project: " + $"'{outputRoot}'."); } } } private static SceneExportResult ExportCandidate( SceneCandidate candidate, int index, int candidateCount, string outputRoot, bool requireAudio) { var sceneName = Path.GetFileNameWithoutExtension(candidate.scenePath); var datasetName = $"TimelineCamera_60fps_YAMO_{StableHash(candidate.scenePath)}_" + SanitizeFileName(sceneName, 72); var datasetPath = Path.Combine(outputRoot, datasetName); var manifestPath = Path.Combine(datasetPath, "dataset_manifest.json"); var result = new SceneExportResult { candidateIndex = index, scenePath = candidate.scenePath, timelinePaths = candidate.timelines .Select(timeline => timeline.assetPath) .ToList(), outputPath = datasetPath, startedUtc = DateTime.UtcNow.ToString("O") }; var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var workingPath = Path.Combine( $"{outputRoot}.working", $"{index:D4}_{StableHash(candidate.scenePath)}_" + Guid.NewGuid().ToString("N")); try { EditorUtility.DisplayProgressBar( "YAMO Camera Dataset Export", $"{index + 1:N0} / {candidateCount:N0}: {sceneName}", (index + 1) / (float)Math.Max(1, candidateCount)); Debug.Log( $"[CW-AI] Exporting scene {index + 1}/{candidateCount}: " + candidate.scenePath); var openedScene = EditorSceneManager.OpenScene( candidate.scenePath, OpenSceneMode.Single); if (!openedScene.IsValid() || !openedScene.isLoaded) { throw new InvalidOperationException( $"Unable to load scene '{candidate.scenePath}'."); } var loadedTimelines = DiscoverLoadedSceneTimelines(openedScene); if (loadedTimelines.Count == 0) { result.status = "not_exportable"; result.error = "The loaded scene has no Cinemachine Timeline with shots."; return result; } candidate = new SceneCandidate { scenePath = candidate.scenePath, timelines = loadedTimelines }; result.timelinePaths = loadedTimelines .Select(timeline => timeline.assetPath) .ToList(); if (File.Exists(manifestPath)) { if (ValidateCompleteDataset( datasetPath, candidate, requireAudio, openedScene, out var existingValidationError)) { PopulateTrackCounts(datasetPath, result); result.status = result.skippedCameraTrackCount > 0 ? "already_complete_with_skips" : "already_complete"; } else { result.status = "invalid_existing"; result.error = existingValidationError; } return result; } if (Directory.Exists(datasetPath)) { result.status = "partial_exists"; result.error = "An existing output directory was preserved and not overwritten."; return result; } TimelineCameraDatasetExporter.ExportActiveScene60FpsToForCli( workingPath, requireAudio, true); var workingManifestPath = Path.Combine( workingPath, "dataset_manifest.json"); if (!File.Exists(workingManifestPath)) { throw new IOException( $"Export completed without a manifest: '{workingManifestPath}'."); } if (!ValidateCompleteDataset( workingPath, candidate, requireAudio, openedScene, out var validationError)) { throw new InvalidDataException( $"Export validation failed: {validationError}"); } Directory.CreateDirectory(outputRoot); Directory.Move(workingPath, datasetPath); PopulateTrackCounts(datasetPath, result); result.status = result.skippedCameraTrackCount > 0 ? "exported_with_skips" : "exported"; } catch (Exception exception) { result.status = exception is InvalidOperationException && exception.Message.IndexOf( "does not contain a supported camera Timeline", StringComparison.Ordinal) >= 0 ? "not_exportable" : "failed"; result.error = exception.ToString(); if (Directory.Exists(workingPath)) { result.partialOutputPath = workingPath; } Debug.LogError( $"[CW-AI] Scene export failed: {candidate.scenePath}\n{exception}"); } finally { stopwatch.Stop(); result.elapsedSeconds = stopwatch.Elapsed.TotalSeconds; result.completedUtc = DateTime.UtcNow.ToString("O"); } return result; } private static void PopulateTrackCounts( string datasetPath, SceneExportResult result) { var manifest = JsonUtility.FromJson( File.ReadAllText( Path.Combine(datasetPath, "dataset_manifest.json"), Encoding.UTF8)); result.discoveredCameraTrackCount = manifest?.discoveredCameraTrackCount ?? 0; result.exportedCameraTrackCount = manifest?.exportedCameraTrackCount ?? 0; result.skippedCameraTrackCount = manifest?.skippedCameraTracks?.Count ?? 0; } private static bool ValidateCompleteDataset( string datasetPath, SceneCandidate candidate, bool requireAudio, Scene loadedScene, out string error) { try { var manifestPath = Path.Combine( datasetPath, "dataset_manifest.json"); if (!File.Exists(manifestPath)) { error = "dataset_manifest.json is missing."; return false; } var manifest = JsonUtility.FromJson( File.ReadAllText(manifestPath, Encoding.UTF8)); if (manifest == null) { error = "dataset_manifest.json could not be parsed."; return false; } if (!string.Equals( manifest.schemaVersion, DatasetSchemaVersion, StringComparison.Ordinal)) { error = $"Unsupported dataset schema '{manifest.schemaVersion}'."; return false; } if (!string.Equals( manifest.scenePath, candidate.scenePath, StringComparison.Ordinal)) { error = $"Manifest scene '{manifest.scenePath}' does not match " + $"'{candidate.scenePath}'."; return false; } var currentFingerprint = TimelineCameraDatasetExporter .ComputeSceneCameraSourceFingerprint(loadedScene); if (string.IsNullOrWhiteSpace(manifest.sourceFingerprint) || !string.Equals( manifest.sourceFingerprint, currentFingerprint, StringComparison.Ordinal)) { error = "The loaded scene camera source fingerprint does not " + "match the manifest."; return false; } if (manifest.sampleRate != 60 || manifest.songs == null || manifest.songs.Count == 0) { error = "Manifest sample rate or song list is invalid."; return false; } var skippedCount = manifest.skippedCameraTracks?.Count ?? 0; if (manifest.discoveredCameraTrackCount <= 0 || manifest.exportedCameraTrackCount != manifest.songs.Count || manifest.discoveredCameraTrackCount != manifest.exportedCameraTrackCount + skippedCount) { error = "Camera track accounting in the manifest is inconsistent."; return false; } var folderNames = new HashSet( StringComparer.OrdinalIgnoreCase); foreach (var song in manifest.songs) { if (song == null || song.frameCount <= 0 || song.sharedBoneCount <= 0 || song.shotCount <= 0 || song.missingCameraFrames != 0 || song.missingJointSamples != 0 || song.missingRootSamples != 0 || string.IsNullOrWhiteSpace(song.cameraTrackName) || !folderNames.Add(song.folderName) || !candidate.timelines.Any(timeline => string.Equals( timeline.assetPath, song.motionTimelineAssetPath, StringComparison.Ordinal)) || !TryResolveChildPath( datasetPath, song.folderName, out var songPath)) { error = "Manifest contains invalid song metadata."; return false; } if (!ValidateFileSize( songPath, song.jointsFile, (long)song.frameCount * song.sharedBoneCount * 3 * sizeof(float), out error) || !ValidateFileSize( songPath, song.cameraFile, (long)song.frameCount * 9 * sizeof(float), out error) || !ValidateFileSize( songPath, song.rootFile, (long)song.frameCount * 14 * sizeof(float), out error) || !ValidateFileSize( songPath, song.shotIndexFile, (long)song.frameCount * sizeof(int), out error) || !ValidateFileSize( songPath, song.timeFile, (long)song.frameCount * sizeof(double), out error) || !ValidateRequiredFile( songPath, "metadata.json", out error, true) || !ValidateRequiredFile( songPath, song.shotsFile, out error, true) || !ValidateRequiredFile( songPath, song.previewFile, out error, true)) { return false; } if (requireAudio && string.IsNullOrWhiteSpace(song.audioFile)) { error = "Required source audio is not embedded."; return false; } if (!string.IsNullOrWhiteSpace(song.audioFile) && !ValidateRequiredFile( songPath, song.audioFile, out error, true)) { return false; } } error = string.Empty; return true; } catch (Exception exception) { error = exception.Message; return false; } } private static bool ValidateFileSize( string directory, string relativePath, long expectedBytes, out string error) { if (!TryResolveChildPath( directory, relativePath, out var fullPath) || !File.Exists(fullPath)) { error = $"Required file '{relativePath}' is missing."; return false; } var actualBytes = new FileInfo(fullPath).Length; if (actualBytes != expectedBytes) { error = $"File '{relativePath}' has {actualBytes} bytes; " + $"{expectedBytes} were expected."; return false; } error = string.Empty; return true; } private static bool ValidateRequiredFile( string directory, string relativePath, out string error, bool requireNonEmpty = false) { if (!TryResolveChildPath( directory, relativePath, out var fullPath) || !File.Exists(fullPath)) { error = $"Required file '{relativePath}' is missing."; return false; } if (requireNonEmpty && new FileInfo(fullPath).Length == 0) { error = $"Required file '{relativePath}' is empty."; return false; } error = string.Empty; return true; } private static bool TryResolveChildPath( string directory, string relativePath, out string fullPath) { fullPath = string.Empty; if (string.IsNullOrWhiteSpace(relativePath)) { return false; } var root = Path.GetFullPath(directory) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var candidate = Path.GetFullPath(Path.Combine(root, relativePath)); if (!candidate.StartsWith( root, StringComparison.OrdinalIgnoreCase)) { return false; } fullPath = candidate; return true; } private static List DiscoverCandidateScenes() { var timelineByGuid = new Dictionary( StringComparer.Ordinal); foreach (var guid in AssetDatabase.FindAssets( "t:TimelineAsset", new[] { AuthoredSceneRoot })) { var assetPath = AssetDatabase.GUIDToAssetPath(guid); var timeline = AssetDatabase.LoadAssetAtPath(assetPath); var inventory = BuildTimelineInventory(timeline); if (inventory == null) { continue; } inventory.assetPath = assetPath; timelineByGuid[guid] = inventory; } var prefabTimelines = BuildPrefabTimelineMap(timelineByGuid); var candidates = new List(); foreach (var guid in AssetDatabase.FindAssets( "t:Scene", new[] { AuthoredSceneRoot })) { var scenePath = AssetDatabase.GUIDToAssetPath(guid); var timelines = FindPlayableDirectorTimelineGuids(scenePath) .Concat(FindReferencedGuids(scenePath)) .Concat(FindSourcePrefabGuids(scenePath) .SelectMany(prefabGuid => ResolvePrefabTimelineGuids( prefabGuid, prefabTimelines, new HashSet(StringComparer.Ordinal)))) .Where(timelineByGuid.ContainsKey) .Select(timelineGuid => timelineByGuid[timelineGuid]) .GroupBy(timeline => timeline.assetPath, StringComparer.Ordinal) .Select(group => group.First()) .OrderBy(timeline => timeline.assetPath, StringComparer.Ordinal) .ToList(); if (timelines.Count == 0) { continue; } candidates.Add(new SceneCandidate { scenePath = scenePath, timelines = timelines }); } return candidates .OrderBy(candidate => candidate.scenePath, StringComparer.Ordinal) .ToList(); } private static TimelineInventory BuildTimelineInventory( TimelineAsset timeline) { if (timeline == null) { return null; } var tracks = timeline.GetOutputTracks().ToArray(); var cameraTracks = tracks.OfType().ToArray(); var shotCount = cameraTracks .SelectMany(track => track.GetClips()) .Count(clip => clip.asset is CinemachineShot); if (shotCount == 0) { return null; } var audioTracks = tracks.OfType().ToArray(); return new TimelineInventory { assetPath = AssetDatabase.GetAssetPath(timeline), timelineName = timeline.name, duration = timeline.duration, cameraTrackCount = cameraTracks.Length, shotCount = shotCount, animationTrackCount = tracks.OfType().Count(), audioTrackCount = audioTracks.Length, audioClipCount = audioTracks .SelectMany(track => track.GetClips()) .Count(clip => clip.asset is AudioPlayableAsset audioAsset && audioAsset.clip != null) }; } private static List DiscoverLoadedSceneTimelines( Scene scene) { return Resources .FindObjectsOfTypeAll() .Where(director => director.gameObject.scene == scene && director.playableAsset is TimelineAsset) .Select(director => BuildTimelineInventory( director.playableAsset as TimelineAsset)) .Where(inventory => inventory != null) .GroupBy( inventory => inventory.assetPath, StringComparer.Ordinal) .Select(group => group.First()) .OrderBy( inventory => inventory.assetPath, StringComparer.Ordinal) .ToList(); } private static Dictionary BuildPrefabTimelineMap( IReadOnlyDictionary timelineByGuid) { var result = new Dictionary( StringComparer.Ordinal); foreach (var prefabGuid in AssetDatabase.FindAssets( "t:Prefab", new[] { AuthoredSceneRoot })) { var prefabPath = AssetDatabase.GUIDToAssetPath(prefabGuid); result[prefabGuid] = new PrefabTimelineReferences { timelineGuids = FindPlayableDirectorTimelineGuids(prefabPath) .Where(timelineByGuid.ContainsKey) .Distinct(StringComparer.Ordinal) .ToList(), sourcePrefabGuids = FindSourcePrefabGuids(prefabPath) .Distinct(StringComparer.Ordinal) .ToList() }; } return result; } private static IEnumerable ResolvePrefabTimelineGuids( string prefabGuid, IReadOnlyDictionary prefabTimelines, HashSet visiting) { if (!visiting.Add(prefabGuid) || !prefabTimelines.TryGetValue( prefabGuid, out var references)) { yield break; } foreach (var timelineGuid in references.timelineGuids) { yield return timelineGuid; } foreach (var sourcePrefabGuid in references.sourcePrefabGuids) { foreach (var timelineGuid in ResolvePrefabTimelineGuids( sourcePrefabGuid, prefabTimelines, visiting)) { yield return timelineGuid; } } visiting.Remove(prefabGuid); } private static DirectorDiagnostic BuildDirectorDiagnostic( UnityEngine.Playables.PlayableDirector director) { var timeline = (TimelineAsset)director.playableAsset; var tracks = timeline.GetOutputTracks().ToArray(); return new DirectorDiagnostic { directorPath = GetHierarchyPath(director.transform), timelinePath = AssetDatabase.GetAssetPath(timeline), timelineName = timeline.name, duration = director.duration, animationTracks = tracks .OfType() .Select(track => BuildAnimationTrackDiagnostic(director, track)) .ToList(), cameraTrackCount = tracks.OfType().Count(), shotCount = tracks .OfType() .SelectMany(track => track.GetClips()) .Count(clip => clip.asset is CinemachineShot), resolvedCameraCount = tracks .OfType() .SelectMany(track => track.GetClips()) .Select(clip => clip.asset as CinemachineShot) .Count(shot => shot != null && shot.VirtualCamera.Resolve(director) != null), audioClipPaths = tracks .OfType() .SelectMany(track => track.GetClips()) .Select(clip => clip.asset as AudioPlayableAsset) .Where(asset => asset?.clip != null) .Select(asset => AssetDatabase.GetAssetPath(asset.clip)) .ToList() }; } private static AnimationTrackDiagnostic BuildAnimationTrackDiagnostic( UnityEngine.Playables.PlayableDirector director, AnimationTrack track) { var binding = director.GetGenericBinding(track); var animator = binding as Animator; var clips = track.GetClips() .Select(clip => clip.asset as AnimationPlayableAsset) .Where(asset => asset?.clip != null) .Select(asset => asset.clip) .ToList(); if (track.infiniteClip != null) { clips.Add(track.infiniteClip); } var transformPaths = clips .Distinct() .SelectMany(AnimationUtility.GetCurveBindings) .Where(bindingInfo => bindingInfo.type == typeof(Transform)) .Select(bindingInfo => bindingInfo.path) .Distinct(StringComparer.Ordinal) .ToArray(); return new AnimationTrackDiagnostic { trackName = track.name, muted = track.muted, bindingType = binding?.GetType().FullName ?? string.Empty, bindingName = binding is UnityEngine.Object unityObject ? unityObject.name : string.Empty, animatorPath = animator != null ? GetHierarchyPath(animator.transform) : string.Empty, animatorIsHuman = animator != null && animator.isHuman, animatorActive = animator != null && animator.gameObject.activeInHierarchy, avatarName = animator?.avatar != null ? animator.avatar.name : string.Empty, avatarIsValid = animator?.avatar != null && animator.avatar.isValid, transformCount = animator != null ? animator.GetComponentsInChildren(true).Length : 0, animationClipCount = clips.Distinct().Count(), transformCurvePathCount = transformPaths.Length, firstTransformPaths = transformPaths.Take(12).ToList() }; } private static IEnumerable FindPlayableDirectorTimelineGuids( string scenePath) { var fullPath = GetFullAssetPath(scenePath); var isPlayableDirector = false; foreach (var line in File.ReadLines(fullPath)) { if (line.StartsWith("--- !u!", StringComparison.Ordinal)) { isPlayableDirector = line.StartsWith( "--- !u!320 ", StringComparison.Ordinal); continue; } if (!isPlayableDirector || line.IndexOf( "m_PlayableAsset:", StringComparison.Ordinal) < 0) { continue; } const string guidMarker = "guid: "; var guidStart = line.IndexOf( guidMarker, StringComparison.Ordinal); if (guidStart < 0) { continue; } guidStart += guidMarker.Length; if (line.Length >= guidStart + 32) { yield return line.Substring(guidStart, 32); } } } private static IEnumerable FindSourcePrefabGuids( string assetPath) { var fullPath = GetFullAssetPath(assetPath); foreach (var line in File.ReadLines(fullPath)) { if (line.IndexOf( "m_SourcePrefab:", StringComparison.Ordinal) < 0) { continue; } const string guidMarker = "guid: "; var guidStart = line.IndexOf( guidMarker, StringComparison.Ordinal); if (guidStart < 0) { continue; } guidStart += guidMarker.Length; if (line.Length >= guidStart + 32) { yield return line.Substring(guidStart, 32); } } } private static IEnumerable FindReferencedGuids( string assetPath) { var fullPath = GetFullAssetPath(assetPath); const string guidMarker = "guid: "; foreach (var line in File.ReadLines(fullPath)) { var searchStart = 0; while (searchStart < line.Length) { var guidStart = line.IndexOf( guidMarker, searchStart, StringComparison.Ordinal); if (guidStart < 0) { break; } guidStart += guidMarker.Length; if (line.Length >= guidStart + 32) { yield return line.Substring(guidStart, 32); } searchStart = guidStart + 32; } } } private static string GetFullAssetPath(string assetPath) { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? throw new InvalidOperationException( "Unable to resolve the Unity project root."); return Path.GetFullPath(Path.Combine(projectRoot, assetPath)); } private static void EnsureEditMode() { if (EditorApplication.isPlayingOrWillChangePlaymode) { throw new InvalidOperationException( "Camera dataset operations must run in Edit Mode."); } } private static void EnsureNoDirtyScenes() { if (PrefabStageUtility.GetCurrentPrefabStage() != null) { throw new InvalidOperationException( "Close Prefab Mode before batch export."); } var dirtyScenePaths = Enumerable.Range(0, SceneManager.sceneCount) .Select(SceneManager.GetSceneAt) .Where(scene => scene.isDirty) .Select(scene => string.IsNullOrWhiteSpace(scene.path) ? $"" : scene.path) .ToArray(); if (dirtyScenePaths.Length > 0) { throw new InvalidOperationException( "Save or discard dirty scenes before batch export: " + string.Join(", ", dirtyScenePaths)); } } private static void RestoreSceneSetup(SceneSetup[] setup) { if (setup != null && setup.Length > 0) { EditorSceneManager.RestoreSceneManagerSetup(setup); return; } EditorSceneManager.NewScene( NewSceneSetup.DefaultGameObjects, NewSceneMode.Single); } private static string StableHash(string value) { using var sha256 = SHA256.Create(); var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(value)); return string.Concat(bytes .Take(4) .Select(item => item.ToString("x2"))); } private static string SanitizeFileName(string value, int maximumLength) { var invalidCharacters = Path.GetInvalidFileNameChars(); var sanitized = new string(value .Select(character => invalidCharacters.Contains(character) ? '_' : character) .ToArray()) .Trim(); if (string.IsNullOrWhiteSpace(sanitized)) { sanitized = "Scene"; } return sanitized.Length <= maximumLength ? sanitized : sanitized.Substring(0, maximumLength); } private static string GetHierarchyPath(Transform transform) { var names = new List(); while (transform != null) { names.Add(transform.name); transform = transform.parent; } names.Reverse(); return string.Join("/", names); } private static void WriteJsonAtomic(string path, T value) { var directory = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } var payload = JsonUtility.ToJson(value, true); const int maximumAttempts = 8; for (var attempt = 1; attempt <= maximumAttempts; attempt++) { var temporaryPath = $"{path}.tmp"; try { File.WriteAllText( temporaryPath, payload, Encoding.UTF8); if (File.Exists(path)) { File.Replace(temporaryPath, path, null); } else { File.Move(temporaryPath, path); } return; } catch (IOException) when (attempt < maximumAttempts) { System.Threading.Thread.Sleep(attempt * 125); } catch (UnauthorizedAccessException) when (attempt < maximumAttempts) { System.Threading.Thread.Sleep(attempt * 125); } } } [Serializable] private sealed class InventoryReport { public string schemaVersion; public string createdUtc; public string unityVersion; public string assetRoot; public int candidateSceneCount; public int uniqueTimelineCount; public List candidates; } [Serializable] private sealed class ActiveSceneDiagnosticReport { public string schemaVersion; public string createdUtc; public string scenePath; public List directors; } [Serializable] private sealed class DirectorDiagnostic { public string directorPath; public string timelinePath; public string timelineName; public double duration; public List animationTracks; public int cameraTrackCount; public int shotCount; public int resolvedCameraCount; public List audioClipPaths; } [Serializable] private sealed class AnimationTrackDiagnostic { public string trackName; public bool muted; public string bindingType; public string bindingName; public string animatorPath; public bool animatorIsHuman; public bool animatorActive; public string avatarName; public bool avatarIsValid; public int transformCount; public int animationClipCount; public int transformCurvePathCount; public List firstTransformPaths; } [Serializable] private sealed class SceneCandidate { public string scenePath; public List timelines; } [Serializable] private sealed class TimelineInventory { public string assetPath; public string timelineName; public double duration; public int cameraTrackCount; public int shotCount; public int animationTrackCount; public int audioTrackCount; public int audioClipCount; } private sealed class PrefabTimelineReferences { public List timelineGuids; public List sourcePrefabGuids; } [Serializable] private sealed class BatchReport { public string schemaVersion; public string startedUtc; public string lastUpdatedUtc; public string completedUtc; public string state; public string unityVersion; public string assetRoot; public string outputRoot; public int candidateSceneCount; public string candidateInventoryFingerprint; public int startIndex; public int endExclusive; public bool requireAudio; public int processedSceneCount; public List results; public string restoreError; public string fatalError; } [Serializable] private sealed class SceneExportResult { public int candidateIndex; public string scenePath; public List timelinePaths; public string outputPath; public string status; public string startedUtc; public string completedUtc; public double elapsedSeconds; public string error; public string partialOutputPath; public int discoveredCameraTrackCount; public int exportedCameraTrackCount; public int skippedCameraTrackCount; } [Serializable] private sealed class DatasetManifestProbe { public string schemaVersion; public string scenePath; public string sourceFingerprint; public int sampleRate; public int discoveredCameraTrackCount; public int exportedCameraTrackCount; public List skippedCameraTracks; public List songs; } [Serializable] private sealed class SkippedCameraTrackProbe { public string timelineAssetPath; public string trackName; public bool muted; public string reason; } [Serializable] private sealed class SongProbe { public string folderName; public int frameCount; public int sharedBoneCount; public int shotCount; public int missingCameraFrames; public int missingJointSamples; public int missingRootSamples; public string cameraTrackName; public string motionTimelineAssetPath; public string audioFile; public string jointsFile; public string cameraFile; public string rootFile; public string shotIndexFile; public string timeFile; public string shotsFile; public string previewFile; } } }