427 lines
15 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|