streamingle-unity-utilities/CameraAI~/Tests/Editor/AICameraCutCorrectionRecorderTests.cs
2026-08-03 04:42:44 +09:00

427 lines
15 KiB
C#

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<TimelineAsset> _timelines =
new List<TimelineAsset>();
[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<CinemachineTrack>()
.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<CinemachineTrack>()
.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<InvalidDataException>(() =>
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<InvalidDataException>(() =>
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<InvalidDataException>(() =>
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<InvalidDataException>(() =>
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<CinemachineTrack>()
.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<int> 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<int> edges)
{
return CreateTimeline(edges.Select(value => (double)value).ToArray());
}
private TimelineAsset CreateTimeline(IReadOnlyList<double> edgeFrames)
{
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
_timelines.Add(timeline);
var track = timeline.CreateTrack<CinemachineTrack>(
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<TimelineAsset>();
_timelines.Add(timeline);
var track = timeline.CreateTrack<CinemachineTrack>(
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;
}
}
}