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(); 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)); 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(); 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); } } [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 childTimeline = null; try { var director = directorObject.AddComponent(); director.playableAsset = timeline; var childDirector = childDirectorObject.AddComponent(); cameraObject.AddComponent(); var brain = cameraObject.AddComponent(); 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 firstChildTimeline = null; TimelineAsset secondChildTimeline = null; try { var director = directorObject.AddComponent(); director.playableAsset = timeline; var firstChildDirector = firstChildObject.AddComponent(); var secondChildDirector = secondChildObject.AddComponent(); cameraObject.AddComponent(); var brain = cameraObject.AddComponent(); firstChildTimeline = CreateTimelineBoundToBrain( firstChildDirector, brain, "First Child Camera Track"); secondChildTimeline = CreateTimelineBoundToBrain( secondChildDirector, brain, "Second Child Camera Track"); Assert.That( AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor( director, timeline, Array.Empty()), 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 firstChildTimeline = null; TimelineAsset secondChildTimeline = null; try { var director = directorObject.AddComponent(); director.playableAsset = timeline; var firstChildDirector = firstChildObject.AddComponent(); var secondChildDirector = secondChildObject.AddComponent(); firstCameraObject.AddComponent(); var firstBrain = firstCameraObject.AddComponent(); secondCameraObject.AddComponent(); var secondBrain = secondCameraObject.AddComponent(); 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() .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(); var childDirector = childDirectorObject.AddComponent(); directCameraObject.AddComponent(); var directBrain = directCameraObject.AddComponent(); childCameraObject.AddComponent(); var childBrain = childCameraObject.AddComponent(); 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(); cameraObject.AddComponent(); var brain = cameraObject.AddComponent(); timeline = CreateTimelineBoundToBrain( director, brain, "Foreign Camera Track"); Assert.That( () => AICameraTimelinePreviewImporter .ResolveCinemachineBrainForEditor( director, timeline, new[] { brain }), Throws.TypeOf() .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(); var camera = cameraObject.AddComponent(); var brain = cameraObject.AddComponent(); Object.DestroyImmediate(camera); timeline = CreateTimelineBoundToBrain( director, brain, "Invalid Camera Track"); Assert.That( () => AICameraTimelinePreviewImporter .ResolveCinemachineBrainForEditor( director, timeline, new[] { brain }), Throws.TypeOf() .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(); var brain = brainObject.AddComponent(); var outputCamera = outputCameraObject.AddComponent(); 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(); try { var director = directorObject.AddComponent(); director.playableAsset = timeline; var mainBrain = mainBrainObject.AddComponent(); mainCameraObject.tag = "MainCamera"; mainCameraObject.AddComponent(); mainBrain.ControlledObject = mainCameraObject; secondaryCameraObject.AddComponent(); var secondaryBrain = secondaryCameraObject.AddComponent(); 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(); var brain = brainObject.AddComponent(); outputCameraObject.AddComponent(); brain.ControlledObject = outputCameraObject; timeline = CreateTimelineBoundToBrain( director, brain, "Foreign Controlled Output Track"); Assert.That( () => AICameraTimelinePreviewImporter .ResolveCinemachineBrainForEditor( director, timeline, new[] { brain }), Throws.TypeOf() .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(); var motionTimeline = ScriptableObject.CreateInstance(); GameObject cloneObject = null; PlayableDirector motionDirector = null; try { var sourceDirector = sourceObject.AddComponent(); sourceDirector.playOnAwake = false; sourceDirector.playableAsset = sourceTimeline; motionDirector = motionObject.AddComponent(); motionDirector.playOnAwake = true; motionDirector.timeUpdateMode = DirectorUpdateMode.Manual; motionDirector.playableAsset = motionTimeline; var authoredCamera = authoredCameraObject .AddComponent(); sourceObject.AddComponent(); 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(); var cloneAuthoredCamera = cloneObject.transform .Find($"{motionObject.name}/{authoredCameraObject.name}") .GetComponent(); 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 motionTimeline = null; GameObject previewObject = null; try { var sourceDirector = sourceObject.AddComponent(); sourceDirector.playOnAwake = false; sourceDirector.playableAsset = sourceTimeline; var motionDirector = motionObject.AddComponent(); motionDirector.playOnAwake = false; var motionAnimator = motionObject.AddComponent(); var authoredVirtualCamera = authoredVirtualCameraObject .AddComponent(); cameraObject.AddComponent(); var authoredBrain = cameraObject.AddComponent(); motionTimeline = CreateTimelineBoundToBrain( motionDirector, authoredBrain, "Authored Camera Track"); var motionTrack = motionTimeline.CreateTrack( null, "Motion Track"); motionDirector.SetGenericBinding(motionTrack, motionAnimator); previewObject = Object.Instantiate(sourceObject); var previewMotionObject = previewObject.transform.Find(motionObject.name).gameObject; var previewMotionDirector = previewMotionObject.GetComponent(); var previewMotionAnimator = previewMotionObject.GetComponent(); var previewAuthoredVirtualCamera = previewMotionObject.transform .Find(authoredVirtualCameraObject.name) .GetComponent(); var authoredCameraTrack = motionTimeline.GetOutputTracks() .OfType() .Single(); var motionTimelineWasDirty = EditorUtility.IsDirty(motionTimeline); var suppressionState = AICameraTimelinePreviewImporter .CaptureAuthoredCinemachineState( previewObject.GetComponent()); 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()); 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(); sourceDirector.playOnAwake = false; var motionDirector = motionObject.AddComponent(); motionDirector.playOnAwake = false; generatedCameraObject.AddComponent(); var generatedBrain = generatedCameraObject.AddComponent(); authoredCameraObject.AddComponent(); var authoredBrain = authoredCameraObject.AddComponent(); sourceTimeline = CreateTimelineBoundToBrain( sourceDirector, generatedBrain, AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName); motionTimeline = CreateTimelineBoundToBrain( motionDirector, authoredBrain, "Authored Camera Track"); var generatedTrack = sourceTimeline.GetOutputTracks() .OfType() .Single(); var authoredTrack = motionTimeline.GetOutputTracks() .OfType() .Single(); previewObject = Object.Instantiate(sourceObject); var previewDirector = previewObject.GetComponent(); var previewMotionDirector = previewObject.transform .Find(motionObject.name) .GetComponent(); 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 motionTimeline = null; try { var destinationDirector = destinationObject.AddComponent(); destinationDirector.playableAsset = destinationTimeline; var motionDirector = motionObject.AddComponent(); var authoredVirtualCamera = authoredVirtualCameraObject .AddComponent(); outputCameraObject.AddComponent(); var brain = outputCameraObject.AddComponent(); motionTimeline = CreateTimelineBoundToBrain( motionDirector, brain, "Authored Camera Track"); var cameraTrack = motionTimeline.GetOutputTracks() .OfType() .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(); var motionTimeline = ScriptableObject.CreateInstance(); PlayableDirector motionDirector = null; try { var sourceDirector = sourceObject.AddComponent(); sourceDirector.playOnAwake = false; sourceDirector.playableAsset = sourceTimeline; motionDirector = motionObject.AddComponent(); motionDirector.playOnAwake = false; motionDirector.timeUpdateMode = DirectorUpdateMode.Manual; motionDirector.playableAsset = motionTimeline; var authoredCamera = authoredCameraObject.AddComponent(); outputCameraObject.AddComponent(); var brain = outputCameraObject.AddComponent(); brain.UpdateMethod = CinemachineBrain.UpdateMethods.ManualUpdate; var track = motionTimeline.CreateTrack( 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() .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 TimelineAsset CreateTimelineBoundToBrain( PlayableDirector director, CinemachineBrain brain, string trackName) { var timeline = ScriptableObject.CreateInstance(); var track = timeline.CreateTrack(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"))); } } }