streamingle-unity-utilities/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs

1734 lines
74 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
using UnityEngine.Timeline;
using Object = UnityEngine.Object;
namespace Streamingle.Editor.Tests
{
public sealed class PreviewCloneActivationProbe : MonoBehaviour
{
internal static int EnableCount { get; set; }
private void OnEnable()
{
EnableCount++;
}
}
public sealed class AICameraTimelinePreviewImporterTests
{
[Test]
public void BrainResolutionUsesOnlySceneBrainWithoutCinemachineTrack()
{
var directorObject = new GameObject("Director");
var cameraObject = new GameObject("Render Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Is.SameAs(brain));
Assert.That(
AICameraTimelinePreviewImporter
.GetPreviewOutputStatusForEditor(brain),
Does.Contain("are active"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionPrefersUniqueMainCamera()
{
var directorObject = new GameObject("Director");
var mainCameraObject = new GameObject("Main Camera");
var secondaryCameraObject = new GameObject("Secondary Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
mainCameraObject.tag = "MainCamera";
mainCameraObject.AddComponent<Camera>();
var mainBrain = mainCameraObject.AddComponent<CinemachineBrain>();
secondaryCameraObject.AddComponent<Camera>();
var secondaryBrain =
secondaryCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { secondaryBrain, mainBrain }),
Is.SameAs(mainBrain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(mainCameraObject);
Object.DestroyImmediate(secondaryCameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionRejectsAmbiguousSceneBrains()
{
var directorObject = new GameObject("Director");
var firstCameraObject = new GameObject("First Camera");
var secondCameraObject = new GameObject("Second Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
firstCameraObject.AddComponent<Camera>();
var firstBrain = firstCameraObject.AddComponent<CinemachineBrain>();
secondCameraObject.AddComponent<Camera>();
var secondBrain = secondCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { firstBrain, secondBrain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("Multiple enabled CinemachineBrain"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(firstCameraObject);
Object.DestroyImmediate(secondCameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionUsesDisabledUniqueDescendantBinding()
{
var directorObject = new GameObject("Director");
var childDirectorObject = new GameObject("Camera Director");
var cameraObject = new GameObject("Configured Camera");
childDirectorObject.transform.SetParent(directorObject.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
TimelineAsset childTimeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var childDirector =
childDirectorObject.AddComponent<PlayableDirector>();
cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
childTimeline = CreateTimelineBoundToBrain(
childDirector,
brain,
"Child Camera Track");
childDirectorObject.SetActive(false);
brain.enabled = false;
LogAssert.Expect(
LogType.Warning,
"The CinemachineBrain explicitly bound to a Timeline or its " +
"Camera is disabled. Camera AI will use the configured Brain " +
"without enabling either component automatically.");
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Is.SameAs(brain));
Assert.That(brain.enabled, Is.False);
Assert.That(
AICameraTimelinePreviewImporter
.GetPreviewOutputStatusForEditor(brain),
Does.Contain("enable it to see AI camera playback"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(childTimeline);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionDeduplicatesSameBrainAcrossDescendants()
{
var directorObject = new GameObject("Director");
var firstChildObject = new GameObject("First Camera Director");
var secondChildObject = new GameObject("Second Camera Director");
var cameraObject = new GameObject("Configured Camera");
firstChildObject.transform.SetParent(directorObject.transform, false);
secondChildObject.transform.SetParent(directorObject.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
TimelineAsset firstChildTimeline = null;
TimelineAsset secondChildTimeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var firstChildDirector =
firstChildObject.AddComponent<PlayableDirector>();
var secondChildDirector =
secondChildObject.AddComponent<PlayableDirector>();
cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
firstChildTimeline = CreateTimelineBoundToBrain(
firstChildDirector,
brain,
"First Child Camera Track");
secondChildTimeline = CreateTimelineBoundToBrain(
secondChildDirector,
brain,
"Second Child Camera Track");
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
Array.Empty<CinemachineBrain>()),
Is.SameAs(brain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(firstChildTimeline);
Object.DestroyImmediate(secondChildTimeline);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionRejectsAmbiguousDescendantBindings()
{
var directorObject = new GameObject("Director");
var firstChildObject = new GameObject("First Camera Director");
var secondChildObject = new GameObject("Second Camera Director");
var firstCameraObject = new GameObject("First Configured Camera");
var secondCameraObject = new GameObject("Second Configured Camera");
firstChildObject.transform.SetParent(directorObject.transform, false);
secondChildObject.transform.SetParent(directorObject.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
TimelineAsset firstChildTimeline = null;
TimelineAsset secondChildTimeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var firstChildDirector =
firstChildObject.AddComponent<PlayableDirector>();
var secondChildDirector =
secondChildObject.AddComponent<PlayableDirector>();
firstCameraObject.AddComponent<Camera>();
var firstBrain =
firstCameraObject.AddComponent<CinemachineBrain>();
secondCameraObject.AddComponent<Camera>();
var secondBrain =
secondCameraObject.AddComponent<CinemachineBrain>();
firstChildTimeline = CreateTimelineBoundToBrain(
firstChildDirector,
firstBrain,
"First Child Camera Track");
secondChildTimeline = CreateTimelineBoundToBrain(
secondChildDirector,
secondBrain,
"Second Child Camera Track");
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { firstBrain, secondBrain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("Descendant PlayableDirectors"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(firstCameraObject);
Object.DestroyImmediate(secondCameraObject);
Object.DestroyImmediate(firstChildTimeline);
Object.DestroyImmediate(secondChildTimeline);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionPrefersDirectBindingOverDescendants()
{
var directorObject = new GameObject("Director");
var childDirectorObject = new GameObject("Camera Director");
var directCameraObject = new GameObject("Direct Camera");
var childCameraObject = new GameObject("Child Camera");
childDirectorObject.transform.SetParent(directorObject.transform, false);
TimelineAsset timeline = null;
TimelineAsset childTimeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
var childDirector =
childDirectorObject.AddComponent<PlayableDirector>();
directCameraObject.AddComponent<Camera>();
var directBrain =
directCameraObject.AddComponent<CinemachineBrain>();
childCameraObject.AddComponent<Camera>();
var childBrain =
childCameraObject.AddComponent<CinemachineBrain>();
timeline = CreateTimelineBoundToBrain(
director,
directBrain,
"Direct Camera Track");
childTimeline = CreateTimelineBoundToBrain(
childDirector,
childBrain,
"Child Camera Track");
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { childBrain, directBrain }),
Is.SameAs(directBrain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(directCameraObject);
Object.DestroyImmediate(childCameraObject);
Object.DestroyImmediate(childTimeline);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionRejectsExplicitBrainFromAnotherScene()
{
var directorObject = new GameObject("Director");
var cameraObject = new GameObject("Foreign Camera");
var foreignScene = EditorSceneManager.NewPreviewScene();
SceneManager.MoveGameObjectToScene(cameraObject, foreignScene);
TimelineAsset timeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
timeline = CreateTimelineBoundToBrain(
director,
brain,
"Foreign Camera Track");
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("same scene"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(timeline);
EditorSceneManager.ClosePreviewScene(foreignScene);
}
}
[Test]
public void BrainResolutionRejectsExplicitBrainWithoutCamera()
{
var directorObject = new GameObject("Director");
var cameraObject = new GameObject("Camera-less Brain");
TimelineAsset timeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
var camera = cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
Object.DestroyImmediate(camera);
timeline = CreateTimelineBoundToBrain(
director,
brain,
"Invalid Camera Track");
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("Camera component"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionAcceptsSeparateControlledOutputCamera()
{
var directorObject = new GameObject("Director");
var brainObject = new GameObject("Configured Brain");
var outputCameraObject = new GameObject("Controlled Output Camera");
TimelineAsset timeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
var brain = brainObject.AddComponent<CinemachineBrain>();
var outputCamera = outputCameraObject.AddComponent<Camera>();
brain.ControlledObject = outputCameraObject;
timeline = CreateTimelineBoundToBrain(
director,
brain,
"Controlled Output Camera Track");
Assert.That(brain.OutputCamera, Is.SameAs(outputCamera));
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Is.SameAs(brain));
Assert.That(
AICameraTimelinePreviewImporter
.GetPreviewOutputStatusForEditor(brain),
Does.Contain(outputCameraObject.name));
Assert.That(
AICameraTimelinePreviewImporter
.GetPreviewOutputStatusForEditor(brain),
Does.Contain("are active"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(brainObject);
Object.DestroyImmediate(outputCameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionUsesControlledOutputMainCameraForFallback()
{
var directorObject = new GameObject("Director");
var mainBrainObject = new GameObject("Main Output Brain");
var mainCameraObject = new GameObject("Main Output Camera");
var secondaryCameraObject = new GameObject("Secondary Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var mainBrain = mainBrainObject.AddComponent<CinemachineBrain>();
mainCameraObject.tag = "MainCamera";
mainCameraObject.AddComponent<Camera>();
mainBrain.ControlledObject = mainCameraObject;
secondaryCameraObject.AddComponent<Camera>();
var secondaryBrain =
secondaryCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { secondaryBrain, mainBrain }),
Is.SameAs(mainBrain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(mainBrainObject);
Object.DestroyImmediate(mainCameraObject);
Object.DestroyImmediate(secondaryCameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionRejectsControlledOutputCameraFromAnotherScene()
{
var directorObject = new GameObject("Director");
var brainObject = new GameObject("Configured Brain");
var outputCameraObject = new GameObject("Foreign Output Camera");
var foreignScene = EditorSceneManager.NewPreviewScene();
SceneManager.MoveGameObjectToScene(outputCameraObject, foreignScene);
TimelineAsset timeline = null;
try
{
var director = directorObject.AddComponent<PlayableDirector>();
var brain = brainObject.AddComponent<CinemachineBrain>();
outputCameraObject.AddComponent<Camera>();
brain.ControlledObject = outputCameraObject;
timeline = CreateTimelineBoundToBrain(
director,
brain,
"Foreign Controlled Output Track");
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("output Camera")
.And.Message.Contains("same scene"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(brainObject);
Object.DestroyImmediate(outputCameraObject);
Object.DestroyImmediate(timeline);
EditorSceneManager.ClosePreviewScene(foreignScene);
}
}
[Test]
public void InactiveStagingCloneNeverActivatesAndPreservesSourcePlayback()
{
var parentObject = new GameObject("Preview Parent");
var sourceObject = new GameObject("Source Director");
var motionObject = new GameObject("Motion Director");
var authoredCameraObject = new GameObject("Authored Virtual Camera");
sourceObject.transform.SetParent(parentObject.transform, false);
motionObject.transform.SetParent(sourceObject.transform, false);
authoredCameraObject.transform.SetParent(
motionObject.transform,
false);
parentObject.transform.position = new Vector3(4f, -2f, 7f);
sourceObject.transform.localPosition = new Vector3(1f, 2f, 3f);
var sourceTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
var motionTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
GameObject cloneObject = null;
PlayableDirector motionDirector = null;
try
{
var sourceDirector =
sourceObject.AddComponent<PlayableDirector>();
sourceDirector.playOnAwake = false;
sourceDirector.playableAsset = sourceTimeline;
motionDirector = motionObject.AddComponent<PlayableDirector>();
motionDirector.playOnAwake = true;
motionDirector.timeUpdateMode = DirectorUpdateMode.Manual;
motionDirector.playableAsset = motionTimeline;
var authoredCamera = authoredCameraObject
.AddComponent<CinemachineCamera>();
sourceObject.AddComponent<PreviewCloneActivationProbe>();
motionDirector.Play();
motionDirector.time = 0.4d;
motionDirector.Evaluate();
var sourceWorldPosition = sourceObject.transform.position;
var sourcePlaybackState = motionDirector.state;
PreviewCloneActivationProbe.EnableCount = 0;
cloneObject = AICameraTimelinePreviewImporter
.InstantiateInactivePreviewCloneForEditor(
sourceObject,
parentObject.transform);
var cloneMotionDirector = cloneObject.transform
.Find(motionObject.name)
.GetComponent<PlayableDirector>();
var cloneAuthoredCamera = cloneObject.transform
.Find($"{motionObject.name}/{authoredCameraObject.name}")
.GetComponent<CinemachineCamera>();
Assert.That(cloneObject.transform.parent, Is.SameAs(parentObject.transform));
Assert.That(cloneObject.transform.position, Is.EqualTo(sourceWorldPosition));
Assert.That(cloneObject.activeSelf, Is.False);
Assert.That(cloneObject.activeInHierarchy, Is.False);
Assert.That(PreviewCloneActivationProbe.EnableCount, Is.Zero);
Assert.That(cloneMotionDirector.playOnAwake, Is.True);
Assert.That(cloneMotionDirector.playableGraph.IsValid(), Is.False);
Assert.That(cloneAuthoredCamera.enabled, Is.True);
Assert.That(cloneAuthoredCamera.isActiveAndEnabled, Is.False);
Assert.That(sourceObject.activeSelf, Is.True);
Assert.That(sourceObject.activeInHierarchy, Is.True);
Assert.That(motionDirector.state, Is.EqualTo(sourcePlaybackState));
Assert.That(motionDirector.time, Is.EqualTo(0.4d).Within(0.000001d));
Assert.That(authoredCamera.enabled, Is.True);
Assert.That(authoredCamera.isActiveAndEnabled, Is.True);
}
finally
{
PreviewCloneActivationProbe.EnableCount = 0;
if (motionDirector != null)
{
motionDirector.Stop();
}
Object.DestroyImmediate(cloneObject);
Object.DestroyImmediate(parentObject);
Object.DestroyImmediate(motionTimeline);
Object.DestroyImmediate(sourceTimeline);
}
}
[Test]
public void PreviewCloneSuppressesOnlyAuthoredCinemachineOutputs()
{
var sourceObject = new GameObject("Source Director");
var motionObject = new GameObject("Motion Director");
var authoredVirtualCameraObject =
new GameObject("Authored Virtual Camera");
var cameraObject = new GameObject("Authored Camera");
motionObject.transform.SetParent(sourceObject.transform, false);
authoredVirtualCameraObject.transform.SetParent(
motionObject.transform,
false);
var sourceTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
TimelineAsset motionTimeline = null;
GameObject previewObject = null;
try
{
var sourceDirector =
sourceObject.AddComponent<PlayableDirector>();
sourceDirector.playOnAwake = false;
sourceDirector.playableAsset = sourceTimeline;
var motionDirector =
motionObject.AddComponent<PlayableDirector>();
motionDirector.playOnAwake = false;
var motionAnimator = motionObject.AddComponent<Animator>();
var authoredVirtualCamera = authoredVirtualCameraObject
.AddComponent<CinemachineCamera>();
cameraObject.AddComponent<Camera>();
var authoredBrain =
cameraObject.AddComponent<CinemachineBrain>();
motionTimeline = CreateTimelineBoundToBrain(
motionDirector,
authoredBrain,
"Authored Camera Track");
var motionTrack = motionTimeline.CreateTrack<AnimationTrack>(
null,
"Motion Track");
motionDirector.SetGenericBinding(motionTrack, motionAnimator);
previewObject = Object.Instantiate(sourceObject);
var previewMotionObject =
previewObject.transform.Find(motionObject.name).gameObject;
var previewMotionDirector =
previewMotionObject.GetComponent<PlayableDirector>();
var previewMotionAnimator =
previewMotionObject.GetComponent<Animator>();
var previewAuthoredVirtualCamera = previewMotionObject.transform
.Find(authoredVirtualCameraObject.name)
.GetComponent<CinemachineCamera>();
var authoredCameraTrack = motionTimeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Single();
var motionTimelineWasDirty = EditorUtility.IsDirty(motionTimeline);
var suppressionState = AICameraTimelinePreviewImporter
.CaptureAuthoredCinemachineState(
previewObject.GetComponent<PlayableDirector>());
Assert.That(previewObject.activeSelf, Is.True);
Assert.That(
previewMotionDirector.GetGenericBinding(authoredCameraTrack),
Is.SameAs(authoredBrain));
Assert.That(
previewMotionDirector.GetGenericBinding(motionTrack),
Is.SameAs(previewMotionAnimator));
Assert.That(previewAuthoredVirtualCamera.enabled, Is.True);
Assert.That(authoredVirtualCamera.enabled, Is.True);
AICameraTimelinePreviewImporter
.SuppressAuthoredCinemachineOutputsForEditor(
previewObject.GetComponent<PlayableDirector>());
Assert.That(
previewMotionDirector.GetGenericBinding(authoredCameraTrack),
Is.Null);
Assert.That(
previewMotionDirector.GetGenericBinding(motionTrack),
Is.SameAs(previewMotionAnimator));
Assert.That(previewAuthoredVirtualCamera.enabled, Is.False);
Assert.That(authoredVirtualCamera.enabled, Is.True);
Assert.That(
previewMotionDirector.playableAsset,
Is.SameAs(motionTimeline));
Assert.That(
motionTimeline.GetOutputTracks(),
Does.Contain(authoredCameraTrack));
Assert.That(
motionTimeline.GetOutputTracks(),
Does.Contain(motionTrack));
Assert.That(
motionDirector.GetGenericBinding(authoredCameraTrack),
Is.SameAs(authoredBrain));
Assert.That(
motionDirector.GetGenericBinding(motionTrack),
Is.SameAs(motionAnimator));
Assert.That(
motionDirector.playableAsset,
Is.SameAs(motionTimeline));
Assert.That(
EditorUtility.IsDirty(motionTimeline),
Is.EqualTo(motionTimelineWasDirty));
AICameraTimelinePreviewImporter.RestoreAuthoredCinemachineState(
suppressionState);
Assert.That(
previewMotionDirector.GetGenericBinding(authoredCameraTrack),
Is.SameAs(authoredBrain));
Assert.That(
previewMotionDirector.GetGenericBinding(motionTrack),
Is.SameAs(previewMotionAnimator));
Assert.That(previewAuthoredVirtualCamera.enabled, Is.True);
Assert.That(authoredVirtualCamera.enabled, Is.True);
}
finally
{
Object.DestroyImmediate(previewObject);
Object.DestroyImmediate(sourceObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(motionTimeline);
Object.DestroyImmediate(sourceTimeline);
}
}
[Test]
public void PreviewCloneKeepsParentGeneratedCinemachineBinding()
{
var sourceObject = new GameObject("Source Director");
var motionObject = new GameObject("Motion Director");
var generatedCameraObject = new GameObject("Generated Camera");
var authoredCameraObject = new GameObject("Authored Camera");
motionObject.transform.SetParent(sourceObject.transform, false);
TimelineAsset sourceTimeline = null;
TimelineAsset motionTimeline = null;
GameObject previewObject = null;
try
{
var sourceDirector =
sourceObject.AddComponent<PlayableDirector>();
sourceDirector.playOnAwake = false;
var motionDirector =
motionObject.AddComponent<PlayableDirector>();
motionDirector.playOnAwake = false;
generatedCameraObject.AddComponent<Camera>();
var generatedBrain =
generatedCameraObject.AddComponent<CinemachineBrain>();
authoredCameraObject.AddComponent<Camera>();
var authoredBrain =
authoredCameraObject.AddComponent<CinemachineBrain>();
sourceTimeline = CreateTimelineBoundToBrain(
sourceDirector,
generatedBrain,
AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName);
motionTimeline = CreateTimelineBoundToBrain(
motionDirector,
authoredBrain,
"Authored Camera Track");
var generatedTrack = sourceTimeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Single();
var authoredTrack = motionTimeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Single();
previewObject = Object.Instantiate(sourceObject);
var previewDirector =
previewObject.GetComponent<PlayableDirector>();
var previewMotionDirector = previewObject.transform
.Find(motionObject.name)
.GetComponent<PlayableDirector>();
AICameraTimelinePreviewImporter
.SuppressAuthoredCinemachineOutputsForEditor(
previewDirector);
Assert.That(
previewDirector.GetGenericBinding(generatedTrack),
Is.SameAs(generatedBrain));
Assert.That(
previewMotionDirector.GetGenericBinding(authoredTrack),
Is.Null);
Assert.That(
sourceDirector.GetGenericBinding(generatedTrack),
Is.SameAs(generatedBrain));
Assert.That(
motionDirector.GetGenericBinding(authoredTrack),
Is.SameAs(authoredBrain));
}
finally
{
Object.DestroyImmediate(previewObject);
Object.DestroyImmediate(sourceObject);
Object.DestroyImmediate(generatedCameraObject);
Object.DestroyImmediate(authoredCameraObject);
Object.DestroyImmediate(motionTimeline);
Object.DestroyImmediate(sourceTimeline);
}
}
[Test]
public void FinalSuppressionDoesNotCreatePartialUndoEntry()
{
var destinationObject = new GameObject("Destination Director");
var motionObject = new GameObject("Motion Director");
var authoredVirtualCameraObject =
new GameObject("Authored Virtual Camera");
var outputCameraObject = new GameObject("Output Camera");
var undoSentinelObject = new GameObject("Undo Sentinel");
motionObject.transform.SetParent(destinationObject.transform, false);
authoredVirtualCameraObject.transform.SetParent(
motionObject.transform,
false);
var destinationTimeline =
ScriptableObject.CreateInstance<TimelineAsset>();
TimelineAsset motionTimeline = null;
try
{
var destinationDirector =
destinationObject.AddComponent<PlayableDirector>();
destinationDirector.playableAsset = destinationTimeline;
var motionDirector =
motionObject.AddComponent<PlayableDirector>();
var authoredVirtualCamera = authoredVirtualCameraObject
.AddComponent<CinemachineCamera>();
outputCameraObject.AddComponent<Camera>();
var brain = outputCameraObject.AddComponent<CinemachineBrain>();
motionTimeline = CreateTimelineBoundToBrain(
motionDirector,
brain,
"Authored Camera Track");
var cameraTrack = motionTimeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Single();
Undo.ClearAll();
Undo.IncrementCurrentGroup();
var sentinelGroup = Undo.GetCurrentGroup();
Undo.RegisterCompleteObjectUndo(
undoSentinelObject.transform,
"Final Suppression Undo Sentinel");
undoSentinelObject.transform.position = Vector3.one;
Undo.CollapseUndoOperations(sentinelGroup);
Undo.FlushUndoRecordObjects();
Undo.IncrementCurrentGroup();
AICameraTimelinePreviewImporter
.SuppressAuthoredCinemachineOutputsForEditor(
destinationDirector,
false);
Undo.FlushUndoRecordObjects();
Assert.That(
motionDirector.GetGenericBinding(cameraTrack),
Is.Null);
Assert.That(authoredVirtualCamera.enabled, Is.False);
Undo.PerformUndo();
Assert.That(undoSentinelObject.transform.position, Is.EqualTo(Vector3.zero));
Assert.That(
motionDirector.GetGenericBinding(cameraTrack),
Is.Null);
Assert.That(authoredVirtualCamera.enabled, Is.False);
}
finally
{
Undo.ClearAll();
Object.DestroyImmediate(destinationObject);
Object.DestroyImmediate(outputCameraObject);
Object.DestroyImmediate(undoSentinelObject);
Object.DestroyImmediate(motionTimeline);
Object.DestroyImmediate(destinationTimeline);
}
}
[Test]
public void SuppressionRebuildsPlayingDirectorAndReleasesBrainOverride()
{
var sourceObject = new GameObject("Source Director");
var motionObject = new GameObject("Motion Director");
var authoredCameraObject = new GameObject("Authored Virtual Camera");
var outputCameraObject = new GameObject("Output Camera");
motionObject.transform.SetParent(sourceObject.transform, false);
authoredCameraObject.transform.SetParent(
motionObject.transform,
false);
var sourceTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
var motionTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
PlayableDirector motionDirector = null;
try
{
var sourceDirector =
sourceObject.AddComponent<PlayableDirector>();
sourceDirector.playOnAwake = false;
sourceDirector.playableAsset = sourceTimeline;
motionDirector = motionObject.AddComponent<PlayableDirector>();
motionDirector.playOnAwake = false;
motionDirector.timeUpdateMode = DirectorUpdateMode.Manual;
motionDirector.playableAsset = motionTimeline;
var authoredCamera =
authoredCameraObject.AddComponent<CinemachineCamera>();
outputCameraObject.AddComponent<Camera>();
var brain = outputCameraObject.AddComponent<CinemachineBrain>();
brain.UpdateMethod = CinemachineBrain.UpdateMethods.ManualUpdate;
var track = motionTimeline.CreateTrack<CinemachineTrack>(
null,
"Authored Camera Track");
var clip = track.CreateDefaultClip();
clip.start = 0d;
clip.duration = 1d;
var shot = clip.asset as CinemachineShot;
Assert.That(shot, Is.Not.Null);
var exposedName = new PropertyName("AuthoredCameraForSuppressionTest");
shot.VirtualCamera.exposedName = exposedName;
shot.VirtualCamera.defaultValue = authoredCamera;
motionDirector.SetReferenceValue(exposedName, authoredCamera);
motionDirector.SetGenericBinding(track, brain);
motionDirector.Play();
motionDirector.time = 0.25d;
motionDirector.Evaluate();
brain.ManualUpdate(100, 1f / 60f);
Assert.That(motionDirector.playableGraph.IsValid(), Is.True);
Assert.That(motionDirector.state, Is.EqualTo(PlayState.Playing));
Assert.That(brain.ActiveVirtualCamera, Is.SameAs(authoredCamera));
var suppressionState = AICameraTimelinePreviewImporter
.CaptureAuthoredCinemachineState(sourceDirector);
AICameraTimelinePreviewImporter
.SuppressAuthoredCinemachineOutputsForEditor(sourceDirector);
brain.ManualUpdate(101, 1f / 60f);
Assert.That(motionDirector.state, Is.EqualTo(PlayState.Playing));
Assert.That(motionDirector.time, Is.EqualTo(0.25d).Within(0.000001d));
Assert.That(motionDirector.GetGenericBinding(track), Is.Null);
Assert.That(authoredCamera.enabled, Is.False);
Assert.That(brain.ActiveVirtualCamera, Is.Not.SameAs(authoredCamera));
AICameraTimelinePreviewImporter.RestoreAuthoredCinemachineState(
suppressionState);
brain.ManualUpdate(102, 1f / 60f);
Assert.That(motionDirector.state, Is.EqualTo(PlayState.Playing));
Assert.That(motionDirector.time, Is.EqualTo(0.25d).Within(0.000001d));
Assert.That(
motionDirector.GetGenericBinding(track),
Is.SameAs(brain));
Assert.That(authoredCamera.enabled, Is.True);
Assert.That(brain.ActiveVirtualCamera, Is.SameAs(authoredCamera));
}
finally
{
if (motionDirector != null)
{
motionDirector.Stop();
}
Object.DestroyImmediate(sourceObject);
Object.DestroyImmediate(outputCameraObject);
Object.DestroyImmediate(motionTimeline);
Object.DestroyImmediate(sourceTimeline);
}
}
[TestCase(false)]
[TestCase(true)]
public void GeneratedDirectorySupportsLegacyAndDeclaredHashes(
bool includeHashes)
{
var directory = CreateGeneratedDirectory(includeHashes);
try
{
Assert.That(
AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Does.Contain("1 frames, 1 shots at 60 FPS"));
}
finally
{
Directory.Delete(directory, true);
}
}
[TestCase("generated_camera_world.f32", "worldCamera")]
[TestCase("time.f64", "time")]
[TestCase("shots.json", "shots")]
public void GeneratedDirectoryRejectsTamperedDeclaredPayloadBeforeParsing(
string fileName,
string payloadName)
{
var directory = CreateGeneratedDirectory(true);
try
{
File.AppendAllText(
Path.Combine(directory, fileName),
"tampered",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
$"{payloadName} payload SHA-256 mismatch"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsAbsolutePayloadPath()
{
var directory = CreateGeneratedDirectory(true);
try
{
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
Path.Combine(directory, "generated_camera_world.f32"));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("must be relative"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsParentTraversalEvenWhenHashMatches()
{
var directory = CreateGeneratedDirectory(true);
var outsidePath = Path.Combine(
Path.GetDirectoryName(directory) ?? Path.GetTempPath(),
$"cw_ai_outside_{Guid.NewGuid():N}.f32");
try
{
File.Copy(
Path.Combine(directory, "generated_camera_world.f32"),
outsidePath);
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
"../" + Path.GetFileName(outsidePath));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("invalid segment"));
}
finally
{
Directory.Delete(directory, true);
if (File.Exists(outsidePath))
{
File.Delete(outsidePath);
}
}
}
[Test]
public void GeneratedDirectoryRejectsExplicitNullHashObject()
{
var directory = CreateGeneratedDirectory(false);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
File.WriteAllText(
metadataPath,
json.Substring(0, json.Length - 1) +
",\"outputSha256\":null}",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256 must be a JSON object"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsIncompleteDeclaredHashes()
{
var directory = CreateGeneratedDirectory(true);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var timePath = Path.Combine(directory, "time.f64");
var json = File.ReadAllText(metadataPath).Replace(
$",\"time\":\"{ComputeSha256(timePath)}\"",
string.Empty);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256.time is missing"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void PreviewProvenancePersistsHiddenRelativeAndAbsoluteFallback()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_root_{Guid.NewGuid():N}");
var generatedDirectory = Path.Combine(
cwAiRoot,
"examples",
"UnityGenerated",
"camera_test");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_Provenance_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var provenance = AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(
timelinePath,
ImportAssetOptions.ForceSynchronousImport);
var reloaded = AssetDatabase.LoadAssetAtPath<TimelineAsset>(
timelinePath);
Assert.That(reloaded, Is.Not.Null);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
reloaded,
out var info),
Is.True);
Assert.That(
info.CwAiRelativeGeneratedFolder,
Is.EqualTo("examples/UnityGenerated/camera_test"));
Assert.That(
info.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(generatedDirectory)));
Assert.That(
info.MetadataSha256,
Is.EqualTo(ComputeSha256(
Path.Combine(generatedDirectory, "metadata.json"))));
Assert.That(
provenance.hideFlags,
Is.EqualTo(
HideFlags.HideInHierarchy |
HideFlags.HideInInspector));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void PreviewProvenanceUndoRedoRestoresMaterializedBase()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_undo_{Guid.NewGuid():N}");
var first = Path.Combine(cwAiRoot, "outputs", "first");
var second = Path.Combine(cwAiRoot, "outputs", "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test AI Camera Provenance Undo");
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
cwAiRoot,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var changed),
Is.True);
Assert.That(
changed.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
Undo.PerformUndo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void FinalTimelineCleanupRemovesPreviewOnlyProvenanceSubasset()
{
var generatedDirectory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_final_{Guid.NewGuid():N}");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceFinal_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
string.Empty,
false);
AssetDatabase.SaveAssets();
AICameraTimelinePreviewImporter
.RemovePreviewGenerationProvenanceForTests(timeline);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out _),
Is.False);
Assert.That(
AssetDatabase.LoadAllAssetsAtPath(timelinePath)
.OfType<AICameraPreviewGenerationProvenance>(),
Is.Empty);
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(generatedDirectory, true);
}
}
[Test]
public void ShotDisplayNameAndProvenanceRestoreInOneUndoGroup()
{
var root = Path.Combine(
Path.GetTempPath(),
$"cw_ai_track_undo_{Guid.NewGuid():N}");
var first = Path.Combine(root, "first");
var second = Path.Combine(root, "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}");
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}");
var timelinePath =
$"Assets/__CWAI_TrackUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var track = timeline.CreateTrack<AnimationTrack>(null, "Shot");
var shot = track.CreateDefaultClip();
shot.displayName = "Before";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
root,
false);
EditorUtility.SetDirty(track);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test Shot and Provenance Undo");
Undo.RegisterCompleteObjectUndo(track, "Test Shot and Provenance Undo");
shot.displayName = "After";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
root,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Undo.PerformUndo();
Assert.That(shot.displayName, Is.EqualTo("Before"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(shot.displayName, Is.EqualTo("After"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(root, true);
}
}
[Test]
public void GeneratedMotionUsesOneAnimatorAndTrackPerCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
firstCamera.transform.SetLocalPositionAndRotation(
new Vector3(1.25f, 1.8f, -3.5f),
Quaternion.Euler(8f, 135f, 0f));
secondCamera.transform.SetLocalPositionAndRotation(
new Vector3(-2f, 0.9f, 6f),
Quaternion.Euler(-4f, 20f, 2f));
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var firstAnimator = firstCamera.GetComponent<Animator>();
var secondAnimator = secondCamera.GetComponent<Animator>();
Assert.That(cameraRoot.GetComponent<Animator>(), Is.Null);
Assert.That(firstAnimator, Is.Not.Null);
Assert.That(secondAnimator, Is.Not.Null);
Assert.That(firstAnimator, Is.Not.SameAs(secondAnimator));
Assert.That(
firstAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(
secondAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(director.GetGenericBinding(firstTrack), Is.SameAs(firstAnimator));
Assert.That(director.GetGenericBinding(secondTrack), Is.SameAs(secondAnimator));
Assert.That(firstTrack.parent, Is.SameAs(group));
Assert.That(secondTrack.parent, Is.SameAs(group));
Assert.That(
firstTrack.position,
Is.EqualTo(firstCamera.transform.localPosition));
Assert.That(
secondTrack.position,
Is.EqualTo(secondCamera.transform.localPosition));
Assert.That(
Quaternion.Angle(
firstTrack.rotation,
firstCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
Quaternion.Angle(
secondTrack.rotation,
secondCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
System.Linq.Enumerable.Count(firstTrack.GetClips()),
Is.EqualTo(1));
Assert.That(
System.Linq.Enumerable.Count(secondTrack.GetClips()),
Is.EqualTo(1));
StringAssert.StartsWith(
AICameraTimelinePreviewImporter.GeneratedAnimationTrackPrefix,
firstTrack.name);
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void ShotSelectionResolvesTimelineClipAssetAndCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var selectedTimelineClip = System.Linq.Enumerable.Single(
secondTrack.GetClips());
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
selectedTimelineClip,
null,
null,
out var timelineIndex),
Is.True);
Assert.That(timelineIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
null,
secondClip,
out var assetIndex),
Is.True);
Assert.That(assetIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
firstCamera,
null,
out var cameraIndex),
Is.True);
Assert.That(cameraIndex, Is.EqualTo(0));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void LegacySharedAnimationTrackStillResolvesShots()
{
var cameraRoot = new GameObject("Legacy AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var track = timeline.CreateTrack<AnimationTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTimelineClip = track.CreateClip(firstClip);
firstTimelineClip.start = 0;
firstTimelineClip.duration = 1;
var secondTimelineClip = track.CreateClip(secondClip);
secondTimelineClip.start = 1;
secondTimelineClip.duration = 1;
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
secondTimelineClip,
null,
null,
out var shotIndex),
Is.True);
Assert.That(shotIndex, Is.EqualTo(1));
}
finally
{
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
private static TimelineAsset CreateTimelineBoundToBrain(
PlayableDirector director,
CinemachineBrain brain,
string trackName)
{
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var track = timeline.CreateTrack<CinemachineTrack>(null, trackName);
director.playableAsset = timeline;
director.SetGenericBinding(track, brain);
return timeline;
}
private static string CreateGeneratedDirectory(bool includeHashes)
{
var directory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_importer_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var cameraPath = Path.Combine(
directory,
"generated_camera_world.f32");
var timePath = Path.Combine(directory, "time.f64");
var shotsPath = Path.Combine(directory, "shots.json");
using (var writer = new BinaryWriter(File.Create(cameraPath)))
{
writer.Write(0f);
writer.Write(1.5f);
writer.Write(-3f);
writer.Write(0f);
writer.Write(0f);
writer.Write(0f);
writer.Write(1f);
writer.Write(60f);
writer.Write(0f);
}
using (var writer = new BinaryWriter(File.Create(timePath)))
{
writer.Write(0d);
}
File.WriteAllText(
shotsPath,
"{\"shots\":[{\"index\":0,\"cameraName\":\"Shot_000\"," +
"\"shotType\":\"medium\",\"startFrame\":0," +
"\"endFrameExclusive\":1}]}",
new UTF8Encoding(false));
var hashes = includeHashes
? ",\"outputSha256\":{" +
$"\"worldCamera\":\"{ComputeSha256(cameraPath)}\"," +
$"\"time\":\"{ComputeSha256(timePath)}\"," +
$"\"shots\":\"{ComputeSha256(shotsPath)}\"}}"
: string.Empty;
File.WriteAllText(
Path.Combine(directory, "metadata.json"),
"{\"songName\":\"Hash Validation Test\",\"sampleRate\":60," +
"\"frameCount\":1,\"duration\":0.016666666666666666," +
"\"worldCameraFile\":\"generated_camera_world.f32\"," +
"\"timeFile\":\"time.f64\",\"shotsFile\":\"shots.json\"," +
$"\"lookAtUsed\":false{hashes}}}",
new UTF8Encoding(false));
return directory;
}
private static void ReplaceMetadataPayloadPath(
string directory,
string propertyName,
string replacementPath)
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
var marker = $"\"{propertyName}\":\"";
var valueStart = json.IndexOf(marker, StringComparison.Ordinal);
Assert.That(valueStart, Is.GreaterThanOrEqualTo(0));
valueStart += marker.Length;
var valueEnd = json.IndexOf('"', valueStart);
Assert.That(valueEnd, Is.GreaterThan(valueStart));
var escapedReplacement = replacementPath
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
json = json.Substring(0, valueStart) +
escapedReplacement +
json.Substring(valueEnd);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
}
private static string ComputeSha256(string path)
{
using var algorithm = SHA256.Create();
return string.Concat(
algorithm.ComputeHash(File.ReadAllBytes(path))
.Select(value => value.ToString("x2")));
}
}
}