3220 lines
129 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|