streamingle-unity-utilities/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs

5401 lines
203 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.Recorder.Timeline;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
public static class AICameraTimelinePreviewImporter
{
public enum CurveSimplificationPreset
{
Exact,
Balanced,
Editable
}
internal const string OriginalDirectorName = "Timeline";
internal const string PreviewDirectorName = "Timeline_AI_Preview";
internal const string PreviewCameraName = "AI_CameraPreview";
internal const string GeneratedAnimationTrackName =
"AI Generated Camera Motion";
internal const string GeneratedAnimationTrackPrefix =
"AI Camera Motion";
internal const string GeneratedCinemachineTrackName =
"AI Generated Cinemachine";
private const string PreviewVirtualCameraName = "GeneratedVirtualCamera";
private const string PreviewAssetFolderName = "AI_CameraPreview";
private const string FinalAssetFolderName = "AI_CameraFinal";
private const string ShotAnimationFolderName = "Shots";
private const string CameraPrefabFolderName = "Cameras";
private const string PreviewGenerationProvenanceName =
"AI Camera Preview Generation Provenance";
private const double CutBoundaryGuardSeconds = 0.000001;
private const int DirectionChangeDetectionFlankFrames = 5;
// Matches the worker's 0.35-second braking radius at 60 fps.
private const int DirectionChangePreserveRadiusFrames = 21;
private const float DirectionChangeMinimumSpeedMetersPerSecond = 0.04f;
// Preserve the generated braking envelope for every concentrated turn
// covered by the worker policy (40 degrees and above), not only an
// almost complete reversal. Linear curve simplification may otherwise
// collapse a mild 40--55 or clear 60/90-degree slowdown into a kink.
private const float DirectionChangeMaximumCosine = 0.76604444f;
private const double PositionCurveAuditTimeToleranceSeconds = 0.000000001;
private const float PositionCurveAuditErrorEpsilonMeters = 0.0001f;
private const int PositionCurveAuditLocalWindowRadiusSamples = 2;
private const float PositionCurveAuditSpeedRatio = 1.5f;
private const float PositionCurveAuditSpeedDeltaMetersPerSecond = 0.25f;
private const float PositionCurveAuditSpeedFloorMetersPerSecond = 0.5f;
private const float PositionCurveAuditAccelerationRatio = 2f;
private const float PositionCurveAuditAccelerationDeltaMetersPerSecondSquared =
5f;
private const float PositionCurveAuditAccelerationFloorMetersPerSecondSquared =
8f;
private const float PositionCurveAuditJerkRatio = 3f;
private const float PositionCurveAuditJerkDeltaMetersPerSecondCubed = 150f;
private const float PositionCurveAuditJerkFloorMetersPerSecondCubed = 250f;
private const float PositionCurveAuditMotionNoiseDeadbandMeters = 0.0005f;
private const float PositionCurveAuditMotionRetentionMinimumMeters = 0.002f;
private const float PositionCurveAuditMotionRetentionRatio = 0.65f;
public const CurveSimplificationPreset DefaultCurveSimplificationPreset =
CurveSimplificationPreset.Balanced;
/// <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);
var positionExactFallbackCount = 0;
foreach (var shotCamera in shotCameras)
{
var generatedClip = BuildShotAnimationClip(
metadata.songName,
metadata.sampleRate,
frames,
times,
shotCamera,
curvePreset,
out var positionUsedExactFallback,
out var positionFallbackReason);
if (positionUsedExactFallback)
{
positionExactFallbackCount++;
Debug.LogWarning(
$"AI camera shot {shotCamera.Definition.index:D3} " +
$"({shotCamera.Definition.cameraName}) used Exact position " +
$"curves after the reduced-curve audit: " +
$"{positionFallbackReason}");
}
var animationClipPath = AssetDatabase.GenerateUniqueAssetPath(
$"{shotAnimationFolder}/" +
$"{shotCamera.Definition.index:D3}_" +
$"{SanitizeAssetName(shotCamera.Definition.cameraName)}.anim");
AssetDatabase.CreateAsset(generatedClip, animationClipPath);
generatedClips.Add(generatedClip);
generatedClipPaths.Add(animationClipPath);
CreateGeneratedAnimationTrackForCamera(
copiedTimeline,
animationGroup,
previewDirector,
shotCamera.Camera.gameObject,
generatedClip,
shotCamera.Definition.index,
shotCamera.Definition.cameraName,
shotCamera.Definition.start,
shotCamera.Definition.duration);
}
var cinemachineTrack = copiedTimeline.CreateTrack<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}",
$"Position Exact Fallbacks: {positionExactFallbackCount:N0}",
$"Preview Director: {GetHierarchyPath(previewDirector.transform)}",
$"Preview Camera Root: {GetHierarchyPath(previewCameraObject.transform)}",
$"Timeline Asset: {copiedTimelinePath}",
$"Animation Assets: {generatedClipPaths.Count:N0} " +
$"under {shotAnimationFolder}",
"Original Timeline asset was not modified.",
"The scene remains unsaved so existing user changes are preserved.");
}
catch
{
if (previewDirectorObject != null)
{
UnityEngine.Object.DestroyImmediate(previewDirectorObject);
}
if (previewCameraObject != null)
{
UnityEngine.Object.DestroyImmediate(previewCameraObject);
}
foreach (var generatedClipPath in generatedClipPaths)
{
AssetDatabase.DeleteAsset(generatedClipPath);
}
AssetDatabase.DeleteAsset(copiedTimelinePath);
originalDirector.gameObject.SetActive(originalDirectorWasActive);
throw;
}
}
/// <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,
out var positionUsedExactFallback,
out var positionFallbackReason);
if (positionUsedExactFallback)
{
Debug.LogWarning(
$"AI camera shot {shotIndex:D3} used Exact position curves " +
$"after the reduced-curve audit: {positionFallbackReason}");
}
var previousDirectorTime = previewDirector.time;
var undoName = $"Regenerate AI Camera Shot {shotIndex:D3}";
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName(undoName);
var undoReverted = false;
try
{
Undo.RegisterCompleteObjectUndo(
existingClip,
undoName);
Undo.RecordObject(
cameraTransform,
undoName);
Undo.RecordObject(
camera,
undoName);
Undo.RegisterCompleteObjectUndo(
selectedAnimationEntry.Track,
undoName);
var preservedAssetName = existingClip.name;
EditorUtility.CopySerialized(replacement, existingClip);
existingClip.name = preservedAssetName;
UnityEngine.Object.DestroyImmediate(replacement);
var firstFrame = frames[definitionForReplacement.startFrame];
cameraTransform.SetLocalPositionAndRotation(
firstFrame.Position,
firstFrame.Rotation);
if (string.IsNullOrEmpty(relativePath))
{
ConfigurePerCameraTrackOffset(
selectedAnimationEntry.Track,
cameraTransform);
}
var lens = camera.Lens;
lens.FieldOfView = firstFrame.FieldOfView;
lens.Dutch = firstFrame.Dutch;
camera.Lens = lens;
selectedTimelineClip.displayName = cameraTransform.name;
var provenance = UpsertPreviewGenerationProvenance(
timeline,
generatedDirectoryPath,
metadataPath,
cwAiRoot,
true,
undoName);
EditorUtility.SetDirty(existingClip);
EditorUtility.SetDirty(provenance);
EditorUtility.SetDirty(camera);
EditorUtility.SetDirty(timeline);
EditorUtility.SetDirty(previewDirector);
EditorSceneManager.MarkSceneDirty(scene);
AssetDatabase.SaveAssetIfDirty(existingClip);
AssetDatabase.SaveAssetIfDirty(provenance);
AssetDatabase.SaveAssetIfDirty(timeline);
previewDirector.time = Math.Max(
definitionForReplacement.start,
Math.Min(
previousDirectorTime,
definitionForReplacement.end - 1.0 / metadata.sampleRate));
previewDirector.RebuildGraph();
previewDirector.Evaluate();
Selection.activeGameObject = camera.gameObject;
}
catch
{
undoReverted = true;
Undo.RevertAllDownToGroup(undoGroup);
throw;
}
finally
{
if (replacement != null)
{
UnityEngine.Object.DestroyImmediate(replacement);
}
if (!undoReverted)
{
Undo.CollapseUndoOperations(undoGroup);
}
}
return string.Join(
Environment.NewLine,
$"AI camera shot {shotIndex:D3} regenerated.",
$"Camera: {cameraTransform.name}",
$"Frames: {definitionForReplacement.startFrame:N0}-" +
$"{definitionForReplacement.endFrameExclusive - 1:N0}",
$"Curve Simplification: {curvePreset}",
positionUsedExactFallback
? $"Position Curves: Exact fallback ({positionFallbackReason})"
: "Position Curves: requested preset passed motion audit",
$"Animation Asset: {AssetDatabase.GetAssetPath(existingClip)}",
"All other generated camera clips were preserved.",
"The scene remains unsaved.");
}
public static string[] GetGeneratedShotNamesForEditor()
{
return TryFindLegacyPreviewContext(
SceneManager.GetActiveScene(),
out var context)
? GetGeneratedShotNames(context.CameraRoot)
: Array.Empty<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,
out bool positionUsedExactFallback,
out string positionFallbackReason)
{
var clip = new AnimationClip
{
name =
$"{songName}_{shotCamera.Definition.index:D3}_" +
$"{shotCamera.Definition.cameraName}",
frameRate = sampleRate,
wrapMode = WrapMode.ClampForever
};
var start = shotCamera.Definition.startFrame;
var end = shotCamera.Definition.endFrameExclusive;
var relativePath = shotCamera.RelativePath;
var sampleCount = end - start;
var localTimes = new double[sampleCount];
var positions = new Vector3[sampleCount];
var rotations = new Quaternion[sampleCount];
var fieldOfView = new float[sampleCount];
var dutch = new float[sampleCount];
var startTime = times[start];
for (var index = 0; index < sampleCount; index++)
{
var frame = frames[start + index];
localTimes[index] = times[start + index] - startTime;
positions[index] = frame.Position;
rotations[index] = frame.Rotation;
fieldOfView[index] = frame.FieldOfView;
dutch[index] = frame.Dutch;
}
var simplified = SimplifyCameraCurves(
localTimes,
positions,
rotations,
fieldOfView,
dutch,
curvePreset);
var positionCurves = BuildPositionCurves(simplified, curvePreset);
positionUsedExactFallback = positionCurves.UsedExactFallback;
positionFallbackReason = positionCurves.FallbackReason;
SetCurve(
clip,
relativePath,
typeof(Transform),
"m_LocalPosition.x",
positionCurves.XCurve);
SetCurve(
clip,
relativePath,
typeof(Transform),
"m_LocalPosition.y",
positionCurves.YCurve);
SetCurve(
clip,
relativePath,
typeof(Transform),
"m_LocalPosition.z",
positionCurves.ZCurve);
SetLinearCurve(
clip,
relativePath,
typeof(Transform),
"m_LocalRotation.x",
BuildKeys(
simplified.Times,
simplified.RotationIndices,
index => simplified.Rotations[index].x));
SetLinearCurve(
clip,
relativePath,
typeof(Transform),
"m_LocalRotation.y",
BuildKeys(
simplified.Times,
simplified.RotationIndices,
index => simplified.Rotations[index].y));
SetLinearCurve(
clip,
relativePath,
typeof(Transform),
"m_LocalRotation.z",
BuildKeys(
simplified.Times,
simplified.RotationIndices,
index => simplified.Rotations[index].z));
SetLinearCurve(
clip,
relativePath,
typeof(Transform),
"m_LocalRotation.w",
BuildKeys(
simplified.Times,
simplified.RotationIndices,
index => simplified.Rotations[index].w));
SetLinearCurve(
clip,
relativePath,
typeof(CinemachineCamera),
"Lens.FieldOfView",
BuildKeys(
simplified.Times,
simplified.FieldOfViewIndices,
index => simplified.FieldOfView[index]));
SetLinearCurve(
clip,
relativePath,
typeof(CinemachineCamera),
"Lens.Dutch",
BuildKeys(
simplified.Times,
simplified.DutchIndices,
index => simplified.Dutch[index]));
clip.EnsureQuaternionContinuity();
return clip;
}
internal static CurveSimplificationSettings
GetCurveSimplificationSettings(CurveSimplificationPreset preset)
{
return preset switch
{
CurveSimplificationPreset.Exact =>
new CurveSimplificationSettings(0f, 0f, 0f, 0f),
CurveSimplificationPreset.Balanced =>
new CurveSimplificationSettings(0.005f, 0.15f, 0.08f, 0.08f),
CurveSimplificationPreset.Editable =>
new CurveSimplificationSettings(0.02f, 0.5f, 0.25f, 0.25f),
_ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null)
};
}
internal static SimplifiedCameraCurves SimplifyCameraCurves(
IReadOnlyList<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));
}
internal static PositionCurveBuildResult BuildPositionCurves(
SimplifiedCameraCurves simplified,
CurveSimplificationPreset preset)
{
if (simplified == null)
{
throw new ArgumentNullException(nameof(simplified));
}
PositionCurveBuildResult result;
if (preset == CurveSimplificationPreset.Exact ||
simplified.Times.Length < 2)
{
result = BuildLinearPositionCurves(
simplified.Times,
simplified.Positions,
CreateAllSampleIndices(simplified.Times.Length),
false,
string.Empty);
}
else
{
var candidate = BuildVelocityAwarePositionCurves(
simplified.Times,
simplified.Positions,
simplified.PositionIndices);
var audit = AuditPositionCurves(
simplified.Times,
simplified.Positions,
candidate.XCurve,
candidate.YCurve,
candidate.ZCurve,
GetCurveSimplificationSettings(preset).PositionErrorMeters);
result = audit.Passed
? candidate
: BuildLinearPositionCurves(
simplified.Times,
simplified.Positions,
CreateAllSampleIndices(simplified.Times.Length),
true,
audit.FailureReason);
}
simplified.SetPositionCurveDiagnostics(
result.UsedExactFallback,
result.FallbackReason);
return result;
}
internal static PositionCurveAuditResult AuditPositionCurves(
IReadOnlyList<double> times,
IReadOnlyList<Vector3> positions,
AnimationCurve xCurve,
AnimationCurve yCurve,
AnimationCurve zCurve,
float maximumPositionErrorMeters)
{
if (times == null || positions == null)
{
throw new ArgumentNullException(nameof(times));
}
if (xCurve == null || yCurve == null || zCurve == null)
{
throw new ArgumentNullException(nameof(xCurve));
}
if (times.Count == 0 || positions.Count != times.Count)
{
throw new ArgumentException(
"Position audit samples must have the same non-zero length.");
}
var allowedPositionError =
maximumPositionErrorMeters + PositionCurveAuditErrorEpsilonMeters;
var evaluatedAtSourceTimes = EvaluatePositionCurves(
times,
xCurve,
yCurve,
zCurve);
for (var index = 0; index < times.Count; index++)
{
var error = Vector3.Distance(
positions[index],
evaluatedAtSourceTimes[index]);
if (!IsFinite(error) || error > allowedPositionError)
{
return PositionCurveAuditResult.Fail(
$"source-sample position error {error:F6} m exceeded " +
$"{allowedPositionError:F6} m at frame {index}");
}
}
var allIndices = CreateAllSampleIndices(times.Count);
var reference = BuildVelocityAwarePositionCurves(
times,
positions,
allIndices);
var auditTimes = BuildPositionCurveAuditTimes(times);
var evaluatedDense = EvaluatePositionCurves(
auditTimes,
xCurve,
yCurve,
zCurve);
var referenceDense = EvaluatePositionCurves(
auditTimes,
reference.XCurve,
reference.YCurve,
reference.ZCurve);
for (var index = 0; index < auditTimes.Count; index++)
{
var error = Vector3.Distance(
referenceDense[index],
evaluatedDense[index]);
if (!IsFinite(error) || error > allowedPositionError)
{
return PositionCurveAuditResult.Fail(
$"source + midpoint position error {error:F6} m exceeded " +
$"{allowedPositionError:F6} m at {auditTimes[index]:F4} s");
}
}
var sourceMotion = MeasurePositionMotion(times, positions);
var evaluatedSourceMotion = MeasurePositionMotion(
times,
evaluatedAtSourceTimes);
if (TryDescribeAlignedMotionRegression(
"source-grid",
sourceMotion,
evaluatedSourceMotion,
out var sourceLocalFailure))
{
return PositionCurveAuditResult.Fail(sourceLocalFailure);
}
if (TryDescribeMotionRegression(
"source-grid",
sourceMotion.Metrics,
evaluatedSourceMotion.Metrics,
out var sourceFailure))
{
return PositionCurveAuditResult.Fail(sourceFailure);
}
if (TryDescribeMotionRetentionRegression(
"source-grid",
positions,
evaluatedAtSourceTimes,
out var sourceRetentionFailure))
{
return PositionCurveAuditResult.Fail(sourceRetentionFailure);
}
var referenceDenseMotion = MeasurePositionMotion(
auditTimes,
referenceDense);
var evaluatedDenseMotion = MeasurePositionMotion(
auditTimes,
evaluatedDense);
if (TryDescribeAlignedMotionRegression(
"source + midpoint",
referenceDenseMotion,
evaluatedDenseMotion,
out var denseLocalFailure))
{
return PositionCurveAuditResult.Fail(denseLocalFailure);
}
if (TryDescribeMotionRegression(
"source + midpoint",
referenceDenseMotion.Metrics,
evaluatedDenseMotion.Metrics,
out var denseFailure))
{
return PositionCurveAuditResult.Fail(denseFailure);
}
if (TryDescribeMotionRetentionRegression(
"source + midpoint",
referenceDense,
evaluatedDense,
out var denseRetentionFailure))
{
return PositionCurveAuditResult.Fail(denseRetentionFailure);
}
return PositionCurveAuditResult.Pass();
}
private static PositionCurveBuildResult BuildVelocityAwarePositionCurves(
IReadOnlyList<double> times,
IReadOnlyList<Vector3> positions,
IReadOnlyList<int> indices)
{
return new PositionCurveBuildResult(
BuildVelocityAwareHermiteCurve(
times,
positions.Select(value => value.x).ToArray(),
indices),
BuildVelocityAwareHermiteCurve(
times,
positions.Select(value => value.y).ToArray(),
indices),
BuildVelocityAwareHermiteCurve(
times,
positions.Select(value => value.z).ToArray(),
indices),
false,
string.Empty);
}
private static PositionCurveBuildResult BuildLinearPositionCurves(
IReadOnlyList<double> times,
IReadOnlyList<Vector3> positions,
IReadOnlyList<int> indices,
bool usedExactFallback,
string fallbackReason)
{
return new PositionCurveBuildResult(
BuildLinearCurve(BuildKeys(
times,
indices,
index => positions[index].x)),
BuildLinearCurve(BuildKeys(
times,
indices,
index => positions[index].y)),
BuildLinearCurve(BuildKeys(
times,
indices,
index => positions[index].z)),
usedExactFallback,
fallbackReason);
}
private static AnimationCurve BuildVelocityAwareHermiteCurve(
IReadOnlyList<double> times,
IReadOnlyList<float> values,
IReadOnlyList<int> indices)
{
var sourceTangents = EstimateSourceTangents(times, values);
var tangents = new float[indices.Count];
for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++)
{
tangents[keyIndex] = sourceTangents[indices[keyIndex]];
}
ClampMonotoneHermiteTangents(times, values, indices, tangents);
var keys = new Keyframe[indices.Count];
for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++)
{
var sampleIndex = indices[keyIndex];
keys[keyIndex] = new Keyframe(
(float)times[sampleIndex],
values[sampleIndex],
tangents[keyIndex],
tangents[keyIndex]);
}
return new AnimationCurve(keys);
}
private static float[] EstimateSourceTangents(
IReadOnlyList<double> times,
IReadOnlyList<float> values)
{
var tangents = new float[values.Count];
if (values.Count < 2)
{
return tangents;
}
tangents[0] = CalculateSecant(
times[0],
values[0],
times[1],
values[1]);
tangents[values.Count - 1] = CalculateSecant(
times[values.Count - 2],
values[values.Count - 2],
times[values.Count - 1],
values[values.Count - 1]);
for (var index = 1; index < values.Count - 1; index++)
{
tangents[index] = CalculateSecant(
times[index - 1],
values[index - 1],
times[index + 1],
values[index + 1]);
}
return tangents;
}
private static void ClampMonotoneHermiteTangents(
IReadOnlyList<double> times,
IReadOnlyList<float> values,
IReadOnlyList<int> indices,
float[] tangents)
{
const float flatEpsilon = 0.0000001f;
for (var segment = 1; segment < indices.Count; segment++)
{
var leftKey = segment - 1;
var rightKey = segment;
var leftSample = indices[leftKey];
var rightSample = indices[rightKey];
var secant = CalculateSecant(
times[leftSample],
values[leftSample],
times[rightSample],
values[rightSample]);
if (Mathf.Abs(secant) <= flatEpsilon)
{
tangents[leftKey] = 0f;
tangents[rightKey] = 0f;
continue;
}
if (tangents[leftKey] * secant <= 0f)
{
tangents[leftKey] = 0f;
}
if (tangents[rightKey] * secant <= 0f)
{
tangents[rightKey] = 0f;
}
var alpha = tangents[leftKey] / secant;
var beta = tangents[rightKey] / secant;
var squaredMagnitude = alpha * alpha + beta * beta;
if (squaredMagnitude <= 9f)
{
continue;
}
var scale = 3f / Mathf.Sqrt(squaredMagnitude);
tangents[leftKey] = scale * alpha * secant;
tangents[rightKey] = scale * beta * secant;
}
}
private static float CalculateSecant(
double leftTime,
float leftValue,
double rightTime,
float rightValue)
{
return (rightValue - leftValue) / (float)(rightTime - leftTime);
}
private static int[] CreateAllSampleIndices(int count)
{
return Enumerable.Range(0, count).ToArray();
}
internal static List<double> BuildPositionCurveAuditTimes(
IReadOnlyList<double> sourceTimes)
{
if (sourceTimes == null)
{
throw new ArgumentNullException(nameof(sourceTimes));
}
if (sourceTimes.Count == 0)
{
throw new ArgumentException(
"Position audit requires at least one source time.",
nameof(sourceTimes));
}
var result = new List<double>(sourceTimes.Count * 2 - 1);
for (var index = 0; index < sourceTimes.Count; index++)
{
var sourceTime = sourceTimes[index];
if (double.IsNaN(sourceTime) || double.IsInfinity(sourceTime))
{
throw new ArgumentException(
"Position audit source times must be finite.",
nameof(sourceTimes));
}
AppendPositionCurveAuditTime(result, sourceTime);
if (index + 1 >= sourceTimes.Count)
{
continue;
}
var nextTime = sourceTimes[index + 1];
var duration = nextTime - sourceTime;
if (duration < -PositionCurveAuditTimeToleranceSeconds)
{
throw new ArgumentException(
"Position audit source times must be ascending.",
nameof(sourceTimes));
}
if (duration > PositionCurveAuditTimeToleranceSeconds &&
(float)sourceTime != (float)nextTime)
{
AppendPositionCurveAuditTime(
result,
sourceTime + duration * 0.5);
}
}
return result;
}
private static void AppendPositionCurveAuditTime(
ICollection<double> result,
double time)
{
if (result.Count == 0)
{
result.Add(time);
return;
}
var previous = result.Last();
if (time - previous <= PositionCurveAuditTimeToleranceSeconds ||
(float)time == (float)previous)
{
return;
}
result.Add(time);
}
private static Vector3[] EvaluatePositionCurves(
IReadOnlyList<double> times,
AnimationCurve xCurve,
AnimationCurve yCurve,
AnimationCurve zCurve)
{
var result = new Vector3[times.Count];
for (var index = 0; index < times.Count; index++)
{
var time = (float)times[index];
result[index] = new Vector3(
xCurve.Evaluate(time),
yCurve.Evaluate(time),
zCurve.Evaluate(time));
}
return result;
}
private static PositionMotionSeries MeasurePositionMotion(
IReadOnlyList<double> times,
IReadOnlyList<Vector3> positions)
{
if (times.Count < 2)
{
return PositionMotionSeries.Empty;
}
var velocities = new Vector3[times.Count - 1];
var velocityTimes = new double[velocities.Length];
var speeds = new float[velocities.Length];
for (var index = 0; index < velocities.Length; index++)
{
var duration = times[index + 1] - times[index];
velocities[index] =
(positions[index + 1] - positions[index]) / (float)duration;
velocityTimes[index] = (times[index + 1] + times[index]) * 0.5;
speeds[index] = velocities[index].magnitude;
}
if (velocities.Length < 2)
{
return new PositionMotionSeries(
new PositionMetricSeries(velocityTimes, speeds),
PositionMetricSeries.Empty,
PositionMetricSeries.Empty);
}
var accelerations = new Vector3[velocities.Length - 1];
var accelerationTimes = new double[accelerations.Length];
var accelerationMagnitudes = new float[accelerations.Length];
for (var index = 0; index < accelerations.Length; index++)
{
var duration = velocityTimes[index + 1] - velocityTimes[index];
accelerations[index] =
(velocities[index + 1] - velocities[index]) / (float)duration;
accelerationTimes[index] =
(velocityTimes[index + 1] + velocityTimes[index]) * 0.5;
accelerationMagnitudes[index] = accelerations[index].magnitude;
}
var jerkTimes = new double[Math.Max(0, accelerations.Length - 1)];
var jerkMagnitudes = new float[jerkTimes.Length];
for (var index = 0; index < accelerations.Length - 1; index++)
{
var duration =
accelerationTimes[index + 1] - accelerationTimes[index];
var jerk =
(accelerations[index + 1] - accelerations[index]) /
(float)duration;
jerkTimes[index] =
(accelerationTimes[index + 1] + accelerationTimes[index]) *
0.5;
jerkMagnitudes[index] = jerk.magnitude;
}
return new PositionMotionSeries(
new PositionMetricSeries(velocityTimes, speeds),
new PositionMetricSeries(
accelerationTimes,
accelerationMagnitudes),
new PositionMetricSeries(jerkTimes, jerkMagnitudes));
}
internal static bool TryDescribeAlignedMotionRegression(
IReadOnlyList<double> times,
IReadOnlyList<Vector3> referencePositions,
IReadOnlyList<Vector3> candidatePositions,
out string failureReason)
{
if (times == null || referencePositions == null ||
candidatePositions == null)
{
throw new ArgumentNullException(nameof(times));
}
if (times.Count != referencePositions.Count ||
times.Count != candidatePositions.Count)
{
throw new ArgumentException(
"Aligned motion samples must have matching lengths.");
}
return TryDescribeAlignedMotionRegression(
"aligned-test-grid",
MeasurePositionMotion(times, referencePositions),
MeasurePositionMotion(times, candidatePositions),
out failureReason);
}
private static bool TryDescribeAlignedMotionRegression(
string sampleLabel,
PositionMotionSeries reference,
PositionMotionSeries candidate,
out string failureReason)
{
if (TryDescribeAlignedMetricRegression(
sampleLabel,
"speed",
"m/s",
reference.Speed,
candidate.Speed,
PositionCurveAuditSpeedRatio,
PositionCurveAuditSpeedDeltaMetersPerSecond,
PositionCurveAuditSpeedFloorMetersPerSecond,
out failureReason))
{
return true;
}
if (TryDescribeAlignedMetricRegression(
sampleLabel,
"acceleration",
"m/s^2",
reference.Acceleration,
candidate.Acceleration,
PositionCurveAuditAccelerationRatio,
PositionCurveAuditAccelerationDeltaMetersPerSecondSquared,
PositionCurveAuditAccelerationFloorMetersPerSecondSquared,
out failureReason))
{
return true;
}
return TryDescribeAlignedMetricRegression(
sampleLabel,
"jerk",
"m/s^3",
reference.Jerk,
candidate.Jerk,
PositionCurveAuditJerkRatio,
PositionCurveAuditJerkDeltaMetersPerSecondCubed,
PositionCurveAuditJerkFloorMetersPerSecondCubed,
out failureReason);
}
private static bool TryDescribeAlignedMetricRegression(
string sampleLabel,
string metricLabel,
string unit,
PositionMetricSeries reference,
PositionMetricSeries candidate,
float ratio,
float absoluteDelta,
float absoluteFloor,
out string failureReason)
{
if (reference.Values.Length != candidate.Values.Length)
{
failureReason =
$"{sampleLabel} {metricLabel} audit grids were not aligned";
return true;
}
var radius = PositionCurveAuditLocalWindowRadiusSamples;
for (var center = 0; center < candidate.Values.Length; center++)
{
var start = Math.Max(0, center - radius);
var end = Math.Min(candidate.Values.Length - 1, center + radius);
var referenceWindowMaximum = 0f;
var candidateWindowMaximum = 0f;
for (var index = start; index <= end; index++)
{
referenceWindowMaximum = Mathf.Max(
referenceWindowMaximum,
reference.Values[index]);
candidateWindowMaximum = Mathf.Max(
candidateWindowMaximum,
candidate.Values[index]);
}
var allowed = Mathf.Max(
absoluteFloor,
Mathf.Max(
referenceWindowMaximum * ratio,
referenceWindowMaximum + absoluteDelta));
if (IsFinite(candidateWindowMaximum) &&
candidateWindowMaximum <= allowed)
{
continue;
}
var sampleTime = candidate.Times.Length > center
? candidate.Times[center]
: 0.0;
failureReason =
$"{sampleLabel} windowed {metricLabel} regressed near " +
$"{sampleTime:F4} s from {referenceWindowMaximum:F3} to " +
$"{candidateWindowMaximum:F3} {unit}; allowed " +
$"{allowed:F3} {unit}";
return true;
}
failureReason = string.Empty;
return false;
}
private static bool TryDescribeMotionRetentionRegression(
string sampleLabel,
IReadOnlyList<Vector3> reference,
IReadOnlyList<Vector3> candidate,
out string failureReason)
{
var referencePathLength = MeasureMeaningfulPathLength(reference);
if (referencePathLength < PositionCurveAuditMotionRetentionMinimumMeters)
{
failureReason = string.Empty;
return false;
}
var candidatePathLength = MeasureMeaningfulPathLength(candidate);
var requiredPathLength = Math.Max(
0f,
referencePathLength * PositionCurveAuditMotionRetentionRatio -
PositionCurveAuditMotionNoiseDeadbandMeters);
if (IsFinite(candidatePathLength) &&
candidatePathLength >= requiredPathLength)
{
failureReason = string.Empty;
return false;
}
failureReason =
$"{sampleLabel} meaningful motion retention fell from " +
$"{referencePathLength:F4} m to {candidatePathLength:F4} m; " +
$"required {requiredPathLength:F4} m after the " +
$"{PositionCurveAuditMotionNoiseDeadbandMeters:F4} m noise " +
"deadband";
return true;
}
private static float MeasureMeaningfulPathLength(
IReadOnlyList<Vector3> positions)
{
if (positions.Count < 2)
{
return 0f;
}
var pathLength = 0f;
var anchor = positions[0];
for (var index = 1; index < positions.Count; index++)
{
var distance = Vector3.Distance(anchor, positions[index]);
if (distance < PositionCurveAuditMotionNoiseDeadbandMeters)
{
continue;
}
pathLength += distance;
anchor = positions[index];
}
return pathLength;
}
private static bool TryDescribeMotionRegression(
string sampleLabel,
PositionMotionMetrics reference,
PositionMotionMetrics candidate,
out string failureReason)
{
if (TryDescribeMetricRegression(
sampleLabel,
"speed",
"m/s",
reference.MaximumSpeed,
candidate.MaximumSpeed,
PositionCurveAuditSpeedRatio,
PositionCurveAuditSpeedDeltaMetersPerSecond,
PositionCurveAuditSpeedFloorMetersPerSecond,
out failureReason))
{
return true;
}
if (TryDescribeMetricRegression(
sampleLabel,
"acceleration",
"m/s^2",
reference.MaximumAcceleration,
candidate.MaximumAcceleration,
PositionCurveAuditAccelerationRatio,
PositionCurveAuditAccelerationDeltaMetersPerSecondSquared,
PositionCurveAuditAccelerationFloorMetersPerSecondSquared,
out failureReason))
{
return true;
}
return TryDescribeMetricRegression(
sampleLabel,
"jerk",
"m/s^3",
reference.MaximumJerk,
candidate.MaximumJerk,
PositionCurveAuditJerkRatio,
PositionCurveAuditJerkDeltaMetersPerSecondCubed,
PositionCurveAuditJerkFloorMetersPerSecondCubed,
out failureReason);
}
private static bool TryDescribeMetricRegression(
string sampleLabel,
string metricLabel,
string unit,
float reference,
float candidate,
float ratio,
float absoluteDelta,
float absoluteFloor,
out string failureReason)
{
var allowed = Mathf.Max(
absoluteFloor,
Mathf.Max(reference * ratio, reference + absoluteDelta));
if (IsFinite(candidate) && candidate <= allowed)
{
failureReason = string.Empty;
return false;
}
failureReason =
$"{sampleLabel} {metricLabel} regressed from {reference:F3} " +
$"to {candidate:F3} {unit}; allowed {allowed:F3} {unit}";
return true;
}
private static Keyframe[] BuildKeys(
IReadOnlyList<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)
{
SetCurve(
clip,
relativePath,
componentType,
propertyName,
BuildLinearCurve(keys));
}
private static AnimationCurve BuildLinearCurve(Keyframe[] keys)
{
var curve = new AnimationCurve(keys);
for (var index = 0; index < curve.length; index++)
{
AnimationUtility.SetKeyLeftTangentMode(
curve,
index,
AnimationUtility.TangentMode.Linear);
AnimationUtility.SetKeyRightTangentMode(
curve,
index,
AnimationUtility.TangentMode.Linear);
}
return curve;
}
private static void SetCurve(
AnimationClip clip,
string relativePath,
Type componentType,
string propertyName,
AnimationCurve curve)
{
AnimationUtility.SetEditorCurve(
clip,
EditorCurveBinding.FloatCurve(
relativePath,
componentType,
propertyName),
curve);
}
private static void SetClipExtrapolationNone(TimelineClip clip)
{
SetNonPublicTimelineClipProperty(
clip,
nameof(TimelineClip.preExtrapolationMode),
TimelineClip.ClipExtrapolation.None);
SetNonPublicTimelineClipProperty(
clip,
nameof(TimelineClip.postExtrapolationMode),
TimelineClip.ClipExtrapolation.None);
}
private static void SetNonPublicTimelineClipProperty(
TimelineClip clip,
string propertyName,
TimelineClip.ClipExtrapolation value)
{
var property = typeof(TimelineClip).GetProperty(
propertyName,
BindingFlags.Instance |
BindingFlags.Public |
BindingFlags.NonPublic);
var setter = property?.GetSetMethod(true)
?? throw new MissingMethodException(
typeof(TimelineClip).FullName,
propertyName);
setter.Invoke(clip, new object[] { value });
}
private static void AddPreviewAssetPath(
ICollection<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 PositionCurveBuildResult
{
internal PositionCurveBuildResult(
AnimationCurve xCurve,
AnimationCurve yCurve,
AnimationCurve zCurve,
bool usedExactFallback,
string fallbackReason)
{
XCurve = xCurve ?? throw new ArgumentNullException(nameof(xCurve));
YCurve = yCurve ?? throw new ArgumentNullException(nameof(yCurve));
ZCurve = zCurve ?? throw new ArgumentNullException(nameof(zCurve));
UsedExactFallback = usedExactFallback;
FallbackReason = fallbackReason ?? string.Empty;
}
internal AnimationCurve XCurve { get; }
internal AnimationCurve YCurve { get; }
internal AnimationCurve ZCurve { get; }
internal bool UsedExactFallback { get; }
internal string FallbackReason { get; }
}
internal readonly struct PositionCurveAuditResult
{
private PositionCurveAuditResult(bool passed, string failureReason)
{
Passed = passed;
FailureReason = failureReason ?? string.Empty;
}
internal bool Passed { get; }
internal string FailureReason { get; }
internal static PositionCurveAuditResult Pass()
{
return new PositionCurveAuditResult(true, string.Empty);
}
internal static PositionCurveAuditResult Fail(string failureReason)
{
return new PositionCurveAuditResult(false, failureReason);
}
}
private readonly struct PositionMetricSeries
{
internal static readonly PositionMetricSeries Empty =
new PositionMetricSeries(
Array.Empty<double>(),
Array.Empty<float>());
internal PositionMetricSeries(double[] times, float[] values)
{
Times = times ?? Array.Empty<double>();
Values = values ?? Array.Empty<float>();
Maximum = Values.Length == 0 ? 0f : Values.Max();
}
internal double[] Times { get; }
internal float[] Values { get; }
internal float Maximum { get; }
}
private readonly struct PositionMotionSeries
{
internal static readonly PositionMotionSeries Empty =
new PositionMotionSeries(
PositionMetricSeries.Empty,
PositionMetricSeries.Empty,
PositionMetricSeries.Empty);
internal PositionMotionSeries(
PositionMetricSeries speed,
PositionMetricSeries acceleration,
PositionMetricSeries jerk)
{
Speed = speed;
Acceleration = acceleration;
Jerk = jerk;
}
internal PositionMetricSeries Speed { get; }
internal PositionMetricSeries Acceleration { get; }
internal PositionMetricSeries Jerk { get; }
internal PositionMotionMetrics Metrics =>
new PositionMotionMetrics(
Speed.Maximum,
Acceleration.Maximum,
Jerk.Maximum);
}
private readonly struct PositionMotionMetrics
{
internal PositionMotionMetrics(
float maximumSpeed,
float maximumAcceleration,
float maximumJerk)
{
MaximumSpeed = maximumSpeed;
MaximumAcceleration = maximumAcceleration;
MaximumJerk = maximumJerk;
}
internal float MaximumSpeed { get; }
internal float MaximumAcceleration { get; }
internal float MaximumJerk { get; }
}
internal sealed class SimplifiedCameraCurves
{
public SimplifiedCameraCurves(
double[] times,
Vector3[] positions,
Quaternion[] rotations,
float[] fieldOfView,
float[] dutch,
int[] positionIndices,
int[] rotationIndices,
int[] fieldOfViewIndices,
int[] dutchIndices)
{
Times = times;
Positions = positions;
Rotations = rotations;
FieldOfView = fieldOfView;
Dutch = dutch;
PositionIndices = positionIndices;
RotationIndices = rotationIndices;
FieldOfViewIndices = fieldOfViewIndices;
DutchIndices = dutchIndices;
}
public double[] Times { get; }
public Vector3[] Positions { get; }
public Quaternion[] Rotations { get; }
public float[] FieldOfView { get; }
public float[] Dutch { get; }
public int[] PositionIndices { get; }
public int[] RotationIndices { get; }
public int[] FieldOfViewIndices { get; }
public int[] DutchIndices { get; }
internal bool PositionUsedExactFallback { get; private set; }
internal string PositionFallbackReason { get; private set; } =
string.Empty;
internal void SetPositionCurveDiagnostics(
bool usedExactFallback,
string fallbackReason)
{
PositionUsedExactFallback = usedExactFallback;
PositionFallbackReason = fallbackReason ?? string.Empty;
}
}
private sealed class PreviewShotCamera
{
public GeneratedShot Definition;
public string RelativePath;
public CinemachineCamera Camera;
}
private readonly struct GeneratedAnimationEntry
{
public GeneratedAnimationEntry(
AnimationTrack track,
TimelineClip clip)
{
Track = track;
Clip = clip;
}
public AnimationTrack Track { get; }
public TimelineClip Clip { get; }
}
private readonly struct PreviewContext
{
public PreviewContext(
Scene scene,
PlayableDirector director,
GameObject cameraRoot)
{
Scene = scene;
Director = director;
CameraRoot = cameraRoot;
}
public Scene Scene { get; }
public PlayableDirector Director { get; }
public GameObject CameraRoot { get; }
}
private sealed class DirectorStateSnapshot
{
public DirectorStateSnapshot(
PlayableDirector director,
TimelineAsset timeline,
double time,
bool activeSelf,
string scenePath,
bool sceneWasDirty,
bool directorWasDirty,
bool gameObjectWasDirty,
bool timelineWasDirty,
TrackAsset[] tracks,
UnityEngine.Object[] bindings,
PropertyName[] referenceNames,
UnityEngine.Object[] referenceValues,
bool[] referenceValuesValid)
{
Director = director;
Timeline = timeline;
Time = time;
ActiveSelf = activeSelf;
ScenePath = scenePath;
SceneWasDirty = sceneWasDirty;
DirectorWasDirty = directorWasDirty;
GameObjectWasDirty = gameObjectWasDirty;
TimelineWasDirty = timelineWasDirty;
Tracks = tracks;
Bindings = bindings;
ReferenceNames = referenceNames;
ReferenceValues = referenceValues;
ReferenceValuesValid = referenceValuesValid;
}
public PlayableDirector Director { get; }
public TimelineAsset Timeline { get; }
public double Time { get; }
public bool ActiveSelf { get; }
public string ScenePath { get; }
public bool SceneWasDirty { get; }
public bool DirectorWasDirty { get; }
public bool GameObjectWasDirty { get; }
public bool TimelineWasDirty { get; }
public TrackAsset[] Tracks { get; }
public UnityEngine.Object[] Bindings { get; }
public PropertyName[] ReferenceNames { get; }
public UnityEngine.Object[] ReferenceValues { get; }
public bool[] ReferenceValuesValid { get; }
}
[Serializable]
private sealed class GeneratedShotFile
{
public List<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;
}
}
}