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;

        /// <summary>
        /// Assets and scene camera root produced by <see cref="SaveFinalForEditor"/>.
        /// The caller is responsible for saving the destination scene when ready.
        /// </summary>
        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<AnimationClip> 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;
            }
        }

        /// <summary>
        /// Creates an unsaved scene preview while preserving the original Timeline asset.
        /// New Timeline and AnimationClip assets are stored beside the active scene.
        /// </summary>
        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);
        }

        /// <summary>
        /// Creates a preview for exactly <paramref name="sourceDirector"/>.
        /// This overload supports scenes containing several song directors under
        /// a shared TimeLine root without consulting any other director.
        /// </summary>
        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<TimelineAsset>(
                copiedTimelinePath);
            if (copiedTimeline == null)
            {
                throw new InvalidOperationException(
                    $"Unable to load copied Timeline '{copiedTimelinePath}'.");
            }

            GameObject previewDirectorObject = null;
            GameObject previewCameraObject = null;
            var generatedClips = new List<AnimationClip>();
            var generatedClipPaths = new List<string>();
            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<GroupTrack>(
                    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<CinemachineTrack>(
                    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;
            }
        }

        /// <summary>
        /// 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.
        /// </summary>
        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<CinemachineTrack>()
                .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<string>(), existingClip);

            var cameraTransform = previewRoot.transform.GetChild(shotIndex);
            var camera = cameraTransform.GetComponent<CinemachineCamera>()
                ?? 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<string>();
        }

        public static string[] GetGeneratedShotNamesForEditor(
            PlayableDirector sourceDirector)
        {
            sourceDirector = RequireSourceDirector(sourceDirector);
            return TryFindPreviewContext(sourceDirector, out var context)
                ? GetGeneratedShotNames(context.CameraRoot)
                : Array.Empty<string>();
        }

        private static string[] GetGeneratedShotNames(GameObject previewRoot)
        {
            if (previewRoot == null)
            {
                return Array.Empty<string>();
            }

            return Enumerable.Range(0, previewRoot.transform.childCount)
                .Select(index =>
                    $"{index:D3} | {previewRoot.transform.GetChild(index).name}")
                .ToArray();
        }

        /// <summary>
        /// A generated shot resolved from either the Timeline selection or the
        /// selected generated camera GameObject.
        /// </summary>
        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; }
        }

        /// <summary>
        /// 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.
        /// </summary>
        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;
        }

        /// <summary>
        /// Selects a generated camera and seeks the preview director into its
        /// shot. This does not save or otherwise modify the scene.
        /// </summary>
        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<string>();
            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<PlayableDirector>()
                                       .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.";
        }

        /// <summary>
        /// Copies the selected source director's current AI preview into a durable
        /// final asset set and binds it to <paramref name="destinationDirector"/>.
        /// The active scene is never changed or saved. The source director, source
        /// preview, scene paths, and both director times are left untouched.
        /// </summary>
        public static FinalizedPreviewResult SaveFinalForEditor(
            PlayableDirector sourceDirector,
            PlayableDirector destinationDirector)
        {
            sourceDirector = RequireSourceDirector(sourceDirector);
            destinationDirector = RequireDestinationDirector(
                sourceDirector,
                destinationDirector);
            return SaveFinalForEditor(
                sourceDirector,
                destinationDirector,
                GetDefaultFinalAssetFolder(
                    sourceDirector,
                    destinationDirector));
        }

        /// <summary>
        /// Same as the two-argument overload, with an explicit Assets-relative
        /// destination folder (for example Assets/MyShow/AI_CameraFinal/SongA).
        /// </summary>
        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<string>();
            var finalClips = new List<AnimationClip>();
            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<TimelineAsset>(
                    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<string> createdAssetPaths,
            ICollection<AnimationClip> 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<string>(), 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<AnimationClip>(
                    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<GeneratedAnimationEntry>();
            }

            return timeline.GetOutputTracks()
                .OfType<AnimationTrack>()
                .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<AnimationTrack>()
                .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<CinemachineTrack>()
                .SingleOrDefault(track =>
                    track.name == GeneratedCinemachineTrackName)
                ?? throw new InvalidOperationException(
                    $"Timeline does not contain '{GeneratedCinemachineTrackName}'.");
        }

        private static TimelineClip[] GetGeneratedCinemachineClips(
            TimelineAsset timeline)
        {
            var track = timeline.GetOutputTracks()
                .OfType<CinemachineTrack>()
                .SingleOrDefault(candidate =>
                    candidate.name == GeneratedCinemachineTrackName);
            return track == null
                ? Array.Empty<TimelineClip>()
                : 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<Animator>()
                        ?? 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<Animator>()
                    ?? 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<CinemachineCamera>()
                    ?? 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<TrackAsset>()
                : 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<CinemachineTrack>()
                    .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<GeneratedShot> ReadShotDefinitions(
            GeneratedCameraMetadata metadata,
            IReadOnlyList<double> times,
            string shotsPath)
        {
            if (string.IsNullOrWhiteSpace(metadata.shotsFile))
            {
                return new List<GeneratedShot>
                {
                    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<GeneratedShotFile>(
                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<PreviewShotCamera> CreateShotCameras(
            Transform cameraRoot,
            IReadOnlyList<GeneratedShot> shotDefinitions,
            IReadOnlyList<GeneratedCameraFrame> frames)
        {
            var result = new List<PreviewShotCamera>(shotDefinitions.Count);
            foreach (var definition in shotDefinitions)
            {
                var cameraObject = new GameObject(definition.cameraName);
                cameraObject.transform.SetParent(cameraRoot, false);
                var camera = cameraObject.AddComponent<CinemachineCamera>();
                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<Animator>();
            if (animator == null)
            {
                animator = cameraObject.AddComponent<Animator>();
            }
            animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
            var safeCameraName = string.IsNullOrWhiteSpace(cameraName)
                ? $"Shot_{shotIndex:D3}"
                : cameraName;
            var animationTrack = timeline.CreateTrack<AnimationTrack>(
                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<GeneratedCameraFrame> frames,
            IReadOnlyList<double> 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<double> times,
            IReadOnlyList<Vector3> positions,
            IReadOnlyList<Quaternion> rotations,
            IReadOnlyList<float> fieldOfView,
            IReadOnlyList<float> 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<double> times,
            IReadOnlyList<int> indices,
            Func<int, float> 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<double> times,
            IReadOnlyList<Vector3> 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<double> times,
            IReadOnlyList<Quaternion> 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<double> times,
            IReadOnlyList<float> 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<double> times,
            float maximumError,
            ISet<int> requiredIndices,
            Func<int, int, int, float, float> measureError)
        {
            if (times.Count <= 1 || maximumError <= 0f)
            {
                return Enumerable.Range(0, times.Count).ToArray();
            }

            var selected = new SortedSet<int>(requiredIndices);
            var anchors = selected.ToArray();
            var pending = new Stack<CurveSegment>();
            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<int> CreateEndpointSet(int count)
        {
            var result = new HashSet<int> { 0 };
            if (count > 1)
            {
                result.Add(count - 1);
            }

            return result;
        }

        private static void AddScalarExtrema(
            int count,
            Func<int, float> selector,
            ISet<int> 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<double> times,
            IReadOnlyList<Vector3> values,
            ISet<int> 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<Quaternion> 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<double> times,
            IReadOnlyList<Vector3> positions,
            IReadOnlyList<Quaternion> rotations,
            IReadOnlyList<float> fieldOfView,
            IReadOnlyList<float> 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<string> 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<PlayableDirector>()
                .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<PlayableDirector>();
            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<PlayableDirector>()
                .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<PlayableDirector>()
                .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<CinemachineCamera>() != null &&
                  animator.transform.parent != null
                    ? animator.transform.parent.gameObject
                    : animator.gameObject
                : null;
            var cameraRoot = boundCameraRoot != null
                ? boundCameraRoot
                : Resources.FindObjectsOfTypeAll<GameObject>()
                    .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<PlayableDirector>();
            var clonedComponents =
                clonedGameObject.GetComponents<PlayableDirector>();
            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<GameObject>()
                .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<CinemachineBrain>(true))
                .ToArray();
            return ResolveCinemachineBrainForEditor(
                director,
                timeline,
                sceneBrains);
        }

        internal static CinemachineBrain ResolveCinemachineBrainForEditor(
            PlayableDirector director,
            TimelineAsset timeline,
            IReadOnlyList<CinemachineBrain> 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<CinemachineTrack>()
                .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<Camera>();
                    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<Camera>();
            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<TrackAsset> EnumerateTracks(
            TimelineAsset timeline)
        {
            foreach (var rootTrack in timeline.GetRootTracks())
            {
                yield return rootTrack;
                foreach (var childTrack in EnumerateChildTracks(rootTrack))
                {
                    yield return childTrack;
                }
            }
        }

        private static IEnumerable<TrackAsset> 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<AICameraPreviewGenerationProvenance>()
                .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<AICameraPreviewGenerationProvenance>()
                .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<GeneratedCameraFrame> frames,
            IReadOnlyList<double> 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<string>();
            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<string>();
            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<GeneratedShot> 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;
        }
    }
}
