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(); try { var director = directorObject.AddComponent(); director.playableAsset = timeline; cameraObject.AddComponent(); var brain = cameraObject.AddComponent(); 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(); try { var director = directorObject.AddComponent(); director.playableAsset = timeline; mainCameraObject.tag = "MainCamera"; mainCameraObject.AddComponent(); var mainBrain = mainCameraObject.AddComponent(); secondaryCameraObject.AddComponent(); var secondaryBrain = secondaryCameraObject.AddComponent(); 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(); try { var director = directorObject.AddComponent(); director.playableAsset = timeline; firstCameraObject.AddComponent(); var firstBrain = firstCameraObject.AddComponent(); secondCameraObject.AddComponent(); var secondBrain = secondCameraObject.AddComponent(); Assert.That( () => AICameraTimelinePreviewImporter .ResolveCinemachineBrainForEditor( director, timeline, new[] { firstBrain, secondBrain }), Throws.TypeOf() .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() .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() .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() .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() .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() .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(); try { AssetDatabase.CreateAsset(timeline, timelinePath); var provenance = AICameraTimelinePreviewImporter .UpsertPreviewGenerationProvenanceForTests( timeline, generatedDirectory, cwAiRoot, false); AssetDatabase.SaveAssets(); AssetDatabase.ImportAsset( timelinePath, ImportAssetOptions.ForceSynchronousImport); var reloaded = AssetDatabase.LoadAssetAtPath( 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(); 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(); 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(), 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(); try { AssetDatabase.CreateAsset(timeline, timelinePath); var track = timeline.CreateTrack(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(); var firstClip = new AnimationClip(); var secondClip = new AnimationClip(); try { var director = directorObject.AddComponent(); director.playableAsset = timeline; var group = timeline.CreateTrack( 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(); var secondAnimator = secondCamera.GetComponent(); Assert.That(cameraRoot.GetComponent(), 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(); var firstClip = new AnimationClip(); var secondClip = new AnimationClip(); try { var director = directorObject.AddComponent(); director.playableAsset = timeline; var group = timeline.CreateTrack( 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(); var firstClip = new AnimationClip(); var secondClip = new AnimationClip(); try { var track = timeline.CreateTrack( 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"))); } } }