868 lines
34 KiB
C#
868 lines
34 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 UnityEngine;
|
|
using UnityEngine.Playables;
|
|
using UnityEngine.Timeline;
|
|
using Object = UnityEngine.Object;
|
|
|
|
namespace Streamingle.Editor.Tests
|
|
{
|
|
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));
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
[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 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")));
|
|
}
|
|
}
|
|
}
|