From 150ef2d30ce4b511cc7bd1e77d615034d2903475 Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Tue, 18 Aug 2026 00:00:12 +0900 Subject: [PATCH] release(camera-ai): publish 0.4.14 importer hotfix --- CameraAI~/CHANGELOG.md | 12 + .../Documentation~/EXTERNAL_INSTALLATION.md | 4 +- .../Editor/AICameraTimelinePreviewImporter.cs | 416 ++++++++- CameraAI~/README.md | 2 +- .../AICameraTimelinePreviewImporterTests.cs | 866 ++++++++++++++++++ CameraAI~/package.json | 2 +- README.md | 2 +- 7 files changed, 1279 insertions(+), 25 deletions(-) diff --git a/CameraAI~/CHANGELOG.md b/CameraAI~/CHANGELOG.md index c75e635..377c6a7 100644 --- a/CameraAI~/CHANGELOG.md +++ b/CameraAI~/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.4.14 - 2026-08-17 + +- Resolved the configured CinemachineBrain from camera tracks on descendant + PlayableDirectors, including Cinemachine 3 `OutputCamera` setups, so + orchestration Timelines can import AI results without duplicating camera + tracks on the parent Timeline. +- Made AI preview cloning inactive from construction and disconnected cloned + authored Cinemachine tracks and virtual cameras before playback. This + prevents the authored Motion Timeline from competing with the generated + Shot tracks while preserving the original scene hierarchy. This is an + Editor-only hotfix; the bundled Windows worker remains 0.1.9. + ## 0.4.13 - 2026-08-16 - Enforced radial distance bounds on the actual float32 camera coordinates, diff --git a/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md index c9e11b2..4f2ff3b 100644 --- a/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md +++ b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md @@ -17,7 +17,7 @@ must remain together. In Unity Package Manager, choose **Add package from git URL** and enter: ```text -https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.19 +https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.20 ``` The initial package download is large because the frozen Windows worker is @@ -26,7 +26,7 @@ it, remove the package lock entry, and add the package again. ## Reference data -Version 0.1.19 includes a compact, read-only `RuntimeData~` bundle with the +Version 0.1.20 includes a compact, read-only `RuntimeData~` bundle with the 263 prepared reference songs, cut policy, and ranker model. An artist workstation does not need a separate `CW-AI` checkout or Python installation. If a newer access-controlled library is available, it remains an optional diff --git a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs index d55db1f..decc324 100644 --- a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs +++ b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs @@ -440,7 +440,7 @@ namespace Streamingle.Editor originalDirector.gameObject.activeSelf; try { - previewDirectorObject = UnityEngine.Object.Instantiate( + previewDirectorObject = InstantiateInactivePreviewCloneForEditor( originalDirector.gameObject, originalDirector.transform.parent); Undo.RegisterCreatedObjectUndo( @@ -456,6 +456,7 @@ namespace Streamingle.Editor originalTimeline, previewDirector, copiedTimeline); + SuppressAuthoredCinemachineOutputsForEditor(previewDirector); MuteCopiedCameraTracks(previewDirector, copiedTimeline); MuteCopiedRecorderTracks(copiedTimeline); @@ -595,6 +596,7 @@ namespace Streamingle.Editor $"Position Dense C2 Fallbacks: {positionDenseFallbackCount:N0}", $"Preview Director: {GetHierarchyPath(previewDirector.transform)}", $"Preview Camera Root: {GetHierarchyPath(previewCameraObject.transform)}", + GetPreviewOutputStatusForEditor(brain), $"Timeline Asset: {copiedTimelinePath}", $"Animation Assets: {generatedClipPaths.Count:N0} " + $"under {shotAnimationFolder}", @@ -1449,6 +1451,8 @@ namespace Streamingle.Editor TimelineAsset finalTimeline = null; GameObject cameraPrefab = null; GameObject finalCameraRoot = null; + AuthoredCinemachineStateSnapshot destinationCinemachineStateBefore = + null; try { var sourceTimelinePath = AssetDatabase.GetAssetPath(sourceTimeline); @@ -1511,6 +1515,11 @@ namespace Streamingle.Editor destinationTimelineBefore, finalTimeline, finalCameraRoot); + destinationCinemachineStateBefore = + CaptureAuthoredCinemachineState(destinationDirector); + SuppressAuthoredCinemachineOutputsForEditor( + destinationDirector, + false); destinationDirector.time = destinationTimeBefore; destinationDirector.RebuildGraph(); destinationDirector.time = destinationTimeBefore; @@ -1545,6 +1554,8 @@ namespace Streamingle.Editor } catch { + RestoreAuthoredCinemachineState( + destinationCinemachineStateBefore); RestoreDestinationDirectorAfterFinalizationFailure( destinationStateBefore, finalTimeline); @@ -1943,6 +1954,7 @@ namespace Streamingle.Editor destinationDirector.SetReferenceValue(exposedName, camera); EditorUtility.SetDirty(shot); } + } internal static void ClearTimelineBindingsForFinalization( @@ -5575,6 +5587,46 @@ namespace Streamingle.Editor return clonedComponents[index]; } + internal static GameObject InstantiateInactivePreviewCloneForEditor( + GameObject sourceObject, + Transform destinationParent) + { + if (sourceObject == null) + { + throw new ArgumentNullException(nameof(sourceObject)); + } + + var sourceScene = sourceObject.scene; + if (!sourceScene.IsValid() || !sourceScene.isLoaded) + { + throw new InvalidOperationException( + "The source Timeline object must belong to a loaded scene."); + } + + var stagingObject = new GameObject( + "__CWAI_InactivePreviewCloneStaging"); + stagingObject.SetActive(false); + try + { + if (stagingObject.scene != sourceScene) + { + SceneManager.MoveGameObjectToScene(stagingObject, sourceScene); + } + + var clone = UnityEngine.Object.Instantiate( + sourceObject, + stagingObject.transform, + true); + clone.SetActive(false); + clone.transform.SetParent(destinationParent, true); + return clone; + } + finally + { + UnityEngine.Object.DestroyImmediate(stagingObject); + } + } + private static void EnsureNoExistingPreview( Scene scene, string previewDirectorName, @@ -5645,30 +5697,49 @@ namespace Streamingle.Editor } var scene = director.gameObject.scene; - var boundBrains = timeline.GetOutputTracks() - .OfType() - .Select(track => director.GetGenericBinding(track) as CinemachineBrain) - .Where(brain => brain != null) - .Distinct() - .ToArray(); - if (boundBrains.Length > 1) + var directlyBoundBrains = GetBoundCinemachineBrains( + director, + timeline); + if (directlyBoundBrains.Length > 1) { throw new InvalidOperationException( "The Timeline has Cinemachine Tracks bound to multiple " + "CinemachineBrain components. Bind every camera track to one Brain."); } - if (boundBrains.Length == 1) + if (directlyBoundBrains.Length == 1) { - var boundBrain = boundBrains[0]; - if (!IsUsableCinemachineBrain(boundBrain, scene)) - { - throw new InvalidOperationException( - "The CinemachineBrain bound to the Timeline must be enabled, " + - "attached to an enabled Camera, and belong to the Timeline scene."); - } + return ValidateExplicitCinemachineBrain( + directlyBoundBrains[0], + scene); + } - return boundBrain; + var descendantBoundBrains = director.gameObject + .GetComponentsInChildren(true) + .Where(descendant => + descendant != null && + descendant != director && + descendant.gameObject.scene == scene && + descendant.playableAsset is TimelineAsset) + .SelectMany(descendant => GetBoundCinemachineBrains( + descendant, + (TimelineAsset)descendant.playableAsset)) + .Distinct() + .ToArray(); + if (descendantBoundBrains.Length > 1) + { + throw new InvalidOperationException( + "Descendant PlayableDirectors under the selected Timeline object " + + "have Cinemachine Tracks bound to multiple CinemachineBrain " + + "components. Bind them to one Brain or bind the selected Timeline " + + "directly."); + } + + if (descendantBoundBrains.Length == 1) + { + return ValidateExplicitCinemachineBrain( + descendantBoundBrains[0], + scene); } var usableBrains = sceneBrains @@ -5678,7 +5749,7 @@ namespace Streamingle.Editor var mainCameraBrains = usableBrains .Where(brain => { - var camera = brain.GetComponent(); + var camera = brain.OutputCamera; return camera != null && camera.CompareTag("MainCamera"); }) .ToArray(); @@ -5714,6 +5785,81 @@ namespace Streamingle.Editor "one Camera as MainCamera or bind a Cinemachine Track explicitly."); } + private static CinemachineBrain[] GetBoundCinemachineBrains( + PlayableDirector director, + TimelineAsset timeline) + { + return timeline.GetOutputTracks() + .OfType() + .Select(track => + director.GetGenericBinding(track) as CinemachineBrain) + .Where(brain => brain != null) + .Distinct() + .ToArray(); + } + + private static CinemachineBrain ValidateExplicitCinemachineBrain( + CinemachineBrain brain, + Scene scene) + { + if (brain.gameObject.scene != scene) + { + throw new InvalidOperationException( + "The CinemachineBrain explicitly bound to a Timeline must belong " + + "to the same scene as the selected Timeline director."); + } + + var camera = brain.OutputCamera; + if (camera == null) + { + throw new InvalidOperationException( + "The CinemachineBrain explicitly bound to a Timeline must have an " + + "output Camera component on its controlled GameObject."); + } + + if (camera.gameObject.scene != scene) + { + throw new InvalidOperationException( + "The CinemachineBrain output Camera explicitly bound to a Timeline " + + "must belong to the same scene as the selected Timeline director."); + } + + if (!brain.isActiveAndEnabled || !camera.isActiveAndEnabled) + { + Debug.LogWarning( + "The CinemachineBrain explicitly bound to a Timeline or its " + + "Camera is disabled. Camera AI will use the configured Brain " + + "without enabling either component automatically.", + brain); + } + + return brain; + } + + internal static string GetPreviewOutputStatusForEditor( + CinemachineBrain brain) + { + if (brain == null) + { + throw new ArgumentNullException(nameof(brain)); + } + + var camera = brain.OutputCamera; + var cameraName = camera != null + ? camera.gameObject.name + : brain.gameObject.name; + if (camera != null && + brain.isActiveAndEnabled && + camera.isActiveAndEnabled) + { + return $"Preview Output: configured '{cameraName}' CinemachineBrain " + + "and Camera are active."; + } + + return $"Preview Output: configured '{cameraName}' CinemachineBrain or " + + "Camera is disabled; enable it to see AI camera playback."; + } + private static bool IsUsableCinemachineBrain( CinemachineBrain brain, Scene scene) @@ -5725,8 +5871,193 @@ namespace Streamingle.Editor return false; } - var camera = brain.GetComponent(); - return camera != null && camera.isActiveAndEnabled; + var camera = brain.OutputCamera; + return camera != null && + camera.gameObject.scene == scene && + camera.isActiveAndEnabled; + } + + internal static void SuppressAuthoredCinemachineOutputsForEditor( + PlayableDirector selectedDirector, + bool recordUndo = true) + { + if (selectedDirector == null) + { + throw new ArgumentNullException(nameof(selectedDirector)); + } + + var directorObject = selectedDirector.gameObject; + var descendantDirectors = directorObject + .GetComponentsInChildren(true) + .Where(descendant => + descendant != null && + descendant != selectedDirector && + descendant.playableAsset is TimelineAsset) + .ToArray(); + foreach (var descendantDirector in descendantDirectors) + { + var descendantTimeline = + (TimelineAsset)descendantDirector.playableAsset; + var cinemachineTracks = descendantTimeline.GetOutputTracks() + .OfType() + .ToArray(); + if (cinemachineTracks.Length == 0) + { + continue; + } + + if (recordUndo) + { + Undo.RecordObject( + descendantDirector, + "Disconnect Authored Cameras From AI Preview"); + } + + foreach (var cinemachineTrack in cinemachineTracks) + { + descendantDirector.ClearGenericBinding(cinemachineTrack); + } + + RebuildDirectorGraphPreservingPlayback(descendantDirector); + + EditorUtility.SetDirty(descendantDirector); + } + + foreach (var authoredCamera in directorObject + .GetComponentsInChildren(true)) + { + if (authoredCamera == null || !authoredCamera.enabled) + { + continue; + } + + if (recordUndo) + { + Undo.RecordObject( + authoredCamera, + "Disable Authored Cameras In AI Output"); + } + + authoredCamera.enabled = false; + EditorUtility.SetDirty(authoredCamera); + } + } + + internal static AuthoredCinemachineStateSnapshot + CaptureAuthoredCinemachineState(PlayableDirector selectedDirector) + { + if (selectedDirector == null) + { + throw new ArgumentNullException(nameof(selectedDirector)); + } + + var directorObject = selectedDirector.gameObject; + var directorStates = directorObject + .GetComponentsInChildren(true) + .Where(descendant => + descendant != null && + descendant != selectedDirector && + descendant.playableAsset is TimelineAsset) + .Select(descendant => + { + var timeline = (TimelineAsset)descendant.playableAsset; + var tracks = timeline.GetOutputTracks() + .OfType() + .ToArray(); + return new AuthoredCinemachineDirectorState( + descendant, + tracks, + tracks.Select(descendant.GetGenericBinding).ToArray()); + }) + .Where(state => state.Tracks.Length > 0) + .ToArray(); + var cameraStates = directorObject + .GetComponentsInChildren(true) + .Where(camera => camera != null) + .Select(camera => new AuthoredVirtualCameraState( + camera, + camera.enabled)) + .ToArray(); + return new AuthoredCinemachineStateSnapshot( + directorStates, + cameraStates); + } + + internal static void RestoreAuthoredCinemachineState( + AuthoredCinemachineStateSnapshot snapshot) + { + if (snapshot == null) + { + return; + } + + foreach (var cameraState in snapshot.CameraStates) + { + if (cameraState.Camera == null) + { + continue; + } + + cameraState.Camera.enabled = cameraState.Enabled; + EditorUtility.SetDirty(cameraState.Camera); + } + + foreach (var directorState in snapshot.DirectorStates) + { + var director = directorState.Director; + if (director == null) + { + continue; + } + + for (var index = 0; index < directorState.Tracks.Length; index++) + { + var track = directorState.Tracks[index]; + if (track == null) + { + continue; + } + + var binding = directorState.Bindings[index]; + if (binding != null) + { + director.SetGenericBinding(track, binding); + } + else + { + director.ClearGenericBinding(track); + } + } + + RebuildDirectorGraphPreservingPlayback(director); + + EditorUtility.SetDirty(director); + } + } + + private static void RebuildDirectorGraphPreservingPlayback( + PlayableDirector director) + { + if (!director.playableGraph.IsValid()) + { + return; + } + + var time = director.time; + var wasPlaying = director.state == PlayState.Playing; + director.Stop(); + director.RebuildGraph(); + director.time = time; + if (wasPlaying) + { + director.Play(); + director.time = time; + } + else + { + director.Evaluate(); + director.time = time; + } } private static void CopyTrackBindings( @@ -6944,6 +7275,51 @@ namespace Streamingle.Editor public GameObject CameraRoot { get; } } + internal sealed class AuthoredCinemachineStateSnapshot + { + public AuthoredCinemachineStateSnapshot( + AuthoredCinemachineDirectorState[] directorStates, + AuthoredVirtualCameraState[] cameraStates) + { + DirectorStates = directorStates; + CameraStates = cameraStates; + } + + public AuthoredCinemachineDirectorState[] DirectorStates { get; } + public AuthoredVirtualCameraState[] CameraStates { get; } + } + + internal sealed class AuthoredCinemachineDirectorState + { + public AuthoredCinemachineDirectorState( + PlayableDirector director, + CinemachineTrack[] tracks, + UnityEngine.Object[] bindings) + { + Director = director; + Tracks = tracks; + Bindings = bindings; + } + + public PlayableDirector Director { get; } + public CinemachineTrack[] Tracks { get; } + public UnityEngine.Object[] Bindings { get; } + } + + internal sealed class AuthoredVirtualCameraState + { + public AuthoredVirtualCameraState( + CinemachineVirtualCameraBase camera, + bool enabled) + { + Camera = camera; + Enabled = enabled; + } + + public CinemachineVirtualCameraBase Camera { get; } + public bool Enabled { get; } + } + private sealed class DirectorStateSnapshot { public DirectorStateSnapshot( diff --git a/CameraAI~/README.md b/CameraAI~/README.md index 52bca8d..4965a43 100644 --- a/CameraAI~/README.md +++ b/CameraAI~/README.md @@ -16,7 +16,7 @@ Cinemachine Track이나 기존 카메라 애니메이션은 필요하지 않으 Unity Package Manager의 `Add package from git URL`에는 다음 주소를 사용할 수 있습니다. -`https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.19` +`https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.20` 배포 패키지에는 Windows x64용 `CWCameraWorker` 폴더 전체가 포함됩니다. Python은 따로 설치하지 않아도 되지만, Git 패키지의 대용량 바이너리를 받으려면 diff --git a/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs b/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs index 74f9274..e77f9b0 100644 --- a/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs +++ b/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs @@ -6,13 +6,26 @@ 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] @@ -34,6 +47,10 @@ namespace Streamingle.Editor.Tests timeline, new[] { brain }), Is.SameAs(brain)); + Assert.That( + AICameraTimelinePreviewImporter + .GetPreviewOutputStatusForEditor(brain), + Does.Contain("are active")); } finally { @@ -111,6 +128,843 @@ namespace Streamingle.Editor.Tests } } + [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( @@ -778,6 +1632,18 @@ namespace Streamingle.Editor.Tests } } + 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( diff --git a/CameraAI~/package.json b/CameraAI~/package.json index 12f7c31..818e6df 100644 --- a/CameraAI~/package.json +++ b/CameraAI~/package.json @@ -1,6 +1,6 @@ { "name": "com.mingle.cw-ai", - "version": "0.4.13", + "version": "0.4.14", "displayName": "Mingle Camera Work AI", "description": "Self-contained high-quality Unity Timeline camera generation with an embedded prepared reference library, per-shot editable clips, and A/B review tools.", "unity": "6000.0", diff --git a/README.md b/README.md index f0c7928..8e0723c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Install **Git and Git LFS before opening Unity**, then use **Add package from git URL** with: ```text -https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.19 +https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.20 ``` The Camera AI package includes the complete Windows x64