7418 lines
279 KiB
C#
7418 lines
279 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;
|
|
private const float RotationCurveAuditAngleErrorEpsilonDegrees = 0.01f;
|
|
private const float RotationCurveAuditSpeedRatio = 1.15f;
|
|
private const float RotationCurveAuditSpeedDeltaDegreesPerSecond = 2f;
|
|
private const float RotationCurveAuditSpeedFloorDegreesPerSecond = 15f;
|
|
private const float RotationCurveAuditAccelerationRatio = 1.25f;
|
|
private const float RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared =
|
|
60f;
|
|
private const float RotationCurveAuditAccelerationFloorDegreesPerSecondSquared =
|
|
240f;
|
|
// Reconstructing a continuous quaternion path from 60 Hz samples can
|
|
// legitimately reveal a higher finite between-frame peak. These limits
|
|
// still reject the former C1 seam spikes (2.3x-3.8x on shots 13/66/68)
|
|
// without treating bounded C2 interpolation as a regression.
|
|
private const float RotationCurveAuditJerkRatio = 1.85f;
|
|
private const float RotationCurveAuditJerkDeltaDegreesPerSecondCubed = 1200f;
|
|
private const float RotationCurveAuditJerkFloorDegreesPerSecondCubed = 1800f;
|
|
private const float RotationCurveAuditMotionNoiseDeadbandDegrees = 0.02f;
|
|
private const float RotationCurveAuditMotionRetentionMinimumDegrees = 0.15f;
|
|
private const float RotationCurveAuditMotionRetentionRatio = 0.75f;
|
|
private const int CurveAuditPrimarySamplesPerSecond = 240;
|
|
private const int CurveAuditConvergenceSamplesPerSecond = 480;
|
|
private const float CurveAuditConvergenceRatio = 1.35f;
|
|
private const double CurveAuditLocalWindowSeconds = 0.125;
|
|
private const float CurveC1AbsoluteTolerance = 0.0001f;
|
|
// AnimationCurve stores key times, values and tangents as floats. At a
|
|
// 60 Hz knot this is below the acceleration represented by one float
|
|
// ULP at ordinary stage scale, while still rejecting a visible C1-only
|
|
// corner by several orders of magnitude.
|
|
private const float CurveC2AbsoluteTolerance = 0.05f;
|
|
private const float CurveC2RelativeTolerance = 0.005f;
|
|
private const float PositionCurveAuditConvergenceSpeedFloor = 0.1f;
|
|
private const float PositionCurveAuditConvergenceAccelerationFloor = 2f;
|
|
// Third finite differences amplify AnimationCurve's float evaluation
|
|
// noise by dt^-3. The analytic C2 seam audit remains the primary guard;
|
|
// these floors reject rate-divergent impulses above the numeric floor.
|
|
private const float PositionCurveAuditConvergenceJerkFloor = 700f;
|
|
private const float RotationCurveAuditConvergenceSpeedFloor = 5f;
|
|
private const float RotationCurveAuditConvergenceAccelerationFloor = 120f;
|
|
private const float RotationCurveAuditConvergenceJerkFloor = 3600f;
|
|
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 = InstantiateInactivePreviewCloneForEditor(
|
|
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);
|
|
SuppressAuthoredCinemachineOutputsForEditor(previewDirector);
|
|
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 positionDenseFallbackCount = 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)
|
|
{
|
|
positionDenseFallbackCount++;
|
|
Debug.LogWarning(
|
|
$"AI camera shot {shotCamera.Definition.index:D3} " +
|
|
$"({shotCamera.Definition.cameraName}) used dense C2 " +
|
|
$"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 Dense C2 Fallbacks: {positionDenseFallbackCount:N0}",
|
|
$"Preview Director: {GetHierarchyPath(previewDirector.transform)}",
|
|
$"Preview Camera Root: {GetHierarchyPath(previewCameraObject.transform)}",
|
|
GetPreviewOutputStatusForEditor(brain),
|
|
$"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 dense C2 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: Dense C2 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;
|
|
AuthoredCinemachineStateSnapshot destinationCinemachineStateBefore =
|
|
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);
|
|
destinationCinemachineStateBefore =
|
|
CaptureAuthoredCinemachineState(destinationDirector);
|
|
SuppressAuthoredCinemachineOutputsForEditor(
|
|
destinationDirector,
|
|
false);
|
|
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
|
|
{
|
|
RestoreAuthoredCinemachineState(
|
|
destinationCinemachineStateBefore);
|
|
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);
|
|
var rotationCurves = BuildRotationCurves(simplified, curvePreset);
|
|
SetRotationCurves(clip, relativePath, rotationCurves);
|
|
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();
|
|
rotationCurves = EnsureAppliedRotationCurveSafety(
|
|
clip,
|
|
relativePath,
|
|
simplified,
|
|
curvePreset,
|
|
rotationCurves);
|
|
if (rotationCurves.UsedDenseFallback)
|
|
{
|
|
Debug.LogWarning(
|
|
$"AI camera shot {shotCamera.Definition.index:D3} " +
|
|
$"({shotCamera.Definition.cameraName}) used dense C2 rotation " +
|
|
$"curves after the final imported-clip audit: " +
|
|
$"{rotationCurves.FallbackReason}");
|
|
}
|
|
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;
|
|
var settings = GetCurveSimplificationSettings(preset);
|
|
if (preset == CurveSimplificationPreset.Exact ||
|
|
simplified.Times.Length < 2)
|
|
{
|
|
result = BuildVelocityAwarePositionCurves(
|
|
simplified.Times,
|
|
simplified.Positions,
|
|
CreateAllSampleIndices(simplified.Times.Length),
|
|
false,
|
|
string.Empty);
|
|
var exactAudit = AuditPositionCurves(
|
|
simplified.Times,
|
|
simplified.Positions,
|
|
result.XCurve,
|
|
result.YCurve,
|
|
result.ZCurve,
|
|
settings.PositionErrorMeters);
|
|
if (!exactAudit.Passed)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Exact dense C2 position curves failed their final " +
|
|
"raw-derived high-rate motion audit: " +
|
|
exactAudit.FailureReason);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var candidate = BuildVelocityAwarePositionCurves(
|
|
simplified.Times,
|
|
simplified.Positions,
|
|
simplified.PositionIndices);
|
|
var audit = AuditPositionCurves(
|
|
simplified.Times,
|
|
simplified.Positions,
|
|
candidate.XCurve,
|
|
candidate.YCurve,
|
|
candidate.ZCurve,
|
|
settings.PositionErrorMeters);
|
|
if (audit.Passed)
|
|
{
|
|
result = candidate;
|
|
}
|
|
else if (TryBuildRefinedPositionCurves(
|
|
simplified.Times,
|
|
simplified.Positions,
|
|
simplified.PositionIndices,
|
|
settings.PositionErrorMeters,
|
|
out var refined))
|
|
{
|
|
result = refined;
|
|
}
|
|
else
|
|
{
|
|
result = BuildAuditedDensePositionFallback(
|
|
simplified.Times,
|
|
simplified.Positions,
|
|
settings.PositionErrorMeters,
|
|
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}");
|
|
}
|
|
}
|
|
|
|
if (TryDescribeC2CurveFailure(xCurve, "position x", out var c2Failure) ||
|
|
TryDescribeC2CurveFailure(yCurve, "position y", out c2Failure) ||
|
|
TryDescribeC2CurveFailure(zCurve, "position z", out c2Failure))
|
|
{
|
|
return PositionCurveAuditResult.Fail(c2Failure);
|
|
}
|
|
|
|
var primaryAuditTimes = BuildUniformCurveAuditTimes(
|
|
times,
|
|
CurveAuditPrimarySamplesPerSecond);
|
|
var convergenceAuditTimes = BuildUniformCurveAuditTimes(
|
|
times,
|
|
CurveAuditConvergenceSamplesPerSecond);
|
|
var evaluatedPrimary = EvaluatePositionCurves(
|
|
primaryAuditTimes,
|
|
xCurve,
|
|
yCurve,
|
|
zCurve);
|
|
var evaluatedConvergence = EvaluatePositionCurves(
|
|
convergenceAuditTimes,
|
|
xCurve,
|
|
yCurve,
|
|
zCurve);
|
|
var sourceMotion = MeasurePositionMotion(times, positions);
|
|
var primaryMotion = MeasurePositionMotion(
|
|
primaryAuditTimes,
|
|
evaluatedPrimary);
|
|
var convergenceMotion = MeasurePositionMotion(
|
|
convergenceAuditTimes,
|
|
evaluatedConvergence);
|
|
if (TryDescribeTimeWindowPositionMotionRegression(
|
|
"240 Hz raw-derived contract",
|
|
sourceMotion,
|
|
primaryMotion,
|
|
out var rawContractFailure))
|
|
{
|
|
return PositionCurveAuditResult.Fail(rawContractFailure);
|
|
}
|
|
|
|
if (TryDescribePositionSamplingConvergence(
|
|
primaryMotion,
|
|
convergenceMotion,
|
|
out var convergenceFailure))
|
|
{
|
|
return PositionCurveAuditResult.Fail(convergenceFailure);
|
|
}
|
|
|
|
if (TryDescribeMotionRetentionRegression(
|
|
"240 Hz",
|
|
positions,
|
|
evaluatedPrimary,
|
|
out var retentionFailure))
|
|
{
|
|
return PositionCurveAuditResult.Fail(retentionFailure);
|
|
}
|
|
|
|
return PositionCurveAuditResult.Pass();
|
|
}
|
|
|
|
private static PositionCurveBuildResult BuildVelocityAwarePositionCurves(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Vector3> positions,
|
|
IReadOnlyList<int> indices,
|
|
bool usedExactFallback = false,
|
|
string fallbackReason = "")
|
|
{
|
|
return new PositionCurveBuildResult(
|
|
BuildC2SplineCurve(
|
|
times,
|
|
positions.Select(value => value.x).ToArray(),
|
|
indices),
|
|
BuildC2SplineCurve(
|
|
times,
|
|
positions.Select(value => value.y).ToArray(),
|
|
indices),
|
|
BuildC2SplineCurve(
|
|
times,
|
|
positions.Select(value => value.z).ToArray(),
|
|
indices),
|
|
usedExactFallback,
|
|
fallbackReason);
|
|
}
|
|
|
|
private static PositionCurveBuildResult BuildAuditedDensePositionFallback(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Vector3> sourcePositions,
|
|
float maximumPositionErrorMeters,
|
|
string initialFailureReason)
|
|
{
|
|
var allIndices = CreateAllSampleIndices(times.Count);
|
|
var exactDense = BuildVelocityAwarePositionCurves(
|
|
times,
|
|
sourcePositions,
|
|
allIndices,
|
|
true,
|
|
initialFailureReason);
|
|
var lastAudit = AuditPositionCurves(
|
|
times,
|
|
sourcePositions,
|
|
exactDense.XCurve,
|
|
exactDense.YCurve,
|
|
exactDense.ZCurve,
|
|
maximumPositionErrorMeters);
|
|
if (lastAudit.Passed)
|
|
{
|
|
return exactDense;
|
|
}
|
|
|
|
var exactFailureReason = lastAudit.FailureReason;
|
|
|
|
var smoothingPassCounts = new[] { 1, 2, 4, 8, 16, 32, 64 };
|
|
foreach (var passCount in smoothingPassCounts)
|
|
{
|
|
var smoothed = SmoothPositionsWithinErrorBudget(
|
|
sourcePositions,
|
|
maximumPositionErrorMeters * 0.9f,
|
|
passCount);
|
|
var candidate = BuildVelocityAwarePositionCurves(
|
|
times,
|
|
smoothed,
|
|
allIndices,
|
|
true,
|
|
initialFailureReason +
|
|
$"; bounded C2 smoothing passes={passCount}");
|
|
lastAudit = AuditPositionCurves(
|
|
times,
|
|
sourcePositions,
|
|
candidate.XCurve,
|
|
candidate.YCurve,
|
|
candidate.ZCurve,
|
|
maximumPositionErrorMeters);
|
|
if (lastAudit.Passed)
|
|
{
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
"Dense C2 position fallback failed its final jerk-bounded " +
|
|
$"motion audit (exact: {exactFailureReason}; final: " +
|
|
$"{lastAudit.FailureReason})");
|
|
}
|
|
|
|
private static bool TryBuildRefinedPositionCurves(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Vector3> sourcePositions,
|
|
IReadOnlyList<int> initialIndices,
|
|
float maximumPositionErrorMeters,
|
|
out PositionCurveBuildResult result)
|
|
{
|
|
const int maximumAddedKeys = 64;
|
|
var indices = new SortedSet<int>(initialIndices);
|
|
var allowedError = maximumPositionErrorMeters +
|
|
PositionCurveAuditErrorEpsilonMeters;
|
|
for (var iteration = 0;
|
|
iteration <= maximumAddedKeys && indices.Count < times.Count;
|
|
iteration++)
|
|
{
|
|
var candidate = BuildVelocityAwarePositionCurves(
|
|
times,
|
|
sourcePositions,
|
|
indices.ToArray());
|
|
var evaluated = EvaluatePositionCurves(
|
|
times,
|
|
candidate.XCurve,
|
|
candidate.YCurve,
|
|
candidate.ZCurve);
|
|
var worstIndex = -1;
|
|
var worstError = 0f;
|
|
for (var index = 0; index < times.Count; index++)
|
|
{
|
|
var error = Vector3.Distance(
|
|
sourcePositions[index],
|
|
evaluated[index]);
|
|
if (error > worstError)
|
|
{
|
|
worstError = error;
|
|
worstIndex = index;
|
|
}
|
|
}
|
|
|
|
if (worstError <= allowedError)
|
|
{
|
|
var audit = AuditPositionCurves(
|
|
times,
|
|
sourcePositions,
|
|
candidate.XCurve,
|
|
candidate.YCurve,
|
|
candidate.ZCurve,
|
|
maximumPositionErrorMeters);
|
|
if (audit.Passed)
|
|
{
|
|
result = candidate;
|
|
return true;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (worstIndex < 0 || !indices.Add(worstIndex))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
result = default;
|
|
return false;
|
|
}
|
|
|
|
internal static RotationCurveBuildResult BuildRotationCurves(
|
|
SimplifiedCameraCurves simplified,
|
|
CurveSimplificationPreset preset)
|
|
{
|
|
if (simplified == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(simplified));
|
|
}
|
|
|
|
var allIndices = CreateAllSampleIndices(simplified.Times.Length);
|
|
IReadOnlyList<int> requestedIndices =
|
|
preset == CurveSimplificationPreset.Exact ||
|
|
simplified.Times.Length < 2
|
|
? allIndices
|
|
: simplified.RotationIndices;
|
|
var candidate = BuildVelocityAwareRotationCurves(
|
|
simplified.Times,
|
|
simplified.Rotations,
|
|
requestedIndices,
|
|
false,
|
|
string.Empty);
|
|
var allowedAngleError = GetCurveSimplificationSettings(preset)
|
|
.QuaternionAngleErrorDegrees;
|
|
var audit = AuditRotationCurves(
|
|
simplified.Times,
|
|
simplified.Rotations,
|
|
candidate.XCurve,
|
|
candidate.YCurve,
|
|
candidate.ZCurve,
|
|
candidate.WCurve,
|
|
allowedAngleError);
|
|
|
|
RotationCurveBuildResult result;
|
|
if (audit.Passed)
|
|
{
|
|
result = candidate;
|
|
}
|
|
else if (preset != CurveSimplificationPreset.Exact &&
|
|
TryBuildRefinedRotationCurves(
|
|
simplified.Times,
|
|
simplified.Rotations,
|
|
requestedIndices,
|
|
allowedAngleError,
|
|
out var refined))
|
|
{
|
|
result = refined;
|
|
}
|
|
else if (preset != CurveSimplificationPreset.Exact)
|
|
{
|
|
result = BuildAuditedDenseRotationFallback(
|
|
simplified.Times,
|
|
simplified.Rotations,
|
|
allowedAngleError,
|
|
audit.FailureReason);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Dense C2 rotation curves failed their final angular " +
|
|
$"motion audit: {audit.FailureReason}");
|
|
}
|
|
|
|
simplified.SetRotationCurveDiagnostics(
|
|
result.UsedDenseFallback,
|
|
result.FallbackReason);
|
|
return result;
|
|
}
|
|
|
|
internal static RotationCurveAuditResult AuditRotationCurves(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Quaternion> rotations,
|
|
AnimationCurve xCurve,
|
|
AnimationCurve yCurve,
|
|
AnimationCurve zCurve,
|
|
AnimationCurve wCurve,
|
|
float maximumAngleErrorDegrees)
|
|
{
|
|
if (times == null || rotations == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(times));
|
|
}
|
|
|
|
if (xCurve == null || yCurve == null ||
|
|
zCurve == null || wCurve == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(xCurve));
|
|
}
|
|
|
|
if (times.Count == 0 || rotations.Count != times.Count)
|
|
{
|
|
throw new ArgumentException(
|
|
"Rotation audit samples must have the same non-zero length.");
|
|
}
|
|
|
|
var continuousRotations = MakeQuaternionSequenceContinuous(rotations);
|
|
if (!TryEvaluateRotationCurves(
|
|
times,
|
|
xCurve,
|
|
yCurve,
|
|
zCurve,
|
|
wCurve,
|
|
out var evaluatedAtSourceTimes,
|
|
out var evaluationFailure))
|
|
{
|
|
return RotationCurveAuditResult.Fail(evaluationFailure);
|
|
}
|
|
|
|
var allowedAngleError =
|
|
maximumAngleErrorDegrees +
|
|
RotationCurveAuditAngleErrorEpsilonDegrees;
|
|
for (var index = 0; index < times.Count; index++)
|
|
{
|
|
var error = Quaternion.Angle(
|
|
continuousRotations[index],
|
|
evaluatedAtSourceTimes[index]);
|
|
if (!IsFinite(error) || error > allowedAngleError)
|
|
{
|
|
return RotationCurveAuditResult.Fail(
|
|
$"source-sample angle error {error:F4} deg exceeded " +
|
|
$"{allowedAngleError:F4} deg at frame {index}");
|
|
}
|
|
}
|
|
|
|
if (TryDescribeC2CurveFailure(xCurve, "rotation x", out var c2Failure) ||
|
|
TryDescribeC2CurveFailure(yCurve, "rotation y", out c2Failure) ||
|
|
TryDescribeC2CurveFailure(zCurve, "rotation z", out c2Failure) ||
|
|
TryDescribeC2CurveFailure(wCurve, "rotation w", out c2Failure))
|
|
{
|
|
return RotationCurveAuditResult.Fail(c2Failure);
|
|
}
|
|
|
|
var primaryAuditTimes = BuildUniformCurveAuditTimes(
|
|
times,
|
|
CurveAuditPrimarySamplesPerSecond);
|
|
var convergenceAuditTimes = BuildUniformCurveAuditTimes(
|
|
times,
|
|
CurveAuditConvergenceSamplesPerSecond);
|
|
if (!TryEvaluateRotationCurves(
|
|
primaryAuditTimes,
|
|
xCurve,
|
|
yCurve,
|
|
zCurve,
|
|
wCurve,
|
|
out var evaluatedPrimary,
|
|
out evaluationFailure))
|
|
{
|
|
return RotationCurveAuditResult.Fail(evaluationFailure);
|
|
}
|
|
|
|
if (!TryEvaluateRotationCurves(
|
|
convergenceAuditTimes,
|
|
xCurve,
|
|
yCurve,
|
|
zCurve,
|
|
wCurve,
|
|
out var evaluatedConvergence,
|
|
out evaluationFailure))
|
|
{
|
|
return RotationCurveAuditResult.Fail(evaluationFailure);
|
|
}
|
|
|
|
var sourceMotion = MeasureAngularMotion(times, continuousRotations);
|
|
var primaryMotion = MeasureAngularMotion(
|
|
primaryAuditTimes,
|
|
evaluatedPrimary);
|
|
var convergenceMotion = MeasureAngularMotion(
|
|
convergenceAuditTimes,
|
|
evaluatedConvergence);
|
|
if (TryDescribeTimeWindowAngularMotionRegression(
|
|
"240 Hz raw-derived contract",
|
|
sourceMotion,
|
|
primaryMotion,
|
|
out var rawContractFailure))
|
|
{
|
|
return RotationCurveAuditResult.Fail(rawContractFailure);
|
|
}
|
|
|
|
if (TryDescribeAngularSamplingConvergence(
|
|
primaryMotion,
|
|
convergenceMotion,
|
|
out var convergenceFailure))
|
|
{
|
|
return RotationCurveAuditResult.Fail(convergenceFailure);
|
|
}
|
|
|
|
if (TryDescribeAngularMotionRetentionRegression(
|
|
"240 Hz",
|
|
continuousRotations,
|
|
evaluatedPrimary,
|
|
out var retentionFailure))
|
|
{
|
|
return RotationCurveAuditResult.Fail(retentionFailure);
|
|
}
|
|
|
|
return RotationCurveAuditResult.Pass();
|
|
}
|
|
|
|
private static RotationCurveBuildResult BuildVelocityAwareRotationCurves(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Quaternion> rotations,
|
|
IReadOnlyList<int> indices,
|
|
bool usedDenseFallback,
|
|
string fallbackReason)
|
|
{
|
|
var continuousRotations = MakeQuaternionSequenceContinuous(rotations);
|
|
return new RotationCurveBuildResult(
|
|
BuildC2SplineCurve(
|
|
times,
|
|
continuousRotations.Select(value => value.x).ToArray(),
|
|
indices),
|
|
BuildC2SplineCurve(
|
|
times,
|
|
continuousRotations.Select(value => value.y).ToArray(),
|
|
indices),
|
|
BuildC2SplineCurve(
|
|
times,
|
|
continuousRotations.Select(value => value.z).ToArray(),
|
|
indices),
|
|
BuildC2SplineCurve(
|
|
times,
|
|
continuousRotations.Select(value => value.w).ToArray(),
|
|
indices),
|
|
usedDenseFallback,
|
|
fallbackReason);
|
|
}
|
|
|
|
private static RotationCurveBuildResult BuildAuditedDenseRotationFallback(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Quaternion> sourceRotations,
|
|
float maximumAngleErrorDegrees,
|
|
string initialFailureReason)
|
|
{
|
|
var continuousSource = MakeQuaternionSequenceContinuous(sourceRotations);
|
|
var allIndices = CreateAllSampleIndices(times.Count);
|
|
var exactDense = BuildVelocityAwareRotationCurves(
|
|
times,
|
|
continuousSource,
|
|
allIndices,
|
|
true,
|
|
initialFailureReason);
|
|
var lastAudit = AuditRotationCurves(
|
|
times,
|
|
continuousSource,
|
|
exactDense.XCurve,
|
|
exactDense.YCurve,
|
|
exactDense.ZCurve,
|
|
exactDense.WCurve,
|
|
maximumAngleErrorDegrees);
|
|
if (lastAudit.Passed)
|
|
{
|
|
return exactDense;
|
|
}
|
|
|
|
var exactFailureReason = lastAudit.FailureReason;
|
|
|
|
var smoothingPassCounts = new[] { 1, 2, 4, 8, 16, 32, 64 };
|
|
foreach (var passCount in smoothingPassCounts)
|
|
{
|
|
var smoothed = SmoothRotationsWithinErrorBudget(
|
|
continuousSource,
|
|
maximumAngleErrorDegrees * 0.65f,
|
|
passCount);
|
|
var candidate = BuildVelocityAwareRotationCurves(
|
|
times,
|
|
smoothed,
|
|
allIndices,
|
|
true,
|
|
initialFailureReason +
|
|
$"; bounded C2 smoothing passes={passCount}");
|
|
lastAudit = AuditRotationCurves(
|
|
times,
|
|
continuousSource,
|
|
candidate.XCurve,
|
|
candidate.YCurve,
|
|
candidate.ZCurve,
|
|
candidate.WCurve,
|
|
maximumAngleErrorDegrees);
|
|
if (lastAudit.Passed)
|
|
{
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
"Dense C2 rotation fallback failed its final angular " +
|
|
$"jerk-bounded audit (exact: {exactFailureReason}; final: " +
|
|
$"{lastAudit.FailureReason})");
|
|
}
|
|
|
|
private static bool TryBuildRefinedRotationCurves(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Quaternion> sourceRotations,
|
|
IReadOnlyList<int> initialIndices,
|
|
float maximumAngleErrorDegrees,
|
|
out RotationCurveBuildResult result)
|
|
{
|
|
const int maximumAddedKeys = 64;
|
|
var continuousSource = MakeQuaternionSequenceContinuous(sourceRotations);
|
|
var indices = new SortedSet<int>(initialIndices);
|
|
var allowedError = maximumAngleErrorDegrees +
|
|
RotationCurveAuditAngleErrorEpsilonDegrees;
|
|
for (var iteration = 0;
|
|
iteration <= maximumAddedKeys && indices.Count < times.Count;
|
|
iteration++)
|
|
{
|
|
var candidate = BuildVelocityAwareRotationCurves(
|
|
times,
|
|
continuousSource,
|
|
indices.ToArray(),
|
|
false,
|
|
string.Empty);
|
|
var evaluated = EvaluateRotationCurves(times, candidate);
|
|
var worstIndex = -1;
|
|
var worstError = 0f;
|
|
for (var index = 0; index < times.Count; index++)
|
|
{
|
|
var error = Quaternion.Angle(
|
|
continuousSource[index],
|
|
evaluated[index]);
|
|
if (error > worstError)
|
|
{
|
|
worstError = error;
|
|
worstIndex = index;
|
|
}
|
|
}
|
|
|
|
if (worstError <= allowedError)
|
|
{
|
|
var audit = AuditRotationCurves(
|
|
times,
|
|
continuousSource,
|
|
candidate.XCurve,
|
|
candidate.YCurve,
|
|
candidate.ZCurve,
|
|
candidate.WCurve,
|
|
maximumAngleErrorDegrees);
|
|
if (audit.Passed)
|
|
{
|
|
result = candidate;
|
|
return true;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (worstIndex < 0 || !indices.Add(worstIndex))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
result = default;
|
|
return false;
|
|
}
|
|
|
|
private static RotationCurveBuildResult EnsureAppliedRotationCurveSafety(
|
|
AnimationClip clip,
|
|
string relativePath,
|
|
SimplifiedCameraCurves simplified,
|
|
CurveSimplificationPreset preset,
|
|
RotationCurveBuildResult requested)
|
|
{
|
|
var applied = ReadAppliedRotationCurves(
|
|
clip,
|
|
relativePath,
|
|
requested.UsedDenseFallback,
|
|
requested.FallbackReason);
|
|
var allowedAngleError = GetCurveSimplificationSettings(preset)
|
|
.QuaternionAngleErrorDegrees;
|
|
var audit = AuditRotationCurves(
|
|
simplified.Times,
|
|
simplified.Rotations,
|
|
applied.XCurve,
|
|
applied.YCurve,
|
|
applied.ZCurve,
|
|
applied.WCurve,
|
|
allowedAngleError);
|
|
if (audit.Passed)
|
|
{
|
|
return applied;
|
|
}
|
|
|
|
var fallbackReason =
|
|
$"post-EnsureQuaternionContinuity clip audit failed: " +
|
|
$"{audit.FailureReason}";
|
|
var fallback = BuildAuditedDenseRotationFallback(
|
|
simplified.Times,
|
|
simplified.Rotations,
|
|
allowedAngleError,
|
|
string.IsNullOrEmpty(requested.FallbackReason)
|
|
? fallbackReason
|
|
: $"{requested.FallbackReason}; {fallbackReason}");
|
|
|
|
// The source quaternions were already sign-normalized before the C2
|
|
// spline was built. If Unity's legacy continuity pass changed the
|
|
// audited curve tangents, overwrite it with the dense C2 contract
|
|
// and validate the exact curves that remain stored in the clip.
|
|
SetRotationCurves(clip, relativePath, fallback);
|
|
applied = ReadAppliedRotationCurves(
|
|
clip,
|
|
relativePath,
|
|
true,
|
|
fallback.FallbackReason);
|
|
var fallbackAudit = AuditRotationCurves(
|
|
simplified.Times,
|
|
simplified.Rotations,
|
|
applied.XCurve,
|
|
applied.YCurve,
|
|
applied.ZCurve,
|
|
applied.WCurve,
|
|
allowedAngleError);
|
|
if (!fallbackAudit.Passed)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The final AnimationClip rotation curves failed the dense " +
|
|
$"C2 safety contract: {fallbackAudit.FailureReason}");
|
|
}
|
|
|
|
simplified.SetRotationCurveDiagnostics(true, applied.FallbackReason);
|
|
return applied;
|
|
}
|
|
|
|
private static void SetRotationCurves(
|
|
AnimationClip clip,
|
|
string relativePath,
|
|
RotationCurveBuildResult curves)
|
|
{
|
|
SetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.x",
|
|
curves.XCurve);
|
|
SetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.y",
|
|
curves.YCurve);
|
|
SetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.z",
|
|
curves.ZCurve);
|
|
SetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.w",
|
|
curves.WCurve);
|
|
}
|
|
|
|
private static RotationCurveBuildResult ReadAppliedRotationCurves(
|
|
AnimationClip clip,
|
|
string relativePath,
|
|
bool usedDenseFallback,
|
|
string fallbackReason)
|
|
{
|
|
return new RotationCurveBuildResult(
|
|
GetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.x"),
|
|
GetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.y"),
|
|
GetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.z"),
|
|
GetCurve(
|
|
clip,
|
|
relativePath,
|
|
typeof(Transform),
|
|
"m_LocalRotation.w"),
|
|
usedDenseFallback,
|
|
fallbackReason);
|
|
}
|
|
|
|
private static Vector3[] SmoothPositionsWithinErrorBudget(
|
|
IReadOnlyList<Vector3> source,
|
|
float maximumDeviationMeters,
|
|
int passCount)
|
|
{
|
|
var original = source.ToArray();
|
|
var current = original.ToArray();
|
|
if (current.Length < 3 || maximumDeviationMeters <= 0f)
|
|
{
|
|
return current;
|
|
}
|
|
|
|
for (var pass = 0; pass < passCount; pass++)
|
|
{
|
|
var filteredValues = current.ToArray();
|
|
var maximumFilteredDeviation = 0f;
|
|
for (var index = 1; index + 1 < current.Length; index++)
|
|
{
|
|
var filtered =
|
|
GetClamped(current, index - 2) * (1f / 16f) +
|
|
GetClamped(current, index - 1) * (4f / 16f) +
|
|
current[index] * (6f / 16f) +
|
|
GetClamped(current, index + 1) * (4f / 16f) +
|
|
GetClamped(current, index + 2) * (1f / 16f);
|
|
var endpointWeight = GetEndpointSmoothingWeight(
|
|
index,
|
|
current.Length);
|
|
filteredValues[index] = Vector3.LerpUnclamped(
|
|
original[index],
|
|
filtered,
|
|
endpointWeight);
|
|
maximumFilteredDeviation = Mathf.Max(
|
|
maximumFilteredDeviation,
|
|
Vector3.Distance(
|
|
original[index],
|
|
filteredValues[index]));
|
|
}
|
|
|
|
var budgetScale = maximumFilteredDeviation <=
|
|
maximumDeviationMeters
|
|
? 1f
|
|
: maximumDeviationMeters / maximumFilteredDeviation;
|
|
var next = original.ToArray();
|
|
for (var index = 1; index + 1 < current.Length; index++)
|
|
{
|
|
next[index] = Vector3.LerpUnclamped(
|
|
original[index],
|
|
filteredValues[index],
|
|
budgetScale);
|
|
}
|
|
next[0] = original[0];
|
|
next[next.Length - 1] = original[original.Length - 1];
|
|
current = next;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private static Quaternion[] SmoothRotationsWithinErrorBudget(
|
|
IReadOnlyList<Quaternion> source,
|
|
float maximumDeviationDegrees,
|
|
int passCount)
|
|
{
|
|
var original = MakeQuaternionSequenceContinuous(source);
|
|
var current = original.ToArray();
|
|
if (current.Length < 3 || maximumDeviationDegrees <= 0f)
|
|
{
|
|
return current;
|
|
}
|
|
|
|
for (var pass = 0; pass < passCount; pass++)
|
|
{
|
|
var filteredValues = current.ToArray();
|
|
var maximumFilteredDeviation = 0f;
|
|
for (var index = 1; index + 1 < current.Length; index++)
|
|
{
|
|
var reference = current[index];
|
|
var weighted = Vector4.zero;
|
|
AccumulateQuaternion(
|
|
ref weighted,
|
|
GetClamped(current, index - 2),
|
|
reference,
|
|
1f);
|
|
AccumulateQuaternion(
|
|
ref weighted,
|
|
GetClamped(current, index - 1),
|
|
reference,
|
|
4f);
|
|
AccumulateQuaternion(
|
|
ref weighted,
|
|
reference,
|
|
reference,
|
|
6f);
|
|
AccumulateQuaternion(
|
|
ref weighted,
|
|
GetClamped(current, index + 1),
|
|
reference,
|
|
4f);
|
|
AccumulateQuaternion(
|
|
ref weighted,
|
|
GetClamped(current, index + 2),
|
|
reference,
|
|
1f);
|
|
var filtered = NormalizeQuaternion(new Quaternion(
|
|
weighted.x,
|
|
weighted.y,
|
|
weighted.z,
|
|
weighted.w));
|
|
if (Quaternion.Dot(original[index], filtered) < 0f)
|
|
{
|
|
filtered = NegateQuaternion(filtered);
|
|
}
|
|
|
|
filteredValues[index] = Quaternion.Slerp(
|
|
original[index],
|
|
filtered,
|
|
GetEndpointSmoothingWeight(index, current.Length));
|
|
maximumFilteredDeviation = Mathf.Max(
|
|
maximumFilteredDeviation,
|
|
Quaternion.Angle(
|
|
original[index],
|
|
filteredValues[index]));
|
|
}
|
|
|
|
var budgetScale = maximumFilteredDeviation <=
|
|
maximumDeviationDegrees
|
|
? 1f
|
|
: maximumDeviationDegrees / maximumFilteredDeviation;
|
|
var next = original.ToArray();
|
|
for (var index = 1; index + 1 < current.Length; index++)
|
|
{
|
|
next[index] = Quaternion.Slerp(
|
|
original[index],
|
|
filteredValues[index],
|
|
budgetScale);
|
|
}
|
|
next[0] = original[0];
|
|
next[next.Length - 1] = original[original.Length - 1];
|
|
current = MakeQuaternionSequenceContinuous(next);
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private static float GetEndpointSmoothingWeight(int index, int count)
|
|
{
|
|
const float fadeFrames = 8f;
|
|
var distanceFromEndpoint = Mathf.Min(index, count - 1 - index);
|
|
var t = Mathf.Clamp01(distanceFromEndpoint / fadeFrames);
|
|
// Quintic smootherstep makes both velocity and acceleration of the
|
|
// smoothing displacement vanish at the preserved shot endpoints.
|
|
return t * t * t * (t * (t * 6f - 15f) + 10f);
|
|
}
|
|
|
|
private static T GetClamped<T>(IReadOnlyList<T> values, int index)
|
|
{
|
|
return values[Mathf.Clamp(index, 0, values.Count - 1)];
|
|
}
|
|
|
|
private static void AccumulateQuaternion(
|
|
ref Vector4 total,
|
|
Quaternion value,
|
|
Quaternion reference,
|
|
float weight)
|
|
{
|
|
if (Quaternion.Dot(reference, value) < 0f)
|
|
{
|
|
value = NegateQuaternion(value);
|
|
}
|
|
|
|
total.x += value.x * weight;
|
|
total.y += value.y * weight;
|
|
total.z += value.z * weight;
|
|
total.w += value.w * weight;
|
|
}
|
|
|
|
private static AnimationCurve BuildC2SplineCurve(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<float> values,
|
|
IReadOnlyList<int> indices)
|
|
{
|
|
if (indices.Count == 1)
|
|
{
|
|
var sampleIndex = indices[0];
|
|
return new AnimationCurve(
|
|
new Keyframe((float)times[sampleIndex], values[sampleIndex], 0f, 0f));
|
|
}
|
|
|
|
var keyCount = indices.Count;
|
|
var keyTimes = new double[keyCount];
|
|
var keyValues = new double[keyCount];
|
|
for (var keyIndex = 0; keyIndex < keyCount; keyIndex++)
|
|
{
|
|
var sampleIndex = indices[keyIndex];
|
|
// Solve against the exact float data that AnimationCurve will
|
|
// store. Solving against the original doubles and rounding only
|
|
// at the end creates measurable acceleration seams at 60 Hz.
|
|
keyTimes[keyIndex] = (float)times[sampleIndex];
|
|
keyValues[keyIndex] = values[sampleIndex];
|
|
}
|
|
|
|
var secondDerivatives = SolveC2SplineSecondDerivatives(
|
|
keyTimes,
|
|
keyValues);
|
|
var tangents = CalculateC2SplineTangents(
|
|
keyTimes,
|
|
keyValues,
|
|
secondDerivatives);
|
|
var keys = new Keyframe[indices.Count];
|
|
for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++)
|
|
{
|
|
keys[keyIndex] = new Keyframe(
|
|
(float)keyTimes[keyIndex],
|
|
(float)keyValues[keyIndex],
|
|
tangents[keyIndex],
|
|
tangents[keyIndex]);
|
|
}
|
|
|
|
return new AnimationCurve(keys);
|
|
}
|
|
|
|
private static double[] SolveC2SplineSecondDerivatives(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<double> values)
|
|
{
|
|
var count = values.Count;
|
|
var secondDerivatives = new double[count];
|
|
if (count <= 2)
|
|
{
|
|
return secondDerivatives;
|
|
}
|
|
|
|
if (count == 3)
|
|
{
|
|
var leftDuration = times[1] - times[0];
|
|
var rightDuration = times[2] - times[1];
|
|
var acceleration = 2.0 *
|
|
((values[2] - values[1]) / rightDuration -
|
|
(values[1] - values[0]) / leftDuration) /
|
|
(leftDuration + rightDuration);
|
|
secondDerivatives[0] = acceleration;
|
|
secondDerivatives[1] = acceleration;
|
|
secondDerivatives[2] = acceleration;
|
|
return secondDerivatives;
|
|
}
|
|
|
|
var interiorCount = count - 2;
|
|
var lower = new double[interiorCount];
|
|
var diagonal = new double[interiorCount];
|
|
var upper = new double[interiorCount];
|
|
var rightHandSide = new double[interiorCount];
|
|
for (var interior = 0; interior < interiorCount; interior++)
|
|
{
|
|
var index = interior + 1;
|
|
var leftDuration = times[index] - times[index - 1];
|
|
var rightDuration = times[index + 1] - times[index];
|
|
lower[interior] = leftDuration;
|
|
diagonal[interior] = 2.0 * (leftDuration + rightDuration);
|
|
upper[interior] = rightDuration;
|
|
rightHandSide[interior] = 6.0 *
|
|
((values[index + 1] - values[index]) / rightDuration -
|
|
(values[index] - values[index - 1]) / leftDuration);
|
|
}
|
|
|
|
// Not-a-knot boundaries make jerk continuous at the first and last
|
|
// interior key. Unlike a natural spline, this does not inject an
|
|
// artificial endpoint acceleration reset into every camera shot.
|
|
var firstDuration = times[1] - times[0];
|
|
var secondDuration = times[2] - times[1];
|
|
lower[0] = 0.0;
|
|
diagonal[0] =
|
|
3.0 * firstDuration +
|
|
2.0 * secondDuration +
|
|
firstDuration * firstDuration / secondDuration;
|
|
upper[0] =
|
|
secondDuration -
|
|
firstDuration * firstDuration / secondDuration;
|
|
|
|
var previousDuration =
|
|
times[count - 2] - times[count - 3];
|
|
var lastDuration =
|
|
times[count - 1] - times[count - 2];
|
|
lower[interiorCount - 1] =
|
|
previousDuration -
|
|
lastDuration * lastDuration / previousDuration;
|
|
diagonal[interiorCount - 1] =
|
|
2.0 * previousDuration +
|
|
3.0 * lastDuration +
|
|
lastDuration * lastDuration / previousDuration;
|
|
upper[interiorCount - 1] = 0.0;
|
|
|
|
for (var index = 1; index < interiorCount; index++)
|
|
{
|
|
var scale = lower[index] / diagonal[index - 1];
|
|
diagonal[index] -= scale * upper[index - 1];
|
|
rightHandSide[index] -= scale * rightHandSide[index - 1];
|
|
}
|
|
|
|
secondDerivatives[count - 2] =
|
|
rightHandSide[interiorCount - 1] /
|
|
diagonal[interiorCount - 1];
|
|
for (var interior = interiorCount - 2; interior >= 0; interior--)
|
|
{
|
|
secondDerivatives[interior + 1] =
|
|
(rightHandSide[interior] -
|
|
upper[interior] * secondDerivatives[interior + 2]) /
|
|
diagonal[interior];
|
|
}
|
|
|
|
secondDerivatives[0] =
|
|
(1.0 + firstDuration / secondDuration) *
|
|
secondDerivatives[1] -
|
|
firstDuration / secondDuration * secondDerivatives[2];
|
|
secondDerivatives[count - 1] =
|
|
(1.0 + lastDuration / previousDuration) *
|
|
secondDerivatives[count - 2] -
|
|
lastDuration / previousDuration *
|
|
secondDerivatives[count - 3];
|
|
|
|
return secondDerivatives;
|
|
}
|
|
|
|
private static float[] CalculateC2SplineTangents(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<double> values,
|
|
IReadOnlyList<double> secondDerivatives)
|
|
{
|
|
var count = values.Count;
|
|
var tangents = new float[count];
|
|
for (var segment = 0; segment < count - 1; segment++)
|
|
{
|
|
var duration = times[segment + 1] - times[segment];
|
|
var secant = (values[segment + 1] - values[segment]) / duration;
|
|
var leftTangent = secant -
|
|
duration *
|
|
(2.0 * secondDerivatives[segment] +
|
|
secondDerivatives[segment + 1]) /
|
|
6.0;
|
|
var rightTangent = secant +
|
|
duration *
|
|
(secondDerivatives[segment] +
|
|
2.0 * secondDerivatives[segment + 1]) /
|
|
6.0;
|
|
if (segment == 0)
|
|
{
|
|
tangents[0] = (float)leftTangent;
|
|
}
|
|
|
|
tangents[segment + 1] = (float)rightTangent;
|
|
}
|
|
|
|
return tangents;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
internal static List<double> BuildUniformCurveAuditTimes(
|
|
IReadOnlyList<double> sourceTimes,
|
|
int samplesPerSecond)
|
|
{
|
|
if (sourceTimes == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(sourceTimes));
|
|
}
|
|
|
|
if (sourceTimes.Count == 0)
|
|
{
|
|
throw new ArgumentException(
|
|
"Curve audit requires at least one source time.",
|
|
nameof(sourceTimes));
|
|
}
|
|
|
|
if (samplesPerSecond <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(samplesPerSecond));
|
|
}
|
|
|
|
var start = sourceTimes[0];
|
|
var end = sourceTimes[sourceTimes.Count - 1];
|
|
var duration = end - start;
|
|
if (duration <= PositionCurveAuditTimeToleranceSeconds)
|
|
{
|
|
return new List<double> { (double)(float)start };
|
|
}
|
|
|
|
var intervalCount = Math.Max(
|
|
1,
|
|
(int)Math.Ceiling(duration * samplesPerSecond));
|
|
var result = new List<double>(intervalCount + 1);
|
|
for (var index = 0; index <= intervalCount; index++)
|
|
{
|
|
var time = (double)(float)(
|
|
start + duration * index / intervalCount);
|
|
if (result.Count == 0 || time > result[result.Count - 1])
|
|
{
|
|
result.Add(time);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
internal static bool TryDescribeC2CurveFailure(
|
|
AnimationCurve curve,
|
|
string label,
|
|
out string failureReason)
|
|
{
|
|
if (curve == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(curve));
|
|
}
|
|
|
|
var keys = curve.keys;
|
|
for (var index = 0; index < keys.Length; index++)
|
|
{
|
|
if (!IsFinite(keys[index].inTangent) ||
|
|
!IsFinite(keys[index].outTangent))
|
|
{
|
|
failureReason =
|
|
$"{label} key {index} has a non-finite tangent";
|
|
return true;
|
|
}
|
|
|
|
if (Mathf.Abs(keys[index].inTangent - keys[index].outTangent) >
|
|
CurveC1AbsoluteTolerance)
|
|
{
|
|
failureReason =
|
|
$"{label} key {index} is not C1: in/out velocity " +
|
|
$"{keys[index].inTangent:F6}/" +
|
|
$"{keys[index].outTangent:F6}";
|
|
return true;
|
|
}
|
|
}
|
|
|
|
for (var index = 1; index + 1 < keys.Length; index++)
|
|
{
|
|
var leftAcceleration = CalculateHermiteSecondDerivativeAtRight(
|
|
keys[index - 1],
|
|
keys[index]);
|
|
var rightAcceleration = CalculateHermiteSecondDerivativeAtLeft(
|
|
keys[index],
|
|
keys[index + 1]);
|
|
var scale = Mathf.Max(
|
|
Mathf.Abs(leftAcceleration),
|
|
Mathf.Abs(rightAcceleration));
|
|
var allowed = Mathf.Max(
|
|
CurveC2AbsoluteTolerance,
|
|
scale * CurveC2RelativeTolerance);
|
|
if (!IsFinite(leftAcceleration) ||
|
|
!IsFinite(rightAcceleration) ||
|
|
Mathf.Abs(leftAcceleration - rightAcceleration) > allowed)
|
|
{
|
|
failureReason =
|
|
$"{label} key {index} is not C2: left/right " +
|
|
$"acceleration {leftAcceleration:F4}/" +
|
|
$"{rightAcceleration:F4}; allowed delta {allowed:F4}";
|
|
return true;
|
|
}
|
|
}
|
|
|
|
failureReason = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
private static float CalculateHermiteSecondDerivativeAtLeft(
|
|
Keyframe left,
|
|
Keyframe right)
|
|
{
|
|
var duration = right.time - left.time;
|
|
var displacement = right.value - left.value;
|
|
return 6f * displacement / (duration * duration) -
|
|
(4f * left.outTangent + 2f * right.inTangent) / duration;
|
|
}
|
|
|
|
private static float CalculateHermiteSecondDerivativeAtRight(
|
|
Keyframe left,
|
|
Keyframe right)
|
|
{
|
|
var duration = right.time - left.time;
|
|
var displacement = right.value - left.value;
|
|
return -6f * displacement / (duration * duration) +
|
|
(2f * left.outTangent + 4f * right.inTangent) / duration;
|
|
}
|
|
|
|
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 Quaternion[] EvaluateRotationCurves(
|
|
IReadOnlyList<double> times,
|
|
RotationCurveBuildResult curves)
|
|
{
|
|
if (curves == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(curves));
|
|
}
|
|
|
|
if (!TryEvaluateRotationCurves(
|
|
times,
|
|
curves.XCurve,
|
|
curves.YCurve,
|
|
curves.ZCurve,
|
|
curves.WCurve,
|
|
out var result,
|
|
out var failureReason))
|
|
{
|
|
throw new InvalidOperationException(failureReason);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static bool TryEvaluateRotationCurves(
|
|
IReadOnlyList<double> times,
|
|
AnimationCurve xCurve,
|
|
AnimationCurve yCurve,
|
|
AnimationCurve zCurve,
|
|
AnimationCurve wCurve,
|
|
out Quaternion[] rotations,
|
|
out string failureReason)
|
|
{
|
|
rotations = new Quaternion[times.Count];
|
|
for (var index = 0; index < times.Count; index++)
|
|
{
|
|
var time = (float)times[index];
|
|
var rotation = new Quaternion(
|
|
xCurve.Evaluate(time),
|
|
yCurve.Evaluate(time),
|
|
zCurve.Evaluate(time),
|
|
wCurve.Evaluate(time));
|
|
if (!IsFinite(rotation.x) ||
|
|
!IsFinite(rotation.y) ||
|
|
!IsFinite(rotation.z) ||
|
|
!IsFinite(rotation.w))
|
|
{
|
|
failureReason =
|
|
$"rotation curve evaluated to a non-finite quaternion " +
|
|
$"at {times[index]:F4} s";
|
|
return false;
|
|
}
|
|
|
|
var squaredMagnitude =
|
|
rotation.x * rotation.x +
|
|
rotation.y * rotation.y +
|
|
rotation.z * rotation.z +
|
|
rotation.w * rotation.w;
|
|
if (squaredMagnitude < 0.000000000001f)
|
|
{
|
|
failureReason =
|
|
$"rotation curve evaluated to a zero quaternion at " +
|
|
$"{times[index]:F4} s";
|
|
return false;
|
|
}
|
|
|
|
rotation = NormalizeQuaternion(rotation);
|
|
if (index > 0 && Quaternion.Dot(rotations[index - 1], rotation) < 0f)
|
|
{
|
|
rotation = NegateQuaternion(rotation);
|
|
}
|
|
|
|
rotations[index] = rotation;
|
|
}
|
|
|
|
failureReason = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static PositionMotionSeries MeasureAngularMotion(
|
|
IReadOnlyList<double> times,
|
|
IReadOnlyList<Quaternion> rotations)
|
|
{
|
|
if (times.Count < 2)
|
|
{
|
|
return PositionMotionSeries.Empty;
|
|
}
|
|
|
|
var continuousRotations = MakeQuaternionSequenceContinuous(rotations);
|
|
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] = CalculateAngularVelocityDegrees(
|
|
continuousRotations[index],
|
|
continuousRotations[index + 1],
|
|
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));
|
|
}
|
|
|
|
private static Vector3 CalculateAngularVelocityDegrees(
|
|
Quaternion start,
|
|
Quaternion end,
|
|
double duration)
|
|
{
|
|
if (duration <= double.Epsilon)
|
|
{
|
|
return Vector3.zero;
|
|
}
|
|
|
|
var delta = NormalizeQuaternion(end * Quaternion.Inverse(start));
|
|
if (delta.w < 0f)
|
|
{
|
|
delta = NegateQuaternion(delta);
|
|
}
|
|
|
|
var sineHalfAngle = Mathf.Sqrt(
|
|
delta.x * delta.x +
|
|
delta.y * delta.y +
|
|
delta.z * delta.z);
|
|
if (sineHalfAngle < 0.0000001f)
|
|
{
|
|
return Vector3.zero;
|
|
}
|
|
|
|
var halfAngle = Mathf.Atan2(
|
|
sineHalfAngle,
|
|
Mathf.Clamp(delta.w, -1f, 1f));
|
|
var degreesPerSecond =
|
|
2f * halfAngle * Mathf.Rad2Deg / (float)duration;
|
|
return new Vector3(delta.x, delta.y, delta.z) /
|
|
sineHalfAngle * degreesPerSecond;
|
|
}
|
|
|
|
private static bool TryDescribeTimeWindowPositionMotionRegression(
|
|
string sampleLabel,
|
|
PositionMotionSeries reference,
|
|
PositionMotionSeries candidate,
|
|
out string failureReason)
|
|
{
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
sampleLabel,
|
|
"speed",
|
|
"m/s",
|
|
reference.Speed,
|
|
candidate.Speed,
|
|
CurveAuditLocalWindowSeconds,
|
|
PositionCurveAuditSpeedRatio,
|
|
PositionCurveAuditSpeedDeltaMetersPerSecond,
|
|
PositionCurveAuditSpeedFloorMetersPerSecond,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
sampleLabel,
|
|
"acceleration",
|
|
"m/s^2",
|
|
reference.Acceleration,
|
|
candidate.Acceleration,
|
|
CurveAuditLocalWindowSeconds,
|
|
PositionCurveAuditAccelerationRatio,
|
|
PositionCurveAuditAccelerationDeltaMetersPerSecondSquared,
|
|
PositionCurveAuditAccelerationFloorMetersPerSecondSquared,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return TryDescribeTimeWindowMetricRegression(
|
|
sampleLabel,
|
|
"jerk",
|
|
"m/s^3",
|
|
reference.Jerk,
|
|
candidate.Jerk,
|
|
CurveAuditLocalWindowSeconds,
|
|
PositionCurveAuditJerkRatio,
|
|
PositionCurveAuditJerkDeltaMetersPerSecondCubed,
|
|
PositionCurveAuditJerkFloorMetersPerSecondCubed,
|
|
out failureReason);
|
|
}
|
|
|
|
private static bool TryDescribePositionSamplingConvergence(
|
|
PositionMotionSeries primary,
|
|
PositionMotionSeries convergence,
|
|
out string failureReason)
|
|
{
|
|
var window = 1.0 / 60.0;
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
"240/480 Hz convergence",
|
|
"speed",
|
|
"m/s",
|
|
primary.Speed,
|
|
convergence.Speed,
|
|
window,
|
|
CurveAuditConvergenceRatio,
|
|
0f,
|
|
PositionCurveAuditConvergenceSpeedFloor,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
"240/480 Hz convergence",
|
|
"acceleration",
|
|
"m/s^2",
|
|
primary.Acceleration,
|
|
convergence.Acceleration,
|
|
window,
|
|
CurveAuditConvergenceRatio,
|
|
0f,
|
|
PositionCurveAuditConvergenceAccelerationFloor,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return TryDescribeTimeWindowMetricRegression(
|
|
"240/480 Hz convergence",
|
|
"jerk",
|
|
"m/s^3",
|
|
primary.Jerk,
|
|
convergence.Jerk,
|
|
window,
|
|
CurveAuditConvergenceRatio,
|
|
0f,
|
|
PositionCurveAuditConvergenceJerkFloor,
|
|
out failureReason);
|
|
}
|
|
|
|
private static bool TryDescribeTimeWindowAngularMotionRegression(
|
|
string sampleLabel,
|
|
PositionMotionSeries reference,
|
|
PositionMotionSeries candidate,
|
|
out string failureReason)
|
|
{
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
sampleLabel,
|
|
"angular speed",
|
|
"deg/s",
|
|
reference.Speed,
|
|
candidate.Speed,
|
|
CurveAuditLocalWindowSeconds,
|
|
RotationCurveAuditSpeedRatio,
|
|
RotationCurveAuditSpeedDeltaDegreesPerSecond,
|
|
RotationCurveAuditSpeedFloorDegreesPerSecond,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
sampleLabel,
|
|
"angular acceleration",
|
|
"deg/s^2",
|
|
reference.Acceleration,
|
|
candidate.Acceleration,
|
|
CurveAuditLocalWindowSeconds,
|
|
RotationCurveAuditAccelerationRatio,
|
|
RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared,
|
|
RotationCurveAuditAccelerationFloorDegreesPerSecondSquared,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return TryDescribeTimeWindowMetricRegression(
|
|
sampleLabel,
|
|
"angular jerk",
|
|
"deg/s^3",
|
|
reference.Jerk,
|
|
candidate.Jerk,
|
|
CurveAuditLocalWindowSeconds,
|
|
RotationCurveAuditJerkRatio,
|
|
RotationCurveAuditJerkDeltaDegreesPerSecondCubed,
|
|
RotationCurveAuditJerkFloorDegreesPerSecondCubed,
|
|
out failureReason);
|
|
}
|
|
|
|
private static bool TryDescribeAngularSamplingConvergence(
|
|
PositionMotionSeries primary,
|
|
PositionMotionSeries convergence,
|
|
out string failureReason)
|
|
{
|
|
var window = 1.0 / 60.0;
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
"240/480 Hz convergence",
|
|
"angular speed",
|
|
"deg/s",
|
|
primary.Speed,
|
|
convergence.Speed,
|
|
window,
|
|
CurveAuditConvergenceRatio,
|
|
0f,
|
|
RotationCurveAuditConvergenceSpeedFloor,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (TryDescribeTimeWindowMetricRegression(
|
|
"240/480 Hz convergence",
|
|
"angular acceleration",
|
|
"deg/s^2",
|
|
primary.Acceleration,
|
|
convergence.Acceleration,
|
|
window,
|
|
CurveAuditConvergenceRatio,
|
|
0f,
|
|
RotationCurveAuditConvergenceAccelerationFloor,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return TryDescribeTimeWindowMetricRegression(
|
|
"240/480 Hz convergence",
|
|
"angular jerk",
|
|
"deg/s^3",
|
|
primary.Jerk,
|
|
convergence.Jerk,
|
|
window,
|
|
CurveAuditConvergenceRatio,
|
|
0f,
|
|
RotationCurveAuditConvergenceJerkFloor,
|
|
out failureReason);
|
|
}
|
|
|
|
private static bool TryDescribeTimeWindowMetricRegression(
|
|
string sampleLabel,
|
|
string metricLabel,
|
|
string unit,
|
|
PositionMetricSeries reference,
|
|
PositionMetricSeries candidate,
|
|
double windowSeconds,
|
|
float ratio,
|
|
float absoluteDelta,
|
|
float absoluteFloor,
|
|
out string failureReason)
|
|
{
|
|
if (candidate.Values.Length == 0)
|
|
{
|
|
failureReason = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
var firstReference = 0;
|
|
var pastLastReference = 0;
|
|
for (var candidateIndex = 0;
|
|
candidateIndex < candidate.Values.Length;
|
|
candidateIndex++)
|
|
{
|
|
var time = candidate.Times[candidateIndex];
|
|
while (firstReference < reference.Times.Length &&
|
|
reference.Times[firstReference] < time - windowSeconds)
|
|
{
|
|
firstReference++;
|
|
}
|
|
|
|
if (pastLastReference < firstReference)
|
|
{
|
|
pastLastReference = firstReference;
|
|
}
|
|
|
|
while (pastLastReference < reference.Times.Length &&
|
|
reference.Times[pastLastReference] <= time + windowSeconds)
|
|
{
|
|
pastLastReference++;
|
|
}
|
|
|
|
var referenceMaximum = 0f;
|
|
for (var referenceIndex = firstReference;
|
|
referenceIndex < pastLastReference;
|
|
referenceIndex++)
|
|
{
|
|
referenceMaximum = Mathf.Max(
|
|
referenceMaximum,
|
|
reference.Values[referenceIndex]);
|
|
}
|
|
|
|
var allowed = Mathf.Max(
|
|
absoluteFloor,
|
|
Mathf.Max(
|
|
referenceMaximum * ratio,
|
|
referenceMaximum + absoluteDelta));
|
|
var candidateValue = candidate.Values[candidateIndex];
|
|
if (IsFinite(candidateValue) && candidateValue <= allowed)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
failureReason =
|
|
$"{sampleLabel} {metricLabel} regressed near {time:F4} s " +
|
|
$"from local raw {referenceMaximum:F3} to " +
|
|
$"{candidateValue:F3} {unit}; allowed {allowed:F3} {unit}";
|
|
return true;
|
|
}
|
|
|
|
failureReason = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryDescribeAlignedAngularMotionRegression(
|
|
string sampleLabel,
|
|
PositionMotionSeries reference,
|
|
PositionMotionSeries candidate,
|
|
out string failureReason)
|
|
{
|
|
if (TryDescribeAlignedMetricRegression(
|
|
sampleLabel,
|
|
"angular speed",
|
|
"deg/s",
|
|
reference.Speed,
|
|
candidate.Speed,
|
|
RotationCurveAuditSpeedRatio,
|
|
RotationCurveAuditSpeedDeltaDegreesPerSecond,
|
|
RotationCurveAuditSpeedFloorDegreesPerSecond,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (TryDescribeAlignedMetricRegression(
|
|
sampleLabel,
|
|
"angular acceleration",
|
|
"deg/s^2",
|
|
reference.Acceleration,
|
|
candidate.Acceleration,
|
|
RotationCurveAuditAccelerationRatio,
|
|
RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared,
|
|
RotationCurveAuditAccelerationFloorDegreesPerSecondSquared,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return TryDescribeAlignedMetricRegression(
|
|
sampleLabel,
|
|
"angular jerk",
|
|
"deg/s^3",
|
|
reference.Jerk,
|
|
candidate.Jerk,
|
|
RotationCurveAuditJerkRatio,
|
|
RotationCurveAuditJerkDeltaDegreesPerSecondCubed,
|
|
RotationCurveAuditJerkFloorDegreesPerSecondCubed,
|
|
out failureReason);
|
|
}
|
|
|
|
private static bool TryDescribeAngularMotionRegression(
|
|
string sampleLabel,
|
|
PositionMotionMetrics reference,
|
|
PositionMotionMetrics candidate,
|
|
out string failureReason)
|
|
{
|
|
if (TryDescribeMetricRegression(
|
|
sampleLabel,
|
|
"angular speed",
|
|
"deg/s",
|
|
reference.MaximumSpeed,
|
|
candidate.MaximumSpeed,
|
|
RotationCurveAuditSpeedRatio,
|
|
RotationCurveAuditSpeedDeltaDegreesPerSecond,
|
|
RotationCurveAuditSpeedFloorDegreesPerSecond,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (TryDescribeMetricRegression(
|
|
sampleLabel,
|
|
"angular acceleration",
|
|
"deg/s^2",
|
|
reference.MaximumAcceleration,
|
|
candidate.MaximumAcceleration,
|
|
RotationCurveAuditAccelerationRatio,
|
|
RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared,
|
|
RotationCurveAuditAccelerationFloorDegreesPerSecondSquared,
|
|
out failureReason))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return TryDescribeMetricRegression(
|
|
sampleLabel,
|
|
"angular jerk",
|
|
"deg/s^3",
|
|
reference.MaximumJerk,
|
|
candidate.MaximumJerk,
|
|
RotationCurveAuditJerkRatio,
|
|
RotationCurveAuditJerkDeltaDegreesPerSecondCubed,
|
|
RotationCurveAuditJerkFloorDegreesPerSecondCubed,
|
|
out failureReason);
|
|
}
|
|
|
|
private static bool TryDescribeAngularMotionRetentionRegression(
|
|
string sampleLabel,
|
|
IReadOnlyList<Quaternion> reference,
|
|
IReadOnlyList<Quaternion> candidate,
|
|
out string failureReason)
|
|
{
|
|
var referencePathLength = MeasureMeaningfulAngularPathLength(reference);
|
|
if (referencePathLength < RotationCurveAuditMotionRetentionMinimumDegrees)
|
|
{
|
|
failureReason = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
var candidatePathLength = MeasureMeaningfulAngularPathLength(candidate);
|
|
var requiredPathLength = Math.Max(
|
|
0f,
|
|
referencePathLength * RotationCurveAuditMotionRetentionRatio -
|
|
RotationCurveAuditMotionNoiseDeadbandDegrees);
|
|
if (IsFinite(candidatePathLength) &&
|
|
candidatePathLength >= requiredPathLength)
|
|
{
|
|
failureReason = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
failureReason =
|
|
$"{sampleLabel} meaningful angular motion retention fell from " +
|
|
$"{referencePathLength:F3} deg to {candidatePathLength:F3} deg; " +
|
|
$"required {requiredPathLength:F3} deg after the " +
|
|
$"{RotationCurveAuditMotionNoiseDeadbandDegrees:F3} deg noise " +
|
|
"deadband";
|
|
return true;
|
|
}
|
|
|
|
private static float MeasureMeaningfulAngularPathLength(
|
|
IReadOnlyList<Quaternion> rotations)
|
|
{
|
|
if (rotations.Count < 2)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
var continuousRotations = MakeQuaternionSequenceContinuous(rotations);
|
|
var pathLength = 0f;
|
|
var anchor = continuousRotations[0];
|
|
for (var index = 1; index < continuousRotations.Length; index++)
|
|
{
|
|
var angle = Quaternion.Angle(anchor, continuousRotations[index]);
|
|
if (angle < RotationCurveAuditMotionNoiseDeadbandDegrees)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
pathLength += angle;
|
|
anchor = continuousRotations[index];
|
|
}
|
|
|
|
return pathLength;
|
|
}
|
|
|
|
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 AnimationCurve GetCurve(
|
|
AnimationClip clip,
|
|
string relativePath,
|
|
Type componentType,
|
|
string propertyName)
|
|
{
|
|
return AnimationUtility.GetEditorCurve(
|
|
clip,
|
|
EditorCurveBinding.FloatCurve(
|
|
relativePath,
|
|
componentType,
|
|
propertyName));
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
internal static GameObject InstantiateInactivePreviewCloneForEditor(
|
|
GameObject sourceObject,
|
|
Transform destinationParent)
|
|
{
|
|
if (sourceObject == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(sourceObject));
|
|
}
|
|
|
|
var sourceScene = sourceObject.scene;
|
|
if (!sourceScene.IsValid() || !sourceScene.isLoaded)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The source Timeline object must belong to a loaded scene.");
|
|
}
|
|
|
|
var stagingObject = new GameObject(
|
|
"__CWAI_InactivePreviewCloneStaging");
|
|
stagingObject.SetActive(false);
|
|
try
|
|
{
|
|
if (stagingObject.scene != sourceScene)
|
|
{
|
|
SceneManager.MoveGameObjectToScene(stagingObject, sourceScene);
|
|
}
|
|
|
|
var clone = UnityEngine.Object.Instantiate(
|
|
sourceObject,
|
|
stagingObject.transform,
|
|
true);
|
|
clone.SetActive(false);
|
|
clone.transform.SetParent(destinationParent, true);
|
|
return clone;
|
|
}
|
|
finally
|
|
{
|
|
UnityEngine.Object.DestroyImmediate(stagingObject);
|
|
}
|
|
}
|
|
|
|
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 directlyBoundBrains = GetBoundCinemachineBrains(
|
|
director,
|
|
timeline);
|
|
if (directlyBoundBrains.Length > 1)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The Timeline has Cinemachine Tracks bound to multiple " +
|
|
"CinemachineBrain components. Bind every camera track to one Brain.");
|
|
}
|
|
|
|
if (directlyBoundBrains.Length == 1)
|
|
{
|
|
return ValidateExplicitCinemachineBrain(
|
|
directlyBoundBrains[0],
|
|
scene);
|
|
}
|
|
|
|
var descendantBoundBrains = director.gameObject
|
|
.GetComponentsInChildren<PlayableDirector>(true)
|
|
.Where(descendant =>
|
|
descendant != null &&
|
|
descendant != director &&
|
|
descendant.gameObject.scene == scene &&
|
|
descendant.playableAsset is TimelineAsset)
|
|
.SelectMany(descendant => GetBoundCinemachineBrains(
|
|
descendant,
|
|
(TimelineAsset)descendant.playableAsset))
|
|
.Distinct()
|
|
.ToArray();
|
|
if (descendantBoundBrains.Length > 1)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Descendant PlayableDirectors under the selected Timeline object " +
|
|
"have Cinemachine Tracks bound to multiple CinemachineBrain " +
|
|
"components. Bind them to one Brain or bind the selected Timeline " +
|
|
"directly.");
|
|
}
|
|
|
|
if (descendantBoundBrains.Length == 1)
|
|
{
|
|
return ValidateExplicitCinemachineBrain(
|
|
descendantBoundBrains[0],
|
|
scene);
|
|
}
|
|
|
|
var usableBrains = sceneBrains
|
|
.Where(brain => IsUsableCinemachineBrain(brain, scene))
|
|
.Distinct()
|
|
.ToArray();
|
|
var mainCameraBrains = usableBrains
|
|
.Where(brain =>
|
|
{
|
|
var camera = brain.OutputCamera;
|
|
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 CinemachineBrain[] GetBoundCinemachineBrains(
|
|
PlayableDirector director,
|
|
TimelineAsset timeline)
|
|
{
|
|
return timeline.GetOutputTracks()
|
|
.OfType<CinemachineTrack>()
|
|
.Select(track =>
|
|
director.GetGenericBinding(track) as CinemachineBrain)
|
|
.Where(brain => brain != null)
|
|
.Distinct()
|
|
.ToArray();
|
|
}
|
|
|
|
private static CinemachineBrain ValidateExplicitCinemachineBrain(
|
|
CinemachineBrain brain,
|
|
Scene scene)
|
|
{
|
|
if (brain.gameObject.scene != scene)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The CinemachineBrain explicitly bound to a Timeline must belong " +
|
|
"to the same scene as the selected Timeline director.");
|
|
}
|
|
|
|
var camera = brain.OutputCamera;
|
|
if (camera == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The CinemachineBrain explicitly bound to a Timeline must have an " +
|
|
"output Camera component on its controlled GameObject.");
|
|
}
|
|
|
|
if (camera.gameObject.scene != scene)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The CinemachineBrain output Camera explicitly bound to a Timeline " +
|
|
"must belong to the same scene as the selected Timeline director.");
|
|
}
|
|
|
|
if (!brain.isActiveAndEnabled || !camera.isActiveAndEnabled)
|
|
{
|
|
Debug.LogWarning(
|
|
"The CinemachineBrain explicitly bound to a Timeline or its " +
|
|
"Camera is disabled. Camera AI will use the configured Brain " +
|
|
"without enabling either component automatically.",
|
|
brain);
|
|
}
|
|
|
|
return brain;
|
|
}
|
|
|
|
internal static string GetPreviewOutputStatusForEditor(
|
|
CinemachineBrain brain)
|
|
{
|
|
if (brain == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(brain));
|
|
}
|
|
|
|
var camera = brain.OutputCamera;
|
|
var cameraName = camera != null
|
|
? camera.gameObject.name
|
|
: brain.gameObject.name;
|
|
if (camera != null &&
|
|
brain.isActiveAndEnabled &&
|
|
camera.isActiveAndEnabled)
|
|
{
|
|
return $"Preview Output: configured '{cameraName}' CinemachineBrain " +
|
|
"and Camera are active.";
|
|
}
|
|
|
|
return $"Preview Output: configured '{cameraName}' CinemachineBrain or " +
|
|
"Camera is disabled; enable it to see AI camera playback.";
|
|
}
|
|
|
|
private static bool IsUsableCinemachineBrain(
|
|
CinemachineBrain brain,
|
|
Scene scene)
|
|
{
|
|
if (brain == null ||
|
|
brain.gameObject.scene != scene ||
|
|
!brain.isActiveAndEnabled)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var camera = brain.OutputCamera;
|
|
return camera != null &&
|
|
camera.gameObject.scene == scene &&
|
|
camera.isActiveAndEnabled;
|
|
}
|
|
|
|
internal static void SuppressAuthoredCinemachineOutputsForEditor(
|
|
PlayableDirector selectedDirector,
|
|
bool recordUndo = true)
|
|
{
|
|
if (selectedDirector == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(selectedDirector));
|
|
}
|
|
|
|
var directorObject = selectedDirector.gameObject;
|
|
var descendantDirectors = directorObject
|
|
.GetComponentsInChildren<PlayableDirector>(true)
|
|
.Where(descendant =>
|
|
descendant != null &&
|
|
descendant != selectedDirector &&
|
|
descendant.playableAsset is TimelineAsset)
|
|
.ToArray();
|
|
foreach (var descendantDirector in descendantDirectors)
|
|
{
|
|
var descendantTimeline =
|
|
(TimelineAsset)descendantDirector.playableAsset;
|
|
var cinemachineTracks = descendantTimeline.GetOutputTracks()
|
|
.OfType<CinemachineTrack>()
|
|
.ToArray();
|
|
if (cinemachineTracks.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (recordUndo)
|
|
{
|
|
Undo.RecordObject(
|
|
descendantDirector,
|
|
"Disconnect Authored Cameras From AI Preview");
|
|
}
|
|
|
|
foreach (var cinemachineTrack in cinemachineTracks)
|
|
{
|
|
descendantDirector.ClearGenericBinding(cinemachineTrack);
|
|
}
|
|
|
|
RebuildDirectorGraphPreservingPlayback(descendantDirector);
|
|
|
|
EditorUtility.SetDirty(descendantDirector);
|
|
}
|
|
|
|
foreach (var authoredCamera in directorObject
|
|
.GetComponentsInChildren<CinemachineVirtualCameraBase>(true))
|
|
{
|
|
if (authoredCamera == null || !authoredCamera.enabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (recordUndo)
|
|
{
|
|
Undo.RecordObject(
|
|
authoredCamera,
|
|
"Disable Authored Cameras In AI Output");
|
|
}
|
|
|
|
authoredCamera.enabled = false;
|
|
EditorUtility.SetDirty(authoredCamera);
|
|
}
|
|
}
|
|
|
|
internal static AuthoredCinemachineStateSnapshot
|
|
CaptureAuthoredCinemachineState(PlayableDirector selectedDirector)
|
|
{
|
|
if (selectedDirector == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(selectedDirector));
|
|
}
|
|
|
|
var directorObject = selectedDirector.gameObject;
|
|
var directorStates = directorObject
|
|
.GetComponentsInChildren<PlayableDirector>(true)
|
|
.Where(descendant =>
|
|
descendant != null &&
|
|
descendant != selectedDirector &&
|
|
descendant.playableAsset is TimelineAsset)
|
|
.Select(descendant =>
|
|
{
|
|
var timeline = (TimelineAsset)descendant.playableAsset;
|
|
var tracks = timeline.GetOutputTracks()
|
|
.OfType<CinemachineTrack>()
|
|
.ToArray();
|
|
return new AuthoredCinemachineDirectorState(
|
|
descendant,
|
|
tracks,
|
|
tracks.Select(descendant.GetGenericBinding).ToArray());
|
|
})
|
|
.Where(state => state.Tracks.Length > 0)
|
|
.ToArray();
|
|
var cameraStates = directorObject
|
|
.GetComponentsInChildren<CinemachineVirtualCameraBase>(true)
|
|
.Where(camera => camera != null)
|
|
.Select(camera => new AuthoredVirtualCameraState(
|
|
camera,
|
|
camera.enabled))
|
|
.ToArray();
|
|
return new AuthoredCinemachineStateSnapshot(
|
|
directorStates,
|
|
cameraStates);
|
|
}
|
|
|
|
internal static void RestoreAuthoredCinemachineState(
|
|
AuthoredCinemachineStateSnapshot snapshot)
|
|
{
|
|
if (snapshot == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var cameraState in snapshot.CameraStates)
|
|
{
|
|
if (cameraState.Camera == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
cameraState.Camera.enabled = cameraState.Enabled;
|
|
EditorUtility.SetDirty(cameraState.Camera);
|
|
}
|
|
|
|
foreach (var directorState in snapshot.DirectorStates)
|
|
{
|
|
var director = directorState.Director;
|
|
if (director == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (var index = 0; index < directorState.Tracks.Length; index++)
|
|
{
|
|
var track = directorState.Tracks[index];
|
|
if (track == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var binding = directorState.Bindings[index];
|
|
if (binding != null)
|
|
{
|
|
director.SetGenericBinding(track, binding);
|
|
}
|
|
else
|
|
{
|
|
director.ClearGenericBinding(track);
|
|
}
|
|
}
|
|
|
|
RebuildDirectorGraphPreservingPlayback(director);
|
|
|
|
EditorUtility.SetDirty(director);
|
|
}
|
|
}
|
|
|
|
private static void RebuildDirectorGraphPreservingPlayback(
|
|
PlayableDirector director)
|
|
{
|
|
if (!director.playableGraph.IsValid())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var time = director.time;
|
|
var wasPlaying = director.state == PlayState.Playing;
|
|
director.Stop();
|
|
director.RebuildGraph();
|
|
director.time = time;
|
|
if (wasPlaying)
|
|
{
|
|
director.Play();
|
|
director.time = time;
|
|
}
|
|
else
|
|
{
|
|
director.Evaluate();
|
|
director.time = time;
|
|
}
|
|
}
|
|
|
|
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 sealed class RotationCurveBuildResult
|
|
{
|
|
internal RotationCurveBuildResult(
|
|
AnimationCurve xCurve,
|
|
AnimationCurve yCurve,
|
|
AnimationCurve zCurve,
|
|
AnimationCurve wCurve,
|
|
bool usedDenseFallback,
|
|
string fallbackReason)
|
|
{
|
|
XCurve = xCurve ?? throw new ArgumentNullException(nameof(xCurve));
|
|
YCurve = yCurve ?? throw new ArgumentNullException(nameof(yCurve));
|
|
ZCurve = zCurve ?? throw new ArgumentNullException(nameof(zCurve));
|
|
WCurve = wCurve ?? throw new ArgumentNullException(nameof(wCurve));
|
|
UsedDenseFallback = usedDenseFallback;
|
|
FallbackReason = fallbackReason ?? string.Empty;
|
|
}
|
|
|
|
internal AnimationCurve XCurve { get; }
|
|
internal AnimationCurve YCurve { get; }
|
|
internal AnimationCurve ZCurve { get; }
|
|
internal AnimationCurve WCurve { get; }
|
|
internal bool UsedDenseFallback { 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);
|
|
}
|
|
}
|
|
|
|
internal readonly struct RotationCurveAuditResult
|
|
{
|
|
private RotationCurveAuditResult(bool passed, string failureReason)
|
|
{
|
|
Passed = passed;
|
|
FailureReason = failureReason ?? string.Empty;
|
|
}
|
|
|
|
internal bool Passed { get; }
|
|
internal string FailureReason { get; }
|
|
|
|
internal static RotationCurveAuditResult Pass()
|
|
{
|
|
return new RotationCurveAuditResult(true, string.Empty);
|
|
}
|
|
|
|
internal static RotationCurveAuditResult Fail(string failureReason)
|
|
{
|
|
return new RotationCurveAuditResult(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 bool RotationUsedDenseFallback { get; private set; }
|
|
internal string RotationFallbackReason { get; private set; } =
|
|
string.Empty;
|
|
|
|
internal void SetPositionCurveDiagnostics(
|
|
bool usedExactFallback,
|
|
string fallbackReason)
|
|
{
|
|
PositionUsedExactFallback = usedExactFallback;
|
|
PositionFallbackReason = fallbackReason ?? string.Empty;
|
|
}
|
|
|
|
internal void SetRotationCurveDiagnostics(
|
|
bool usedDenseFallback,
|
|
string fallbackReason)
|
|
{
|
|
RotationUsedDenseFallback = usedDenseFallback;
|
|
RotationFallbackReason = 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; }
|
|
}
|
|
|
|
internal sealed class AuthoredCinemachineStateSnapshot
|
|
{
|
|
public AuthoredCinemachineStateSnapshot(
|
|
AuthoredCinemachineDirectorState[] directorStates,
|
|
AuthoredVirtualCameraState[] cameraStates)
|
|
{
|
|
DirectorStates = directorStates;
|
|
CameraStates = cameraStates;
|
|
}
|
|
|
|
public AuthoredCinemachineDirectorState[] DirectorStates { get; }
|
|
public AuthoredVirtualCameraState[] CameraStates { get; }
|
|
}
|
|
|
|
internal sealed class AuthoredCinemachineDirectorState
|
|
{
|
|
public AuthoredCinemachineDirectorState(
|
|
PlayableDirector director,
|
|
CinemachineTrack[] tracks,
|
|
UnityEngine.Object[] bindings)
|
|
{
|
|
Director = director;
|
|
Tracks = tracks;
|
|
Bindings = bindings;
|
|
}
|
|
|
|
public PlayableDirector Director { get; }
|
|
public CinemachineTrack[] Tracks { get; }
|
|
public UnityEngine.Object[] Bindings { get; }
|
|
}
|
|
|
|
internal sealed class AuthoredVirtualCameraState
|
|
{
|
|
public AuthoredVirtualCameraState(
|
|
CinemachineVirtualCameraBase camera,
|
|
bool enabled)
|
|
{
|
|
Camera = camera;
|
|
Enabled = enabled;
|
|
}
|
|
|
|
public CinemachineVirtualCameraBase Camera { get; }
|
|
public bool Enabled { 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;
|
|
}
|
|
}
|
|
}
|