using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using NUnit.Framework; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace Streamingle.Editor { public sealed class TimelineCameraDatasetExporterTests { [Test] public void SceneCameraSourceFingerprintNormalizesDependencyOrder() { var reordered = TimelineCameraDatasetExporter .ComposeSceneCameraSourceFingerprint( "semantic-fingerprint", new[] { "Assets\\Source\\B.anim|hash-b", "Assets/Source/A.anim|hash-a", "Assets/Source/B.anim|hash-b" }); var canonical = TimelineCameraDatasetExporter .ComposeSceneCameraSourceFingerprint( "semantic-fingerprint", new[] { "Assets/Source/A.anim|hash-a", "Assets/Source/B.anim|hash-b" }); Assert.That(reordered, Is.EqualTo(canonical)); Assert.That(reordered, Has.Length.EqualTo(64)); } [Test] public void SceneCameraSourceFingerprintMatchingSupportsLegacyAndFailsClosed() { Assert.That( TimelineCameraDatasetExporter .TryMatchSceneCameraSourceFingerprint( TimelineCameraDatasetExporter .LegacySourceFingerprintSchema, "legacy-fingerprint", "strong-fingerprint", "legacy-fingerprint", out var legacyAccepted, out var legacyError), Is.True, legacyError); Assert.That(legacyAccepted, Is.True); Assert.That( TimelineCameraDatasetExporter .TryMatchSceneCameraSourceFingerprint( TimelineCameraDatasetExporter.SourceFingerprintSchema, "strong-fingerprint", "strong-fingerprint", "legacy-fingerprint", out legacyAccepted, out var strongError), Is.True, strongError); Assert.That(legacyAccepted, Is.False); Assert.That( TimelineCameraDatasetExporter .TryMatchSceneCameraSourceFingerprint( TimelineCameraDatasetExporter.SourceFingerprintSchema, "changed-fingerprint", "strong-fingerprint", "legacy-fingerprint", out _, out var mismatchError), Is.False); StringAssert.Contains("does not match", mismatchError); } [Test] public void GenerationInputExportDoesNotRequireAnAuthoredCamera() { var outputDirectory = Path.Combine( Path.GetTempPath(), "CWAI_GenerationInput_" + Guid.NewGuid().ToString("N")); var directorObject = new GameObject("GenerationInputDirector"); var characterObject = new GameObject("Character"); var hipsObject = new GameObject("Hips"); var headObject = new GameObject("Head"); var animator = characterObject.AddComponent(); hipsObject.transform.SetParent(characterObject.transform, false); headObject.transform.SetParent(hipsObject.transform, false); headObject.transform.localPosition = Vector3.up; var timeline = ScriptableObject.CreateInstance(); timeline.name = "MotionAndAudioOnly"; var motionClip = new AnimationClip { name = "Motion" }; motionClip.SetCurve( "Hips", typeof(Transform), "m_LocalPosition.x", AnimationCurve.Linear(0f, 0f, 1f, 0.1f)); motionClip.SetCurve( "Hips/Head", typeof(Transform), "m_LocalPosition.y", AnimationCurve.Linear(0f, 1f, 1f, 1.05f)); var animationTrack = timeline.CreateTrack(); var animationTimelineClip = animationTrack.CreateClip(); ((AnimationPlayableAsset)animationTimelineClip.asset).clip = motionClip; animationTimelineClip.duration = 1.0; var audioClip = AudioClip.Create( "Audio", 6000, 1, 6000, false); var audioSamples = Enumerable.Range(0, audioClip.samples) .Select(index => 0.25f * Mathf.Sin(index * 0.1f)) .ToArray(); Assert.That(audioClip.SetData(audioSamples, 0), Is.True); var audioTrack = timeline.CreateTrack(); var audioTimelineClip = audioTrack.CreateClip(); ((AudioPlayableAsset)audioTimelineClip.asset).clip = audioClip; audioTimelineClip.duration = 1.0; var director = directorObject.AddComponent(); director.playableAsset = timeline; director.SetGenericBinding(animationTrack, animator); directorObject.SetActive(false); try { TimelineCameraDatasetExporter .ExportGenerationInput60FpsToForCli( director, outputDirectory, false); Assert.That(directorObject.activeSelf, Is.False); var manifest = File.ReadAllText(Path.Combine( outputDirectory, "dataset_manifest.json")); StringAssert.Contains("\"inputKind\": \"generation_input\"", manifest); StringAssert.Contains("\"hasAuthoredCamera\": false", manifest); StringAssert.Contains("\"audioFingerprint\": \"", manifest); var songDirectory = Directory.GetDirectories(outputDirectory).Single(); Assert.That( new FileInfo(Path.Combine(songDirectory, "camera.f32")).Length, Is.EqualTo(60L * 9 * sizeof(float))); Assert.That( new FileInfo(Path.Combine(songDirectory, "shot_index.i32")).Length, Is.EqualTo(60L * sizeof(int))); Assert.That( new FileInfo(Path.Combine(songDirectory, "audio_features.f32")).Length, Is.EqualTo(60L * 2 * sizeof(float))); using (var cameraReader = new BinaryReader(File.OpenRead( Path.Combine(songDirectory, "camera.f32")))) { Assert.That(float.IsNaN(cameraReader.ReadSingle()), Is.True); } using (var shotReader = new BinaryReader(File.OpenRead( Path.Combine(songDirectory, "shot_index.i32")))) { Assert.That(shotReader.ReadInt32(), Is.EqualTo(-1)); } using (var featureReader = new BinaryReader(File.OpenRead( Path.Combine(songDirectory, "audio_features.f32")))) { var values = new float[120]; for (var index = 0; index < values.Length; index++) { values[index] = featureReader.ReadSingle(); } Assert.That(values.All(float.IsFinite), Is.True); Assert.That(values.Where((_, index) => index % 2 == 0).Max(), Is.GreaterThan(0f)); } var shots = File.ReadAllText(Path.Combine(songDirectory, "shots.json")); StringAssert.Contains("\"shots\": []", shots); } finally { if (Directory.Exists(outputDirectory)) { Directory.Delete(outputDirectory, true); } UnityEngine.Object.DestroyImmediate(audioClip); UnityEngine.Object.DestroyImmediate(motionClip); UnityEngine.Object.DestroyImmediate(timeline); UnityEngine.Object.DestroyImmediate(directorObject); UnityEngine.Object.DestroyImmediate(characterObject); } } [Test] public void GenerationInputSummaryReportsMissingSelection() { var summary = TimelineCameraDatasetExporter .GetGenerationInputSummaryForEditor(null); Assert.That(summary.isValid, Is.False); Assert.That(summary.error, Is.Not.Empty); } [Test] public void GenerationInputFingerprintRefusesDirtyScene() { var directorObject = new GameObject("DirtySourceDirector"); var director = directorObject.AddComponent(); try { UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty( directorObject.scene); Assert.That( TimelineCameraDatasetExporter .TryComputeGenerationInputSourceFingerprint( director, out var fingerprint, out var error), Is.False); Assert.That(fingerprint, Is.Empty); StringAssert.Contains("unsaved changes", error); } finally { UnityEngine.Object.DestroyImmediate(directorObject); } } [Test] public void ReusableGenerationInputValidationAcceptsCompleteExport() { var root = CreateTemporaryDirectory(); const string fingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; try { var candidate = CreateReusableGenerationInputFixture( root, "GenerationInput_60fps_20260802_120000_000", fingerprint); Assert.That( TimelineCameraDatasetExporter .TryValidateReusableGenerationInputExport( candidate, fingerprint, out var error), Is.True, error); } finally { Directory.Delete(root, true); } } [Test] public void ReusableGenerationInputValidationRejectsChangedSourceAndAudio() { var root = CreateTemporaryDirectory(); const string fingerprint = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; try { var candidate = CreateReusableGenerationInputFixture( root, "GenerationInput_60fps_20260802_120000_000", fingerprint); Assert.That( TimelineCameraDatasetExporter .TryValidateReusableGenerationInputExport( candidate, "changed-source", out _), Is.False); File.AppendAllText( Path.Combine(candidate, "01_Test", "audio_source.wav"), "tampered"); Assert.That( TimelineCameraDatasetExporter .TryValidateReusableGenerationInputExport( candidate, fingerprint, out var error), Is.False); StringAssert.Contains("fingerprint", error); } finally { Directory.Delete(root, true); } } [Test] public void ReusableGenerationInputSearchSkipsIncompleteNewestCandidate() { var root = CreateTemporaryDirectory(); const string fingerprint = "cccccccccccccccccccccccccccccccc" + "cccccccccccccccccccccccccccccccc"; try { var expected = CreateReusableGenerationInputFixture( root, "GenerationInput_60fps_20260802_120000_000", fingerprint); var incomplete = CreateReusableGenerationInputFixture( root, "GenerationInput_60fps_20260802_120001_000", fingerprint); File.Delete(Path.Combine( incomplete, "01_Test", "root.f32")); Assert.That( TimelineCameraDatasetExporter .TryFindReusableGenerationInputExport( root, fingerprint, out var actual, out var error), Is.True, error); Assert.That(actual, Is.EqualTo(Path.GetFullPath(expected))); } finally { Directory.Delete(root, true); } } [Test] public void ReusableGenerationInputValidationRejectsSameSizeBinaryCorruption() { var root = CreateTemporaryDirectory(); const string fingerprint = "dddddddddddddddddddddddddddddddd" + "dddddddddddddddddddddddddddddddd"; try { var candidate = CreateReusableGenerationInputFixture( root, "GenerationInput_60fps_20260802_120000_000", fingerprint); var jointsPath = Path.Combine( candidate, "01_Test", "joints_world.f32"); var bytes = File.ReadAllBytes(jointsPath); bytes[0] ^= 0x01; File.WriteAllBytes(jointsPath, bytes); Assert.That( TimelineCameraDatasetExporter .TryValidateReusableGenerationInputExport( candidate, fingerprint, out var error), Is.False); StringAssert.Contains("fingerprint", error); } finally { Directory.Delete(root, true); } } [Test] public void ReusableGenerationInputSearchFailsClosedOnMalformedManifest() { var root = CreateTemporaryDirectory(); try { var candidate = Path.Combine( root, "GenerationInput_60fps_20260802_120000_000"); Directory.CreateDirectory(candidate); File.WriteAllText( Path.Combine(candidate, "dataset_manifest.json"), "{"); Assert.That( TimelineCameraDatasetExporter .TryFindReusableGenerationInputExport( root, "source", out var actual, out _), Is.False); Assert.That(actual, Is.Empty); } finally { Directory.Delete(root, true); } } private static string CreateTemporaryDirectory() { var path = Path.Combine( Path.GetTempPath(), "CWAI_ReusableGenerationInput_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(path); return path; } private static string CreateReusableGenerationInputFixture( string root, string directoryName, string sourceFingerprint) { const int frameCount = 2; const int boneCount = 2; var candidate = Path.Combine(root, directoryName); var songDirectory = Path.Combine(candidate, "01_Test"); Directory.CreateDirectory(songDirectory); var audioPath = Path.Combine(songDirectory, "audio_source.wav"); File.WriteAllBytes(audioPath, new byte[] { 1, 2, 3, 4, 5 }); var audioFingerprint = ComputeSha256(audioPath); var song = new GenerationInputSongFixture { songName = "Test", role = "generation_input", inputKind = "generation_input", hasAuthoredCamera = false, folderName = "01_Test", audioFile = "audio_source.wav", audioFingerprint = audioFingerprint, sampleRate = 60, frameCount = frameCount, sharedBoneCount = boneCount, shotCount = 0, missingCameraFrames = frameCount, missingJointSamples = 0, missingRootSamples = 0, jointsFile = "joints_world.f32", cameraFile = "camera.f32", rootFile = "root.f32", shotIndexFile = "shot_index.i32", timeFile = "time.f64", shotsFile = "shots.json", previewFile = "preview.csv", audioFeaturesFile = "audio_features.f32" }; var manifest = new GenerationInputManifestFixture { schemaVersion = "1.3", role = "generation_input", inputKind = "generation_input", hasAuthoredCamera = false, sampleRate = 60, sourceFingerprint = sourceFingerprint, discoveredCameraTrackCount = 0, exportedCameraTrackCount = 0, skippedCameraTracks = new List(), sharedBones = new List { "Hips", "Head" }, songs = new List { song } }; File.WriteAllText( Path.Combine(songDirectory, "shots.json"), JsonUtility.ToJson(new ShotFileFixture { timelineName = "Test", shots = new List() }, true)); File.WriteAllText( Path.Combine(songDirectory, "preview.csv"), "frame,time\n0,0\n"); WriteZeroBytes( Path.Combine(songDirectory, song.jointsFile), frameCount * boneCount * 3 * sizeof(float)); WriteZeroBytes( Path.Combine(songDirectory, song.rootFile), frameCount * 14 * sizeof(float)); WriteZeroBytes( Path.Combine(songDirectory, song.timeFile), frameCount * sizeof(double)); WriteZeroBytes( Path.Combine(songDirectory, song.audioFeaturesFile), frameCount * 2 * sizeof(float)); using (var writer = new BinaryWriter(File.Create( Path.Combine(songDirectory, song.cameraFile)))) { for (var index = 0; index < frameCount * 9; index++) { writer.Write(float.NaN); } } using (var writer = new BinaryWriter(File.Create( Path.Combine(songDirectory, song.shotIndexFile)))) { for (var index = 0; index < frameCount; index++) { writer.Write(-1); } } song.jointsFingerprint = ComputeSha256( Path.Combine(songDirectory, song.jointsFile)); song.cameraFingerprint = ComputeSha256( Path.Combine(songDirectory, song.cameraFile)); song.rootFingerprint = ComputeSha256( Path.Combine(songDirectory, song.rootFile)); song.shotIndexFingerprint = ComputeSha256( Path.Combine(songDirectory, song.shotIndexFile)); song.timeFingerprint = ComputeSha256( Path.Combine(songDirectory, song.timeFile)); song.audioFeaturesFingerprint = ComputeSha256( Path.Combine(songDirectory, song.audioFeaturesFile)); File.WriteAllText( Path.Combine(candidate, "dataset_manifest.json"), JsonUtility.ToJson(manifest, true)); File.WriteAllText( Path.Combine(songDirectory, "metadata.json"), JsonUtility.ToJson(song, true)); return candidate; } private static void WriteZeroBytes(string path, int count) { File.WriteAllBytes(path, new byte[count]); } private static string ComputeSha256(string path) { using var stream = File.OpenRead(path); using var sha256 = SHA256.Create(); return string.Concat( sha256.ComputeHash(stream) .Select(value => value.ToString("x2"))); } [Serializable] private sealed class GenerationInputManifestFixture { public string schemaVersion; public string role; public string inputKind; public bool hasAuthoredCamera; public int sampleRate; public string sourceFingerprint; public int discoveredCameraTrackCount; public int exportedCameraTrackCount; public List skippedCameraTracks; public List sharedBones; public List songs; } [Serializable] private sealed class GenerationInputSongFixture { public string songName; public string role; public string inputKind; public bool hasAuthoredCamera; public string folderName; public string audioFile; public string audioFingerprint; public int sampleRate; public int frameCount; public int sharedBoneCount; public int shotCount; public int missingCameraFrames; public int missingJointSamples; public int missingRootSamples; public string jointsFile; public string jointsFingerprint; public string cameraFile; public string cameraFingerprint; public string rootFile; public string rootFingerprint; public string shotIndexFile; public string shotIndexFingerprint; public string timeFile; public string timeFingerprint; public string shotsFile; public string previewFile; public string audioFeaturesFile; public string audioFeaturesFingerprint; } [Serializable] private sealed class ShotFileFixture { public string timelineName; public List shots; } [Serializable] private sealed class ShotFixture { } } }