streamingle-unity-utilities/CameraAI~/Editor/TimelineCameraBatchExporter.cs

3220 lines
129 KiB
C#

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<UnityEngine.Playables.PlayableDirector>()
.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<int>(
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<SceneSubjectResult>()
};
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<int>(),
Array.Empty<int>());
}
public static string ExportAuthoredScenesPreservingRevisionsForCli(
string outputRoot,
int startIndex = 0,
int maxScenes = 0,
bool requireAudio = false)
{
return ExportAuthoredScenesForCliInternal(
outputRoot,
startIndex,
maxScenes,
requireAudio,
true,
Array.Empty<int>(),
Array.Empty<int>());
}
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<int>());
}
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<int> skippedCandidateIndexes,
IReadOnlyCollection<int> 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<int>(
skippedCandidateIndexes ?? Array.Empty<int>());
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<int>(
forcedRevisionCandidateIndexes ?? Array.Empty<int>());
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<SceneExportResult>()
};
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<TimelineSubjectResult>()
};
}
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<TimelineSubjectResult>()
};
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<UnityEngine.Playables.PlayableDirector>()
.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<string>(),
boundPerformerPaths = new List<string>(),
cameraTargetPaths = new List<string>(),
cameraTargetAnimatorPaths = new List<string>(),
subjectAnimatorPaths = new List<string>(),
animationTracks =
new List<AnimationSubjectEvidence>(),
cameraTracks = new List<CameraSubjectEvidence>()
});
}
}
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<AnimationSubjectEvidence>();
var boundAnimators = new List<Animator>();
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<CameraSubjectEvidence>();
var cameraTargetPaths = new HashSet<string>(StringComparer.Ordinal);
var cameraPerformerPaths = new HashSet<string>(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<ShotSubjectEvidence>()
};
var trackTargetPaths = new HashSet<string>(
StringComparer.Ordinal);
var trackPerformerPaths = new HashSet<string>(
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<string>(),
lookAtGroupMemberPaths = new List<string>(),
followPerformerPaths = new List<string>(),
lookAtPerformerPaths = new List<string>(),
targetPerformerPaths = new List<string>()
};
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<Animator>();
}
return binding is Component component
? component.GetComponent<Animator>()
: null;
}
private static Animator SelectExporterAnimator(
IReadOnlyCollection<TrackAsset> tracks,
UnityEngine.Playables.PlayableDirector director)
{
return tracks
.OfType<AnimationTrack>()
.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<Transform>(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<CinemachineVirtualCameraBase>() == 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<Animator>() != 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<Animator> boundAnimators)
{
var result = new CameraTargetResolution
{
authored = target != null,
targetPath = target != null
? GetHierarchyPath(target)
: string.Empty,
memberPaths = new List<string>(),
performerPaths = new List<string>()
};
if (target == null)
{
return result;
}
var effectiveTargets = new List<Transform>();
if (target.TryGetComponent<CinemachineTargetGroup>(out var group))
{
effectiveTargets.AddRange((group.Targets ??
new List<CinemachineTargetGroup.Target>())
.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<Animator>();
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<int> ParseCandidateIndexes(
string indexesCsv,
string parameterName)
{
if (string.IsNullOrWhiteSpace(indexesCsv))
{
return Array.Empty<int>();
}
var indexes = new HashSet<int>();
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<SourceFingerprintRegistryEntry>()
};
}
SourceFingerprintRegistry registry;
try
{
registry = JsonUtility.FromJson<SourceFingerprintRegistry>(
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<DatasetManifestProbe>(
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<DatasetManifestProbe>(
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<string>(
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<SceneCandidate> DiscoverCandidateScenes()
{
var timelineByGuid = new Dictionary<string, TimelineInventory>(
StringComparer.Ordinal);
foreach (var guid in AssetDatabase.FindAssets(
"t:TimelineAsset",
new[] { AuthoredSceneRoot }))
{
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
var timeline = AssetDatabase.LoadAssetAtPath<TimelineAsset>(assetPath);
var inventory = BuildTimelineInventory(timeline);
if (inventory == null)
{
continue;
}
inventory.assetPath = assetPath;
timelineByGuid[guid] = inventory;
}
var prefabTimelines = BuildPrefabTimelineMap(timelineByGuid);
var candidates = new List<SceneCandidate>();
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<string>(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<CinemachineTrack>().ToArray();
var shotCount = cameraTracks
.SelectMany(track => track.GetClips())
.Count(clip => clip.asset is CinemachineShot);
if (shotCount == 0)
{
return null;
}
var audioTracks = tracks.OfType<AudioTrack>().ToArray();
return new TimelineInventory
{
assetPath = AssetDatabase.GetAssetPath(timeline),
timelineName = timeline.name,
duration = timeline.duration,
cameraTrackCount = cameraTracks.Length,
shotCount = shotCount,
animationTrackCount = tracks.OfType<AnimationTrack>().Count(),
audioTrackCount = audioTracks.Length,
audioClipCount = audioTracks
.SelectMany(track => track.GetClips())
.Count(clip =>
clip.asset is AudioPlayableAsset audioAsset &&
audioAsset.clip != null)
};
}
private static List<TimelineInventory> DiscoverLoadedSceneTimelines(
Scene scene)
{
return Resources
.FindObjectsOfTypeAll<UnityEngine.Playables.PlayableDirector>()
.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<string, PrefabTimelineReferences>
BuildPrefabTimelineMap(
IReadOnlyDictionary<string, TimelineInventory> timelineByGuid)
{
var result =
new Dictionary<string, PrefabTimelineReferences>(
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<string> ResolvePrefabTimelineGuids(
string prefabGuid,
IReadOnlyDictionary<string, PrefabTimelineReferences> prefabTimelines,
HashSet<string> 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<AnimationTrack>()
.Select(track => BuildAnimationTrackDiagnostic(director, track))
.ToList(),
cameraTrackCount = tracks.OfType<CinemachineTrack>().Count(),
shotCount = tracks
.OfType<CinemachineTrack>()
.SelectMany(track => track.GetClips())
.Count(clip => clip.asset is CinemachineShot),
resolvedCameraCount = tracks
.OfType<CinemachineTrack>()
.SelectMany(track => track.GetClips())
.Select(clip => clip.asset as CinemachineShot)
.Count(shot =>
shot != null &&
shot.VirtualCamera.Resolve(director) != null),
audioClipPaths = tracks
.OfType<AudioTrack>()
.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<Transform>(true).Length
: 0,
animationClipCount = clips.Distinct().Count(),
transformCurvePathCount = transformPaths.Length,
firstTransformPaths = transformPaths.Take(12).ToList()
};
}
private static IEnumerable<string> 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<string> 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<string> 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)
? $"<untitled:{scene.name}>"
: 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<string>();
while (transform != null)
{
names.Add(transform.name);
transform = transform.parent;
}
names.Reverse();
return string.Join("/", names);
}
private static void WriteJsonAtomic<T>(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<SceneCandidate> candidates;
}
[Serializable]
private sealed class ActiveSceneDiagnosticReport
{
public string schemaVersion;
public string createdUtc;
public string scenePath;
public List<DirectorDiagnostic> directors;
}
[Serializable]
private sealed class DirectorDiagnostic
{
public string directorPath;
public string timelinePath;
public string timelineName;
public double duration;
public List<AnimationTrackDiagnostic> animationTracks;
public int cameraTrackCount;
public int shotCount;
public int resolvedCameraCount;
public List<string> 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<string> firstTransformPaths;
}
[Serializable]
private sealed class SceneCandidate
{
public string scenePath;
public List<TimelineInventory> 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<string> timelineGuids;
public List<string> 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<int> skippedCandidateIndexes;
public int processedSceneCount;
public List<SceneSubjectResult> results;
public string restoreError;
public string fatalError;
}
[Serializable]
private sealed class SceneSubjectResult
{
public int candidateIndex;
public string scenePath;
public List<string> candidateTimelinePaths;
public string status;
public string reason;
public string error;
public string startedUtc;
public string completedUtc;
public double elapsedSeconds;
public int timelineCount;
public List<TimelineSubjectResult> 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<string> boundAnimatorPaths;
public List<string> boundPerformerPaths;
public List<string> cameraTargetPaths;
public List<string> cameraTargetAnimatorPaths;
public List<string> subjectAnimatorPaths;
public int unresolvedAnimationTrackCount;
public int unresolvedShotCount;
public int unlinkedCameraTargetCount;
public List<AnimationSubjectEvidence> animationTracks;
public List<CameraSubjectEvidence> 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<string> cameraTargetPaths;
public List<string> subjectPerformerPaths;
public List<ShotSubjectEvidence> 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<string> followGroupMemberPaths;
public List<string> lookAtGroupMemberPaths;
public List<string> followPerformerPaths;
public List<string> lookAtPerformerPaths;
public List<string> targetPerformerPaths;
public bool targetEvidenceComplete;
}
private sealed class CameraTargetResolution
{
public bool authored;
public bool resolved;
public string targetPath;
public List<string> memberPaths;
public List<string> 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<int> skippedCandidateIndexes;
public List<int> forcedRevisionCandidateIndexes;
public int processedSceneCount;
public List<SceneExportResult> results;
public string restoreError;
public string fatalError;
}
[Serializable]
private sealed class SceneExportResult
{
public int candidateIndex;
public string scenePath;
public List<string> 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<SourceFingerprintRegistryEntry> 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<SkippedCameraTrackProbe> skippedCameraTracks;
public List<SongProbe> 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;
}
}
}