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; 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); foreach (var shotCamera in shotCameras) { var generatedClip = BuildShotAnimationClip( metadata.songName, metadata.sampleRate, frames, times, shotCamera, curvePreset); 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}", $"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); 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}", $"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) { 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); SetLinearCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.x", BuildKeys( simplified.Times, simplified.PositionIndices, index => simplified.Positions[index].x)); SetLinearCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.y", BuildKeys( simplified.Times, simplified.PositionIndices, index => simplified.Positions[index].y)); SetLinearCurve( clip, relativePath, typeof(Transform), "m_LocalPosition.z", BuildKeys( simplified.Times, simplified.PositionIndices, index => simplified.Positions[index].z)); 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)); } 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) { 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); } 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 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; } } 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; } } }