streamingle-unity-utilities/CameraAI~/Editor/TimelineCameraBatchExporter.cs
2026-08-03 04:42:44 +09:00

1346 lines
50 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
public static class TimelineCameraBatchExporter
{
private const string AuthoredSceneRoot = "Assets/Resourcedata/Character";
private const string ReportFileName = "yamo_batch_report.json";
private const string DatasetSchemaVersion = "1.3";
public static string WriteAuthoredSceneInventoryForCli(string outputPath)
{
EnsureEditMode();
var candidates = DiscoverCandidateScenes();
var report = new InventoryReport
{
schemaVersion = "1.0",
createdUtc = DateTime.UtcNow.ToString("O"),
unityVersion = Application.unityVersion,
assetRoot = AuthoredSceneRoot,
candidateSceneCount = candidates.Count,
uniqueTimelineCount = candidates
.SelectMany(candidate => candidate.timelines)
.Select(timeline => timeline.assetPath)
.Distinct(StringComparer.Ordinal)
.Count(),
candidates = candidates
};
var fullPath = Path.GetFullPath(outputPath);
WriteJsonAtomic(fullPath, report);
return fullPath;
}
public static string WriteActiveSceneDiagnosticsForCli(string outputPath)
{
EnsureEditMode();
var scene = SceneManager.GetActiveScene();
var report = new ActiveSceneDiagnosticReport
{
schemaVersion = "1.0",
createdUtc = DateTime.UtcNow.ToString("O"),
scenePath = scene.path,
directors = Resources.FindObjectsOfTypeAll<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 ExportAuthoredScenesForCli(
string outputRoot,
int startIndex = 0,
int maxScenes = 0,
bool requireAudio = false)
{
EnsureEditMode();
EnsureNoDirtyScenes();
var fullOutputRoot = Path.GetFullPath(outputRoot)
.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar);
EnsureSafeOutputRoot(fullOutputRoot);
Directory.CreateDirectory(fullOutputRoot);
var reportPath = Path.Combine(fullOutputRoot, ReportFileName);
var candidates = DiscoverCandidateScenes();
if (candidates.Count == 0)
{
throw new InvalidOperationException(
"No authored camera scene candidates were discovered.");
}
if (startIndex < 0 || startIndex >= candidates.Count)
{
throw new ArgumentOutOfRangeException(
nameof(startIndex),
startIndex,
$"Expected 0 through {candidates.Count - 1}.");
}
var firstIndex = startIndex;
var endExclusive = maxScenes > 0
? Math.Min(candidates.Count, firstIndex + maxScenes)
: candidates.Count;
if (endExclusive <= firstIndex)
{
throw new ArgumentOutOfRangeException(
nameof(maxScenes),
maxScenes,
"The requested range contains no scenes.");
}
var report = new BatchReport
{
schemaVersion = "1.1",
startedUtc = DateTime.UtcNow.ToString("O"),
lastUpdatedUtc = DateTime.UtcNow.ToString("O"),
state = "running",
unityVersion = Application.unityVersion,
assetRoot = AuthoredSceneRoot,
outputRoot = fullOutputRoot,
candidateSceneCount = candidates.Count,
candidateInventoryFingerprint = StableHash(string.Join(
"\n",
candidates.Select(candidate =>
candidate.scenePath + "|" +
string.Join(
",",
candidate.timelines.Select(timeline =>
timeline.assetPath))))),
startIndex = firstIndex,
endExclusive = endExclusive,
requireAudio = requireAudio,
results = new List<SceneExportResult>()
};
WriteJsonAtomic(reportPath, report);
var originalSetup = EditorSceneManager.GetSceneManagerSetup();
try
{
for (var index = firstIndex; index < endExclusive; index++)
{
var candidate = candidates[index];
var result = ExportCandidate(
candidate,
index,
candidates.Count,
fullOutputRoot,
requireAudio);
report.results.Add(result);
report.processedSceneCount = report.results.Count;
report.lastUpdatedUtc = DateTime.UtcNow.ToString("O");
WriteJsonAtomic(reportPath, report);
}
}
catch (Exception exception)
{
report.state = "failed";
report.fatalError = exception.ToString();
throw;
}
finally
{
EditorUtility.ClearProgressBar();
try
{
RestoreSceneSetup(originalSetup);
}
catch (Exception exception)
{
report.restoreError = exception.ToString();
}
if (!string.Equals(
report.state,
"failed",
StringComparison.Ordinal))
{
var hasSceneErrors = report.results.Any(result =>
result.status != "exported" &&
result.status != "already_complete" &&
result.status != "exported_with_skips" &&
result.status != "already_complete_with_skips");
var hasSkippedTracks = report.results.Any(result =>
result.skippedCameraTrackCount > 0);
report.state = string.IsNullOrWhiteSpace(report.restoreError)
? hasSceneErrors
? "completed_with_errors"
: hasSkippedTracks
? "completed_with_skips"
: "completed"
: "completed_with_restore_error";
}
report.completedUtc = DateTime.UtcNow.ToString("O");
report.lastUpdatedUtc = report.completedUtc;
WriteJsonAtomic(reportPath, report);
}
if (!string.Equals(
report.state,
"completed",
StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Batch finished with state '{report.state}'. " +
$"See '{reportPath}'.");
}
return reportPath;
}
private static void EnsureSafeOutputRoot(string outputRoot)
{
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
?? throw new InvalidOperationException(
"Unable to resolve the Unity project root.");
foreach (var protectedPath in new[]
{
projectRoot,
Path.Combine(projectRoot, "Assets"),
Path.Combine(projectRoot, "Packages"),
Path.Combine(projectRoot, "Library")
})
{
var normalizedProtected = Path.GetFullPath(protectedPath)
.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar);
if (string.Equals(
outputRoot.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar),
normalizedProtected,
StringComparison.OrdinalIgnoreCase) ||
outputRoot.StartsWith(
normalizedProtected + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Output root must be outside the Unity project: " +
$"'{outputRoot}'.");
}
}
}
private static SceneExportResult ExportCandidate(
SceneCandidate candidate,
int index,
int candidateCount,
string outputRoot,
bool requireAudio)
{
var sceneName = Path.GetFileNameWithoutExtension(candidate.scenePath);
var datasetName =
$"TimelineCamera_60fps_YAMO_{StableHash(candidate.scenePath)}_" +
SanitizeFileName(sceneName, 72);
var datasetPath = Path.Combine(outputRoot, datasetName);
var manifestPath = Path.Combine(datasetPath, "dataset_manifest.json");
var result = new SceneExportResult
{
candidateIndex = index,
scenePath = candidate.scenePath,
timelinePaths = candidate.timelines
.Select(timeline => timeline.assetPath)
.ToList(),
outputPath = datasetPath,
startedUtc = DateTime.UtcNow.ToString("O")
};
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var workingPath = Path.Combine(
$"{outputRoot}.working",
$"{index:D4}_{StableHash(candidate.scenePath)}_" +
Guid.NewGuid().ToString("N"));
try
{
EditorUtility.DisplayProgressBar(
"YAMO Camera Dataset Export",
$"{index + 1:N0} / {candidateCount:N0}: {sceneName}",
(index + 1) / (float)Math.Max(1, candidateCount));
Debug.Log(
$"[CW-AI] Exporting scene {index + 1}/{candidateCount}: " +
candidate.scenePath);
var openedScene = EditorSceneManager.OpenScene(
candidate.scenePath,
OpenSceneMode.Single);
if (!openedScene.IsValid() || !openedScene.isLoaded)
{
throw new InvalidOperationException(
$"Unable to load scene '{candidate.scenePath}'.");
}
var loadedTimelines = DiscoverLoadedSceneTimelines(openedScene);
if (loadedTimelines.Count == 0)
{
result.status = "not_exportable";
result.error =
"The loaded scene has no Cinemachine Timeline with shots.";
return result;
}
candidate = new SceneCandidate
{
scenePath = candidate.scenePath,
timelines = loadedTimelines
};
result.timelinePaths = loadedTimelines
.Select(timeline => timeline.assetPath)
.ToList();
if (File.Exists(manifestPath))
{
if (ValidateCompleteDataset(
datasetPath,
candidate,
requireAudio,
openedScene,
out var existingValidationError))
{
PopulateTrackCounts(datasetPath, result);
result.status = result.skippedCameraTrackCount > 0
? "already_complete_with_skips"
: "already_complete";
}
else
{
result.status = "invalid_existing";
result.error = existingValidationError;
}
return result;
}
if (Directory.Exists(datasetPath))
{
result.status = "partial_exists";
result.error =
"An existing output directory was preserved and not overwritten.";
return result;
}
TimelineCameraDatasetExporter.ExportActiveScene60FpsToForCli(
workingPath,
requireAudio,
true);
var workingManifestPath = Path.Combine(
workingPath,
"dataset_manifest.json");
if (!File.Exists(workingManifestPath))
{
throw new IOException(
$"Export completed without a manifest: '{workingManifestPath}'.");
}
if (!ValidateCompleteDataset(
workingPath,
candidate,
requireAudio,
openedScene,
out var validationError))
{
throw new InvalidDataException(
$"Export validation failed: {validationError}");
}
Directory.CreateDirectory(outputRoot);
Directory.Move(workingPath, datasetPath);
PopulateTrackCounts(datasetPath, result);
result.status = result.skippedCameraTrackCount > 0
? "exported_with_skips"
: "exported";
}
catch (Exception exception)
{
result.status =
exception is InvalidOperationException &&
exception.Message.IndexOf(
"does not contain a supported camera Timeline",
StringComparison.Ordinal) >= 0
? "not_exportable"
: "failed";
result.error = exception.ToString();
if (Directory.Exists(workingPath))
{
result.partialOutputPath = workingPath;
}
Debug.LogError(
$"[CW-AI] Scene export failed: {candidate.scenePath}\n{exception}");
}
finally
{
stopwatch.Stop();
result.elapsedSeconds = stopwatch.Elapsed.TotalSeconds;
result.completedUtc = DateTime.UtcNow.ToString("O");
}
return result;
}
private static void PopulateTrackCounts(
string datasetPath,
SceneExportResult result)
{
var manifest = JsonUtility.FromJson<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,
Scene loadedScene,
out string error)
{
try
{
var manifestPath = Path.Combine(
datasetPath,
"dataset_manifest.json");
if (!File.Exists(manifestPath))
{
error = "dataset_manifest.json is missing.";
return false;
}
var manifest = JsonUtility.FromJson<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;
}
var currentFingerprint =
TimelineCameraDatasetExporter
.ComputeSceneCameraSourceFingerprint(loadedScene);
if (string.IsNullOrWhiteSpace(manifest.sourceFingerprint) ||
!string.Equals(
manifest.sourceFingerprint,
currentFingerprint,
StringComparison.Ordinal))
{
error =
"The loaded scene camera source fingerprint does not " +
"match the manifest.";
return false;
}
if (manifest.sampleRate != 60 ||
manifest.songs == null ||
manifest.songs.Count == 0)
{
error = "Manifest sample rate or song list is invalid.";
return false;
}
var skippedCount =
manifest.skippedCameraTracks?.Count ?? 0;
if (manifest.discoveredCameraTrackCount <= 0 ||
manifest.exportedCameraTrackCount !=
manifest.songs.Count ||
manifest.discoveredCameraTrackCount !=
manifest.exportedCameraTrackCount + skippedCount)
{
error =
"Camera track accounting in the manifest is inconsistent.";
return false;
}
var folderNames = new HashSet<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 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 BatchReport
{
public string schemaVersion;
public string startedUtc;
public string lastUpdatedUtc;
public string completedUtc;
public string state;
public string unityVersion;
public string assetRoot;
public string outputRoot;
public int candidateSceneCount;
public string candidateInventoryFingerprint;
public int startIndex;
public int endExclusive;
public bool requireAudio;
public int processedSceneCount;
public List<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 outputPath;
public string status;
public string startedUtc;
public string completedUtc;
public double elapsedSeconds;
public string error;
public string partialOutputPath;
public int discoveredCameraTrackCount;
public int exportedCameraTrackCount;
public int skippedCameraTrackCount;
}
[Serializable]
private sealed class DatasetManifestProbe
{
public string schemaVersion;
public string scenePath;
public string sourceFingerprint;
public int sampleRate;
public int discoveredCameraTrackCount;
public int exportedCameraTrackCount;
public List<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;
}
}
}