using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Security.Cryptography; using Unity.Cinemachine; using UnityEditor; using UnityEditor.Recorder.Timeline; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Playables; using UnityEngine.SceneManagement; using UnityEngine.Timeline; namespace Streamingle.Editor { public static class AICameraTimelinePreviewImporter { public enum CurveSimplificationPreset { Exact, Balanced, Editable } internal const string OriginalDirectorName = "Timeline"; internal const string PreviewDirectorName = "Timeline_AI_Preview"; internal const string PreviewCameraName = "AI_CameraPreview"; internal const string GeneratedAnimationTrackName = "AI Generated Camera Motion"; internal const string GeneratedAnimationTrackPrefix = "AI Camera Motion"; internal const string GeneratedCinemachineTrackName = "AI Generated Cinemachine"; private const string PreviewVirtualCameraName = "GeneratedVirtualCamera"; private const string PreviewAssetFolderName = "AI_CameraPreview"; private const string FinalAssetFolderName = "AI_CameraFinal"; private const string ShotAnimationFolderName = "Shots"; private const string CameraPrefabFolderName = "Cameras"; private const string PreviewGenerationProvenanceName = "AI Camera Preview Generation Provenance"; private const double CutBoundaryGuardSeconds = 0.000001; private const int DirectionChangeDetectionFlankFrames = 5; // Matches the worker's 0.35-second braking radius at 60 fps. private const int DirectionChangePreserveRadiusFrames = 21; private const float DirectionChangeMinimumSpeedMetersPerSecond = 0.04f; // Preserve the generated braking envelope for every concentrated turn // covered by the worker policy (40 degrees and above), not only an // almost complete reversal. Linear curve simplification may otherwise // collapse a mild 40--55 or clear 60/90-degree slowdown into a kink. private const float DirectionChangeMaximumCosine = 0.76604444f; private const double PositionCurveAuditTimeToleranceSeconds = 0.000000001; private const float PositionCurveAuditErrorEpsilonMeters = 0.0001f; private const int PositionCurveAuditLocalWindowRadiusSamples = 2; private const float PositionCurveAuditSpeedRatio = 1.5f; private const float PositionCurveAuditSpeedDeltaMetersPerSecond = 0.25f; private const float PositionCurveAuditSpeedFloorMetersPerSecond = 0.5f; private const float PositionCurveAuditAccelerationRatio = 2f; private const float PositionCurveAuditAccelerationDeltaMetersPerSecondSquared = 5f; private const float PositionCurveAuditAccelerationFloorMetersPerSecondSquared = 8f; private const float PositionCurveAuditJerkRatio = 3f; private const float PositionCurveAuditJerkDeltaMetersPerSecondCubed = 150f; private const float PositionCurveAuditJerkFloorMetersPerSecondCubed = 250f; private const float PositionCurveAuditMotionNoiseDeadbandMeters = 0.0005f; private const float PositionCurveAuditMotionRetentionMinimumMeters = 0.002f; private const float PositionCurveAuditMotionRetentionRatio = 0.65f; public const CurveSimplificationPreset DefaultCurveSimplificationPreset = CurveSimplificationPreset.Balanced; /// /// Assets and scene camera root produced by . /// The caller is responsible for saving the destination scene when ready. /// public sealed class FinalizedPreviewResult { internal FinalizedPreviewResult( string assetFolderPath, TimelineAsset timeline, AnimationClip[] animationClips, GameObject cameraPrefab, GameObject cameraRoot) { AssetFolderPath = assetFolderPath; Timeline = timeline; AnimationClips = animationClips; CameraPrefab = cameraPrefab; CameraRoot = cameraRoot; } public string AssetFolderPath { get; } public TimelineAsset Timeline { get; } public IReadOnlyList AnimationClips { get; } public GameObject CameraPrefab { get; } public GameObject CameraRoot { get; } } internal readonly struct CurveSimplificationSettings { public CurveSimplificationSettings( float positionErrorMeters, float quaternionAngleErrorDegrees, float fieldOfViewErrorDegrees, float dutchErrorDegrees) { PositionErrorMeters = positionErrorMeters; QuaternionAngleErrorDegrees = quaternionAngleErrorDegrees; FieldOfViewErrorDegrees = fieldOfViewErrorDegrees; DutchErrorDegrees = dutchErrorDegrees; } public float PositionErrorMeters { get; } public float QuaternionAngleErrorDegrees { get; } public float FieldOfViewErrorDegrees { get; } public float DutchErrorDegrees { get; } } internal readonly struct PreviewGenerationProvenanceInfo { internal PreviewGenerationProvenanceInfo( string cwAiRelativeGeneratedFolder, string absoluteGeneratedFolder, string metadataSha256) { CwAiRelativeGeneratedFolder = cwAiRelativeGeneratedFolder ?? string.Empty; AbsoluteGeneratedFolder = absoluteGeneratedFolder ?? string.Empty; MetadataSha256 = metadataSha256 ?? string.Empty; } internal string CwAiRelativeGeneratedFolder { get; } internal string AbsoluteGeneratedFolder { get; } internal string MetadataSha256 { get; } } [MenuItem("Tools/Streamingle/Timeline/Create AI Camera Preview")] public static void CreatePreviewFromMenu() { var generatedDirectory = EditorUtility.OpenFolderPanel( "Select generated AI camera directory", Directory.GetParent(Application.dataPath)?.FullName ?? string.Empty, string.Empty); if (string.IsNullOrWhiteSpace(generatedDirectory)) { return; } try { var result = CreatePreviewForCli(generatedDirectory); Debug.Log(result); } catch (Exception exception) { Debug.LogException(exception); throw; } } [MenuItem("Tools/Streamingle/Timeline/Remove AI Camera Preview")] public static void RemovePreviewFromMenu() { try { Debug.Log(RemovePreviewForCli()); } catch (Exception exception) { Debug.LogException(exception); throw; } } /// /// Creates an unsaved scene preview while preserving the original Timeline asset. /// New Timeline and AnimationClip assets are stored beside the active scene. /// public static string ValidateGeneratedDirectoryForCli( string generatedDirectory) { var generatedDirectoryPath = Path.GetFullPath(generatedDirectory); var metadataPath = Path.Combine(generatedDirectoryPath, "metadata.json"); if (!File.Exists(metadataPath)) { throw new FileNotFoundException( "Generated camera metadata was not found.", metadataPath); } var metadata = ReadGeneratedCameraMetadata(metadataPath); ValidateMetadata(metadata); var cameraPath = ResolveGeneratedPayloadPath( generatedDirectoryPath, metadata.worldCameraFile, "worldCamera"); var timePath = ResolveGeneratedPayloadPath( generatedDirectoryPath, metadata.timeFile, "time"); var shotsPath = ResolveOptionalGeneratedPayloadPath( generatedDirectoryPath, metadata.shotsFile, "shots"); ValidateGeneratedOutputHashes( metadata, cameraPath, timePath, shotsPath); var frames = ReadCameraFrames(cameraPath, metadata.frameCount); var times = ReadTimes(timePath, metadata.frameCount); ValidateGeneratedCameraData(metadata, frames, times); var shots = ReadShotDefinitions(metadata, times, shotsPath); return $"{metadata.songName}: {metadata.frameCount:N0} frames, " + $"{shots.Count:N0} shots at {metadata.sampleRate} FPS"; } public static string CreatePreviewForCli(string generatedDirectory) { return CreatePreviewForCli( generatedDirectory, DefaultCurveSimplificationPreset); } public static string CreatePreviewForCli( string generatedDirectory, CurveSimplificationPreset curvePreset) { return CreatePreview(generatedDirectory, curvePreset, null); } /// /// Creates a preview for exactly . /// This overload supports scenes containing several song directors under /// a shared TimeLine root without consulting any other director. /// public static string CreatePreviewForCli( string generatedDirectory, CurveSimplificationPreset curvePreset, PlayableDirector sourceDirector) { return CreatePreview( generatedDirectory, curvePreset, RequireSourceDirector(sourceDirector)); } public static string CreatePreviewForCli( string generatedDirectory, CurveSimplificationPreset curvePreset, PlayableDirector sourceDirector, string cwAiRoot) { return CreatePreview( generatedDirectory, curvePreset, RequireSourceDirector(sourceDirector), cwAiRoot); } public static string CreatePreviewForCli( string generatedDirectory, PlayableDirector sourceDirector) { return CreatePreviewForCli( generatedDirectory, DefaultCurveSimplificationPreset, sourceDirector); } public static string CreatePreviewWithPresetForCli( string generatedDirectory, string curvePreset) { return CreatePreview( generatedDirectory, ParseCurveSimplificationPreset(curvePreset), null); } public static string CreatePreviewWithPresetForCli( string generatedDirectory, string curvePreset, PlayableDirector sourceDirector) { return CreatePreviewForCli( generatedDirectory, ParseCurveSimplificationPreset(curvePreset), sourceDirector); } private static string CreatePreview( string generatedDirectory, CurveSimplificationPreset curvePreset, PlayableDirector explicitSourceDirector, string cwAiRoot = null) { if (EditorApplication.isPlayingOrWillChangePlaymode) { throw new InvalidOperationException( "AI camera preview must be created in Edit Mode."); } var generatedDirectoryPath = Path.GetFullPath(generatedDirectory); var metadataPath = Path.Combine(generatedDirectoryPath, "metadata.json"); if (!File.Exists(metadataPath)) { throw new FileNotFoundException( "Generated camera metadata was not found.", metadataPath); } var metadata = ReadGeneratedCameraMetadata(metadataPath); ValidateMetadata(metadata); var cameraPath = ResolveGeneratedPayloadPath( generatedDirectoryPath, metadata.worldCameraFile, "worldCamera"); var timePath = ResolveGeneratedPayloadPath( generatedDirectoryPath, metadata.timeFile, "time"); var shotsPath = ResolveOptionalGeneratedPayloadPath( generatedDirectoryPath, metadata.shotsFile, "shots"); ValidateGeneratedOutputHashes( metadata, cameraPath, timePath, shotsPath); var frames = ReadCameraFrames(cameraPath, metadata.frameCount); var times = ReadTimes(timePath, metadata.frameCount); ValidateGeneratedCameraData(metadata, frames, times); var shotDefinitions = ReadShotDefinitions(metadata, times, shotsPath); var isExplicitSource = explicitSourceDirector != null; var scene = isExplicitSource ? explicitSourceDirector.gameObject.scene : SceneManager.GetActiveScene(); var originalDirector = explicitSourceDirector ?? FindOriginalDirector(scene); var originalTimeline = originalDirector.playableAsset as TimelineAsset ?? throw new InvalidOperationException( $"'{OriginalDirectorName}' is not bound to a TimelineAsset."); var originalTimelinePath = AssetDatabase.GetAssetPath(originalTimeline); if (string.IsNullOrWhiteSpace(originalTimelinePath)) { throw new InvalidOperationException( "The original Timeline is not a project asset."); } var previewDirectorName = isExplicitSource ? GetScopedPreviewDirectorName(originalDirector) : PreviewDirectorName; var previewCameraName = isExplicitSource ? GetScopedPreviewCameraName(originalDirector) : PreviewCameraName; EnsureNoExistingPreview( scene, previewDirectorName, previewCameraName); var brain = FindCinemachineBrain(originalDirector, originalTimeline); var assetFolder = EnsurePreviewAssetFolder(scene.path); if (isExplicitSource) { assetFolder = EnsureAssetSubfolder( assetFolder, GetDirectorAssetScopeName(originalDirector)); } var shotAnimationFolder = EnsureAssetSubfolder( assetFolder, ShotAnimationFolderName); var copiedTimelinePath = AssetDatabase.GenerateUniqueAssetPath( $"{assetFolder}/{originalTimeline.name}_AI_Preview.playable"); if (!AssetDatabase.CopyAsset(originalTimelinePath, copiedTimelinePath)) { throw new IOException( $"Unable to copy Timeline asset to '{copiedTimelinePath}'."); } AssetDatabase.ImportAsset( copiedTimelinePath, ImportAssetOptions.ForceSynchronousImport); var copiedTimeline = AssetDatabase.LoadAssetAtPath( copiedTimelinePath); if (copiedTimeline == null) { throw new InvalidOperationException( $"Unable to load copied Timeline '{copiedTimelinePath}'."); } GameObject previewDirectorObject = null; GameObject previewCameraObject = null; var generatedClips = new List(); var generatedClipPaths = new List(); var originalDirectorWasActive = originalDirector.gameObject.activeSelf; try { previewDirectorObject = UnityEngine.Object.Instantiate( originalDirector.gameObject, originalDirector.transform.parent); Undo.RegisterCreatedObjectUndo( previewDirectorObject, "Create AI Timeline Preview"); previewDirectorObject.name = previewDirectorName; var previewDirector = GetClonedDirectorComponent( originalDirector, previewDirectorObject); previewDirector.playableAsset = copiedTimeline; CopyTrackBindings( originalDirector, originalTimeline, previewDirector, copiedTimeline); MuteCopiedCameraTracks(previewDirector, copiedTimeline); MuteCopiedRecorderTracks(copiedTimeline); previewCameraObject = new GameObject(previewCameraName); if (previewCameraObject.scene != scene) { SceneManager.MoveGameObjectToScene(previewCameraObject, scene); } Undo.RegisterCreatedObjectUndo( previewCameraObject, "Create AI Camera Preview"); previewCameraObject.transform.SetPositionAndRotation( Vector3.zero, Quaternion.identity); var shotCameras = CreateShotCameras( previewCameraObject.transform, shotDefinitions, frames); var animationGroup = copiedTimeline.CreateTrack( null, GeneratedAnimationTrackName); var positionExactFallbackCount = 0; foreach (var shotCamera in shotCameras) { var generatedClip = BuildShotAnimationClip( metadata.songName, metadata.sampleRate, frames, times, shotCamera, curvePreset, out var positionUsedExactFallback, out var positionFallbackReason); if (positionUsedExactFallback) { positionExactFallbackCount++; Debug.LogWarning( $"AI camera shot {shotCamera.Definition.index:D3} " + $"({shotCamera.Definition.cameraName}) used Exact position " + $"curves after the reduced-curve audit: " + $"{positionFallbackReason}"); } var animationClipPath = AssetDatabase.GenerateUniqueAssetPath( $"{shotAnimationFolder}/" + $"{shotCamera.Definition.index:D3}_" + $"{SanitizeAssetName(shotCamera.Definition.cameraName)}.anim"); AssetDatabase.CreateAsset(generatedClip, animationClipPath); generatedClips.Add(generatedClip); generatedClipPaths.Add(animationClipPath); CreateGeneratedAnimationTrackForCamera( copiedTimeline, animationGroup, previewDirector, shotCamera.Camera.gameObject, generatedClip, shotCamera.Definition.index, shotCamera.Definition.cameraName, shotCamera.Definition.start, shotCamera.Definition.duration); } var cinemachineTrack = copiedTimeline.CreateTrack( null, GeneratedCinemachineTrackName); foreach (var shotCamera in shotCameras) { var shotTimelineClip = cinemachineTrack.CreateDefaultClip(); shotTimelineClip.displayName = shotCamera.Definition.cameraName; shotTimelineClip.start = shotCamera.Definition.start; shotTimelineClip.duration = shotCamera.Definition.duration - (shotCamera.Definition.endFrameExclusive < metadata.frameCount ? CutBoundaryGuardSeconds : 0); shotTimelineClip.easeInDuration = 0; shotTimelineClip.easeOutDuration = 0; SetClipExtrapolationNone(shotTimelineClip); var shot = shotTimelineClip.asset as CinemachineShot ?? throw new InvalidOperationException( "Unable to create a Cinemachine shot clip."); shot.DisplayName = shotCamera.Definition.cameraName; var exposedName = new PropertyName( $"AI_Camera_{Guid.NewGuid():N}"); shot.VirtualCamera.exposedName = exposedName; shot.VirtualCamera.defaultValue = shotCamera.Camera; previewDirector.SetReferenceValue( exposedName, shotCamera.Camera); } previewDirector.SetGenericBinding(cinemachineTrack, brain); Undo.RecordObject( originalDirector.gameObject, "Activate AI Timeline Preview"); originalDirector.gameObject.SetActive(false); previewDirectorObject.SetActive(true); previewDirector.time = 0; previewDirector.RebuildGraph(); previewDirector.Evaluate(); var provenance = UpsertPreviewGenerationProvenance( copiedTimeline, generatedDirectoryPath, metadataPath, cwAiRoot, false, "Create AI Camera Preview Provenance"); EditorUtility.SetDirty(copiedTimeline); EditorUtility.SetDirty(previewDirector); EditorUtility.SetDirty(provenance); foreach (var generatedClip in generatedClips) { EditorUtility.SetDirty(generatedClip); } EditorSceneManager.MarkSceneDirty(scene); AssetDatabase.SaveAssetIfDirty(copiedTimeline); AssetDatabase.SaveAssetIfDirty(provenance); foreach (var generatedClip in generatedClips) { AssetDatabase.SaveAssetIfDirty(generatedClip); } Selection.activeGameObject = previewDirectorObject; return string.Join( Environment.NewLine, "AI camera Timeline preview created.", $"Song: {metadata.songName}", $"Frames: {metadata.frameCount:N0} at {metadata.sampleRate} FPS", $"Shots: {shotDefinitions.Count:N0}", $"Curve Simplification: {curvePreset}", $"Position Exact Fallbacks: {positionExactFallbackCount:N0}", $"Preview Director: {GetHierarchyPath(previewDirector.transform)}", $"Preview Camera Root: {GetHierarchyPath(previewCameraObject.transform)}", $"Timeline Asset: {copiedTimelinePath}", $"Animation Assets: {generatedClipPaths.Count:N0} " + $"under {shotAnimationFolder}", "Original Timeline asset was not modified.", "The scene remains unsaved so existing user changes are preserved."); } catch { if (previewDirectorObject != null) { UnityEngine.Object.DestroyImmediate(previewDirectorObject); } if (previewCameraObject != null) { UnityEngine.Object.DestroyImmediate(previewCameraObject); } foreach (var generatedClipPath in generatedClipPaths) { AssetDatabase.DeleteAsset(generatedClipPath); } AssetDatabase.DeleteAsset(copiedTimelinePath); originalDirector.gameObject.SetActive(originalDirectorWasActive); throw; } } /// /// Replaces one generated camera animation while preserving every other /// camera, Timeline clip, and AnimationClip asset GUID. /// The candidate must use exactly the same frame count and shot boundaries. /// public static string RegenerateShotForCli( string generatedDirectory, int shotIndex) { return RegenerateShot( generatedDirectory, shotIndex, DefaultCurveSimplificationPreset, null); } public static string RegenerateShotForCli( string generatedDirectory, int shotIndex, CurveSimplificationPreset curvePreset) { return RegenerateShot( generatedDirectory, shotIndex, curvePreset, null); } public static string RegenerateShotForCli( string generatedDirectory, int shotIndex, CurveSimplificationPreset curvePreset, PlayableDirector sourceDirector) { return RegenerateShot( generatedDirectory, shotIndex, curvePreset, RequireSourceDirector(sourceDirector)); } public static string RegenerateShotForCli( string generatedDirectory, int shotIndex, CurveSimplificationPreset curvePreset, PlayableDirector sourceDirector, string cwAiRoot) { return RegenerateShot( generatedDirectory, shotIndex, curvePreset, RequireSourceDirector(sourceDirector), cwAiRoot); } public static string RegenerateShotForCli( string generatedDirectory, int shotIndex, PlayableDirector sourceDirector) { return RegenerateShotForCli( generatedDirectory, shotIndex, DefaultCurveSimplificationPreset, sourceDirector); } public static string RegenerateShotWithPresetForCli( string generatedDirectory, int shotIndex, string curvePreset) { return RegenerateShot( generatedDirectory, shotIndex, ParseCurveSimplificationPreset(curvePreset), null); } public static string RegenerateShotWithPresetForCli( string generatedDirectory, int shotIndex, string curvePreset, PlayableDirector sourceDirector) { return RegenerateShotForCli( generatedDirectory, shotIndex, ParseCurveSimplificationPreset(curvePreset), sourceDirector); } private static string RegenerateShot( string generatedDirectory, int shotIndex, CurveSimplificationPreset curvePreset, PlayableDirector explicitSourceDirector, string cwAiRoot = null) { if (EditorApplication.isPlayingOrWillChangePlaymode) { throw new InvalidOperationException( "An AI camera shot must be regenerated in Edit Mode."); } var generatedDirectoryPath = Path.GetFullPath(generatedDirectory); var metadataPath = Path.Combine(generatedDirectoryPath, "metadata.json"); if (!File.Exists(metadataPath)) { throw new FileNotFoundException( "Generated camera metadata was not found.", metadataPath); } var metadata = ReadGeneratedCameraMetadata(metadataPath); ValidateMetadata(metadata); var cameraPath = ResolveGeneratedPayloadPath( generatedDirectoryPath, metadata.worldCameraFile, "worldCamera"); var timePath = ResolveGeneratedPayloadPath( generatedDirectoryPath, metadata.timeFile, "time"); var shotsPath = ResolveOptionalGeneratedPayloadPath( generatedDirectoryPath, metadata.shotsFile, "shots"); ValidateGeneratedOutputHashes( metadata, cameraPath, timePath, shotsPath); var frames = ReadCameraFrames(cameraPath, metadata.frameCount); var times = ReadTimes(timePath, metadata.frameCount); ValidateGeneratedCameraData(metadata, frames, times); var shotDefinitions = ReadShotDefinitions(metadata, times, shotsPath); if (shotIndex < 0 || shotIndex >= shotDefinitions.Count) { throw new ArgumentOutOfRangeException( nameof(shotIndex), shotIndex, $"Shot index must be between 0 and {shotDefinitions.Count - 1}."); } var context = explicitSourceDirector != null ? FindPreviewContext(explicitSourceDirector) : FindLegacyPreviewContext(SceneManager.GetActiveScene()); var scene = context.Scene; var previewDirector = context.Director; var previewRoot = context.CameraRoot; var timeline = previewDirector.playableAsset as TimelineAsset ?? throw new InvalidOperationException( "The AI preview director is not bound to a TimelineAsset."); var animationEntries = GetGeneratedAnimationEntries(timeline); var animationClips = animationEntries .Select(entry => entry.Clip) .ToArray(); var cinemachineTrack = timeline.GetOutputTracks() .OfType() .SingleOrDefault(track => track.name == GeneratedCinemachineTrackName) ?? throw new InvalidOperationException( $"Timeline does not contain '{GeneratedCinemachineTrackName}'."); var cinemachineClips = cinemachineTrack.GetClips() .OrderBy(clip => clip.start) .ToArray(); if (animationClips.Length != shotDefinitions.Count || cinemachineClips.Length != shotDefinitions.Count || previewRoot.transform.childCount != shotDefinitions.Count) { throw new InvalidDataException( "Candidate shot count does not match the generated Timeline. " + "Run a full generation instead."); } var tolerance = 0.25 / metadata.sampleRate; for (var index = 0; index < shotDefinitions.Count; index++) { var definition = shotDefinitions[index]; if (Math.Abs(animationClips[index].start - definition.start) > tolerance || Math.Abs(animationClips[index].duration - definition.duration) > tolerance || Math.Abs(cinemachineClips[index].start - definition.start) > tolerance) { throw new InvalidDataException( $"Candidate shot {index} has different cut boundaries. " + "Run a full generation instead."); } } var selectedAnimationEntry = animationEntries[shotIndex]; var selectedTimelineClip = selectedAnimationEntry.Clip; var existingClip = selectedTimelineClip.animationClip ?? throw new InvalidOperationException( $"Shot {shotIndex} does not reference an AnimationClip."); AddPreviewAssetPath(new List(), existingClip); var cameraTransform = previewRoot.transform.GetChild(shotIndex); var camera = cameraTransform.GetComponent() ?? throw new InvalidOperationException( $"Generated camera {shotIndex} is missing CinemachineCamera."); var definitionForReplacement = shotDefinitions[shotIndex]; var boundAnimator = previewDirector.GetGenericBinding( selectedAnimationEntry.Track) as Animator; var relativePath = boundAnimator != null && boundAnimator.gameObject == camera.gameObject ? string.Empty : cameraTransform.name; var replacement = BuildShotAnimationClip( metadata.songName, metadata.sampleRate, frames, times, new PreviewShotCamera { Definition = definitionForReplacement, RelativePath = relativePath, Camera = camera }, curvePreset, out var positionUsedExactFallback, out var positionFallbackReason); if (positionUsedExactFallback) { Debug.LogWarning( $"AI camera shot {shotIndex:D3} used Exact position curves " + $"after the reduced-curve audit: {positionFallbackReason}"); } var previousDirectorTime = previewDirector.time; var undoName = $"Regenerate AI Camera Shot {shotIndex:D3}"; Undo.IncrementCurrentGroup(); var undoGroup = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName(undoName); var undoReverted = false; try { Undo.RegisterCompleteObjectUndo( existingClip, undoName); Undo.RecordObject( cameraTransform, undoName); Undo.RecordObject( camera, undoName); Undo.RegisterCompleteObjectUndo( selectedAnimationEntry.Track, undoName); var preservedAssetName = existingClip.name; EditorUtility.CopySerialized(replacement, existingClip); existingClip.name = preservedAssetName; UnityEngine.Object.DestroyImmediate(replacement); var firstFrame = frames[definitionForReplacement.startFrame]; cameraTransform.SetLocalPositionAndRotation( firstFrame.Position, firstFrame.Rotation); if (string.IsNullOrEmpty(relativePath)) { ConfigurePerCameraTrackOffset( selectedAnimationEntry.Track, cameraTransform); } var lens = camera.Lens; lens.FieldOfView = firstFrame.FieldOfView; lens.Dutch = firstFrame.Dutch; camera.Lens = lens; selectedTimelineClip.displayName = cameraTransform.name; var provenance = UpsertPreviewGenerationProvenance( timeline, generatedDirectoryPath, metadataPath, cwAiRoot, true, undoName); EditorUtility.SetDirty(existingClip); EditorUtility.SetDirty(provenance); EditorUtility.SetDirty(camera); EditorUtility.SetDirty(timeline); EditorUtility.SetDirty(previewDirector); EditorSceneManager.MarkSceneDirty(scene); AssetDatabase.SaveAssetIfDirty(existingClip); AssetDatabase.SaveAssetIfDirty(provenance); AssetDatabase.SaveAssetIfDirty(timeline); previewDirector.time = Math.Max( definitionForReplacement.start, Math.Min( previousDirectorTime, definitionForReplacement.end - 1.0 / metadata.sampleRate)); previewDirector.RebuildGraph(); previewDirector.Evaluate(); Selection.activeGameObject = camera.gameObject; } catch { undoReverted = true; Undo.RevertAllDownToGroup(undoGroup); throw; } finally { if (replacement != null) { UnityEngine.Object.DestroyImmediate(replacement); } if (!undoReverted) { Undo.CollapseUndoOperations(undoGroup); } } return string.Join( Environment.NewLine, $"AI camera shot {shotIndex:D3} regenerated.", $"Camera: {cameraTransform.name}", $"Frames: {definitionForReplacement.startFrame:N0}-" + $"{definitionForReplacement.endFrameExclusive - 1:N0}", $"Curve Simplification: {curvePreset}", positionUsedExactFallback ? $"Position Curves: Exact fallback ({positionFallbackReason})" : "Position Curves: requested preset passed motion audit", $"Animation Asset: {AssetDatabase.GetAssetPath(existingClip)}", "All other generated camera clips were preserved.", "The scene remains unsaved."); } public static string[] GetGeneratedShotNamesForEditor() { return TryFindLegacyPreviewContext( SceneManager.GetActiveScene(), out var context) ? GetGeneratedShotNames(context.CameraRoot) : Array.Empty(); } public static string[] GetGeneratedShotNamesForEditor( PlayableDirector sourceDirector) { sourceDirector = RequireSourceDirector(sourceDirector); return TryFindPreviewContext(sourceDirector, out var context) ? GetGeneratedShotNames(context.CameraRoot) : Array.Empty(); } private static string[] GetGeneratedShotNames(GameObject previewRoot) { if (previewRoot == null) { return Array.Empty(); } return Enumerable.Range(0, previewRoot.transform.childCount) .Select(index => $"{index:D3} | {previewRoot.transform.GetChild(index).name}") .ToArray(); } /// /// A generated shot resolved from either the Timeline selection or the /// selected generated camera GameObject. /// public readonly struct GeneratedShotSelection { internal GeneratedShotSelection( int shotIndex, string cameraName, double start, double duration, GameObject cameraObject, AnimationClip animationClip) { ShotIndex = shotIndex; CameraName = cameraName; Start = start; Duration = duration; CameraObject = cameraObject; AnimationClip = animationClip; } public int ShotIndex { get; } public string CameraName { get; } public double Start { get; } public double Duration { get; } public GameObject CameraObject { get; } public AnimationClip AnimationClip { get; } } /// /// Resolves the camera or clip currently selected by the user. Timeline /// clip selection takes priority, followed by an AnimationClip asset and /// a generated camera selected in the Hierarchy. /// public static bool TryGetSelectedGeneratedShotForEditor( PlayableDirector sourceDirector, out GeneratedShotSelection selection) { sourceDirector = RequireSourceDirector(sourceDirector); if (!TryFindPreviewContext(sourceDirector, out var context)) { selection = default; return false; } var timeline = context.Director.playableAsset as TimelineAsset; if (timeline == null || !TryResolveGeneratedShotIndexForEditor( timeline, context.CameraRoot, GetTimelineEditorSelectedClip(), Selection.activeGameObject, Selection.activeObject, out var shotIndex)) { selection = default; return false; } var entries = GetGeneratedAnimationEntries(timeline); if (shotIndex < 0 || shotIndex >= entries.Length || shotIndex >= context.CameraRoot.transform.childCount) { selection = default; return false; } var cameraObject = context.CameraRoot.transform .GetChild(shotIndex) .gameObject; var timelineClip = entries[shotIndex].Clip; selection = new GeneratedShotSelection( shotIndex, cameraObject.name, timelineClip.start, timelineClip.duration, cameraObject, timelineClip.animationClip); return true; } public static int GetSelectedGeneratedShotIndexForEditor( PlayableDirector sourceDirector) { return TryGetSelectedGeneratedShotForEditor( sourceDirector, out var selection) ? selection.ShotIndex : -1; } public static bool TryGetSelectedGeneratedShotForEditor( PlayableDirector sourceDirector, out int shotIndex, out string cameraName) { if (TryGetSelectedGeneratedShotForEditor( sourceDirector, out GeneratedShotSelection selection)) { shotIndex = selection.ShotIndex; cameraName = selection.CameraName; return true; } shotIndex = -1; cameraName = string.Empty; return false; } /// /// Selects a generated camera and seeks the preview director into its /// shot. This does not save or otherwise modify the scene. /// public static GeneratedShotSelection SelectGeneratedShotForEditor( PlayableDirector sourceDirector, int shotIndex) { sourceDirector = RequireSourceDirector(sourceDirector); var context = FindPreviewContext(sourceDirector); var timeline = context.Director.playableAsset as TimelineAsset ?? throw new InvalidOperationException( "The AI preview director is not bound to a TimelineAsset."); var entries = GetGeneratedAnimationEntries(timeline); if (shotIndex < 0 || shotIndex >= entries.Length || shotIndex >= context.CameraRoot.transform.childCount) { throw new ArgumentOutOfRangeException(nameof(shotIndex)); } var timelineClip = entries[shotIndex].Clip; var cameraObject = context.CameraRoot.transform .GetChild(shotIndex) .gameObject; context.Director.time = timelineClip.start; context.Director.RebuildGraph(); context.Director.Evaluate(); Selection.activeGameObject = cameraObject; return new GeneratedShotSelection( shotIndex, cameraObject.name, timelineClip.start, timelineClip.duration, cameraObject, timelineClip.animationClip); } public static string RegenerateSelectedShotForCli( string generatedDirectory, PlayableDirector sourceDirector) { return RegenerateSelectedShotForCli( generatedDirectory, DefaultCurveSimplificationPreset, sourceDirector); } public static string RegenerateSelectedShotForCli( string generatedDirectory, CurveSimplificationPreset curvePreset, PlayableDirector sourceDirector) { sourceDirector = RequireSourceDirector(sourceDirector); if (!TryGetSelectedGeneratedShotForEditor( sourceDirector, out var selection)) { throw new InvalidOperationException( "Select an AI camera or one of its Timeline clips before regenerating."); } return RegenerateShot( generatedDirectory, selection.ShotIndex, curvePreset, sourceDirector); } internal static bool TryResolveGeneratedShotIndexForEditor( TimelineAsset timeline, GameObject cameraRoot, TimelineClip selectedTimelineClip, GameObject selectedGameObject, UnityEngine.Object selectedAsset, out int shotIndex) { shotIndex = -1; if (timeline == null || cameraRoot == null) { return false; } var animationEntries = GetGeneratedAnimationEntries(timeline); var cinemachineClips = GetGeneratedCinemachineClips(timeline); if (selectedTimelineClip != null) { shotIndex = Array.FindIndex( animationEntries, entry => ReferenceEquals(entry.Clip, selectedTimelineClip)); if (shotIndex < 0) { shotIndex = Array.FindIndex( cinemachineClips, clip => ReferenceEquals(clip, selectedTimelineClip)); } if (shotIndex >= 0) { return true; } } if (selectedAsset != null) { shotIndex = Array.FindIndex( animationEntries, entry => entry.Clip.animationClip == selectedAsset || entry.Clip.asset == selectedAsset); if (shotIndex < 0) { shotIndex = Array.FindIndex( cinemachineClips, clip => clip.asset == selectedAsset); } if (shotIndex >= 0) { return true; } } var selectedTransform = selectedGameObject != null ? selectedGameObject.transform : null; while (selectedTransform != null && selectedTransform.parent != cameraRoot.transform) { selectedTransform = selectedTransform.parent; } if (selectedTransform == null || selectedTransform.parent != cameraRoot.transform) { return false; } shotIndex = selectedTransform.GetSiblingIndex(); return shotIndex >= 0 && shotIndex < cameraRoot.transform.childCount && shotIndex < animationEntries.Length; } private static TimelineClip GetTimelineEditorSelectedClip() { try { var timelineEditorType = Type.GetType( "UnityEditor.Timeline.TimelineEditor, Unity.Timeline.Editor"); var selectedClipProperty = timelineEditorType?.GetProperty( "selectedClip", BindingFlags.Public | BindingFlags.Static); return selectedClipProperty?.GetValue(null) as TimelineClip; } catch { // Timeline selection is optional. Hierarchy/asset selection still // provides a stable fallback when no Timeline window is open. return null; } } public static string RemovePreviewForCli() { return RemovePreview(null); } public static string RemovePreviewForCli( PlayableDirector sourceDirector) { return RemovePreview(RequireSourceDirector(sourceDirector)); } private static string RemovePreview( PlayableDirector explicitSourceDirector) { if (EditorApplication.isPlayingOrWillChangePlaymode) { throw new InvalidOperationException( "AI camera preview must be removed in Edit Mode."); } var scene = explicitSourceDirector != null ? explicitSourceDirector.gameObject.scene : SceneManager.GetActiveScene(); PreviewContext context; var found = explicitSourceDirector != null ? TryFindPreviewContext(explicitSourceDirector, out context) : TryFindLegacyPreviewContext(scene, out context); var previewDirector = found ? context.Director : null; var previewCamera = found ? context.CameraRoot : null; var deletedAssets = new List(); var scopedAssetFolder = found && explicitSourceDirector != null ? $"{GetPreviewAssetFolderPath(scene.path)}/" + GetDirectorAssetScopeName(explicitSourceDirector) : string.Empty; if (previewDirector != null) { var timeline = previewDirector.playableAsset as TimelineAsset; if (timeline != null) { var animationClips = GetGeneratedAnimationEntries(timeline) .Select(entry => entry.Clip.animationClip) .Where(clip => clip != null) .ToArray(); foreach (var animationClip in animationClips) { AddPreviewAssetPath(deletedAssets, animationClip); } AddPreviewAssetPath(deletedAssets, timeline); } UnityEngine.Object.DestroyImmediate(previewDirector.gameObject); } if (previewCamera != null) { UnityEngine.Object.DestroyImmediate(previewCamera); } if (!string.IsNullOrEmpty(scopedAssetFolder) && AssetDatabase.IsValidFolder(scopedAssetFolder)) { if (!scopedAssetFolder.Contains( $"/{PreviewAssetFolderName}/")) { throw new InvalidOperationException( $"Refusing to remove non-preview folder " + $"'{scopedAssetFolder}'."); } if (!AssetDatabase.DeleteAsset(scopedAssetFolder)) { throw new IOException( $"Unable to remove preview asset folder " + $"'{scopedAssetFolder}'."); } } else { foreach (var assetPath in deletedAssets.Distinct()) { AssetDatabase.DeleteAsset(assetPath); } } // A full regeneration removes and recreates the same scoped asset // folder in one editor action. Force the deletion into the asset // database before GenerateUniqueAssetPath is called again; without // this refresh Unity can briefly report the deleted parent as valid // and return an empty destination path for the copied Timeline. AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); var originalDirector = explicitSourceDirector ?? Resources.FindObjectsOfTypeAll() .FirstOrDefault(director => director.gameObject.scene == scene && director.name == OriginalDirectorName); if (originalDirector != null && (explicitSourceDirector == null || found)) { originalDirector.gameObject.SetActive(true); originalDirector.time = 0; originalDirector.RebuildGraph(); originalDirector.Evaluate(); } EditorSceneManager.MarkSceneDirty(scene); return $"Removed AI preview objects and {deletedAssets.Count} preview assets."; } /// /// Copies the selected source director's current AI preview into a durable /// final asset set and binds it to . /// The active scene is never changed or saved. The source director, source /// preview, scene paths, and both director times are left untouched. /// public static FinalizedPreviewResult SaveFinalForEditor( PlayableDirector sourceDirector, PlayableDirector destinationDirector) { sourceDirector = RequireSourceDirector(sourceDirector); destinationDirector = RequireDestinationDirector( sourceDirector, destinationDirector); return SaveFinalForEditor( sourceDirector, destinationDirector, GetDefaultFinalAssetFolder( sourceDirector, destinationDirector)); } /// /// Same as the two-argument overload, with an explicit Assets-relative /// destination folder (for example Assets/MyShow/AI_CameraFinal/SongA). /// public static FinalizedPreviewResult SaveFinalForEditor( PlayableDirector sourceDirector, PlayableDirector destinationDirector, string finalAssetFolder) { if (EditorApplication.isPlayingOrWillChangePlaymode) { throw new InvalidOperationException( "AI camera final assets must be created in Edit Mode."); } sourceDirector = RequireSourceDirector(sourceDirector); destinationDirector = RequireDestinationDirector( sourceDirector, destinationDirector); var context = FindPreviewContext(sourceDirector); var sourceTimeline = context.Director.playableAsset as TimelineAsset ?? throw new InvalidOperationException( "The AI preview director is not bound to a TimelineAsset."); var destinationTimelineBefore = destinationDirector.playableAsset as TimelineAsset ?? throw new InvalidOperationException( "The destination director is not bound to a TimelineAsset."); var destinationTimeBefore = destinationDirector.time; var destinationStateBefore = CaptureDirectorState(destinationDirector); var sourceStateBefore = CaptureDirectorState(sourceDirector); var previewStateBefore = CaptureDirectorState(context.Director); var previewCameraRootWasDirty = EditorUtility.IsDirty(context.CameraRoot); var activeSceneBefore = SceneManager.GetActiveScene(); var normalizedFolder = NormalizeFinalAssetFolder(finalAssetFolder); EnsureAssetFolderRecursively(normalizedFolder); var shotFolder = EnsureAssetSubfolder( normalizedFolder, ShotAnimationFolderName); var cameraFolder = EnsureAssetSubfolder( normalizedFolder, CameraPrefabFolderName); var createdAssetPaths = new List(); var finalClips = new List(); TimelineAsset finalTimeline = null; GameObject cameraPrefab = null; GameObject finalCameraRoot = null; try { var sourceTimelinePath = AssetDatabase.GetAssetPath(sourceTimeline); if (string.IsNullOrWhiteSpace(sourceTimelinePath)) { throw new InvalidOperationException( "The AI preview Timeline is not a project asset."); } var finalTimelinePath = AssetDatabase.GenerateUniqueAssetPath( $"{normalizedFolder}/" + $"{SanitizeAssetName(sourceTimeline.name)}_Final.playable"); if (!AssetDatabase.CopyAsset(sourceTimelinePath, finalTimelinePath)) { throw new IOException( $"Unable to copy the final Timeline to '{finalTimelinePath}'."); } createdAssetPaths.Add(finalTimelinePath); AssetDatabase.ImportAsset( finalTimelinePath, ImportAssetOptions.ForceSynchronousImport); finalTimeline = AssetDatabase.LoadAssetAtPath( finalTimelinePath) ?? throw new InvalidOperationException( $"Unable to load copied Timeline '{finalTimelinePath}'."); RemovePreviewGenerationProvenance(finalTimeline); CopyGeneratedAnimationAssets( sourceTimeline, finalTimeline, shotFolder, createdAssetPaths, finalClips); var cameraPrefabPath = AssetDatabase.GenerateUniqueAssetPath( $"{cameraFolder}/" + $"{SanitizeAssetName(sourceDirector.name)}_AI_Cameras.prefab"); cameraPrefab = PrefabUtility.SaveAsPrefabAsset( context.CameraRoot, cameraPrefabPath, out var prefabSaved); if (!prefabSaved || cameraPrefab == null) { throw new IOException( $"Unable to save generated cameras to '{cameraPrefabPath}'."); } createdAssetPaths.Add(cameraPrefabPath); finalCameraRoot = PrefabUtility.InstantiatePrefab( cameraPrefab, destinationDirector.gameObject.scene) as GameObject ?? throw new InvalidOperationException( "Unable to instantiate final cameras in the destination scene."); finalCameraRoot.name = $"AI_CameraFinal__{GetDirectorScopeLabel(sourceDirector)}"; BindFinalTimelineToDestination( destinationDirector, destinationTimelineBefore, finalTimeline, finalCameraRoot); destinationDirector.time = destinationTimeBefore; destinationDirector.RebuildGraph(); destinationDirector.time = destinationTimeBefore; EditorUtility.SetDirty(finalTimeline); EditorUtility.SetDirty(destinationDirector); EditorUtility.SetDirty(finalCameraRoot); foreach (var clip in finalClips) { EditorUtility.SetDirty(clip); AssetDatabase.SaveAssetIfDirty(clip); } AssetDatabase.SaveAssetIfDirty(finalTimeline); AssetDatabase.SaveAssetIfDirty(cameraPrefab); EditorSceneManager.MarkSceneDirty( destinationDirector.gameObject.scene); AssertFinalizationSourceWasPreserved( sourceStateBefore, previewStateBefore, context.CameraRoot, previewCameraRootWasDirty, activeSceneBefore); return new FinalizedPreviewResult( normalizedFolder, finalTimeline, finalClips.ToArray(), cameraPrefab, finalCameraRoot); } catch { RestoreDestinationDirectorAfterFinalizationFailure( destinationStateBefore, finalTimeline); if (finalCameraRoot != null) { UnityEngine.Object.DestroyImmediate(finalCameraRoot); } for (var index = createdAssetPaths.Count - 1; index >= 0; index--) { AssetDatabase.DeleteAsset(createdAssetPaths[index]); } AssetDatabase.SaveAssets(); throw; } } private static PlayableDirector RequireDestinationDirector( PlayableDirector sourceDirector, PlayableDirector destinationDirector) { if (destinationDirector == null) { throw new ArgumentNullException(nameof(destinationDirector)); } if (destinationDirector == sourceDirector) { throw new ArgumentException( "The source director cannot also be the final destination. " + "Use a director in the copied/final scene so the source stays unchanged.", nameof(destinationDirector)); } if (destinationDirector.gameObject.scene == sourceDirector.gameObject.scene) { throw new ArgumentException( "The final destination director must be in a different loaded " + "scene so the source scene and its dirty state remain untouched.", nameof(destinationDirector)); } var scene = destinationDirector.gameObject.scene; if (!scene.IsValid() || !scene.isLoaded) { throw new InvalidOperationException( "The destination director must belong to a loaded scene."); } if (destinationDirector.playableAsset is not TimelineAsset) { throw new InvalidOperationException( "The destination director is not bound to a TimelineAsset."); } return destinationDirector; } private static string GetDefaultFinalAssetFolder( PlayableDirector sourceDirector, PlayableDirector destinationDirector) { var scenePath = destinationDirector.gameObject.scene.path ?.Replace('\\', '/'); if (string.IsNullOrWhiteSpace(scenePath)) { throw new InvalidOperationException( "The destination scene must be saved under Assets when no " + "explicit final asset folder is supplied."); } var sceneFolder = Path.GetDirectoryName(scenePath) ?.Replace('\\', '/'); if (string.IsNullOrWhiteSpace(sceneFolder)) { throw new InvalidOperationException( "The destination scene must be saved under Assets when no " + "explicit final asset folder is supplied."); } return $"{sceneFolder}/{FinalAssetFolderName}/" + SanitizeAssetName(sourceDirector.name); } internal static string NormalizeFinalAssetFolder(string path) { if (string.IsNullOrWhiteSpace(path)) { throw new ArgumentException( "A final asset folder under Assets is required.", nameof(path)); } var normalized = path.Trim().Replace('\\', '/').TrimEnd('/'); if (Path.IsPathRooted(normalized) || normalized.Equals("Assets", StringComparison.OrdinalIgnoreCase) || !normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( $"Final asset folder must be a dedicated folder under Assets: '{path}'.", nameof(path)); } var segments = normalized.Split('/'); if (segments.Any(segment => string.IsNullOrWhiteSpace(segment) || segment == "." || segment == "..")) { throw new ArgumentException( $"Final asset folder contains an invalid segment: '{path}'.", nameof(path)); } var projectRoot = Path.GetDirectoryName(Application.dataPath) ?? throw new InvalidOperationException( "Unable to resolve the Unity project root."); var fullPath = Path.GetFullPath(Path.Combine(projectRoot, normalized)); var assetsRoot = Path.GetFullPath(Application.dataPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!fullPath.StartsWith( assetsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( $"Final asset folder resolves outside Assets: '{path}'.", nameof(path)); } return normalized; } private static void EnsureAssetFolderRecursively(string assetFolder) { var segments = assetFolder.Split('/'); var current = segments[0]; for (var index = 1; index < segments.Length; index++) { var next = $"{current}/{segments[index]}"; if (!AssetDatabase.IsValidFolder(next)) { var guid = AssetDatabase.CreateFolder(current, segments[index]); if (string.IsNullOrWhiteSpace(guid)) { throw new IOException( $"Unable to create asset folder '{next}'."); } } current = next; } } private static void CopyGeneratedAnimationAssets( TimelineAsset sourceTimeline, TimelineAsset finalTimeline, string shotFolder, ICollection createdAssetPaths, ICollection finalClips) { var sourceClips = GetGeneratedAnimationEntries(sourceTimeline) .Select(entry => entry.Clip) .ToArray(); var copiedTimelineClips = GetGeneratedAnimationEntries(finalTimeline) .Select(entry => entry.Clip) .ToArray(); if (sourceClips.Length == 0 || sourceClips.Length != copiedTimelineClips.Length) { throw new InvalidOperationException( "The copied Timeline does not match its generated animation clips."); } for (var index = 0; index < sourceClips.Length; index++) { var sourceClip = sourceClips[index].animationClip ?? throw new InvalidOperationException( $"Generated shot {index} has no AnimationClip."); AddPreviewAssetPath(new List(), sourceClip); var sourceClipPath = AssetDatabase.GetAssetPath(sourceClip); var finalClipPath = AssetDatabase.GenerateUniqueAssetPath( $"{shotFolder}/{index:D3}_" + $"{SanitizeAssetName(sourceClip.name)}.anim"); if (!AssetDatabase.CopyAsset(sourceClipPath, finalClipPath)) { throw new IOException( $"Unable to copy generated shot to '{finalClipPath}'."); } createdAssetPaths.Add(finalClipPath); AssetDatabase.ImportAsset( finalClipPath, ImportAssetOptions.ForceSynchronousImport); var finalClip = AssetDatabase.LoadAssetAtPath( finalClipPath) ?? throw new InvalidOperationException( $"Unable to load copied animation '{finalClipPath}'."); var animationPlayable = copiedTimelineClips[index].asset as AnimationPlayableAsset ?? throw new InvalidOperationException( $"Final shot {index} is not an AnimationPlayableAsset."); animationPlayable.clip = finalClip; EditorUtility.SetDirty(animationPlayable); finalClips.Add(finalClip); } } private static GeneratedAnimationEntry[] GetGeneratedAnimationEntries( TimelineAsset timeline) { if (timeline == null) { return Array.Empty(); } return timeline.GetOutputTracks() .OfType() .Where(IsGeneratedAnimationTrack) .SelectMany(track => track.GetClips().Select(clip => new GeneratedAnimationEntry(track, clip))) .OrderBy(entry => entry.Clip.start) .ThenBy(entry => entry.Clip.displayName) .ToArray(); } private static AnimationTrack[] GetGeneratedAnimationTracks( TimelineAsset timeline) { return timeline.GetOutputTracks() .OfType() .Where(IsGeneratedAnimationTrack) .OrderBy(track => track.GetClips() .Select(clip => clip.start) .DefaultIfEmpty(double.MaxValue) .Min()) .ToArray(); } private static bool IsGeneratedAnimationTrack(AnimationTrack track) { if (track == null) { return false; } if (track.name == GeneratedAnimationTrackName) { // Legacy previews used one root AnimationTrack with all shots. return true; } return track.parent is GroupTrack group && group.name == GeneratedAnimationTrackName; } private static CinemachineTrack GetGeneratedCinemachineTrack( TimelineAsset timeline) { return timeline.GetOutputTracks() .OfType() .SingleOrDefault(track => track.name == GeneratedCinemachineTrackName) ?? throw new InvalidOperationException( $"Timeline does not contain '{GeneratedCinemachineTrackName}'."); } private static TimelineClip[] GetGeneratedCinemachineClips( TimelineAsset timeline) { var track = timeline.GetOutputTracks() .OfType() .SingleOrDefault(candidate => candidate.name == GeneratedCinemachineTrackName); return track == null ? Array.Empty() : track.GetClips() .OrderBy(clip => clip.start) .ToArray(); } private static void BindFinalTimelineToDestination( PlayableDirector destinationDirector, TimelineAsset destinationTimelineBefore, TimelineAsset finalTimeline, GameObject finalCameraRoot) { var destinationTracks = destinationTimelineBefore.GetOutputTracks() .Where(track => !IsGeneratedCameraTrack(track)) .ToArray(); var finalBaseTracks = finalTimeline.GetOutputTracks() .Where(track => !IsGeneratedCameraTrack(track)) .ToArray(); if (destinationTracks.Length != finalBaseTracks.Length) { throw new InvalidOperationException( "The destination Timeline structure does not match the preview source."); } var bindings = new UnityEngine.Object[destinationTracks.Length]; for (var index = 0; index < destinationTracks.Length; index++) { if (destinationTracks[index].GetType() != finalBaseTracks[index].GetType() || destinationTracks[index].name != finalBaseTracks[index].name) { throw new InvalidOperationException( $"Destination Timeline track {index} does not match the preview source."); } bindings[index] = destinationDirector.GetGenericBinding(destinationTracks[index]); } var brain = FindCinemachineBrain( destinationDirector, destinationTimelineBefore); var animationTracks = GetGeneratedAnimationTracks(finalTimeline); var animationEntries = GetGeneratedAnimationEntries(finalTimeline); var cinemachineTrack = GetGeneratedCinemachineTrack(finalTimeline); var shots = cinemachineTrack.GetClips() .OrderBy(clip => clip.start) .ToArray(); if (shots.Length != finalCameraRoot.transform.childCount) { throw new InvalidOperationException( "Final camera count does not match the generated Cinemachine shots."); } if (animationEntries.Length != shots.Length) { throw new InvalidOperationException( "Final camera count does not match the generated animation shots."); } // PlayableDirector keeps serialized binding keys even after its // playableAsset changes. Clear every old key so the Final scene does // not retain a hidden dependency on the disposable preview Timeline. ClearTimelineBindingsForFinalization( destinationDirector, destinationTimelineBefore); destinationDirector.playableAsset = finalTimeline; for (var index = 0; index < finalBaseTracks.Length; index++) { destinationDirector.SetGenericBinding( finalBaseTracks[index], bindings[index]); } var usesPerCameraAnimationTracks = animationTracks.Length == shots.Length && animationTracks.All(track => track.GetClips().Count() == 1); if (usesPerCameraAnimationTracks) { for (var index = 0; index < animationTracks.Length; index++) { var animator = finalCameraRoot.transform.GetChild(index) .GetComponent() ?? throw new InvalidOperationException( $"Final camera {index} is missing its Animator."); destinationDirector.SetGenericBinding( animationTracks[index], animator); } } else if (animationTracks.Length == 1) { // Backward compatibility for previews created before per-camera // Animator tracks were introduced. var animator = finalCameraRoot.GetComponent() ?? throw new InvalidOperationException( "The legacy final camera root is missing its Animator."); destinationDirector.SetGenericBinding( animationTracks[0], animator); } else { throw new InvalidOperationException( "Generated camera animation tracks do not map one-to-one to shots."); } destinationDirector.SetGenericBinding(cinemachineTrack, brain); for (var index = 0; index < shots.Length; index++) { var camera = finalCameraRoot.transform.GetChild(index) .GetComponent() ?? throw new InvalidOperationException( $"Final camera {index} is missing CinemachineCamera."); var shot = shots[index].asset as CinemachineShot ?? throw new InvalidOperationException( $"Final shot {index} is not a CinemachineShot."); var exposedName = shot.VirtualCamera.exposedName; shot.VirtualCamera.defaultValue = camera; destinationDirector.SetReferenceValue(exposedName, camera); EditorUtility.SetDirty(shot); } } internal static void ClearTimelineBindingsForFinalization( PlayableDirector director, TimelineAsset timeline) { if (director == null) { throw new ArgumentNullException(nameof(director)); } if (timeline == null) { throw new ArgumentNullException(nameof(timeline)); } foreach (var track in timeline.GetOutputTracks()) { director.ClearGenericBinding(track); } } private static bool IsGeneratedCameraTrack(TrackAsset track) { return track is AnimationTrack animationTrack ? IsGeneratedAnimationTrack(animationTrack) : track.name == GeneratedCinemachineTrackName; } private static DirectorStateSnapshot CaptureDirectorState( PlayableDirector director) { var timeline = director.playableAsset as TimelineAsset ?? throw new InvalidOperationException( $"Director '{director.name}' is not bound to a TimelineAsset."); var tracks = timeline.GetOutputTracks().ToArray(); var referenceNames = timeline.GetOutputTracks() .SelectMany(track => track.GetClips()) .Select(clip => clip.asset as CinemachineShot) .Where(shot => shot != null) .Select(shot => shot.VirtualCamera.exposedName) .Distinct() .ToArray(); var referenceValues = new UnityEngine.Object[referenceNames.Length]; var referenceValuesValid = new bool[referenceNames.Length]; for (var index = 0; index < referenceNames.Length; index++) { referenceValues[index] = director.GetReferenceValue( referenceNames[index], out referenceValuesValid[index]) as UnityEngine.Object; } return new DirectorStateSnapshot( director, timeline, director.time, director.gameObject.activeSelf, director.gameObject.scene.path, director.gameObject.scene.isDirty, EditorUtility.IsDirty(director), EditorUtility.IsDirty(director.gameObject), EditorUtility.IsDirty(timeline), tracks, tracks.Select(director.GetGenericBinding).ToArray(), referenceNames, referenceValues, referenceValuesValid); } private static void AssertFinalizationSourceWasPreserved( DirectorStateSnapshot sourceStateBefore, DirectorStateSnapshot previewStateBefore, GameObject previewCameraRoot, bool previewCameraRootWasDirty, Scene activeSceneBefore) { AssertDirectorStateWasPreserved(sourceStateBefore); AssertDirectorStateWasPreserved(previewStateBefore); if (EditorUtility.IsDirty(previewCameraRoot) != previewCameraRootWasDirty || SceneManager.GetActiveScene() != activeSceneBefore) { throw new InvalidOperationException( "Finalization changed the preview camera or active scene unexpectedly."); } } private static void AssertDirectorStateWasPreserved( DirectorStateSnapshot snapshot) { var director = snapshot.Director; var scene = director.gameObject.scene; var currentTimeline = director.playableAsset as TimelineAsset; var currentTracks = currentTimeline == null ? Array.Empty() : currentTimeline.GetOutputTracks().ToArray(); var bindingsMatch = currentTracks.Length == snapshot.Tracks.Length; for (var index = 0; bindingsMatch && index < currentTracks.Length; index++) { bindingsMatch = currentTracks[index] == snapshot.Tracks[index] && director.GetGenericBinding(currentTracks[index]) == snapshot.Bindings[index]; } if (currentTimeline != snapshot.Timeline || Math.Abs(director.time - snapshot.Time) > double.Epsilon || director.gameObject.activeSelf != snapshot.ActiveSelf || scene.path != snapshot.ScenePath || scene.isDirty != snapshot.SceneWasDirty || EditorUtility.IsDirty(director) != snapshot.DirectorWasDirty || EditorUtility.IsDirty(director.gameObject) != snapshot.GameObjectWasDirty || EditorUtility.IsDirty(snapshot.Timeline) != snapshot.TimelineWasDirty || !bindingsMatch) { throw new InvalidOperationException( $"Finalization changed source state for '{director.name}'."); } } private static void RestoreDestinationDirectorAfterFinalizationFailure( DirectorStateSnapshot snapshot, TimelineAsset finalTimeline) { var destinationDirector = snapshot?.Director; if (destinationDirector == null) { return; } if (finalTimeline != null) { foreach (var track in finalTimeline.GetOutputTracks()) { destinationDirector.ClearGenericBinding(track); } var cinemachineTrack = finalTimeline.GetOutputTracks() .OfType() .FirstOrDefault(track => track.name == GeneratedCinemachineTrackName); if (cinemachineTrack != null) { foreach (var clip in cinemachineTrack.GetClips()) { if (clip.asset is CinemachineShot shot) { destinationDirector.ClearReferenceValue( shot.VirtualCamera.exposedName); } } } } destinationDirector.playableAsset = snapshot.Timeline; for (var index = 0; index < snapshot.Tracks.Length; index++) { destinationDirector.SetGenericBinding( snapshot.Tracks[index], snapshot.Bindings[index]); } for (var index = 0; index < snapshot.ReferenceNames.Length; index++) { if (snapshot.ReferenceValuesValid[index]) { destinationDirector.SetReferenceValue( snapshot.ReferenceNames[index], snapshot.ReferenceValues[index]); } else { destinationDirector.ClearReferenceValue( snapshot.ReferenceNames[index]); } } destinationDirector.gameObject.SetActive(snapshot.ActiveSelf); destinationDirector.time = snapshot.Time; destinationDirector.RebuildGraph(); destinationDirector.time = snapshot.Time; } private static List ReadShotDefinitions( GeneratedCameraMetadata metadata, IReadOnlyList times, string shotsPath) { if (string.IsNullOrWhiteSpace(metadata.shotsFile)) { return new List { new() { index = 0, cameraName = PreviewVirtualCameraName, shotType = "continuous", startFrame = 0, endFrameExclusive = metadata.frameCount, start = 0, end = metadata.duration, duration = metadata.duration } }; } if (string.IsNullOrWhiteSpace(shotsPath)) { throw new InvalidDataException( "Generated shot payload path was not resolved."); } var shotFile = JsonUtility.FromJson( File.ReadAllText(shotsPath)); if (shotFile?.shots == null || shotFile.shots.Count == 0) { throw new InvalidDataException( "Generated shot file does not contain any shots."); } var ordered = shotFile.shots .OrderBy(shot => shot.startFrame) .ToList(); var expectedStart = 0; for (var index = 0; index < ordered.Count; index++) { var shot = ordered[index]; if (shot.startFrame != expectedStart || shot.endFrameExclusive <= shot.startFrame || shot.endFrameExclusive > metadata.frameCount) { throw new InvalidDataException( $"Shot {index} has an invalid or non-contiguous frame range."); } shot.index = index; shot.cameraName = string.IsNullOrWhiteSpace(shot.cameraName) ? $"Shot_{index:D3}" : shot.cameraName.Replace('/', '_').Replace('\\', '_'); shot.start = shot.startFrame / (double)metadata.sampleRate; shot.end = shot.endFrameExclusive / (double)metadata.sampleRate; shot.duration = shot.end - shot.start; expectedStart = shot.endFrameExclusive; } if (expectedStart != metadata.frameCount) { throw new InvalidDataException( "Generated shots do not cover the full camera timeline."); } return ordered; } private static List CreateShotCameras( Transform cameraRoot, IReadOnlyList shotDefinitions, IReadOnlyList frames) { var result = new List(shotDefinitions.Count); foreach (var definition in shotDefinitions) { var cameraObject = new GameObject(definition.cameraName); cameraObject.transform.SetParent(cameraRoot, false); var camera = cameraObject.AddComponent(); var initialFrame = frames[definition.startFrame]; cameraObject.transform.SetLocalPositionAndRotation( initialFrame.Position, initialFrame.Rotation); var lens = camera.Lens; lens.FieldOfView = initialFrame.FieldOfView; lens.Dutch = initialFrame.Dutch; camera.Lens = lens; result.Add(new PreviewShotCamera { Definition = definition, // Every generated camera is bound to its own AnimationTrack, // so the clip animates the bound object itself rather than a // child path below one shared root Animator. RelativePath = string.Empty, Camera = camera }); } return result; } internal static AnimationTrack CreateGeneratedAnimationTrackForCamera( TimelineAsset timeline, GroupTrack animationGroup, PlayableDirector director, GameObject cameraObject, AnimationClip animationClip, int shotIndex, string cameraName, double start, double duration) { if (timeline == null) { throw new ArgumentNullException(nameof(timeline)); } if (animationGroup == null) { throw new ArgumentNullException(nameof(animationGroup)); } if (director == null) { throw new ArgumentNullException(nameof(director)); } if (cameraObject == null) { throw new ArgumentNullException(nameof(cameraObject)); } if (animationClip == null) { throw new ArgumentNullException(nameof(animationClip)); } if (duration <= 0) { throw new ArgumentOutOfRangeException(nameof(duration)); } var animator = cameraObject.GetComponent(); if (animator == null) { animator = cameraObject.AddComponent(); } animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; var safeCameraName = string.IsNullOrWhiteSpace(cameraName) ? $"Shot_{shotIndex:D3}" : cameraName; var animationTrack = timeline.CreateTrack( animationGroup, $"{GeneratedAnimationTrackPrefix} {shotIndex:D3} | " + safeCameraName); ConfigurePerCameraTrackOffset( animationTrack, cameraObject.transform); var timelineClip = animationTrack.CreateClip(animationClip); timelineClip.displayName = safeCameraName; timelineClip.start = start; timelineClip.duration = duration; timelineClip.clipIn = 0; timelineClip.easeInDuration = 0; timelineClip.easeOutDuration = 0; SetClipExtrapolationNone(timelineClip); director.SetGenericBinding(animationTrack, animator); return animationTrack; } internal static void ConfigurePerCameraTrackOffset( AnimationTrack animationTrack, Transform cameraTransform) { if (animationTrack == null) { throw new ArgumentNullException(nameof(animationTrack)); } if (cameraTransform == null) { throw new ArgumentNullException(nameof(cameraTransform)); } // A finite AnimationTrack treats root Transform curves as motion // relative to the clip's first sample. Seed the track offset with // that first world/local pose so the evaluated camera stays at the // generated coordinates instead of collapsing around scene origin. animationTrack.trackOffset = TrackOffset.ApplyTransformOffsets; animationTrack.position = cameraTransform.localPosition; animationTrack.rotation = cameraTransform.localRotation; animationTrack.infiniteClipOffsetPosition = Vector3.zero; animationTrack.infiniteClipOffsetRotation = Quaternion.identity; } private static AnimationClip BuildShotAnimationClip( string songName, int sampleRate, IReadOnlyList frames, IReadOnlyList times, PreviewShotCamera shotCamera, CurveSimplificationPreset curvePreset, out bool positionUsedExactFallback, out string positionFallbackReason) { var clip = new AnimationClip { name = $"{songName}_{shotCamera.Definition.index:D3}_" + $"{shotCamera.Definition.cameraName}", frameRate = sampleRate, wrapMode = WrapMode.ClampForever }; var start = shotCamera.Definition.startFrame; var end = shotCamera.Definition.endFrameExclusive; var relativePath = shotCamera.RelativePath; var sampleCount = end - start; var localTimes = new double[sampleCount]; var positions = new Vector3[sampleCount]; var rotations = new Quaternion[sampleCount]; var fieldOfView = new float[sampleCount]; var dutch = new float[sampleCount]; var startTime = times[start]; for (var index = 0; index < sampleCount; index++) { var frame = frames[start + index]; localTimes[index] = times[start + index] - startTime; positions[index] = frame.Position; rotations[index] = frame.Rotation; fieldOfView[index] = frame.FieldOfView; dutch[index] = frame.Dutch; } var simplified = SimplifyCameraCurves( localTimes, positions, rotations, fieldOfView, dutch, curvePreset); var positionCurves = BuildPositionCurves(simplified, curvePreset); positionUsedExactFallback = positionCurves.UsedExactFallback; positionFallbackReason = positionCurves.FallbackReason; SetCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.x", positionCurves.XCurve); SetCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.y", positionCurves.YCurve); SetCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.z", positionCurves.ZCurve); SetLinearCurve( clip, relativePath, typeof(Transform), "m_LocalRotation.x", BuildKeys( simplified.Times, simplified.RotationIndices, index => simplified.Rotations[index].x)); SetLinearCurve( clip, relativePath, typeof(Transform), "m_LocalRotation.y", BuildKeys( simplified.Times, simplified.RotationIndices, index => simplified.Rotations[index].y)); SetLinearCurve( clip, relativePath, typeof(Transform), "m_LocalRotation.z", BuildKeys( simplified.Times, simplified.RotationIndices, index => simplified.Rotations[index].z)); SetLinearCurve( clip, relativePath, typeof(Transform), "m_LocalRotation.w", BuildKeys( simplified.Times, simplified.RotationIndices, index => simplified.Rotations[index].w)); SetLinearCurve( clip, relativePath, typeof(CinemachineCamera), "Lens.FieldOfView", BuildKeys( simplified.Times, simplified.FieldOfViewIndices, index => simplified.FieldOfView[index])); SetLinearCurve( clip, relativePath, typeof(CinemachineCamera), "Lens.Dutch", BuildKeys( simplified.Times, simplified.DutchIndices, index => simplified.Dutch[index])); clip.EnsureQuaternionContinuity(); return clip; } internal static CurveSimplificationSettings GetCurveSimplificationSettings(CurveSimplificationPreset preset) { return preset switch { CurveSimplificationPreset.Exact => new CurveSimplificationSettings(0f, 0f, 0f, 0f), CurveSimplificationPreset.Balanced => new CurveSimplificationSettings(0.005f, 0.15f, 0.08f, 0.08f), CurveSimplificationPreset.Editable => new CurveSimplificationSettings(0.02f, 0.5f, 0.25f, 0.25f), _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null) }; } internal static SimplifiedCameraCurves SimplifyCameraCurves( IReadOnlyList times, IReadOnlyList positions, IReadOnlyList rotations, IReadOnlyList fieldOfView, IReadOnlyList dutch, CurveSimplificationPreset preset) { ValidateCurveSampleCounts( times, positions, rotations, fieldOfView, dutch); var settings = GetCurveSimplificationSettings(preset); var continuousRotations = MakeQuaternionSequenceContinuous(rotations); return new SimplifiedCameraCurves( times.ToArray(), positions.ToArray(), continuousRotations, fieldOfView.ToArray(), dutch.ToArray(), SimplifyVectorIndices( times, positions, settings.PositionErrorMeters), SimplifyQuaternionIndices( times, continuousRotations, settings.QuaternionAngleErrorDegrees), SimplifyScalarIndices( times, fieldOfView, settings.FieldOfViewErrorDegrees), SimplifyScalarIndices( times, dutch, settings.DutchErrorDegrees)); } internal static PositionCurveBuildResult BuildPositionCurves( SimplifiedCameraCurves simplified, CurveSimplificationPreset preset) { if (simplified == null) { throw new ArgumentNullException(nameof(simplified)); } PositionCurveBuildResult result; if (preset == CurveSimplificationPreset.Exact || simplified.Times.Length < 2) { result = BuildLinearPositionCurves( simplified.Times, simplified.Positions, CreateAllSampleIndices(simplified.Times.Length), false, string.Empty); } else { var candidate = BuildVelocityAwarePositionCurves( simplified.Times, simplified.Positions, simplified.PositionIndices); var audit = AuditPositionCurves( simplified.Times, simplified.Positions, candidate.XCurve, candidate.YCurve, candidate.ZCurve, GetCurveSimplificationSettings(preset).PositionErrorMeters); result = audit.Passed ? candidate : BuildLinearPositionCurves( simplified.Times, simplified.Positions, CreateAllSampleIndices(simplified.Times.Length), true, audit.FailureReason); } simplified.SetPositionCurveDiagnostics( result.UsedExactFallback, result.FallbackReason); return result; } internal static PositionCurveAuditResult AuditPositionCurves( IReadOnlyList times, IReadOnlyList positions, AnimationCurve xCurve, AnimationCurve yCurve, AnimationCurve zCurve, float maximumPositionErrorMeters) { if (times == null || positions == null) { throw new ArgumentNullException(nameof(times)); } if (xCurve == null || yCurve == null || zCurve == null) { throw new ArgumentNullException(nameof(xCurve)); } if (times.Count == 0 || positions.Count != times.Count) { throw new ArgumentException( "Position audit samples must have the same non-zero length."); } var allowedPositionError = maximumPositionErrorMeters + PositionCurveAuditErrorEpsilonMeters; var evaluatedAtSourceTimes = EvaluatePositionCurves( times, xCurve, yCurve, zCurve); for (var index = 0; index < times.Count; index++) { var error = Vector3.Distance( positions[index], evaluatedAtSourceTimes[index]); if (!IsFinite(error) || error > allowedPositionError) { return PositionCurveAuditResult.Fail( $"source-sample position error {error:F6} m exceeded " + $"{allowedPositionError:F6} m at frame {index}"); } } var allIndices = CreateAllSampleIndices(times.Count); var reference = BuildVelocityAwarePositionCurves( times, positions, allIndices); var auditTimes = BuildPositionCurveAuditTimes(times); var evaluatedDense = EvaluatePositionCurves( auditTimes, xCurve, yCurve, zCurve); var referenceDense = EvaluatePositionCurves( auditTimes, reference.XCurve, reference.YCurve, reference.ZCurve); for (var index = 0; index < auditTimes.Count; index++) { var error = Vector3.Distance( referenceDense[index], evaluatedDense[index]); if (!IsFinite(error) || error > allowedPositionError) { return PositionCurveAuditResult.Fail( $"source + midpoint position error {error:F6} m exceeded " + $"{allowedPositionError:F6} m at {auditTimes[index]:F4} s"); } } var sourceMotion = MeasurePositionMotion(times, positions); var evaluatedSourceMotion = MeasurePositionMotion( times, evaluatedAtSourceTimes); if (TryDescribeAlignedMotionRegression( "source-grid", sourceMotion, evaluatedSourceMotion, out var sourceLocalFailure)) { return PositionCurveAuditResult.Fail(sourceLocalFailure); } if (TryDescribeMotionRegression( "source-grid", sourceMotion.Metrics, evaluatedSourceMotion.Metrics, out var sourceFailure)) { return PositionCurveAuditResult.Fail(sourceFailure); } if (TryDescribeMotionRetentionRegression( "source-grid", positions, evaluatedAtSourceTimes, out var sourceRetentionFailure)) { return PositionCurveAuditResult.Fail(sourceRetentionFailure); } var referenceDenseMotion = MeasurePositionMotion( auditTimes, referenceDense); var evaluatedDenseMotion = MeasurePositionMotion( auditTimes, evaluatedDense); if (TryDescribeAlignedMotionRegression( "source + midpoint", referenceDenseMotion, evaluatedDenseMotion, out var denseLocalFailure)) { return PositionCurveAuditResult.Fail(denseLocalFailure); } if (TryDescribeMotionRegression( "source + midpoint", referenceDenseMotion.Metrics, evaluatedDenseMotion.Metrics, out var denseFailure)) { return PositionCurveAuditResult.Fail(denseFailure); } if (TryDescribeMotionRetentionRegression( "source + midpoint", referenceDense, evaluatedDense, out var denseRetentionFailure)) { return PositionCurveAuditResult.Fail(denseRetentionFailure); } return PositionCurveAuditResult.Pass(); } private static PositionCurveBuildResult BuildVelocityAwarePositionCurves( IReadOnlyList times, IReadOnlyList positions, IReadOnlyList indices) { return new PositionCurveBuildResult( BuildVelocityAwareHermiteCurve( times, positions.Select(value => value.x).ToArray(), indices), BuildVelocityAwareHermiteCurve( times, positions.Select(value => value.y).ToArray(), indices), BuildVelocityAwareHermiteCurve( times, positions.Select(value => value.z).ToArray(), indices), false, string.Empty); } private static PositionCurveBuildResult BuildLinearPositionCurves( IReadOnlyList times, IReadOnlyList positions, IReadOnlyList indices, bool usedExactFallback, string fallbackReason) { return new PositionCurveBuildResult( BuildLinearCurve(BuildKeys( times, indices, index => positions[index].x)), BuildLinearCurve(BuildKeys( times, indices, index => positions[index].y)), BuildLinearCurve(BuildKeys( times, indices, index => positions[index].z)), usedExactFallback, fallbackReason); } private static AnimationCurve BuildVelocityAwareHermiteCurve( IReadOnlyList times, IReadOnlyList values, IReadOnlyList indices) { var sourceTangents = EstimateSourceTangents(times, values); var tangents = new float[indices.Count]; for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++) { tangents[keyIndex] = sourceTangents[indices[keyIndex]]; } ClampMonotoneHermiteTangents(times, values, indices, tangents); var keys = new Keyframe[indices.Count]; for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++) { var sampleIndex = indices[keyIndex]; keys[keyIndex] = new Keyframe( (float)times[sampleIndex], values[sampleIndex], tangents[keyIndex], tangents[keyIndex]); } return new AnimationCurve(keys); } private static float[] EstimateSourceTangents( IReadOnlyList times, IReadOnlyList values) { var tangents = new float[values.Count]; if (values.Count < 2) { return tangents; } tangents[0] = CalculateSecant( times[0], values[0], times[1], values[1]); tangents[values.Count - 1] = CalculateSecant( times[values.Count - 2], values[values.Count - 2], times[values.Count - 1], values[values.Count - 1]); for (var index = 1; index < values.Count - 1; index++) { tangents[index] = CalculateSecant( times[index - 1], values[index - 1], times[index + 1], values[index + 1]); } return tangents; } private static void ClampMonotoneHermiteTangents( IReadOnlyList times, IReadOnlyList values, IReadOnlyList indices, float[] tangents) { const float flatEpsilon = 0.0000001f; for (var segment = 1; segment < indices.Count; segment++) { var leftKey = segment - 1; var rightKey = segment; var leftSample = indices[leftKey]; var rightSample = indices[rightKey]; var secant = CalculateSecant( times[leftSample], values[leftSample], times[rightSample], values[rightSample]); if (Mathf.Abs(secant) <= flatEpsilon) { tangents[leftKey] = 0f; tangents[rightKey] = 0f; continue; } if (tangents[leftKey] * secant <= 0f) { tangents[leftKey] = 0f; } if (tangents[rightKey] * secant <= 0f) { tangents[rightKey] = 0f; } var alpha = tangents[leftKey] / secant; var beta = tangents[rightKey] / secant; var squaredMagnitude = alpha * alpha + beta * beta; if (squaredMagnitude <= 9f) { continue; } var scale = 3f / Mathf.Sqrt(squaredMagnitude); tangents[leftKey] = scale * alpha * secant; tangents[rightKey] = scale * beta * secant; } } private static float CalculateSecant( double leftTime, float leftValue, double rightTime, float rightValue) { return (rightValue - leftValue) / (float)(rightTime - leftTime); } private static int[] CreateAllSampleIndices(int count) { return Enumerable.Range(0, count).ToArray(); } internal static List BuildPositionCurveAuditTimes( IReadOnlyList sourceTimes) { if (sourceTimes == null) { throw new ArgumentNullException(nameof(sourceTimes)); } if (sourceTimes.Count == 0) { throw new ArgumentException( "Position audit requires at least one source time.", nameof(sourceTimes)); } var result = new List(sourceTimes.Count * 2 - 1); for (var index = 0; index < sourceTimes.Count; index++) { var sourceTime = sourceTimes[index]; if (double.IsNaN(sourceTime) || double.IsInfinity(sourceTime)) { throw new ArgumentException( "Position audit source times must be finite.", nameof(sourceTimes)); } AppendPositionCurveAuditTime(result, sourceTime); if (index + 1 >= sourceTimes.Count) { continue; } var nextTime = sourceTimes[index + 1]; var duration = nextTime - sourceTime; if (duration < -PositionCurveAuditTimeToleranceSeconds) { throw new ArgumentException( "Position audit source times must be ascending.", nameof(sourceTimes)); } if (duration > PositionCurveAuditTimeToleranceSeconds && (float)sourceTime != (float)nextTime) { AppendPositionCurveAuditTime( result, sourceTime + duration * 0.5); } } return result; } private static void AppendPositionCurveAuditTime( ICollection result, double time) { if (result.Count == 0) { result.Add(time); return; } var previous = result.Last(); if (time - previous <= PositionCurveAuditTimeToleranceSeconds || (float)time == (float)previous) { return; } result.Add(time); } private static Vector3[] EvaluatePositionCurves( IReadOnlyList times, AnimationCurve xCurve, AnimationCurve yCurve, AnimationCurve zCurve) { var result = new Vector3[times.Count]; for (var index = 0; index < times.Count; index++) { var time = (float)times[index]; result[index] = new Vector3( xCurve.Evaluate(time), yCurve.Evaluate(time), zCurve.Evaluate(time)); } return result; } private static PositionMotionSeries MeasurePositionMotion( IReadOnlyList times, IReadOnlyList positions) { if (times.Count < 2) { return PositionMotionSeries.Empty; } var velocities = new Vector3[times.Count - 1]; var velocityTimes = new double[velocities.Length]; var speeds = new float[velocities.Length]; for (var index = 0; index < velocities.Length; index++) { var duration = times[index + 1] - times[index]; velocities[index] = (positions[index + 1] - positions[index]) / (float)duration; velocityTimes[index] = (times[index + 1] + times[index]) * 0.5; speeds[index] = velocities[index].magnitude; } if (velocities.Length < 2) { return new PositionMotionSeries( new PositionMetricSeries(velocityTimes, speeds), PositionMetricSeries.Empty, PositionMetricSeries.Empty); } var accelerations = new Vector3[velocities.Length - 1]; var accelerationTimes = new double[accelerations.Length]; var accelerationMagnitudes = new float[accelerations.Length]; for (var index = 0; index < accelerations.Length; index++) { var duration = velocityTimes[index + 1] - velocityTimes[index]; accelerations[index] = (velocities[index + 1] - velocities[index]) / (float)duration; accelerationTimes[index] = (velocityTimes[index + 1] + velocityTimes[index]) * 0.5; accelerationMagnitudes[index] = accelerations[index].magnitude; } var jerkTimes = new double[Math.Max(0, accelerations.Length - 1)]; var jerkMagnitudes = new float[jerkTimes.Length]; for (var index = 0; index < accelerations.Length - 1; index++) { var duration = accelerationTimes[index + 1] - accelerationTimes[index]; var jerk = (accelerations[index + 1] - accelerations[index]) / (float)duration; jerkTimes[index] = (accelerationTimes[index + 1] + accelerationTimes[index]) * 0.5; jerkMagnitudes[index] = jerk.magnitude; } return new PositionMotionSeries( new PositionMetricSeries(velocityTimes, speeds), new PositionMetricSeries( accelerationTimes, accelerationMagnitudes), new PositionMetricSeries(jerkTimes, jerkMagnitudes)); } internal static bool TryDescribeAlignedMotionRegression( IReadOnlyList times, IReadOnlyList referencePositions, IReadOnlyList candidatePositions, out string failureReason) { if (times == null || referencePositions == null || candidatePositions == null) { throw new ArgumentNullException(nameof(times)); } if (times.Count != referencePositions.Count || times.Count != candidatePositions.Count) { throw new ArgumentException( "Aligned motion samples must have matching lengths."); } return TryDescribeAlignedMotionRegression( "aligned-test-grid", MeasurePositionMotion(times, referencePositions), MeasurePositionMotion(times, candidatePositions), out failureReason); } private static bool TryDescribeAlignedMotionRegression( string sampleLabel, PositionMotionSeries reference, PositionMotionSeries candidate, out string failureReason) { if (TryDescribeAlignedMetricRegression( sampleLabel, "speed", "m/s", reference.Speed, candidate.Speed, PositionCurveAuditSpeedRatio, PositionCurveAuditSpeedDeltaMetersPerSecond, PositionCurveAuditSpeedFloorMetersPerSecond, out failureReason)) { return true; } if (TryDescribeAlignedMetricRegression( sampleLabel, "acceleration", "m/s^2", reference.Acceleration, candidate.Acceleration, PositionCurveAuditAccelerationRatio, PositionCurveAuditAccelerationDeltaMetersPerSecondSquared, PositionCurveAuditAccelerationFloorMetersPerSecondSquared, out failureReason)) { return true; } return TryDescribeAlignedMetricRegression( sampleLabel, "jerk", "m/s^3", reference.Jerk, candidate.Jerk, PositionCurveAuditJerkRatio, PositionCurveAuditJerkDeltaMetersPerSecondCubed, PositionCurveAuditJerkFloorMetersPerSecondCubed, out failureReason); } private static bool TryDescribeAlignedMetricRegression( string sampleLabel, string metricLabel, string unit, PositionMetricSeries reference, PositionMetricSeries candidate, float ratio, float absoluteDelta, float absoluteFloor, out string failureReason) { if (reference.Values.Length != candidate.Values.Length) { failureReason = $"{sampleLabel} {metricLabel} audit grids were not aligned"; return true; } var radius = PositionCurveAuditLocalWindowRadiusSamples; for (var center = 0; center < candidate.Values.Length; center++) { var start = Math.Max(0, center - radius); var end = Math.Min(candidate.Values.Length - 1, center + radius); var referenceWindowMaximum = 0f; var candidateWindowMaximum = 0f; for (var index = start; index <= end; index++) { referenceWindowMaximum = Mathf.Max( referenceWindowMaximum, reference.Values[index]); candidateWindowMaximum = Mathf.Max( candidateWindowMaximum, candidate.Values[index]); } var allowed = Mathf.Max( absoluteFloor, Mathf.Max( referenceWindowMaximum * ratio, referenceWindowMaximum + absoluteDelta)); if (IsFinite(candidateWindowMaximum) && candidateWindowMaximum <= allowed) { continue; } var sampleTime = candidate.Times.Length > center ? candidate.Times[center] : 0.0; failureReason = $"{sampleLabel} windowed {metricLabel} regressed near " + $"{sampleTime:F4} s from {referenceWindowMaximum:F3} to " + $"{candidateWindowMaximum:F3} {unit}; allowed " + $"{allowed:F3} {unit}"; return true; } failureReason = string.Empty; return false; } private static bool TryDescribeMotionRetentionRegression( string sampleLabel, IReadOnlyList reference, IReadOnlyList candidate, out string failureReason) { var referencePathLength = MeasureMeaningfulPathLength(reference); if (referencePathLength < PositionCurveAuditMotionRetentionMinimumMeters) { failureReason = string.Empty; return false; } var candidatePathLength = MeasureMeaningfulPathLength(candidate); var requiredPathLength = Math.Max( 0f, referencePathLength * PositionCurveAuditMotionRetentionRatio - PositionCurveAuditMotionNoiseDeadbandMeters); if (IsFinite(candidatePathLength) && candidatePathLength >= requiredPathLength) { failureReason = string.Empty; return false; } failureReason = $"{sampleLabel} meaningful motion retention fell from " + $"{referencePathLength:F4} m to {candidatePathLength:F4} m; " + $"required {requiredPathLength:F4} m after the " + $"{PositionCurveAuditMotionNoiseDeadbandMeters:F4} m noise " + "deadband"; return true; } private static float MeasureMeaningfulPathLength( IReadOnlyList positions) { if (positions.Count < 2) { return 0f; } var pathLength = 0f; var anchor = positions[0]; for (var index = 1; index < positions.Count; index++) { var distance = Vector3.Distance(anchor, positions[index]); if (distance < PositionCurveAuditMotionNoiseDeadbandMeters) { continue; } pathLength += distance; anchor = positions[index]; } return pathLength; } private static bool TryDescribeMotionRegression( string sampleLabel, PositionMotionMetrics reference, PositionMotionMetrics candidate, out string failureReason) { if (TryDescribeMetricRegression( sampleLabel, "speed", "m/s", reference.MaximumSpeed, candidate.MaximumSpeed, PositionCurveAuditSpeedRatio, PositionCurveAuditSpeedDeltaMetersPerSecond, PositionCurveAuditSpeedFloorMetersPerSecond, out failureReason)) { return true; } if (TryDescribeMetricRegression( sampleLabel, "acceleration", "m/s^2", reference.MaximumAcceleration, candidate.MaximumAcceleration, PositionCurveAuditAccelerationRatio, PositionCurveAuditAccelerationDeltaMetersPerSecondSquared, PositionCurveAuditAccelerationFloorMetersPerSecondSquared, out failureReason)) { return true; } return TryDescribeMetricRegression( sampleLabel, "jerk", "m/s^3", reference.MaximumJerk, candidate.MaximumJerk, PositionCurveAuditJerkRatio, PositionCurveAuditJerkDeltaMetersPerSecondCubed, PositionCurveAuditJerkFloorMetersPerSecondCubed, out failureReason); } private static bool TryDescribeMetricRegression( string sampleLabel, string metricLabel, string unit, float reference, float candidate, float ratio, float absoluteDelta, float absoluteFloor, out string failureReason) { var allowed = Mathf.Max( absoluteFloor, Mathf.Max(reference * ratio, reference + absoluteDelta)); if (IsFinite(candidate) && candidate <= allowed) { failureReason = string.Empty; return false; } failureReason = $"{sampleLabel} {metricLabel} regressed from {reference:F3} " + $"to {candidate:F3} {unit}; allowed {allowed:F3} {unit}"; return true; } private static Keyframe[] BuildKeys( IReadOnlyList times, IReadOnlyList indices, Func selector) { var keys = new Keyframe[indices.Count]; for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++) { var sampleIndex = indices[keyIndex]; keys[keyIndex] = new Keyframe( (float)times[sampleIndex], selector(sampleIndex)); } return keys; } private static int[] SimplifyVectorIndices( IReadOnlyList times, IReadOnlyList values, float maximumError) { var required = CreateEndpointSet(values.Count); AddScalarExtrema(values.Count, index => values[index].x, required); AddScalarExtrema(values.Count, index => values[index].y, required); AddScalarExtrema(values.Count, index => values[index].z, required); AddDirectionChangeNeighborhoods(times, values, required); return SimplifyIndices( times, maximumError, required, (index, left, right, blend) => Vector3.Distance( values[index], Vector3.LerpUnclamped(values[left], values[right], blend))); } private static int[] SimplifyQuaternionIndices( IReadOnlyList times, IReadOnlyList values, float maximumAngleError) { var required = CreateEndpointSet(values.Count); AddScalarExtrema(values.Count, index => values[index].x, required); AddScalarExtrema(values.Count, index => values[index].y, required); AddScalarExtrema(values.Count, index => values[index].z, required); AddScalarExtrema(values.Count, index => values[index].w, required); return SimplifyIndices( times, maximumAngleError, required, (index, left, right, blend) => Quaternion.Angle( values[index], NormalizedQuaternionLerp(values[left], values[right], blend))); } private static int[] SimplifyScalarIndices( IReadOnlyList times, IReadOnlyList values, float maximumError) { var required = CreateEndpointSet(values.Count); AddScalarExtrema(values.Count, index => values[index], required); return SimplifyIndices( times, maximumError, required, (index, left, right, blend) => Mathf.Abs( values[index] - Mathf.LerpUnclamped(values[left], values[right], blend))); } private static int[] SimplifyIndices( IReadOnlyList times, float maximumError, ISet requiredIndices, Func measureError) { if (times.Count <= 1 || maximumError <= 0f) { return Enumerable.Range(0, times.Count).ToArray(); } var selected = new SortedSet(requiredIndices); var anchors = selected.ToArray(); var pending = new Stack(); for (var anchorIndex = 1; anchorIndex < anchors.Length; anchorIndex++) { pending.Push(new CurveSegment( anchors[anchorIndex - 1], anchors[anchorIndex])); } while (pending.Count > 0) { var segment = pending.Pop(); if (segment.End - segment.Start <= 1) { continue; } var duration = times[segment.End] - times[segment.Start]; var greatestError = -1f; var greatestErrorIndex = -1; for (var index = segment.Start + 1; index < segment.End; index++) { var blend = duration > double.Epsilon ? (float)((times[index] - times[segment.Start]) / duration) : (index - segment.Start) / (float)(segment.End - segment.Start); var error = measureError( index, segment.Start, segment.End, blend); if (error > greatestError) { greatestError = error; greatestErrorIndex = index; } } if (greatestErrorIndex < 0 || greatestError <= maximumError) { continue; } selected.Add(greatestErrorIndex); pending.Push(new CurveSegment(segment.Start, greatestErrorIndex)); pending.Push(new CurveSegment(greatestErrorIndex, segment.End)); } return selected.ToArray(); } private static HashSet CreateEndpointSet(int count) { var result = new HashSet { 0 }; if (count > 1) { result.Add(count - 1); } return result; } private static void AddScalarExtrema( int count, Func selector, ISet indices) { var minimumIndex = 0; var maximumIndex = 0; for (var index = 1; index < count; index++) { if (selector(index) < selector(minimumIndex)) { minimumIndex = index; } if (selector(index) > selector(maximumIndex)) { maximumIndex = index; } } indices.Add(minimumIndex); indices.Add(maximumIndex); } private static void AddDirectionChangeNeighborhoods( IReadOnlyList times, IReadOnlyList values, ISet indices) { var flank = DirectionChangeDetectionFlankFrames; if (values.Count < flank * 2 + 1) { return; } for (var center = flank; center + flank < values.Count; center++) { var beforeDuration = times[center] - times[center - flank]; var afterDuration = times[center + flank] - times[center]; if (beforeDuration <= double.Epsilon || afterDuration <= double.Epsilon) { continue; } var beforeVelocity = (values[center] - values[center - flank]) / (float)beforeDuration; var afterVelocity = (values[center + flank] - values[center]) / (float)afterDuration; var beforeSpeed = beforeVelocity.magnitude; var afterSpeed = afterVelocity.magnitude; if (Mathf.Min(beforeSpeed, afterSpeed) < DirectionChangeMinimumSpeedMetersPerSecond) { continue; } var cosine = Vector3.Dot(beforeVelocity, afterVelocity) / (beforeSpeed * afterSpeed); if (cosine > DirectionChangeMaximumCosine) { continue; } var start = Mathf.Max( 0, center - DirectionChangePreserveRadiusFrames); var end = Mathf.Min( values.Count - 1, center + DirectionChangePreserveRadiusFrames); for (var index = start; index <= end; index++) { indices.Add(index); } } } private static Quaternion[] MakeQuaternionSequenceContinuous( IReadOnlyList rotations) { var result = new Quaternion[rotations.Count]; for (var index = 0; index < rotations.Count; index++) { var rotation = NormalizeQuaternion(rotations[index]); if (index > 0 && Quaternion.Dot(result[index - 1], rotation) < 0f) { rotation = NegateQuaternion(rotation); } result[index] = rotation; } return result; } private static Quaternion NormalizedQuaternionLerp( Quaternion left, Quaternion right, float blend) { return NormalizeQuaternion(new Quaternion( Mathf.LerpUnclamped(left.x, right.x, blend), Mathf.LerpUnclamped(left.y, right.y, blend), Mathf.LerpUnclamped(left.z, right.z, blend), Mathf.LerpUnclamped(left.w, right.w, blend))); } private static Quaternion NormalizeQuaternion(Quaternion value) { var magnitude = Mathf.Sqrt( value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w); if (magnitude < 0.000001f) { throw new ArgumentException("A camera rotation quaternion is zero."); } var inverseMagnitude = 1f / magnitude; return new Quaternion( value.x * inverseMagnitude, value.y * inverseMagnitude, value.z * inverseMagnitude, value.w * inverseMagnitude); } private static Quaternion NegateQuaternion(Quaternion value) { return new Quaternion(-value.x, -value.y, -value.z, -value.w); } private static void ValidateCurveSampleCounts( IReadOnlyList times, IReadOnlyList positions, IReadOnlyList rotations, IReadOnlyList fieldOfView, IReadOnlyList dutch) { if (times == null || positions == null || rotations == null || fieldOfView == null || dutch == null) { throw new ArgumentNullException(nameof(times)); } if (times.Count == 0 || positions.Count != times.Count || rotations.Count != times.Count || fieldOfView.Count != times.Count || dutch.Count != times.Count) { throw new ArgumentException( "Camera curve sample arrays must have the same non-zero length."); } for (var index = 1; index < times.Count; index++) { if (times[index] <= times[index - 1]) { throw new ArgumentException( "Camera curve sample times must be strictly increasing."); } } } private static CurveSimplificationPreset ParseCurveSimplificationPreset( string value) { if (!Enum.TryParse(value?.Trim(), true, out CurveSimplificationPreset preset) || !Enum.IsDefined(typeof(CurveSimplificationPreset), preset)) { throw new ArgumentException( "Curve preset must be Exact, Balanced, or Editable.", nameof(value)); } return preset; } private static void SetLinearCurve( AnimationClip clip, string relativePath, Type componentType, string propertyName, Keyframe[] keys) { SetCurve( clip, relativePath, componentType, propertyName, BuildLinearCurve(keys)); } private static AnimationCurve BuildLinearCurve(Keyframe[] keys) { var curve = new AnimationCurve(keys); for (var index = 0; index < curve.length; index++) { AnimationUtility.SetKeyLeftTangentMode( curve, index, AnimationUtility.TangentMode.Linear); AnimationUtility.SetKeyRightTangentMode( curve, index, AnimationUtility.TangentMode.Linear); } return curve; } private static void SetCurve( AnimationClip clip, string relativePath, Type componentType, string propertyName, AnimationCurve curve) { AnimationUtility.SetEditorCurve( clip, EditorCurveBinding.FloatCurve( relativePath, componentType, propertyName), curve); } private static void SetClipExtrapolationNone(TimelineClip clip) { SetNonPublicTimelineClipProperty( clip, nameof(TimelineClip.preExtrapolationMode), TimelineClip.ClipExtrapolation.None); SetNonPublicTimelineClipProperty( clip, nameof(TimelineClip.postExtrapolationMode), TimelineClip.ClipExtrapolation.None); } private static void SetNonPublicTimelineClipProperty( TimelineClip clip, string propertyName, TimelineClip.ClipExtrapolation value) { var property = typeof(TimelineClip).GetProperty( propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); var setter = property?.GetSetMethod(true) ?? throw new MissingMethodException( typeof(TimelineClip).FullName, propertyName); setter.Invoke(clip, new object[] { value }); } private static void AddPreviewAssetPath( ICollection paths, UnityEngine.Object asset) { if (asset == null) { return; } var path = AssetDatabase.GetAssetPath(asset).Replace('\\', '/'); if (!path.Contains($"/{PreviewAssetFolderName}/")) { throw new InvalidOperationException( $"Refusing to remove non-preview asset '{path}'."); } paths.Add(path); } private static GeneratedCameraFrame[] ReadCameraFrames( string path, int frameCount) { const int valuesPerFrame = 9; var expectedBytes = (long)frameCount * valuesPerFrame * sizeof(float); var file = new FileInfo(path); var actualBytes = file.Exists ? file.Length : -1; if (actualBytes != expectedBytes) { throw new InvalidDataException( $"Generated camera size is {actualBytes:N0} bytes; " + $"expected {expectedBytes:N0}."); } var result = new GeneratedCameraFrame[frameCount]; using var reader = new BinaryReader(new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.SequentialScan)); for (var index = 0; index < frameCount; index++) { result[index] = new GeneratedCameraFrame { Position = new Vector3( reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), Rotation = new Quaternion( reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), FieldOfView = reader.ReadSingle(), Dutch = reader.ReadSingle() }; } return result; } private static double[] ReadTimes(string path, int frameCount) { var expectedBytes = (long)frameCount * sizeof(double); var file = new FileInfo(path); var actualBytes = file.Exists ? file.Length : -1; if (actualBytes != expectedBytes) { throw new InvalidDataException( $"Generated time size is {actualBytes:N0} bytes; " + $"expected {expectedBytes:N0}."); } var result = new double[frameCount]; using var reader = new BinaryReader(new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.SequentialScan)); for (var index = 0; index < frameCount; index++) { result[index] = reader.ReadDouble(); } return result; } private static PlayableDirector FindOriginalDirector(Scene scene) { return Resources.FindObjectsOfTypeAll() .Where(director => director.gameObject.scene == scene && director.name == OriginalDirectorName) .SingleOrDefault() ?? throw new InvalidOperationException( $"Scene '{scene.name}' does not contain one active " + $"'{OriginalDirectorName}' PlayableDirector."); } private static PlayableDirector RequireSourceDirector( PlayableDirector sourceDirector) { if (sourceDirector == null) { throw new ArgumentNullException(nameof(sourceDirector)); } var scene = sourceDirector.gameObject.scene; if (!scene.IsValid() || !scene.isLoaded) { throw new InvalidOperationException( $"Source director '{sourceDirector.name}' must belong to a loaded scene."); } if (sourceDirector.playableAsset is not TimelineAsset) { throw new InvalidOperationException( $"Source director '{sourceDirector.name}' is not bound to a TimelineAsset."); } return sourceDirector; } internal static string GetScopedPreviewDirectorName( PlayableDirector sourceDirector) { sourceDirector = RequireSourceDirector(sourceDirector); return $"{PreviewDirectorName}__{GetDirectorScopeLabel(sourceDirector)}"; } internal static bool IsPreviewDirectorForEditor( PlayableDirector director) { if (director == null || !(director.name == PreviewDirectorName || director.name.StartsWith( PreviewDirectorName + "__", StringComparison.Ordinal))) { return false; } return director.playableAsset is TimelineAsset timeline && timeline.GetOutputTracks().Any(track => track is AnimationTrack animationTrack && IsGeneratedAnimationTrack(animationTrack) || track.name == GeneratedCinemachineTrackName); } internal static string GetScopedPreviewCameraName( PlayableDirector sourceDirector) { sourceDirector = RequireSourceDirector(sourceDirector); return $"{PreviewCameraName}__{GetDirectorScopeLabel(sourceDirector)}"; } private static string GetDirectorAssetScopeName( PlayableDirector sourceDirector) { return $"Source_{GetDirectorScopeLabel(sourceDirector)}"; } private static string GetDirectorScopeLabel( PlayableDirector sourceDirector) { return $"{SanitizeAssetName(sourceDirector.name)}_" + $"{ComputeStableHash(GetDirectorScopeSeed(sourceDirector)):X12}"; } private static string GetDirectorScopeSeed( PlayableDirector sourceDirector) { var scene = sourceDirector.gameObject.scene; var siblingDirectors = sourceDirector.gameObject.GetComponents(); var componentIndex = Array.IndexOf( siblingDirectors, sourceDirector); var timelinePath = AssetDatabase.GetAssetPath( sourceDirector.playableAsset); var timelineGuid = string.IsNullOrWhiteSpace(timelinePath) ? string.Empty : AssetDatabase.AssetPathToGUID(timelinePath); // Scene-object instance IDs change after every domain reload, and // AssetDatabase local-file IDs are not consistently available for // unsaved scene state. Derive the scope from serialized hierarchy // order instead so the same preview remains discoverable. return $"{scene.path}|{scene.name}|" + $"{GetStableHierarchyKey(sourceDirector.transform)}|" + $"director:{componentIndex}|timeline:{timelineGuid}"; } private static ulong ComputeStableHash(string value) { const ulong offset = 14695981039346656037UL; const ulong prime = 1099511628211UL; var hash = offset; foreach (var character in value ?? string.Empty) { hash ^= character; hash *= prime; } return hash & 0xFFFFFFFFFFFFUL; } private static bool TryFindPreviewContext( PlayableDirector sourceDirector, out PreviewContext context) { var scene = sourceDirector.gameObject.scene; var expectedDirectorName = GetScopedPreviewDirectorName(sourceDirector); var expectedCameraName = GetScopedPreviewCameraName(sourceDirector); var previewDirector = Resources.FindObjectsOfTypeAll() .FirstOrDefault(director => director.gameObject.scene == scene && director.name == expectedDirectorName); if (previewDirector == null) { if (sourceDirector.name == OriginalDirectorName) { return TryFindLegacyPreviewContext(scene, out context); } context = default; return false; } context = CreatePreviewContext( scene, previewDirector, expectedCameraName); return true; } private static PreviewContext FindPreviewContext( PlayableDirector sourceDirector) { if (TryFindPreviewContext(sourceDirector, out var context)) { return context; } throw new InvalidOperationException( $"Scene '{sourceDirector.gameObject.scene.name}' does not contain " + $"an AI preview for '{GetHierarchyPath(sourceDirector.transform)}'."); } private static bool TryFindLegacyPreviewContext( Scene scene, out PreviewContext context) { var previewDirector = Resources.FindObjectsOfTypeAll() .FirstOrDefault(director => director.gameObject.scene == scene && director.name == PreviewDirectorName); if (previewDirector == null) { context = default; return false; } context = CreatePreviewContext( scene, previewDirector, PreviewCameraName); return true; } private static PreviewContext FindLegacyPreviewContext(Scene scene) { if (TryFindLegacyPreviewContext(scene, out var context)) { return context; } throw new InvalidOperationException( $"Scene '{scene.name}' does not contain '{PreviewDirectorName}'."); } private static PreviewContext CreatePreviewContext( Scene scene, PlayableDirector previewDirector, string fallbackCameraName) { var timeline = previewDirector.playableAsset as TimelineAsset ?? throw new InvalidOperationException( $"Preview director '{previewDirector.name}' is not bound to a TimelineAsset."); var animationTrack = GetGeneratedAnimationTracks(timeline) .FirstOrDefault(); var animator = animationTrack == null ? null : previewDirector.GetGenericBinding(animationTrack) as Animator; var boundCameraRoot = animator != null && animator.gameObject.scene == scene ? animator.GetComponent() != null && animator.transform.parent != null ? animator.transform.parent.gameObject : animator.gameObject : null; var cameraRoot = boundCameraRoot != null ? boundCameraRoot : Resources.FindObjectsOfTypeAll() .FirstOrDefault(gameObject => gameObject.scene == scene && gameObject.name == fallbackCameraName); if (cameraRoot == null) { throw new InvalidOperationException( $"Preview '{previewDirector.name}' has no bound generated camera root."); } return new PreviewContext(scene, previewDirector, cameraRoot); } private static PlayableDirector GetClonedDirectorComponent( PlayableDirector sourceDirector, GameObject clonedGameObject) { var sourceComponents = sourceDirector.gameObject.GetComponents(); var clonedComponents = clonedGameObject.GetComponents(); var index = Array.IndexOf(sourceComponents, sourceDirector); if (index < 0 || index >= clonedComponents.Length) { throw new InvalidOperationException( "Unable to resolve the selected PlayableDirector on its preview clone."); } return clonedComponents[index]; } private static void EnsureNoExistingPreview( Scene scene, string previewDirectorName, string previewCameraName) { var existing = Resources.FindObjectsOfTypeAll() .FirstOrDefault(gameObject => gameObject.scene == scene && (gameObject.name == previewDirectorName || gameObject.name == previewCameraName)); if (existing != null) { throw new InvalidOperationException( $"Preview object '{existing.name}' already exists. " + "Remove or rename it before generating another preview."); } } private static CinemachineBrain FindCinemachineBrain( PlayableDirector director, TimelineAsset timeline) { if (director == null) { throw new ArgumentNullException(nameof(director)); } if (timeline == null) { throw new ArgumentNullException(nameof(timeline)); } var scene = director.gameObject.scene; if (!scene.IsValid() || !scene.isLoaded) { throw new InvalidOperationException( "The Timeline director must belong to a loaded scene."); } var sceneBrains = scene.GetRootGameObjects() .SelectMany(root => root.GetComponentsInChildren(true)) .ToArray(); return ResolveCinemachineBrainForEditor( director, timeline, sceneBrains); } internal static CinemachineBrain ResolveCinemachineBrainForEditor( PlayableDirector director, TimelineAsset timeline, IReadOnlyList sceneBrains) { if (director == null) { throw new ArgumentNullException(nameof(director)); } if (timeline == null) { throw new ArgumentNullException(nameof(timeline)); } if (sceneBrains == null) { throw new ArgumentNullException(nameof(sceneBrains)); } var scene = director.gameObject.scene; var boundBrains = timeline.GetOutputTracks() .OfType() .Select(track => director.GetGenericBinding(track) as CinemachineBrain) .Where(brain => brain != null) .Distinct() .ToArray(); if (boundBrains.Length > 1) { throw new InvalidOperationException( "The Timeline has Cinemachine Tracks bound to multiple " + "CinemachineBrain components. Bind every camera track to one Brain."); } if (boundBrains.Length == 1) { var boundBrain = boundBrains[0]; if (!IsUsableCinemachineBrain(boundBrain, scene)) { throw new InvalidOperationException( "The CinemachineBrain bound to the Timeline must be enabled, " + "attached to an enabled Camera, and belong to the Timeline scene."); } return boundBrain; } var usableBrains = sceneBrains .Where(brain => IsUsableCinemachineBrain(brain, scene)) .Distinct() .ToArray(); var mainCameraBrains = usableBrains .Where(brain => { var camera = brain.GetComponent(); return camera != null && camera.CompareTag("MainCamera"); }) .ToArray(); if (mainCameraBrains.Length == 1) { return mainCameraBrains[0]; } if (mainCameraBrains.Length > 1) { throw new InvalidOperationException( "Multiple enabled MainCamera-tagged Cameras have a " + "CinemachineBrain in the Timeline scene. Keep exactly one Main Camera " + "or bind a Cinemachine Track explicitly."); } if (usableBrains.Length == 1) { return usableBrains[0]; } if (usableBrains.Length == 0) { throw new InvalidOperationException( "No enabled CinemachineBrain with an enabled Camera was found in " + "the Timeline scene. Add a CinemachineBrain to the Main Camera or " + "bind a Cinemachine Track explicitly."); } throw new InvalidOperationException( "Multiple enabled CinemachineBrain components were found in the " + "Timeline scene and none belongs to a unique Main Camera. Tag exactly " + "one Camera as MainCamera or bind a Cinemachine Track explicitly."); } private static bool IsUsableCinemachineBrain( CinemachineBrain brain, Scene scene) { if (brain == null || brain.gameObject.scene != scene || !brain.isActiveAndEnabled) { return false; } var camera = brain.GetComponent(); return camera != null && camera.isActiveAndEnabled; } private static void CopyTrackBindings( PlayableDirector originalDirector, TimelineAsset originalTimeline, PlayableDirector copiedDirector, TimelineAsset copiedTimeline) { var originalTracks = originalTimeline.GetOutputTracks().ToArray(); var copiedTracks = copiedTimeline.GetOutputTracks().ToArray(); if (originalTracks.Length != copiedTracks.Length) { throw new InvalidOperationException( "The copied Timeline track structure does not match the original."); } for (var index = 0; index < originalTracks.Length; index++) { copiedDirector.SetGenericBinding( copiedTracks[index], originalDirector.GetGenericBinding(originalTracks[index])); } } private static void MuteCopiedCameraTracks( PlayableDirector director, TimelineAsset timeline) { foreach (var track in timeline.GetOutputTracks()) { if (track is CinemachineTrack) { track.muted = true; continue; } if (track is not AnimationTrack) { continue; } var animator = director.GetGenericBinding(track) as Animator; if (animator == null) { continue; } var path = GetHierarchyPath(animator.transform); if (animator.name.StartsWith("Cam", StringComparison.Ordinal) || path.Contains("/Cam/")) { track.muted = true; } } } internal static int MuteCopiedRecorderTracks(TimelineAsset timeline) { if (timeline == null) { throw new ArgumentNullException(nameof(timeline)); } var mutedCount = 0; foreach (var track in EnumerateTracks(timeline)) { if (track is not RecorderTrack || track.muted) { continue; } track.muted = true; mutedCount++; } return mutedCount; } private static IEnumerable EnumerateTracks( TimelineAsset timeline) { foreach (var rootTrack in timeline.GetRootTracks()) { yield return rootTrack; foreach (var childTrack in EnumerateChildTracks(rootTrack)) { yield return childTrack; } } } private static IEnumerable EnumerateChildTracks( TrackAsset parent) { foreach (var child in parent.GetChildTracks()) { yield return child; foreach (var descendant in EnumerateChildTracks(child)) { yield return descendant; } } } internal static bool TryGetPreviewGenerationProvenanceForEditor( PlayableDirector sourceDirector, out PreviewGenerationProvenanceInfo provenance) { sourceDirector = RequireSourceDirector(sourceDirector); if (!TryFindPreviewContext(sourceDirector, out var context) || context.Director.playableAsset is not TimelineAsset timeline) { provenance = default; return false; } return TryGetPreviewGenerationProvenance( timeline, out provenance); } internal static bool TryGetPreviewTimelineForEditor( PlayableDirector sourceDirector, out TimelineAsset timeline) { sourceDirector = RequireSourceDirector(sourceDirector); if (!TryFindPreviewContext(sourceDirector, out var context) || context.Director.playableAsset is not TimelineAsset value) { timeline = null; return false; } timeline = value; return true; } internal static void EnsurePreviewGenerationProvenanceForEditor( PlayableDirector sourceDirector, string generatedDirectory, string cwAiRoot) { sourceDirector = RequireSourceDirector(sourceDirector); var context = FindPreviewContext(sourceDirector); var timeline = context.Director.playableAsset as TimelineAsset ?? throw new InvalidOperationException( "The AI preview director is not bound to a TimelineAsset."); if (FindPreviewGenerationProvenance(timeline) != null) { return; } var generatedDirectoryPath = Path.GetFullPath(generatedDirectory); var metadataPath = Path.Combine( generatedDirectoryPath, "metadata.json"); var created = UpsertPreviewGenerationProvenance( timeline, generatedDirectoryPath, metadataPath, cwAiRoot, false, "Migrate AI Camera Preview Provenance"); AssetDatabase.SaveAssetIfDirty(created); AssetDatabase.SaveAssetIfDirty(timeline); } internal static bool TryGetPreviewGenerationProvenanceForTests( TimelineAsset timeline, out PreviewGenerationProvenanceInfo provenance) { return TryGetPreviewGenerationProvenance( timeline, out provenance); } internal static AICameraPreviewGenerationProvenance UpsertPreviewGenerationProvenanceForTests( TimelineAsset timeline, string generatedDirectory, string cwAiRoot, bool recordUndo) { var generatedDirectoryPath = Path.GetFullPath(generatedDirectory); return UpsertPreviewGenerationProvenance( timeline, generatedDirectoryPath, Path.Combine(generatedDirectoryPath, "metadata.json"), cwAiRoot, recordUndo, "Update AI Camera Preview Provenance"); } internal static void RemovePreviewGenerationProvenanceForTests( TimelineAsset timeline) { RemovePreviewGenerationProvenance(timeline); } private static bool TryGetPreviewGenerationProvenance( TimelineAsset timeline, out PreviewGenerationProvenanceInfo provenance) { var asset = FindPreviewGenerationProvenance(timeline); if (asset == null) { provenance = default; return false; } if (!string.Equals( asset.SchemaVersion, AICameraPreviewGenerationProvenance.CurrentSchemaVersion, StringComparison.Ordinal)) { throw new InvalidDataException( $"Unsupported AI preview provenance schema: " + $"{asset.SchemaVersion}"); } provenance = new PreviewGenerationProvenanceInfo( asset.CwAiRelativeGeneratedFolder, asset.AbsoluteGeneratedFolder, asset.MetadataSha256); return true; } private static AICameraPreviewGenerationProvenance UpsertPreviewGenerationProvenance( TimelineAsset timeline, string generatedDirectoryPath, string metadataPath, string cwAiRoot, bool recordUndo, string undoName) { if (timeline == null) { throw new ArgumentNullException(nameof(timeline)); } var timelinePath = AssetDatabase.GetAssetPath(timeline); if (string.IsNullOrWhiteSpace(timelinePath)) { throw new InvalidOperationException( "AI preview provenance requires a persistent Timeline asset."); } if (!File.Exists(metadataPath)) { throw new FileNotFoundException( "Generated camera metadata was not found.", metadataPath); } var fullGeneratedDirectory = Path.GetFullPath( generatedDirectoryPath); var metadataSha256 = ComputeSha256(metadataPath); var relativeGeneratedDirectory = BuildCwAiRelativeGeneratedDirectory( fullGeneratedDirectory, cwAiRoot); var provenance = FindPreviewGenerationProvenance(timeline); if (provenance == null) { provenance = ScriptableObject.CreateInstance< AICameraPreviewGenerationProvenance>(); provenance.name = PreviewGenerationProvenanceName; provenance.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector; AssetDatabase.AddObjectToAsset(provenance, timeline); if (recordUndo) { Undo.RegisterCreatedObjectUndo(provenance, undoName); } } else { if (recordUndo) { Undo.RegisterCompleteObjectUndo(provenance, undoName); } provenance.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector; } provenance.SetValues( relativeGeneratedDirectory, fullGeneratedDirectory, metadataSha256); EditorUtility.SetDirty(provenance); EditorUtility.SetDirty(timeline); return provenance; } private static AICameraPreviewGenerationProvenance FindPreviewGenerationProvenance(TimelineAsset timeline) { if (timeline == null) { return null; } var timelinePath = AssetDatabase.GetAssetPath(timeline); if (string.IsNullOrWhiteSpace(timelinePath)) { return null; } var matches = AssetDatabase.LoadAllAssetsAtPath(timelinePath) .OfType() .ToArray(); if (matches.Length > 1) { throw new InvalidDataException( "AI preview Timeline contains duplicate generation provenance."); } return matches.SingleOrDefault(); } private static string BuildCwAiRelativeGeneratedDirectory( string generatedDirectoryPath, string cwAiRoot) { var resolvedRoot = ResolveCwAiRootForProvenance( generatedDirectoryPath, cwAiRoot); if (string.IsNullOrWhiteSpace(resolvedRoot)) { return string.Empty; } var fullRoot = Path.GetFullPath(resolvedRoot) .TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var fullGenerated = Path.GetFullPath(generatedDirectoryPath); var relative = Path.GetRelativePath(fullRoot, fullGenerated); if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative) || relative.Equals("..", StringComparison.Ordinal) || relative.StartsWith( ".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || relative.StartsWith( ".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)) { return string.Empty; } return relative .Replace(Path.DirectorySeparatorChar, '/') .Replace(Path.AltDirectorySeparatorChar, '/'); } private static string ResolveCwAiRootForProvenance( string generatedDirectoryPath, string cwAiRoot) { if (!string.IsNullOrWhiteSpace(cwAiRoot)) { return Path.GetFullPath(cwAiRoot); } for (var current = new DirectoryInfo(generatedDirectoryPath); current != null; current = current.Parent) { if (File.Exists(Path.Combine( current.FullName, "MachineLearning", "CameraDirector", "generate_hybrid.py"))) { return current.FullName; } } return string.Empty; } private static void RemovePreviewGenerationProvenance( TimelineAsset timeline) { if (timeline == null) { return; } var timelinePath = AssetDatabase.GetAssetPath(timeline); if (string.IsNullOrWhiteSpace(timelinePath)) { return; } var assets = AssetDatabase.LoadAllAssetsAtPath(timelinePath) .OfType() .ToArray(); foreach (var provenance in assets) { UnityEngine.Object.DestroyImmediate(provenance, true); } if (assets.Length > 0) { EditorUtility.SetDirty(timeline); AssetDatabase.SaveAssetIfDirty(timeline); AssetDatabase.ImportAsset( timelinePath, ImportAssetOptions.ForceSynchronousImport); } } private static string EnsurePreviewAssetFolder(string scenePath) { var previewFolder = GetPreviewAssetFolderPath(scenePath); var sceneFolder = Path.GetDirectoryName(previewFolder) ?.Replace('\\', '/'); if (!AssetDatabase.IsValidFolder(previewFolder)) { AssetDatabase.CreateFolder(sceneFolder, PreviewAssetFolderName); } return previewFolder; } private static string GetPreviewAssetFolderPath(string scenePath) { var sceneFolder = Path.GetDirectoryName(scenePath) ?.Replace('\\', '/'); if (string.IsNullOrWhiteSpace(sceneFolder)) { throw new InvalidOperationException( "The active scene must be saved under Assets."); } return $"{sceneFolder}/{PreviewAssetFolderName}"; } private static string EnsureAssetSubfolder( string parentFolder, string folderName) { var path = $"{parentFolder}/{folderName}"; if (!AssetDatabase.IsValidFolder(path)) { AssetDatabase.CreateFolder(parentFolder, folderName); } return path; } private static string SanitizeAssetName(string value) { var invalid = Path.GetInvalidFileNameChars(); var characters = string.IsNullOrWhiteSpace(value) ? "Shot".ToCharArray() : value.Select(character => invalid.Contains(character) || character == '/' || character == '\\' ? '_' : character) .ToArray(); return new string(characters); } private static void ValidateGeneratedCameraData( GeneratedCameraMetadata metadata, IReadOnlyList frames, IReadOnlyList times) { var expectedDuration = metadata.frameCount / (double)metadata.sampleRate; if (Math.Abs(metadata.duration - expectedDuration) > 0.5 / metadata.sampleRate) { throw new InvalidDataException( "Generated camera duration does not match frame count and FPS."); } for (var index = 0; index < times.Count; index++) { var expectedTime = index / (double)metadata.sampleRate; if (double.IsNaN(times[index]) || double.IsInfinity(times[index]) || Math.Abs(times[index] - expectedTime) > 0.000001) { throw new InvalidDataException( $"Generated camera time is invalid at frame {index}."); } var frame = frames[index]; if (!IsFinite(frame.Position.x) || !IsFinite(frame.Position.y) || !IsFinite(frame.Position.z) || !IsFinite(frame.Rotation.x) || !IsFinite(frame.Rotation.y) || !IsFinite(frame.Rotation.z) || !IsFinite(frame.Rotation.w) || !IsFinite(frame.FieldOfView) || !IsFinite(frame.Dutch) || frame.FieldOfView <= 0 || frame.FieldOfView >= 180 || frame.Rotation.x * frame.Rotation.x + frame.Rotation.y * frame.Rotation.y + frame.Rotation.z * frame.Rotation.z + frame.Rotation.w * frame.Rotation.w < 0.000001f) { throw new InvalidDataException( $"Generated camera contains invalid values at frame {index}."); } } } private static bool IsFinite(float value) { return !float.IsNaN(value) && !float.IsInfinity(value); } private static void ValidateMetadata(GeneratedCameraMetadata metadata) { if (metadata == null || metadata.frameCount <= 0 || metadata.sampleRate <= 0 || metadata.duration <= 0 || string.IsNullOrWhiteSpace(metadata.worldCameraFile) || string.IsNullOrWhiteSpace(metadata.timeFile)) { throw new InvalidDataException( "Generated camera metadata is incomplete."); } if (metadata.lookAtUsed) { throw new InvalidDataException( "LookAt-based generated cameras are not supported."); } } private static string ResolveOptionalGeneratedPayloadPath( string generatedDirectory, string declaredPath, string payloadName) { return string.IsNullOrWhiteSpace(declaredPath) ? null : ResolveGeneratedPayloadPath( generatedDirectory, declaredPath, payloadName); } private static string ResolveGeneratedPayloadPath( string generatedDirectory, string declaredPath, string payloadName) { if (string.IsNullOrWhiteSpace(generatedDirectory)) { throw new ArgumentException( "Generated directory is required.", nameof(generatedDirectory)); } if (string.IsNullOrWhiteSpace(declaredPath)) { throw new InvalidDataException( $"metadata path for {payloadName} is missing."); } if (Path.IsPathRooted(declaredPath)) { throw new InvalidDataException( $"metadata path for {payloadName} must be relative to the " + "generated directory."); } var segments = declaredPath.Split( new[] { '/', '\\' }, StringSplitOptions.None); var invalidFileNameCharacters = Path.GetInvalidFileNameChars(); if (segments.Length == 0 || segments.Any(segment => string.IsNullOrWhiteSpace(segment) || segment == "." || segment == ".." || segment.IndexOfAny(invalidFileNameCharacters) >= 0)) { throw new InvalidDataException( $"metadata path for {payloadName} contains an invalid segment."); } var pathComparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; var rootPath = Path.GetFullPath(generatedDirectory); var fileSystemRoot = Path.GetPathRoot(rootPath); if (!string.Equals(rootPath, fileSystemRoot, pathComparison)) { rootPath = rootPath.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } if (!Directory.Exists(rootPath)) { throw new DirectoryNotFoundException( $"Generated directory was not found: {rootPath}"); } var rootAttributes = File.GetAttributes(rootPath); if ((rootAttributes & FileAttributes.Directory) == 0 || (rootAttributes & FileAttributes.ReparsePoint) != 0) { throw new InvalidDataException( "Generated directory must be a physical directory, not a " + "symlink, junction, or other reparse point."); } var fullPath = Path.GetFullPath( Path.Combine(rootPath, string.Join( Path.DirectorySeparatorChar.ToString(), segments))); var rootPrefix = rootPath.EndsWith( Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) || rootPath.EndsWith( Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal) ? rootPath : rootPath + Path.DirectorySeparatorChar; if (!fullPath.StartsWith(rootPrefix, pathComparison)) { throw new InvalidDataException( $"metadata path for {payloadName} resolves outside the " + "generated directory."); } var currentPath = rootPath; for (var index = 0; index < segments.Length; index++) { currentPath = Path.Combine(currentPath, segments[index]); if (!File.Exists(currentPath) && !Directory.Exists(currentPath)) { throw new FileNotFoundException( $"Generated {payloadName} payload was not found.", currentPath); } var attributes = File.GetAttributes(currentPath); if ((attributes & FileAttributes.ReparsePoint) != 0) { throw new InvalidDataException( $"metadata path for {payloadName} crosses a symlink, " + "junction, or other reparse point."); } var isLast = index == segments.Length - 1; var isDirectory = (attributes & FileAttributes.Directory) != 0; if ((!isLast && !isDirectory) || (isLast && (isDirectory || (attributes & FileAttributes.Device) != 0))) { throw new InvalidDataException( $"Generated {payloadName} payload must resolve to a " + "regular file."); } } if (!string.Equals(currentPath, fullPath, pathComparison)) { throw new InvalidDataException( $"metadata path for {payloadName} did not resolve canonically."); } return fullPath; } private static GeneratedCameraMetadata ReadGeneratedCameraMetadata( string metadataPath) { var json = File.ReadAllText(metadataPath); var outputSha256Declared = TryGetTopLevelJsonPropertyValueStart( json, "outputSha256", out var outputSha256ValueStart); if (outputSha256Declared && (outputSha256ValueStart >= json.Length || json[outputSha256ValueStart] != '{')) { throw new InvalidDataException( "metadata.outputSha256 must be a JSON object."); } var metadata = new GeneratedCameraMetadata(); JsonUtility.FromJsonOverwrite(json, metadata); metadata.OutputSha256Declared = outputSha256Declared; if (!outputSha256Declared) { // JsonUtility eagerly constructs nested serializable objects even // when the property is absent. Preserve the legacy schema signal. metadata.outputSha256 = null; } else if (metadata.outputSha256 == null) { throw new InvalidDataException( "metadata.outputSha256 must be a JSON object."); } return metadata; } private static bool TryGetTopLevelJsonPropertyValueStart( string json, string propertyName, out int valueStart) { valueStart = -1; if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(propertyName)) { return false; } var objectDepth = 0; for (var index = 0; index < json.Length; index++) { var character = json[index]; if (character == '{') { objectDepth++; continue; } if (character == '}') { objectDepth--; continue; } if (character != '"') { continue; } var stringStart = index + 1; var escaped = false; var stringEnd = stringStart; for (; stringEnd < json.Length; stringEnd++) { var stringCharacter = json[stringEnd]; if (escaped) { escaped = false; continue; } if (stringCharacter == '\\') { escaped = true; continue; } if (stringCharacter == '"') { break; } } if (stringEnd >= json.Length) { return false; } index = stringEnd; if (objectDepth != 1 || stringEnd - stringStart != propertyName.Length || string.CompareOrdinal( json, stringStart, propertyName, 0, propertyName.Length) != 0) { continue; } var separator = stringEnd + 1; while (separator < json.Length && char.IsWhiteSpace(json[separator])) { separator++; } if (separator >= json.Length || json[separator] != ':') { continue; } valueStart = separator + 1; while (valueStart < json.Length && char.IsWhiteSpace(json[valueStart])) { valueStart++; } return true; } return false; } private static void ValidateGeneratedOutputHashes( GeneratedCameraMetadata metadata, string cameraPath, string timePath, string shotsPath) { if (!metadata.OutputSha256Declared) { // Legacy Native and Hybrid outputs predate payload hashes. return; } if (metadata.outputSha256 == null) { throw new InvalidDataException( "metadata.outputSha256 must be a JSON object."); } ValidateGeneratedOutputHash( "worldCamera", metadata.outputSha256.worldCamera, cameraPath); ValidateGeneratedOutputHash( "time", metadata.outputSha256.time, timePath); if (string.IsNullOrWhiteSpace(metadata.shotsFile)) { if (!string.IsNullOrWhiteSpace(metadata.outputSha256.shots)) { throw new InvalidDataException( "metadata.outputSha256.shots is present but metadata.shotsFile " + "is empty."); } return; } ValidateGeneratedOutputHash( "shots", metadata.outputSha256.shots, shotsPath); } private static void ValidateGeneratedOutputHash( string payloadName, string expectedSha256, string path) { if (string.IsNullOrWhiteSpace(expectedSha256)) { throw new InvalidDataException( $"metadata.outputSha256.{payloadName} is missing."); } var normalizedExpected = expectedSha256.Trim(); if (normalizedExpected.Length != 64 || normalizedExpected.Any(character => !Uri.IsHexDigit(character))) { throw new InvalidDataException( $"metadata.outputSha256.{payloadName} is not a valid SHA-256 value."); } if (!File.Exists(path)) { throw new FileNotFoundException( $"Generated {payloadName} payload was not found.", path); } var actualSha256 = ComputeSha256(path); if (!string.Equals( normalizedExpected, actualSha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException( $"Generated {payloadName} payload SHA-256 mismatch. " + $"Expected {normalizedExpected}; actual {actualSha256}."); } } private static string ComputeSha256(string path) { using var algorithm = SHA256.Create(); using var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.SequentialScan); return string.Concat( algorithm.ComputeHash(stream) .Select(value => value.ToString("x2"))); } private static string GetHierarchyPath(Transform transform) { var names = new List(); while (transform != null) { names.Add(transform.name); transform = transform.parent; } names.Reverse(); return string.Join("/", names); } private static string GetStableHierarchyKey(Transform transform) { var segments = new List(); while (transform != null) { // Names alone are ambiguous when sibling objects intentionally // share a name. Serialized sibling order survives reloads while // Unity instance IDs do not. segments.Add($"{transform.name}[{transform.GetSiblingIndex()}]"); transform = transform.parent; } segments.Reverse(); return string.Join("/", segments); } private sealed class GeneratedCameraFrame { public Vector3 Position; public Quaternion Rotation; public float FieldOfView; public float Dutch; } private readonly struct CurveSegment { public CurveSegment(int start, int end) { Start = start; End = end; } public int Start { get; } public int End { get; } } internal sealed class PositionCurveBuildResult { internal PositionCurveBuildResult( AnimationCurve xCurve, AnimationCurve yCurve, AnimationCurve zCurve, bool usedExactFallback, string fallbackReason) { XCurve = xCurve ?? throw new ArgumentNullException(nameof(xCurve)); YCurve = yCurve ?? throw new ArgumentNullException(nameof(yCurve)); ZCurve = zCurve ?? throw new ArgumentNullException(nameof(zCurve)); UsedExactFallback = usedExactFallback; FallbackReason = fallbackReason ?? string.Empty; } internal AnimationCurve XCurve { get; } internal AnimationCurve YCurve { get; } internal AnimationCurve ZCurve { get; } internal bool UsedExactFallback { get; } internal string FallbackReason { get; } } internal readonly struct PositionCurveAuditResult { private PositionCurveAuditResult(bool passed, string failureReason) { Passed = passed; FailureReason = failureReason ?? string.Empty; } internal bool Passed { get; } internal string FailureReason { get; } internal static PositionCurveAuditResult Pass() { return new PositionCurveAuditResult(true, string.Empty); } internal static PositionCurveAuditResult Fail(string failureReason) { return new PositionCurveAuditResult(false, failureReason); } } private readonly struct PositionMetricSeries { internal static readonly PositionMetricSeries Empty = new PositionMetricSeries( Array.Empty(), Array.Empty()); internal PositionMetricSeries(double[] times, float[] values) { Times = times ?? Array.Empty(); Values = values ?? Array.Empty(); Maximum = Values.Length == 0 ? 0f : Values.Max(); } internal double[] Times { get; } internal float[] Values { get; } internal float Maximum { get; } } private readonly struct PositionMotionSeries { internal static readonly PositionMotionSeries Empty = new PositionMotionSeries( PositionMetricSeries.Empty, PositionMetricSeries.Empty, PositionMetricSeries.Empty); internal PositionMotionSeries( PositionMetricSeries speed, PositionMetricSeries acceleration, PositionMetricSeries jerk) { Speed = speed; Acceleration = acceleration; Jerk = jerk; } internal PositionMetricSeries Speed { get; } internal PositionMetricSeries Acceleration { get; } internal PositionMetricSeries Jerk { get; } internal PositionMotionMetrics Metrics => new PositionMotionMetrics( Speed.Maximum, Acceleration.Maximum, Jerk.Maximum); } private readonly struct PositionMotionMetrics { internal PositionMotionMetrics( float maximumSpeed, float maximumAcceleration, float maximumJerk) { MaximumSpeed = maximumSpeed; MaximumAcceleration = maximumAcceleration; MaximumJerk = maximumJerk; } internal float MaximumSpeed { get; } internal float MaximumAcceleration { get; } internal float MaximumJerk { get; } } internal sealed class SimplifiedCameraCurves { public SimplifiedCameraCurves( double[] times, Vector3[] positions, Quaternion[] rotations, float[] fieldOfView, float[] dutch, int[] positionIndices, int[] rotationIndices, int[] fieldOfViewIndices, int[] dutchIndices) { Times = times; Positions = positions; Rotations = rotations; FieldOfView = fieldOfView; Dutch = dutch; PositionIndices = positionIndices; RotationIndices = rotationIndices; FieldOfViewIndices = fieldOfViewIndices; DutchIndices = dutchIndices; } public double[] Times { get; } public Vector3[] Positions { get; } public Quaternion[] Rotations { get; } public float[] FieldOfView { get; } public float[] Dutch { get; } public int[] PositionIndices { get; } public int[] RotationIndices { get; } public int[] FieldOfViewIndices { get; } public int[] DutchIndices { get; } internal bool PositionUsedExactFallback { get; private set; } internal string PositionFallbackReason { get; private set; } = string.Empty; internal void SetPositionCurveDiagnostics( bool usedExactFallback, string fallbackReason) { PositionUsedExactFallback = usedExactFallback; PositionFallbackReason = fallbackReason ?? string.Empty; } } private sealed class PreviewShotCamera { public GeneratedShot Definition; public string RelativePath; public CinemachineCamera Camera; } private readonly struct GeneratedAnimationEntry { public GeneratedAnimationEntry( AnimationTrack track, TimelineClip clip) { Track = track; Clip = clip; } public AnimationTrack Track { get; } public TimelineClip Clip { get; } } private readonly struct PreviewContext { public PreviewContext( Scene scene, PlayableDirector director, GameObject cameraRoot) { Scene = scene; Director = director; CameraRoot = cameraRoot; } public Scene Scene { get; } public PlayableDirector Director { get; } public GameObject CameraRoot { get; } } private sealed class DirectorStateSnapshot { public DirectorStateSnapshot( PlayableDirector director, TimelineAsset timeline, double time, bool activeSelf, string scenePath, bool sceneWasDirty, bool directorWasDirty, bool gameObjectWasDirty, bool timelineWasDirty, TrackAsset[] tracks, UnityEngine.Object[] bindings, PropertyName[] referenceNames, UnityEngine.Object[] referenceValues, bool[] referenceValuesValid) { Director = director; Timeline = timeline; Time = time; ActiveSelf = activeSelf; ScenePath = scenePath; SceneWasDirty = sceneWasDirty; DirectorWasDirty = directorWasDirty; GameObjectWasDirty = gameObjectWasDirty; TimelineWasDirty = timelineWasDirty; Tracks = tracks; Bindings = bindings; ReferenceNames = referenceNames; ReferenceValues = referenceValues; ReferenceValuesValid = referenceValuesValid; } public PlayableDirector Director { get; } public TimelineAsset Timeline { get; } public double Time { get; } public bool ActiveSelf { get; } public string ScenePath { get; } public bool SceneWasDirty { get; } public bool DirectorWasDirty { get; } public bool GameObjectWasDirty { get; } public bool TimelineWasDirty { get; } public TrackAsset[] Tracks { get; } public UnityEngine.Object[] Bindings { get; } public PropertyName[] ReferenceNames { get; } public UnityEngine.Object[] ReferenceValues { get; } public bool[] ReferenceValuesValid { get; } } [Serializable] private sealed class GeneratedShotFile { public List shots; } [Serializable] private sealed class GeneratedShot { public int index; public string cameraName; public string shotType; public int startFrame; public int endFrameExclusive; public double start; public double end; public double duration; public string sourceSong; public int sourceShotIndex; } [Serializable] private sealed class GeneratedCameraMetadata { public string songName; public int sampleRate; public int frameCount; public double duration; public string worldCameraFile; public string timeFile; public string shotsFile; public GeneratedOutputSha256 outputSha256; public bool lookAtUsed; public bool OutputSha256Declared { get; set; } } [Serializable] private sealed class GeneratedOutputSha256 { public string worldCamera; public string time; public string shots; } } }