1346 lines
50 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|