using System; using System.Collections.Generic; using System.Globalization; 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 SourceFingerprintRegistryFileName = "camera_source_fingerprint_registry.json"; private const string SourceFingerprintRegistrySchemaVersion = "1.0"; private const string DatasetSchemaVersion = "1.3"; private const int RevisionFingerprintLength = 16; 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 WriteAuthoredSceneSubjectReportForCli( string outputPath, int startIndex = 0, int maxScenes = 0, string skipIndexesCsv = "") { EnsureEditMode(); EnsureNoDirtyScenes(); 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 skippedIndexes = new HashSet( ParseCandidateIndexes(skipIndexesCsv, nameof(skipIndexesCsv))); var invalidSkippedIndexes = skippedIndexes .Where(index => index < firstIndex || index >= endExclusive) .OrderBy(index => index) .ToList(); if (invalidSkippedIndexes.Count > 0) { throw new ArgumentOutOfRangeException( nameof(skipIndexesCsv), "Skipped candidate indexes must fall inside the requested " + $"range [{firstIndex}, {endExclusive}): " + string.Join(", ", invalidSkippedIndexes)); } var fullOutputPath = Path.GetFullPath(outputPath); var outputDirectory = Path.GetDirectoryName(fullOutputPath) ?? throw new InvalidOperationException( $"Unable to resolve the report directory for '{outputPath}'."); EnsureSafeOutputRoot(outputDirectory); var report = new SubjectBatchReport { schemaVersion = "1.0", startedUtc = DateTime.UtcNow.ToString("O"), lastUpdatedUtc = DateTime.UtcNow.ToString("O"), state = "running", unityVersion = Application.unityVersion, assetRoot = AuthoredSceneRoot, outputPath = fullOutputPath, 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, skippedCandidateIndexes = skippedIndexes .OrderBy(index => index) .ToList(), results = new List() }; WriteJsonAtomic(fullOutputPath, report); var originalSetup = EditorSceneManager.GetSceneManagerSetup(); try { for (var index = firstIndex; index < endExclusive; index++) { var candidate = candidates[index]; var result = skippedIndexes.Contains(index) ? BuildSkippedSubjectResult(candidate, index) : ScanSubjectCandidate( candidate, index, candidates.Count); report.results.Add(result); report.processedSceneCount = report.results.Count; report.lastUpdatedUtc = DateTime.UtcNow.ToString("O"); WriteJsonAtomic(fullOutputPath, 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 hasErrors = report.results.Any(result => result.status == "failed" || result.status == "analyzed_with_errors"); var hasSkips = report.results.Any(result => result.status == "skipped_dependency"); report.state = string.IsNullOrWhiteSpace(report.restoreError) ? hasErrors ? "completed_with_errors" : hasSkips ? "completed_with_skips" : "completed" : "completed_with_restore_error"; } report.completedUtc = DateTime.UtcNow.ToString("O"); report.lastUpdatedUtc = report.completedUtc; WriteJsonAtomic(fullOutputPath, report); } if (!string.IsNullOrWhiteSpace(report.restoreError)) { throw new InvalidOperationException( $"The subject report was written to '{fullOutputPath}', " + "but the original scene setup could not be restored."); } return fullOutputPath; } public static string ExportAuthoredScenesForCli( string outputRoot, int startIndex = 0, int maxScenes = 0, bool requireAudio = false) { return ExportAuthoredScenesForCliInternal( outputRoot, startIndex, maxScenes, requireAudio, false, Array.Empty(), Array.Empty()); } public static string ExportAuthoredScenesPreservingRevisionsForCli( string outputRoot, int startIndex = 0, int maxScenes = 0, bool requireAudio = false) { return ExportAuthoredScenesForCliInternal( outputRoot, startIndex, maxScenes, requireAudio, true, Array.Empty(), Array.Empty()); } public static string ExportAuthoredScenesPreservingRevisionsWithSkipsForCli( string outputRoot, int startIndex = 0, int maxScenes = 0, bool requireAudio = false, string skipIndexesCsv = "") { return ExportAuthoredScenesForCliInternal( outputRoot, startIndex, maxScenes, requireAudio, true, ParseCandidateIndexes( skipIndexesCsv, nameof(skipIndexesCsv)), Array.Empty()); } public static string ExportAuthoredScenesPreservingRevisionsWithOverridesForCli( string outputRoot, int startIndex = 0, int maxScenes = 0, bool requireAudio = false, string skipIndexesCsv = "", string forceRevisionIndexesCsv = "") { return ExportAuthoredScenesForCliInternal( outputRoot, startIndex, maxScenes, requireAudio, true, ParseCandidateIndexes( skipIndexesCsv, nameof(skipIndexesCsv)), ParseCandidateIndexes( forceRevisionIndexesCsv, nameof(forceRevisionIndexesCsv))); } private static string ExportAuthoredScenesForCliInternal( string outputRoot, int startIndex, int maxScenes, bool requireAudio, bool preserveRevisions, IReadOnlyCollection skippedCandidateIndexes, IReadOnlyCollection forcedRevisionCandidateIndexes) { EnsureEditMode(); EnsureNoDirtyScenes(); var fullOutputRoot = Path.GetFullPath(outputRoot) .TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); EnsureSafeOutputRoot(fullOutputRoot); Directory.CreateDirectory(fullOutputRoot); var reportPath = Path.Combine(fullOutputRoot, ReportFileName); var sourceFingerprintRegistryPath = Path.Combine( fullOutputRoot, SourceFingerprintRegistryFileName); var sourceFingerprintRegistry = LoadSourceFingerprintRegistry( sourceFingerprintRegistryPath); 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 skippedIndexSet = new HashSet( skippedCandidateIndexes ?? Array.Empty()); var outOfRangeSkippedIndexes = skippedIndexSet .Where(index => index < firstIndex || index >= endExclusive) .OrderBy(index => index) .ToList(); if (outOfRangeSkippedIndexes.Count > 0) { throw new ArgumentOutOfRangeException( nameof(skippedCandidateIndexes), "Skipped candidate indexes must fall inside the requested " + $"range [{firstIndex}, {endExclusive}): " + string.Join(", ", outOfRangeSkippedIndexes)); } var sortedSkippedIndexes = skippedIndexSet .OrderBy(index => index) .ToList(); var forcedRevisionIndexSet = new HashSet( forcedRevisionCandidateIndexes ?? Array.Empty()); if (!preserveRevisions && forcedRevisionIndexSet.Count > 0) { throw new ArgumentException( "Forced revisions require preserveRevisions=true.", nameof(forcedRevisionCandidateIndexes)); } var outOfRangeForcedIndexes = forcedRevisionIndexSet .Where(index => index < firstIndex || index >= endExclusive) .OrderBy(index => index) .ToList(); if (outOfRangeForcedIndexes.Count > 0) { throw new ArgumentOutOfRangeException( nameof(forcedRevisionCandidateIndexes), "Forced revision candidate indexes must fall inside the " + $"requested range [{firstIndex}, {endExclusive}): " + string.Join(", ", outOfRangeForcedIndexes)); } var conflictingIndexes = skippedIndexSet .Intersect(forcedRevisionIndexSet) .OrderBy(index => index) .ToList(); if (conflictingIndexes.Count > 0) { throw new ArgumentException( "A candidate cannot be both skipped and forced to a " + "revision: " + string.Join(", ", conflictingIndexes)); } var sortedForcedRevisionIndexes = forcedRevisionIndexSet .OrderBy(index => index) .ToList(); var report = new BatchReport { schemaVersion = preserveRevisions ? "1.3" : "1.1", startedUtc = DateTime.UtcNow.ToString("O"), lastUpdatedUtc = DateTime.UtcNow.ToString("O"), state = "running", unityVersion = Application.unityVersion, assetRoot = AuthoredSceneRoot, outputRoot = fullOutputRoot, sourceFingerprintRegistryPath = sourceFingerprintRegistryPath, sourceFingerprintRegistrySchema = SourceFingerprintRegistrySchemaVersion, sourceFingerprintRegistryEntryCount = sourceFingerprintRegistry.entries.Count, 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, preserveRevisions = preserveRevisions, skippedCandidateIndexes = sortedSkippedIndexes, forcedRevisionCandidateIndexes = sortedForcedRevisionIndexes, results = new List() }; WriteJsonAtomic(reportPath, report); var originalSetup = EditorSceneManager.GetSceneManagerSetup(); try { for (var index = firstIndex; index < endExclusive; index++) { var candidate = candidates[index]; var registryEntry = FindSourceFingerprintRegistryEntry( sourceFingerprintRegistry, BuildBaseDatasetName(candidate.scenePath), candidate.scenePath); var result = skippedIndexSet.Contains(index) ? BuildSkippedCandidateResult( candidate, index, fullOutputRoot) : ExportCandidate( candidate, index, candidates.Count, fullOutputRoot, requireAudio, preserveRevisions, forcedRevisionIndexSet.Contains(index), registryEntry); if (IsCompleteExportResult(result)) { result.sourceFingerprintBaselineRecorded = RecordSourceFingerprintBaseline( sourceFingerprintRegistry, fullOutputRoot, result); if (result.sourceFingerprintBaselineRecorded) { WriteJsonAtomic( sourceFingerprintRegistryPath, sourceFingerprintRegistry); } } report.results.Add(result); report.processedSceneCount = report.results.Count; report.sourceFingerprintRegistryEntryCount = sourceFingerprintRegistry.entries.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" && result.status != "skipped_dependency"); var hasSkippedTracks = report.results.Any(result => result.skippedCameraTrackCount > 0); var hasSkippedCandidates = report.results.Any(result => result.status == "skipped_dependency"); report.state = string.IsNullOrWhiteSpace(report.restoreError) ? hasSceneErrors ? "completed_with_errors" : hasSkippedTracks || hasSkippedCandidates ? "completed_with_skips" : "completed" : "completed_with_restore_error"; } report.completedUtc = DateTime.UtcNow.ToString("O"); report.lastUpdatedUtc = report.completedUtc; WriteJsonAtomic(reportPath, report); } var completedSuccessfully = string.Equals( report.state, "completed", StringComparison.Ordinal) || string.Equals( report.state, "completed_with_skips", StringComparison.Ordinal); if (!completedSuccessfully) { 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 SceneSubjectResult BuildSkippedSubjectResult( SceneCandidate candidate, int index) { var timestamp = DateTime.UtcNow.ToString("O"); return new SceneSubjectResult { candidateIndex = index, scenePath = candidate.scenePath, candidateTimelinePaths = candidate.timelines .Select(timeline => timeline.assetPath) .ToList(), status = "skipped_dependency", reason = "candidate_index_listed_in_skipIndexesCsv", error = "The candidate was skipped explicitly because its Unity " + "dependencies are unavailable. The scene was not opened.", startedUtc = timestamp, completedUtc = timestamp, timelines = new List() }; } private static SceneSubjectResult ScanSubjectCandidate( SceneCandidate candidate, int index, int candidateCount) { var result = new SceneSubjectResult { candidateIndex = index, scenePath = candidate.scenePath, candidateTimelinePaths = candidate.timelines .Select(timeline => timeline.assetPath) .ToList(), startedUtc = DateTime.UtcNow.ToString("O"), timelines = new List() }; var stopwatch = System.Diagnostics.Stopwatch.StartNew(); try { EditorUtility.DisplayProgressBar( "YAMO Timeline Subject Scan", $"{index + 1:N0} / {candidateCount:N0}: " + Path.GetFileNameWithoutExtension(candidate.scenePath), (index + 1) / (float)Math.Max(1, candidateCount)); var scene = EditorSceneManager.OpenScene( candidate.scenePath, OpenSceneMode.Single); if (!scene.IsValid() || !scene.isLoaded) { throw new InvalidOperationException( $"Unable to load scene '{candidate.scenePath}'."); } var directors = Resources .FindObjectsOfTypeAll() .Where(director => director.gameObject.scene == scene && director.playableAsset is TimelineAsset) .OrderBy( director => GetHierarchyPath(director.transform), StringComparer.Ordinal) .ThenBy( director => AssetDatabase.GetAssetPath( director.playableAsset), StringComparer.Ordinal) .ToList(); foreach (var director in directors) { try { result.timelines.Add( AnalyzeTimelineSubjects(director)); } catch (Exception exception) { var timeline = director.playableAsset as TimelineAsset; result.timelines.Add(new TimelineSubjectResult { status = "failed", directorPath = GetHierarchyPath(director.transform), timelineAssetPath = timeline != null ? AssetDatabase.GetAssetPath(timeline) : string.Empty, timelineName = timeline?.name ?? string.Empty, subjectMode = "unknown", subjectCountKnown = false, subjectCount = -1, confidence = "low", reason = "Timeline subject analysis failed.", error = exception.ToString(), boundAnimatorPaths = new List(), boundPerformerPaths = new List(), cameraTargetPaths = new List(), cameraTargetAnimatorPaths = new List(), subjectAnimatorPaths = new List(), animationTracks = new List(), cameraTracks = new List() }); } } result.timelineCount = result.timelines.Count; if (directors.Count == 0) { result.status = "analyzed_with_errors"; result.reason = "The loaded scene contains no Timeline PlayableDirector."; } else if (result.timelines.Any(timeline => timeline.status == "failed")) { result.status = "analyzed_with_errors"; result.reason = "One or more Timelines could not be analyzed."; } else { result.status = "analyzed"; result.reason = "Subjects were classified independently per Director/Timeline."; } } catch (Exception exception) { result.status = "failed"; result.reason = "The candidate scene could not be analyzed."; result.error = exception.ToString(); } finally { stopwatch.Stop(); result.elapsedSeconds = stopwatch.Elapsed.TotalSeconds; result.completedUtc = DateTime.UtcNow.ToString("O"); } return result; } private static TimelineSubjectResult AnalyzeTimelineSubjects( UnityEngine.Playables.PlayableDirector director) { var timeline = director.playableAsset as TimelineAsset ?? throw new InvalidOperationException( "PlayableDirector does not reference a TimelineAsset."); var tracks = timeline.GetOutputTracks().ToArray(); var animationEvidence = new List(); var boundAnimators = new List(); var unresolvedAnimationTrackCount = 0; var selectedExporterAnimator = SelectExporterAnimator( tracks, director); for (var trackIndex = 0; trackIndex < tracks.Length; trackIndex++) { if (tracks[trackIndex] is not AnimationTrack track) { continue; } var binding = director.GetGenericBinding(track); var animator = ResolveBoundAnimator(binding); if (animator != null && !track.mutedInHierarchy && IsExporterCharacterAnimator(animator) && !boundAnimators.Contains(animator)) { boundAnimators.Add(animator); } else if (animator == null) { unresolvedAnimationTrackCount++; } animationEvidence.Add(new AnimationSubjectEvidence { trackIndex = trackIndex, trackName = track.name, muted = track.mutedInHierarchy, bindingType = binding?.GetType().FullName ?? string.Empty, bindingPath = GetBindingHierarchyPath(binding), animatorPath = animator != null ? GetHierarchyPath(animator.transform) : string.Empty, performerPath = animator != null ? GetHierarchyPath( NormalizePerformerTransform(animator)) : string.Empty, animatorResolved = animator != null }); } var cameraEvidence = new List(); var cameraTargetPaths = new HashSet(StringComparer.Ordinal); var cameraPerformerPaths = new HashSet(StringComparer.Ordinal); var unresolvedShotCount = 0; var unlinkedCameraTargetCount = 0; var sourceCameraTrackIndex = 0; var exportCameraTrackIndex = 0; for (var trackIndex = 0; trackIndex < tracks.Length; trackIndex++) { if (tracks[trackIndex] is not CinemachineTrack track) { continue; } var clips = track .GetClips() .Where(clip => clip.asset is CinemachineShot) .OrderBy(item => item.start) .ThenBy(item => item.end) .ToList(); if (clips.Count == 0) { continue; } var trackEvidence = new CameraSubjectEvidence { cameraTrackIndex = sourceCameraTrackIndex++, exportCameraTrackIndex = -1, outputTrackIndex = trackIndex, trackName = track.name, muted = track.mutedInHierarchy, selectedExporterAnimatorPath = selectedExporterAnimator != null ? GetHierarchyPath( selectedExporterAnimator.transform) : string.Empty, selectedExporterPerformerPath = selectedExporterAnimator != null ? GetHierarchyPath( NormalizePerformerTransform( selectedExporterAnimator)) : string.Empty, shots = new List() }; var trackTargetPaths = new HashSet( StringComparer.Ordinal); var trackPerformerPaths = new HashSet( StringComparer.Ordinal); var trackHasUncertainty = false; foreach (var clip in clips) { var shot = (CinemachineShot)clip.asset; var shotEvidence = new ShotSubjectEvidence { clipName = clip.displayName, start = clip.start, end = clip.end, followGroupMemberPaths = new List(), lookAtGroupMemberPaths = new List(), followPerformerPaths = new List(), lookAtPerformerPaths = new List(), targetPerformerPaths = new List() }; try { var virtualCamera = shot.VirtualCamera.Resolve(director); if (virtualCamera == null) { unresolvedShotCount++; trackHasUncertainty = true; shotEvidence.status = "unresolved_camera"; shotEvidence.error = "The Cinemachine shot virtual camera is unresolved."; trackEvidence.shots.Add(shotEvidence); continue; } shotEvidence.status = "resolved"; shotEvidence.virtualCameraResolved = true; shotEvidence.virtualCameraPath = GetHierarchyPath(virtualCamera.transform); var follow = virtualCamera.Follow; var lookAt = virtualCamera.LookAt; var followResolution = ResolveCameraTarget( follow, boundAnimators); var lookAtResolution = ResolveCameraTarget( lookAt, boundAnimators); shotEvidence.followTargetPath = followResolution.targetPath; shotEvidence.lookAtTargetPath = lookAtResolution.targetPath; shotEvidence.followTargetAuthored = followResolution.authored; shotEvidence.followTargetResolved = followResolution.resolved; shotEvidence.lookAtTargetAuthored = lookAtResolution.authored; shotEvidence.lookAtTargetResolved = lookAtResolution.resolved; shotEvidence.followGroupMemberPaths = followResolution.memberPaths; shotEvidence.lookAtGroupMemberPaths = lookAtResolution.memberPaths; shotEvidence.followPerformerPaths = followResolution.performerPaths; shotEvidence.lookAtPerformerPaths = lookAtResolution.performerPaths; foreach (var resolution in new[] { followResolution, lookAtResolution }) { if (!string.IsNullOrWhiteSpace( resolution.targetPath)) { trackTargetPaths.Add(resolution.targetPath); cameraTargetPaths.Add(resolution.targetPath); } foreach (var memberPath in resolution.memberPaths) { trackTargetPaths.Add(memberPath); cameraTargetPaths.Add(memberPath); } if (resolution.authored && !resolution.resolved) { unlinkedCameraTargetCount++; trackHasUncertainty = true; } foreach (var performerPath in resolution.performerPaths) { trackPerformerPaths.Add(performerPath); cameraPerformerPaths.Add(performerPath); if (!shotEvidence.targetPerformerPaths.Contains( performerPath)) { shotEvidence.targetPerformerPaths.Add( performerPath); } } } var hasAuthoredTarget = followResolution.authored || lookAtResolution.authored; shotEvidence.targetEvidenceComplete = hasAuthoredTarget && shotEvidence.targetPerformerPaths.Count > 0 && (!followResolution.authored || followResolution.resolved) && (!lookAtResolution.authored || lookAtResolution.resolved); if (!shotEvidence.targetEvidenceComplete) { trackHasUncertainty = true; shotEvidence.status = "unknown_target"; shotEvidence.reason = !hasAuthoredTarget ? "The virtual camera has no Follow or LookAt target." : "An authored camera target could not be mapped " + "to a performer."; } else { shotEvidence.reason = "All authored targets map to performer roots."; } shotEvidence.targetPerformerPaths.Sort( StringComparer.Ordinal); } catch (Exception exception) { unresolvedShotCount++; trackHasUncertainty = true; shotEvidence.status = "failed"; shotEvidence.error = exception.ToString(); } trackEvidence.shots.Add(shotEvidence); } trackEvidence.shotCount = trackEvidence.shots.Count; var coverageEnd = clips[0].end; var hasCoverageGap = false; foreach (var clip in clips.Skip(1)) { if (clip.start > coverageEnd + 1e-7) { hasCoverageGap = true; break; } coverageEnd = Math.Max(coverageEnd, clip.end); } var unresolvedVirtualCameraCount = trackEvidence.shots.Count( shotEvidence => !shotEvidence.virtualCameraResolved); trackEvidence.passesExporterTrackFilter = unresolvedVirtualCameraCount == 0 && !hasCoverageGap; if (trackEvidence.passesExporterTrackFilter) { trackEvidence.exportCameraTrackIndex = exportCameraTrackIndex++; } else { trackEvidence.exporterTrackFilterReason = $"unresolvedShots={unresolvedVirtualCameraCount}; " + $"coverageGap={hasCoverageGap}"; } trackEvidence.cameraTargetPaths = trackTargetPaths .OrderBy(path => path, StringComparer.Ordinal) .ToList(); trackEvidence.subjectPerformerPaths = trackPerformerPaths .OrderBy(path => path, StringComparer.Ordinal) .ToList(); ClassifyCameraTrack(trackEvidence, trackHasUncertainty); cameraEvidence.Add(trackEvidence); } var boundAnimatorPaths = boundAnimators .Select(animator => GetHierarchyPath(animator.transform)) .Distinct(StringComparer.Ordinal) .OrderBy(path => path, StringComparer.Ordinal) .ToList(); var boundPerformerPaths = boundAnimators .Select(animator => GetHierarchyPath( NormalizePerformerTransform(animator))) .Distinct(StringComparer.Ordinal) .OrderBy(path => path, StringComparer.Ordinal) .ToList(); var timelineSubjectMode = cameraEvidence.Count == 1 ? cameraEvidence[0].subjectMode : "unknown"; var timelineSubjectCountKnown = cameraEvidence.Count == 1 && cameraEvidence[0].subjectCountKnown; var timelineSubjectCount = timelineSubjectCountKnown ? cameraEvidence[0].subjectCount : -1; var timelineConfidence = cameraEvidence.Count == 1 ? cameraEvidence[0].confidence : "low"; var timelineReason = cameraEvidence.Count == 1 ? cameraEvidence[0].reason : cameraEvidence.Count == 0 ? "No Cinemachine track variant exists; subject remains unknown." : "Subject labels are reported independently per cameraTrackIndex."; return new TimelineSubjectResult { status = "analyzed", directorPath = GetHierarchyPath(director.transform), timelineAssetPath = AssetDatabase.GetAssetPath(timeline), timelineName = timeline.name, duration = director.duration, subjectMode = timelineSubjectMode, subjectCountKnown = timelineSubjectCountKnown, subjectCount = timelineSubjectCount, confidence = timelineConfidence, reason = timelineReason, selectedExporterAnimatorPath = selectedExporterAnimator != null ? GetHierarchyPath(selectedExporterAnimator.transform) : string.Empty, selectedExporterPerformerPath = selectedExporterAnimator != null ? GetHierarchyPath( NormalizePerformerTransform( selectedExporterAnimator)) : string.Empty, boundAnimatorPaths = boundAnimatorPaths, boundPerformerPaths = boundPerformerPaths, cameraTargetPaths = cameraTargetPaths .OrderBy(path => path, StringComparer.Ordinal) .ToList(), cameraTargetAnimatorPaths = cameraPerformerPaths .OrderBy(path => path, StringComparer.Ordinal) .ToList(), subjectAnimatorPaths = cameraPerformerPaths .OrderBy(path => path, StringComparer.Ordinal) .ToList(), unresolvedAnimationTrackCount = unresolvedAnimationTrackCount, unresolvedShotCount = unresolvedShotCount, unlinkedCameraTargetCount = unlinkedCameraTargetCount, animationTracks = animationEvidence, cameraTracks = cameraEvidence }; } private static void ClassifyCameraTrack( CameraSubjectEvidence evidence, bool hasUncertainty) { var count = evidence.subjectPerformerPaths.Count; evidence.subjectMode = "unknown"; evidence.subjectCountKnown = false; evidence.subjectCount = -1; evidence.confidence = "low"; if (count >= 2) { evidence.subjectMode = "multi_subject"; evidence.subjectCountKnown = !hasUncertainty; evidence.subjectCount = evidence.subjectCountKnown ? count : -1; evidence.confidence = hasUncertainty ? "medium" : "high"; evidence.reason = hasUncertainty ? "At least two performer roots are explicitly targeted, " + "but some shot target evidence is incomplete." : $"All shot targets resolve to {count} performer roots."; } else if (count == 1 && !hasUncertainty) { evidence.subjectMode = "single_subject"; evidence.subjectCountKnown = true; evidence.subjectCount = 1; evidence.confidence = "high"; evidence.reason = "Every shot target resolves to the same performer root."; } else { evidence.reason = count == 1 ? "One performer is evidenced, but at least one shot target " + "is null, static, or unresolved." : "No shot target can be confirmed as a performer; static " + "or null targets remain unknown."; } var selectedPath = evidence.selectedExporterPerformerPath; evidence.selectedAnimatorTargetMatchKnown = !string.IsNullOrWhiteSpace(selectedPath) && !hasUncertainty && count > 0; evidence.allTargetsMatchSelectedExporterAnimator = evidence.selectedAnimatorTargetMatchKnown && evidence.subjectPerformerPaths.All(path => string.Equals( path, selectedPath, StringComparison.Ordinal)); } private static Animator ResolveBoundAnimator(object binding) { if (binding is Animator animator) { return animator; } if (binding is GameObject gameObject) { return gameObject.GetComponent(); } return binding is Component component ? component.GetComponent() : null; } private static Animator SelectExporterAnimator( IReadOnlyCollection tracks, UnityEngine.Playables.PlayableDirector director) { return tracks .OfType() .Where(track => !track.mutedInHierarchy) .Select(track => director.GetGenericBinding(track) as Animator) .Where(IsExporterCharacterAnimator) .Distinct() .OrderByDescending(animator => animator.isHuman) .ThenByDescending(animator => animator.avatar != null && animator.avatar.isValid) .ThenByDescending(animator => animator.GetComponentsInChildren(true).Length) .FirstOrDefault(); } private static bool IsExporterCharacterAnimator(Animator animator) { if (animator == null) { return false; } var path = $"/{GetHierarchyPath(animator.transform)}/"; return path.IndexOf( "/Cam/", StringComparison.OrdinalIgnoreCase) < 0 && path.IndexOf( "/Cams/", StringComparison.OrdinalIgnoreCase) < 0 && path.IndexOf( "/Camera/", StringComparison.OrdinalIgnoreCase) < 0 && animator.GetComponent() == null && path.IndexOf( "Missing Prefab with guid:", StringComparison.OrdinalIgnoreCase) < 0 && animator.name.IndexOf( "Placeholder for referenced Animator", StringComparison.OrdinalIgnoreCase) < 0; } private static Transform NormalizePerformerTransform(Animator animator) { var performer = animator.transform; for (var ancestor = performer.parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.GetComponent() != null) { performer = ancestor; } } return performer; } private static string GetBindingHierarchyPath(object binding) { return binding switch { Component component => GetHierarchyPath(component.transform), GameObject gameObject => GetHierarchyPath(gameObject.transform), _ => string.Empty }; } private static CameraTargetResolution ResolveCameraTarget( Transform target, IReadOnlyCollection boundAnimators) { var result = new CameraTargetResolution { authored = target != null, targetPath = target != null ? GetHierarchyPath(target) : string.Empty, memberPaths = new List(), performerPaths = new List() }; if (target == null) { return result; } var effectiveTargets = new List(); if (target.TryGetComponent(out var group)) { effectiveTargets.AddRange((group.Targets ?? new List()) .Where(member => member != null && member.Weight > 0f && member.Object != null) .Select(member => member.Object)); result.memberPaths = effectiveTargets .Select(GetHierarchyPath) .Distinct(StringComparer.Ordinal) .OrderBy(path => path, StringComparer.Ordinal) .ToList(); } else { effectiveTargets.Add(target); } var animators = new HashSet(); foreach (var effectiveTarget in effectiveTargets) { foreach (var animator in boundAnimators) { if (animator != null && (effectiveTarget == animator.transform || effectiveTarget.IsChildOf(animator.transform) || animator.transform.IsChildOf(effectiveTarget))) { animators.Add(animator); } } } result.performerPaths = animators .Where(animator => animator != null) .Select(animator => GetHierarchyPath( NormalizePerformerTransform(animator))) .Distinct(StringComparer.Ordinal) .OrderBy(path => path, StringComparer.Ordinal) .ToList(); result.resolved = effectiveTargets.Count > 0 && result.performerPaths.Count > 0; return result; } private static IReadOnlyCollection ParseCandidateIndexes( string indexesCsv, string parameterName) { if (string.IsNullOrWhiteSpace(indexesCsv)) { return Array.Empty(); } var indexes = new HashSet(); foreach (var rawValue in indexesCsv.Split(',')) { var value = rawValue.Trim(); if (value.Length == 0 || !int.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) { throw new FormatException( $"Invalid candidate index '{rawValue}' in " + $"{parameterName} '{indexesCsv}'."); } indexes.Add(index); } return indexes.OrderBy(index => index).ToArray(); } private static SceneExportResult BuildSkippedCandidateResult( SceneCandidate candidate, int index, string outputRoot) { var datasetPath = Path.Combine( outputRoot, BuildBaseDatasetName(candidate.scenePath)); var timestamp = DateTime.UtcNow.ToString("O"); Debug.LogWarning( $"[CW-AI] Skipping candidate {index}: " + $"{candidate.scenePath}"); return new SceneExportResult { candidateIndex = index, scenePath = candidate.scenePath, timelinePaths = candidate.timelines .Select(timeline => timeline.assetPath) .ToList(), baseOutputPath = datasetPath, outputPath = datasetPath, status = "skipped_dependency", reason = "candidate_index_listed_in_skipIndexesCsv", error = "The candidate was skipped explicitly because its Unity " + "dependencies are unavailable. The scene was not opened.", startedUtc = timestamp, completedUtc = timestamp, elapsedSeconds = 0.0 }; } private static string BuildBaseDatasetName(string scenePath) { var sceneName = Path.GetFileNameWithoutExtension(scenePath); return $"TimelineCamera_60fps_YAMO_{StableHash(scenePath)}_" + SanitizeFileName(sceneName, 72); } private static SourceFingerprintRegistry LoadSourceFingerprintRegistry( string registryPath) { if (!File.Exists(registryPath)) { return new SourceFingerprintRegistry { schemaVersion = SourceFingerprintRegistrySchemaVersion, fingerprintSchema = TimelineCameraDatasetExporter .SourceFingerprintSchema, entries = new List() }; } SourceFingerprintRegistry registry; try { registry = JsonUtility.FromJson( File.ReadAllText(registryPath, Encoding.UTF8)); } catch (Exception exception) { throw new InvalidDataException( $"Unable to read source fingerprint registry " + $"'{registryPath}'.", exception); } if (registry == null || !string.Equals( registry.schemaVersion, SourceFingerprintRegistrySchemaVersion, StringComparison.Ordinal) || !string.Equals( registry.fingerprintSchema, TimelineCameraDatasetExporter.SourceFingerprintSchema, StringComparison.Ordinal)) { throw new InvalidDataException( $"Source fingerprint registry '{registryPath}' has an " + "unsupported or missing schema marker."); } if (registry.entries == null) { throw new InvalidDataException( $"Source fingerprint registry '{registryPath}' is " + "missing its entries array."); } var duplicateDatasetNames = registry.entries .Where(entry => entry != null) .GroupBy(entry => entry.datasetName, StringComparer.Ordinal) .Where(group => group.Count() > 1) .Select(group => group.Key) .ToArray(); if (registry.entries.Any(entry => entry == null) || duplicateDatasetNames.Length > 0) { throw new InvalidDataException( $"Source fingerprint registry '{registryPath}' contains " + "null or duplicate dataset entries: " + string.Join(", ", duplicateDatasetNames)); } foreach (var entry in registry.entries) { ValidateSourceFingerprintRegistryEntry(entry, registryPath); } return registry; } private static void ValidateSourceFingerprintRegistryEntry( SourceFingerprintRegistryEntry entry, string registryPath) { var expectedDatasetName = string.IsNullOrWhiteSpace(entry.scenePath) ? string.Empty : BuildBaseDatasetName(entry.scenePath); if (string.IsNullOrWhiteSpace(entry.datasetName) || string.IsNullOrWhiteSpace(entry.scenePath) || string.IsNullOrWhiteSpace(entry.sourceFingerprint) || string.IsNullOrWhiteSpace(entry.legacySourceFingerprint) || string.IsNullOrWhiteSpace(entry.selectedOutputDirectory) || !string.Equals( entry.datasetName, expectedDatasetName, StringComparison.Ordinal) || (!string.Equals( entry.selectedOutputDirectory, entry.datasetName, StringComparison.Ordinal) && !entry.selectedOutputDirectory.StartsWith( entry.datasetName + "_rev_", StringComparison.Ordinal)) || entry.selectedOutputDirectory == "." || entry.selectedOutputDirectory == ".." || entry.sourceFingerprint.Length != 64 || entry.sourceFingerprint.Any(character => !Uri.IsHexDigit(character)) || entry.legacySourceFingerprint.Length != 64 || entry.legacySourceFingerprint.Any(character => !Uri.IsHexDigit(character)) || !string.Equals( entry.selectedOutputDirectory, Path.GetFileName(entry.selectedOutputDirectory), StringComparison.Ordinal) || entry.selectedOutputDirectory.IndexOfAny( new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }) >= 0) { throw new InvalidDataException( $"Source fingerprint registry '{registryPath}' contains " + "an invalid entry."); } } private static SourceFingerprintRegistryEntry FindSourceFingerprintRegistryEntry( SourceFingerprintRegistry registry, string datasetName, string scenePath) { var entry = registry.entries.SingleOrDefault(candidate => string.Equals( candidate.datasetName, datasetName, StringComparison.Ordinal)); if (entry != null && !string.Equals( entry.scenePath, scenePath, StringComparison.Ordinal)) { throw new InvalidDataException( $"Source fingerprint registry entry '{datasetName}' " + $"belongs to '{entry.scenePath}', not '{scenePath}'."); } return entry; } private static bool IsCompleteExportResult(SceneExportResult result) { return result != null && (result.status == "exported" || result.status == "exported_with_skips" || result.status == "already_complete" || result.status == "already_complete_with_skips"); } private static bool RecordSourceFingerprintBaseline( SourceFingerprintRegistry registry, string outputRoot, SceneExportResult result) { if (!string.Equals( result.sourceFingerprintSchema, TimelineCameraDatasetExporter.SourceFingerprintSchema, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(result.sourceFingerprint)) { throw new InvalidDataException( "A successful export result is missing its strong source " + "fingerprint."); } var fullOutputRoot = Path.GetFullPath(outputRoot).TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var fullSelectedOutput = Path.GetFullPath(result.outputPath) .TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!string.Equals( Path.GetDirectoryName(fullSelectedOutput), fullOutputRoot, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException( $"Registered dataset output '{fullSelectedOutput}' is " + $"not an immediate child of '{fullOutputRoot}'."); } var selectedOutputDirectory = Path.GetFileName(fullSelectedOutput); var datasetName = Path.GetFileName(result.baseOutputPath); var entry = FindSourceFingerprintRegistryEntry( registry, datasetName, result.scenePath); if (entry != null && string.Equals( entry.sourceFingerprint, result.sourceFingerprint, StringComparison.Ordinal) && string.Equals( entry.legacySourceFingerprint, result.legacySourceFingerprint, StringComparison.Ordinal) && string.Equals( entry.selectedOutputDirectory, selectedOutputDirectory, StringComparison.Ordinal)) { return false; } var timestamp = DateTime.UtcNow.ToString("O"); if (entry == null) { entry = new SourceFingerprintRegistryEntry { datasetName = datasetName, scenePath = result.scenePath }; registry.entries.Add(entry); } entry.sourceFingerprint = result.sourceFingerprint; entry.legacySourceFingerprint = result.legacySourceFingerprint; entry.selectedOutputDirectory = selectedOutputDirectory; entry.updatedUtc = timestamp; registry.entries = registry.entries .OrderBy(item => item.datasetName, StringComparer.Ordinal) .ToList(); registry.updatedUtc = timestamp; return true; } private static string ResolveRegisteredDatasetPath( string outputRoot, SourceFingerprintRegistryEntry entry) { var fullOutputRoot = Path.GetFullPath(outputRoot).TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var registeredPath = Path.GetFullPath(Path.Combine( fullOutputRoot, entry.selectedOutputDirectory)); if (!string.Equals( Path.GetDirectoryName(registeredPath), fullOutputRoot, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException( $"Registered output '{entry.selectedOutputDirectory}' " + "escapes the dataset output root."); } return registeredPath; } private static bool AreSamePath(string first, string second) { return string.Equals( Path.GetFullPath(first).TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), Path.GetFullPath(second).TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), StringComparison.OrdinalIgnoreCase); } private static SceneExportResult ExportCandidate( SceneCandidate candidate, int index, int candidateCount, string outputRoot, bool requireAudio, bool preserveRevisions, bool forceRevision, SourceFingerprintRegistryEntry registryEntry) { var sceneName = Path.GetFileNameWithoutExtension(candidate.scenePath); var datasetName = BuildBaseDatasetName(candidate.scenePath); 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(), baseOutputPath = datasetPath, outputPath = datasetPath, forceRevisionRequested = forceRevision, 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(); var currentFingerprint = TimelineCameraDatasetExporter .ComputeSceneCameraSourceFingerprint( openedScene, out var currentLegacyFingerprint); result.sourceFingerprintSchema = TimelineCameraDatasetExporter.SourceFingerprintSchema; result.sourceFingerprint = currentFingerprint; result.legacySourceFingerprint = currentLegacyFingerprint; var selectedDatasetPath = datasetPath; var selectedManifestPath = manifestPath; if (registryEntry != null) { result.sourceFingerprintRegistryEntryFound = true; result.registeredSourceFingerprint = registryEntry.sourceFingerprint; var registeredDatasetPath = ResolveRegisteredDatasetPath(outputRoot, registryEntry); result.registeredOutputPath = registeredDatasetPath; var registryFingerprintMatches = string.Equals( registryEntry.sourceFingerprint, currentFingerprint, StringComparison.Ordinal); result.sourceFingerprintRegistryMatched = registryFingerprintMatches; if (registryFingerprintMatches) { var registeredManifestPath = Path.Combine( registeredDatasetPath, "dataset_manifest.json"); if (!File.Exists(registeredManifestPath)) { result.status = "invalid_registered_output"; result.error = "The strong fingerprint registry points to a " + "missing dataset manifest: " + registeredManifestPath; return result; } if (!ValidateCompleteDataset( registeredDatasetPath, candidate, requireAudio, currentFingerprint, currentLegacyFingerprint, out var legacyRegisteredFingerprintMatched, out var registeredValidationError)) { result.status = "invalid_registered_output"; result.error = registeredValidationError; return result; } var registeredIsRevision = !AreSamePath( registeredDatasetPath, datasetPath); var forceAlreadySatisfied = forceRevision && registeredIsRevision && !legacyRegisteredFingerprintMatched; if (!forceRevision || forceAlreadySatisfied) { result.outputPath = registeredDatasetPath; result.isRevision = registeredIsRevision; if (registeredIsRevision) { result.revisionOfOutputPath = datasetPath; result.revisionSourceFingerprint = registryEntry.sourceFingerprint; result.revisionReason = "Selected by the strong fingerprint " + "registry."; } result.forceRevisionApplied = forceAlreadySatisfied; result.fingerprintMigrationRequired = legacyRegisteredFingerprintMatched; PopulateTrackCounts(registeredDatasetPath, result); result.status = result.skippedCameraTrackCount > 0 ? "already_complete_with_skips" : "already_complete"; return result; } if (!preserveRevisions) { result.status = "invalid_existing"; result.error = "A forced revision requires revision-preserving " + "mode."; return result; } result.revisionReason = legacyRegisteredFingerprintMatched ? "Explicit forced migration from the " + "registered legacy output to the strong " + "dependency fingerprint." : "Explicit forced revision from the " + "registered base output."; } else { if (!preserveRevisions) { result.status = "invalid_existing"; result.error = "The current strong source fingerprint differs " + "from the registered baseline."; return result; } result.revisionReason = "The current strong source fingerprint differs " + "from the registered baseline " + registryEntry.sourceFingerprint + "."; } selectedDatasetPath = BuildRevisionDatasetPath( outputRoot, datasetName, currentFingerprint); selectedManifestPath = Path.Combine( selectedDatasetPath, "dataset_manifest.json"); result.outputPath = selectedDatasetPath; result.isRevision = true; result.revisionOfOutputPath = datasetPath; result.revisionSourceFingerprint = currentFingerprint; result.forceRevisionApplied = forceRevision; } else if (File.Exists(manifestPath)) { var baseIsValid = ValidateCompleteDataset( datasetPath, candidate, requireAudio, currentFingerprint, currentLegacyFingerprint, out var legacyBaseFingerprintMatched, out var existingValidationError); result.legacyBaseFingerprintMatched = legacyBaseFingerprintMatched; var strongRevisionPath = BuildRevisionDatasetPath( outputRoot, datasetName, currentFingerprint); var strongRevisionManifestPath = Path.Combine( strongRevisionPath, "dataset_manifest.json"); if (baseIsValid && (!preserveRevisions || (!forceRevision && !File.Exists(strongRevisionManifestPath)))) { PopulateTrackCounts(datasetPath, result); result.status = result.skippedCameraTrackCount > 0 ? "already_complete_with_skips" : "already_complete"; result.fingerprintMigrationRequired = legacyBaseFingerprintMatched; } else if (!preserveRevisions) { result.status = "invalid_existing"; result.error = existingValidationError; } else { selectedDatasetPath = strongRevisionPath; selectedManifestPath = strongRevisionManifestPath; result.outputPath = selectedDatasetPath; result.isRevision = true; result.revisionOfOutputPath = datasetPath; result.revisionSourceFingerprint = currentFingerprint; result.forceRevisionApplied = forceRevision; result.revisionReason = forceRevision && legacyBaseFingerprintMatched ? "Explicit forced migration from a matching " + "legacy semantic fingerprint to the strong " + "dependency fingerprint." : forceRevision ? "Explicit forced revision. Base " + "validation: " + existingValidationError : baseIsValid ? "An existing strong revision for " + "the current source supersedes the " + "legacy base." : existingValidationError; } if (!preserveRevisions || !string.IsNullOrWhiteSpace(result.status)) { return result; } } else if (Directory.Exists(datasetPath)) { if (!preserveRevisions) { result.status = "partial_exists"; result.error = "An existing output directory was preserved and not overwritten."; return result; } selectedDatasetPath = BuildRevisionDatasetPath( outputRoot, datasetName, currentFingerprint); selectedManifestPath = Path.Combine( selectedDatasetPath, "dataset_manifest.json"); result.outputPath = selectedDatasetPath; result.isRevision = true; result.revisionOfOutputPath = datasetPath; result.revisionSourceFingerprint = currentFingerprint; result.forceRevisionApplied = forceRevision; result.revisionReason = forceRevision ? "Explicit forced revision; the partial base " + "output directory was preserved." : "The base output directory is partial and was " + "preserved."; } if (result.isRevision && File.Exists(selectedManifestPath)) { if (ValidateCompleteDataset( selectedDatasetPath, candidate, requireAudio, currentFingerprint, currentLegacyFingerprint, out var legacyRevisionFingerprintMatched, out var revisionValidationError)) { PopulateTrackCounts(selectedDatasetPath, result); result.status = result.skippedCameraTrackCount > 0 ? "already_complete_with_skips" : "already_complete"; result.fingerprintMigrationRequired = legacyRevisionFingerprintMatched; } else { result.status = "invalid_existing_revision"; result.error = revisionValidationError; } return result; } if (result.isRevision && registryEntry == null && !forceRevision) { var legacyRevisionPath = BuildRevisionDatasetPath( outputRoot, datasetName, currentLegacyFingerprint); var legacyRevisionManifestPath = Path.Combine( legacyRevisionPath, "dataset_manifest.json"); if (!AreSamePath( legacyRevisionPath, selectedDatasetPath) && File.Exists(legacyRevisionManifestPath) && ValidateCompleteDataset( legacyRevisionPath, candidate, requireAudio, currentFingerprint, currentLegacyFingerprint, out var legacyRevisionFingerprintMatched, out _) && legacyRevisionFingerprintMatched) { result.outputPath = legacyRevisionPath; result.legacyRevisionReused = true; result.revisionSourceFingerprint = currentLegacyFingerprint; result.revisionReason = "Reused the complete legacy revision as the first " + "strong fingerprint registry baseline."; result.fingerprintMigrationRequired = true; PopulateTrackCounts(legacyRevisionPath, result); result.status = result.skippedCameraTrackCount > 0 ? "already_complete_with_skips" : "already_complete"; return result; } } if (result.isRevision && Directory.Exists(selectedDatasetPath)) { result.status = "partial_revision_exists"; result.error = "An existing revision 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, currentFingerprint, currentLegacyFingerprint, out _, out var validationError)) { throw new InvalidDataException( $"Export validation failed: {validationError}"); } Directory.CreateDirectory(outputRoot); Directory.Move(workingPath, selectedDatasetPath); PopulateTrackCounts(selectedDatasetPath, 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, string currentStrongFingerprint, string currentLegacyFingerprint, out bool legacyFingerprintMatched, out string error) { legacyFingerprintMatched = false; 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; } if (!TimelineCameraDatasetExporter .TryMatchSceneCameraSourceFingerprint( manifest.sourceFingerprintSchema, manifest.sourceFingerprint, currentStrongFingerprint, currentLegacyFingerprint, out legacyFingerprintMatched, out var fingerprintError)) { error = fingerprintError; 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 BuildRevisionDatasetPath( string outputRoot, string datasetName, string sourceFingerprint) { if (string.IsNullOrWhiteSpace(sourceFingerprint) || sourceFingerprint.Length < RevisionFingerprintLength || sourceFingerprint .Take(RevisionFingerprintLength) .Any(character => !Uri.IsHexDigit(character))) { throw new InvalidDataException( "A full hexadecimal source fingerprint is required for " + "a preserved dataset revision."); } var revisionSuffix = sourceFingerprint .Substring(0, RevisionFingerprintLength) .ToLowerInvariant(); return Path.Combine( outputRoot, $"{datasetName}_rev_{revisionSuffix}"); } 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 SubjectBatchReport { public string schemaVersion; public string startedUtc; public string lastUpdatedUtc; public string completedUtc; public string state; public string unityVersion; public string assetRoot; public string outputPath; public int candidateSceneCount; public string candidateInventoryFingerprint; public int startIndex; public int endExclusive; public List skippedCandidateIndexes; public int processedSceneCount; public List results; public string restoreError; public string fatalError; } [Serializable] private sealed class SceneSubjectResult { public int candidateIndex; public string scenePath; public List candidateTimelinePaths; public string status; public string reason; public string error; public string startedUtc; public string completedUtc; public double elapsedSeconds; public int timelineCount; public List timelines; } [Serializable] private sealed class TimelineSubjectResult { public string status; public string directorPath; public string timelineAssetPath; public string timelineName; public double duration; public string subjectMode; public bool subjectCountKnown; public int subjectCount; public string confidence; public string reason; public string error; public string selectedExporterAnimatorPath; public string selectedExporterPerformerPath; public List boundAnimatorPaths; public List boundPerformerPaths; public List cameraTargetPaths; public List cameraTargetAnimatorPaths; public List subjectAnimatorPaths; public int unresolvedAnimationTrackCount; public int unresolvedShotCount; public int unlinkedCameraTargetCount; public List animationTracks; public List cameraTracks; } [Serializable] private sealed class AnimationSubjectEvidence { public int trackIndex; public string trackName; public bool muted; public string bindingType; public string bindingPath; public string animatorPath; public string performerPath; public bool animatorResolved; } [Serializable] private sealed class CameraSubjectEvidence { public int cameraTrackIndex; public int exportCameraTrackIndex; public int outputTrackIndex; public string trackName; public bool muted; public bool passesExporterTrackFilter; public string exporterTrackFilterReason; public string subjectMode; public bool subjectCountKnown; public int subjectCount; public string confidence; public string reason; public string selectedExporterAnimatorPath; public string selectedExporterPerformerPath; public bool selectedAnimatorTargetMatchKnown; public bool allTargetsMatchSelectedExporterAnimator; public int shotCount; public List cameraTargetPaths; public List subjectPerformerPaths; public List shots; } [Serializable] private sealed class ShotSubjectEvidence { public string status; public string reason; public string error; public string clipName; public double start; public double end; public bool virtualCameraResolved; public string virtualCameraPath; public string followTargetPath; public string lookAtTargetPath; public bool followTargetAuthored; public bool followTargetResolved; public bool lookAtTargetAuthored; public bool lookAtTargetResolved; public List followGroupMemberPaths; public List lookAtGroupMemberPaths; public List followPerformerPaths; public List lookAtPerformerPaths; public List targetPerformerPaths; public bool targetEvidenceComplete; } private sealed class CameraTargetResolution { public bool authored; public bool resolved; public string targetPath; public List memberPaths; public List performerPaths; } [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 string sourceFingerprintRegistryPath; public string sourceFingerprintRegistrySchema; public int sourceFingerprintRegistryEntryCount; public int candidateSceneCount; public string candidateInventoryFingerprint; public int startIndex; public int endExclusive; public bool requireAudio; public bool preserveRevisions; public List skippedCandidateIndexes; public List forcedRevisionCandidateIndexes; 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 baseOutputPath; public string outputPath; public bool isRevision; public string revisionOfOutputPath; public string revisionSourceFingerprint; public string revisionReason; public bool forceRevisionRequested; public bool forceRevisionApplied; public string sourceFingerprintSchema; public string sourceFingerprint; public string legacySourceFingerprint; public bool legacyBaseFingerprintMatched; public bool legacyRevisionReused; public bool fingerprintMigrationRequired; public bool sourceFingerprintRegistryEntryFound; public bool sourceFingerprintRegistryMatched; public bool sourceFingerprintBaselineRecorded; public string registeredSourceFingerprint; public string registeredOutputPath; public string status; public string reason; 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 SourceFingerprintRegistry { public string schemaVersion; public string fingerprintSchema; public string updatedUtc; public List entries; } [Serializable] private sealed class SourceFingerprintRegistryEntry { public string datasetName; public string scenePath; public string sourceFingerprint; public string legacySourceFingerprint; public string selectedOutputDirectory; public string updatedUtc; } [Serializable] private sealed class DatasetManifestProbe { public string schemaVersion; public string scenePath; public string sourceFingerprintSchema; 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; } } }