using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using NUnit.Framework; using Unity.Cinemachine; using UnityEditor; using UnityEngine; using UnityEngine.Timeline; namespace Streamingle.Editor.Tests { public sealed class AICameraCutCorrectionRecorderTests { private string _temporaryRoot; private readonly List _timelines = new List(); [SetUp] public void SetUp() { _temporaryRoot = Path.Combine( Path.GetTempPath(), $"cw_ai_cut_corrections_{Guid.NewGuid():N}"); Directory.CreateDirectory(_temporaryRoot); } [TearDown] public void TearDown() { foreach (var timeline in _timelines.Where(value => value != null)) { UnityEngine.Object.DestroyImmediate(timeline); } _timelines.Clear(); if (!string.IsNullOrWhiteSpace(_temporaryRoot) && Directory.Exists(_temporaryRoot)) { Directory.Delete(_temporaryRoot, true); } } [Test] public void CaptureRecordsKeepMoveAddAndDeleteWithoutRawEditorId() { var generated = CreateGeneration( 60, 720, new[] { 0, 60, 180, 600, 720 }); var timeline = CreateTimeline( new[] { 0, 60, 210, 300, 720 }); const string rawEditorId = "operator-secret@example.com"; var path = AICameraCutCorrectionRecorder .CaptureTimelineForTests( timeline, generated, rawEditorId, Path.Combine(_temporaryRoot, "logs"), new DateTime( 2026, 8, 2, 5, 4, 3, 210, DateTimeKind.Utc)); var json = File.ReadAllText(path); Assert.That( json, Does.Contain( $"\"schemaVersion\": " + $"\"{AICameraCutCorrectionRecorder.CurrentSchemaVersion}\"")); Assert.That(json, Does.Contain("\"sampleRate\": 60")); Assert.That(json, Does.Contain("\"songId\": \"song-1\"")); Assert.That(json, Does.Contain("\"datasetId\": \"dataset-1\"")); Assert.That(json, Does.Contain("\"keepCount\": 1")); Assert.That(json, Does.Contain("\"moveCount\": 1")); Assert.That(json, Does.Contain("\"addCount\": 1")); Assert.That(json, Does.Contain("\"deleteCount\": 1")); Assert.That(json, Does.Contain("\"deltaFrames\": 30")); Assert.That( json, Does.Contain("editor-style-sha256:")); Assert.That(json, Does.Not.Contain(rawEditorId)); var bytes = File.ReadAllBytes(path); Assert.That( bytes.Take(3).ToArray(), Is.Not.EqualTo(new byte[] { 0xEF, 0xBB, 0xBF })); Assert.That( Directory.GetFiles( Path.GetDirectoryName(path), "*.tmp-*"), Is.Empty); } [Test] public void UnchangedBoundariesAreRecordedAsAcceptedKeeps() { var generated = CreateGeneration( 60, 720, new[] { 0, 60, 180, 600, 720 }); var timeline = CreateTimeline( new[] { 0, 60, 180, 600, 720 }); var path = AICameraCutCorrectionRecorder .CaptureTimelineForTests( timeline, generated, "stable-editor", Path.Combine(_temporaryRoot, "logs"), DateTime.UnixEpoch); var json = File.ReadAllText(path); Assert.That(json, Does.Contain("\"keepCount\": 3")); Assert.That(json, Does.Contain("\"moveCount\": 0")); Assert.That(json, Does.Contain("\"addCount\": 0")); Assert.That(json, Does.Contain("\"deleteCount\": 0")); } [Test] public void SameInputsProduceDeterministicSnapshotContent() { var generated = CreateGeneration( 60, 720, new[] { 0, 60, 180, 600, 720 }); var timeline = CreateTimeline( new[] { 0, 60, 210, 300, 720 }); var capturedUtc = new DateTime( 2026, 8, 2, 1, 2, 3, DateTimeKind.Utc); var first = AICameraCutCorrectionRecorder .CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs-a"), capturedUtc); var second = AICameraCutCorrectionRecorder .CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs-b"), capturedUtc); Assert.That( File.ReadAllText(second), Is.EqualTo(File.ReadAllText(first))); } [Test] public void CaptureDoesNotModifyTimelineOrDirtyState() { var generated = CreateGeneration( 60, 720, new[] { 0, 60, 180, 600, 720 }); var timeline = CreateTimeline( new[] { 0, 60, 210, 300, 720 }); var before = timeline.GetOutputTracks() .OfType() .Single() .GetClips() .OrderBy(clip => clip.start) .Select(clip => new[] { clip.start, clip.duration, clip.easeInDuration, clip.easeOutDuration }) .ToArray(); var wasDirty = EditorUtility.IsDirty(timeline); AICameraCutCorrectionRecorder.CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs"), DateTime.UtcNow); var after = timeline.GetOutputTracks() .OfType() .Single() .GetClips() .OrderBy(clip => clip.start) .Select(clip => new[] { clip.start, clip.duration, clip.easeInDuration, clip.easeOutDuration }) .ToArray(); Assert.That(after, Is.EqualTo(before)); Assert.That(EditorUtility.IsDirty(timeline), Is.EqualTo(wasDirty)); } [Test] public void RejectsMetadataThatIsNotSixtyFps() { var generated = CreateGeneration( 30, 720, new[] { 0, 720 }); var timeline = CreateTimeline(new[] { 0, 720 }); var exception = Assert.Throws(() => AICameraCutCorrectionRecorder.CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs"), DateTime.UtcNow)); Assert.That(exception.Message, Does.Contain("60 FPS")); } [Test] public void RejectsStalePreviewMetadataHash() { var generated = CreateGeneration( 60, 720, new[] { 0, 720 }); var timeline = CreateTimeline(new[] { 0, 720 }); var exception = Assert.Throws(() => AICameraCutCorrectionRecorder.CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs"), DateTime.UtcNow, new string('0', 64))); Assert.That(exception.Message, Does.Contain("different generation")); } [Test] public void RejectsGapBetweenEditedCinemachineClips() { var generated = CreateGeneration( 60, 720, new[] { 0, 60, 720 }); var timeline = CreateTimelineWithGap(); var exception = Assert.Throws(() => AICameraCutCorrectionRecorder.CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs"), DateTime.UtcNow)); Assert.That(exception.Message, Does.Contain("gap or overlap")); } [Test] public void RejectsSubframeEditedBoundary() { var generated = CreateGeneration( 60, 720, new[] { 0, 60, 720 }); var timeline = CreateTimeline( new[] { 0d, 60.4d, 720d }); var exception = Assert.Throws(() => AICameraCutCorrectionRecorder.CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs"), DateTime.UtcNow)); Assert.That(exception.Message, Does.Contain("not aligned")); } [Test] public void AcceptsImportersMicroscopicHardCutGuard() { var generated = CreateGeneration( 60, 720, new[] { 0, 60, 180, 720 }); var timeline = CreateTimeline( new[] { 0, 60, 180, 720 }); var clips = timeline.GetOutputTracks() .OfType() .Single() .GetClips() .OrderBy(clip => clip.start) .ToArray(); clips[0].duration -= 0.000001d; clips[1].duration -= 0.000001d; var path = AICameraCutCorrectionRecorder .CaptureTimelineForTests( timeline, generated, "editor-a", Path.Combine(_temporaryRoot, "logs"), DateTime.UtcNow); Assert.That(File.Exists(path), Is.True); Assert.That( File.ReadAllText(path), Does.Contain("\"keepCount\": 2")); } private string CreateGeneration( int sampleRate, int frameCount, IReadOnlyList edges) { var generated = Path.Combine( _temporaryRoot, $"generated-{Guid.NewGuid():N}"); Directory.CreateDirectory(generated); var hashA = new string('a', 64); var hashB = new string('b', 64); var hashC = new string('c', 64); var metadata = "{\n" + " \"songName\": \"Test Song\",\n" + " \"songId\": \"song-1\",\n" + " \"datasetId\": \"dataset-1\",\n" + $" \"sampleRate\": {sampleRate},\n" + $" \"frameCount\": {frameCount},\n" + " \"shotsFile\": \"shots.json\",\n" + " \"outputSha256\": {\n" + $" \"worldCamera\": \"{hashA}\",\n" + $" \"time\": \"{hashB}\",\n" + $" \"shots\": \"{hashC}\"\n" + " }\n" + "}\n"; File.WriteAllText( Path.Combine(generated, "metadata.json"), metadata, new UTF8Encoding(false)); var shots = new StringBuilder(); shots.Append("{\n \"shots\": [\n"); for (var index = 0; index < edges.Count - 1; index++) { shots.Append( $" {{ \"startFrame\": {edges[index]}, " + $"\"endFrameExclusive\": {edges[index + 1]} }}"); shots.Append(index + 1 < edges.Count - 1 ? ",\n" : "\n"); } shots.Append(" ]\n}\n"); File.WriteAllText( Path.Combine(generated, "shots.json"), shots.ToString(), new UTF8Encoding(false)); return generated; } private TimelineAsset CreateTimeline(IReadOnlyList edges) { return CreateTimeline(edges.Select(value => (double)value).ToArray()); } private TimelineAsset CreateTimeline(IReadOnlyList edgeFrames) { var timeline = ScriptableObject.CreateInstance(); _timelines.Add(timeline); var track = timeline.CreateTrack( null, AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName); for (var index = 0; index < edgeFrames.Count - 1; index++) { var clip = track.CreateDefaultClip(); clip.start = edgeFrames[index] / AICameraCutCorrectionRecorder.RequiredSampleRate; clip.duration = (edgeFrames[index + 1] - edgeFrames[index]) / AICameraCutCorrectionRecorder.RequiredSampleRate; clip.easeInDuration = 0d; clip.easeOutDuration = 0d; } return timeline; } private TimelineAsset CreateTimelineWithGap() { var timeline = ScriptableObject.CreateInstance(); _timelines.Add(timeline); var track = timeline.CreateTrack( null, AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName); var first = track.CreateDefaultClip(); first.start = 0d; first.duration = 59d / AICameraCutCorrectionRecorder.RequiredSampleRate; first.easeInDuration = 0d; first.easeOutDuration = 0d; var second = track.CreateDefaultClip(); second.start = 60d / AICameraCutCorrectionRecorder.RequiredSampleRate; second.duration = 660d / AICameraCutCorrectionRecorder.RequiredSampleRate; second.easeInDuration = 0d; second.easeOutDuration = 0d; return timeline; } } }