feat: add Camera Work AI package

This commit is contained in:
KINDNICK
2026-08-03 04:42:44 +09:00
parent 26c578416f
commit 02d76bc87c
2187 changed files with 119610 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1c974ef7459d45caa99203db8ef311b1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,165 @@
using System;
using NUnit.Framework;
using UnityEditor.Recorder.Timeline;
using UnityEngine;
using UnityEngine.Timeline;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraABRenderTests
{
[Test]
public void ToInclusiveEndFrame_UsesEndExclusiveContract()
{
Assert.That(
AICameraABRenderUtility.ToInclusiveEndFrame(10, 20, 100),
Is.EqualTo(19));
}
[Test]
public void ValidateFrameRange_RejectsEmptyRange()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => AICameraABRenderUtility.ValidateFrameRange(
10,
10,
100));
}
[Test]
public void ValidateOutputSettings_RejectsOddMp4Dimensions()
{
Assert.Throws<ArgumentException>(
() => AICameraABRenderUtility.ValidateOutputSettings(
AICameraABRenderCodec.MP4,
1919,
1080));
Assert.DoesNotThrow(
() => AICameraABRenderUtility.ValidateOutputSettings(
AICameraABRenderCodec.WEBM,
1919,
1080));
}
[Test]
public void ValidateCandidatePair_RejectsDifferentTimeData()
{
var candidateA = CreateProbe("same-time");
var candidateB = CreateProbe("different-time");
var request = CreateRequest();
Assert.Throws<System.IO.InvalidDataException>(
() => AICameraABRenderUtility.ValidateCandidatePair(
candidateA,
candidateB,
request));
}
[Test]
public void ValidateCandidatePair_RejectsDifferentDatasets()
{
var candidateA = CreateProbe("same-time", "dataset-a");
var candidateB = CreateProbe("same-time", "dataset-b");
Assert.Throws<System.IO.InvalidDataException>(
() => AICameraABRenderUtility.ValidateCandidatePair(
candidateA,
candidateB,
CreateRequest()));
}
[Test]
public void MuteCopiedRecorderTracks_MutesExistingRecorderTrack()
{
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var recorderTrack = timeline.CreateTrack<RecorderTrack>(
null,
"Existing Recorder");
recorderTrack.muted = false;
var mutedCount =
AICameraTimelinePreviewImporter.MuteCopiedRecorderTracks(
timeline);
Assert.That(mutedCount, Is.EqualTo(1));
Assert.That(recorderTrack.muted, Is.True);
Assert.That(
AICameraTimelinePreviewImporter.MuteCopiedRecorderTracks(
timeline),
Is.Zero);
}
finally
{
UnityEngine.Object.DestroyImmediate(timeline);
}
}
[Test]
public void WriteJsonAtomic_ReplacesExistingStateWithoutTempFiles()
{
var directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"cw-ai-ab-json-{Guid.NewGuid():N}");
var path = System.IO.Path.Combine(directory, "active_job.json");
try
{
AICameraABRenderUtility.WriteJsonAtomic(
path,
new AICameraABRenderJobState { jobId = "first" });
AICameraABRenderUtility.WriteJsonAtomic(
path,
new AICameraABRenderJobState { jobId = "second" });
var restored =
AICameraABRenderUtility.ReadJson<AICameraABRenderJobState>(
path);
Assert.That(restored.jobId, Is.EqualTo("second"));
Assert.That(
System.IO.Directory.GetFiles(directory, "*.tmp"),
Is.Empty);
}
finally
{
if (System.IO.Directory.Exists(directory))
{
System.IO.Directory.Delete(directory, true);
}
}
}
private static AICameraABCandidateProbe CreateProbe(
string timeSha,
string datasetId = "dataset")
{
return new AICameraABCandidateProbe
{
Metadata = new AICameraABGeneratedCameraMetadata(),
Result = new AICameraABCandidateResult
{
sampleRate = 60,
frameCount = 120,
duration = 2,
timeSha256 = timeSha,
songId = "song",
datasetId = datasetId,
targetEvaluationGroupId = "group",
targetAspectRatio = 16.0 / 9.0
}
};
}
private static AICameraABRenderRequest CreateRequest()
{
return new AICameraABRenderRequest
{
startFrame = 0,
endFrameExclusive = 120,
codec = AICameraABRenderCodec.MP4,
width = 1920,
height = 1080
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 99556bdd71254adaa3f4d8654875a01d
@@ -0,0 +1,126 @@
using System;
using System.IO;
using NUnit.Framework;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraCliRunnerTests
{
[Test]
public void ResolveExecutablePrefersConfiguredWorker()
{
var root = CreateTemporaryDirectory();
try
{
var configured = Path.Combine(root, "custom-worker.exe");
File.WriteAllText(configured, string.Empty);
Assert.That(
AICameraCliRunner.ResolveExecutable(
configured,
string.Empty,
string.Empty,
string.Empty),
Is.EqualTo(Path.GetFullPath(configured)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ResolveExecutableFindsConventionalDistributionFolder()
{
var root = CreateTemporaryDirectory();
try
{
var worker = Path.Combine(
root,
"dist",
"CWCameraWorker",
AICameraCliRunner.ExecutableName);
Directory.CreateDirectory(Path.GetDirectoryName(worker));
File.WriteAllText(worker, string.Empty);
Assert.That(
AICameraCliRunner.ResolveExecutable(
string.Empty,
root,
string.Empty,
string.Empty),
Is.EqualTo(Path.GetFullPath(worker)));
}
finally
{
Directory.Delete(root, true);
}
}
[TestCase("plain", "plain")]
[TestCase("two words", "\"two words\"")]
[TestCase("", "\"\"")]
public void QuoteArgumentUsesWindowsProcessEscaping(
string argument,
string expected)
{
Assert.That(AICameraCliRunner.QuoteArgument(argument), Is.EqualTo(expected));
}
[Test]
public void JsonLineEventParsesProgressAndIdentity()
{
var parsed = AICameraCliRunner.TryParseEvent(
"{\"type\":\"progress\",\"protocolVersion\":\"1\"," +
"\"jobId\":\"unity-1\",\"phase\":\"generate\"," +
"\"progress\":0.25,\"etaSeconds\":12.5}",
out var cliEvent);
Assert.That(parsed, Is.True);
Assert.That(cliEvent.EventType, Is.EqualTo("progress"));
Assert.That(cliEvent.JobId, Is.EqualTo("unity-1"));
Assert.That(cliEvent.Progress, Is.EqualTo(0.25f));
Assert.That(cliEvent.EtaSeconds, Is.EqualTo(12.5d));
}
[Test]
public void PrefixedEventParsesButOrdinaryLogDoesNot()
{
Assert.That(
AICameraCliRunner.TryParseEvent(
"CWAI_EVENT {\"eventType\":\"stage\",\"phase\":\"prepare\"}",
out var cliEvent),
Is.True);
Assert.That(cliEvent.EventType, Is.EqualTo("stage"));
Assert.That(
AICameraCliRunner.TryParseEvent("preparing camera data", out _),
Is.False);
}
[Test]
public void ResultEventParsesGeneratedOutputPath()
{
var expected = Path.Combine(
Path.GetTempPath(),
"cw-ai generated result");
var jsonPath = expected.Replace("\\", "\\\\");
Assert.That(
AICameraCliRunner.TryParseEvent(
"{\"type\":\"result\",\"protocolVersion\":\"1\"," +
"\"outputPath\":\"" + jsonPath + "\"}",
out var cliEvent),
Is.True);
Assert.That(cliEvent.OutputPath, Is.EqualTo(expected));
}
private static string CreateTemporaryDirectory()
{
var path = Path.Combine(
Path.GetTempPath(),
"cw-ai-cli-runner-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 30c854c9afbd493f8b2e660c23584f83
@@ -0,0 +1,484 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
public sealed class AICameraCurveSimplifierTests
{
[Test]
public void ExactPresetKeepsEverySampleAndNormalizesQuaternionSigns()
{
var samples = CreateSamples(121);
var result = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
var expected = Enumerable.Range(0, samples.Times.Length).ToArray();
CollectionAssert.AreEqual(expected, result.PositionIndices);
CollectionAssert.AreEqual(expected, result.RotationIndices);
CollectionAssert.AreEqual(expected, result.FieldOfViewIndices);
CollectionAssert.AreEqual(expected, result.DutchIndices);
for (var index = 1; index < result.Rotations.Length; index++)
{
Assert.That(
Quaternion.Dot(
result.Rotations[index - 1],
result.Rotations[index]),
Is.GreaterThanOrEqualTo(0f),
$"Quaternion sign changed at sample {index}.");
}
}
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)]
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)]
public void ReducedPresetsRespectConfiguredVectorAngleAndLensErrors(
AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
{
var samples = CreateSamples(241);
var result = Simplify(samples, preset);
var settings =
AICameraTimelinePreviewImporter.GetCurveSimplificationSettings(preset);
AssertVectorError(
result,
settings.PositionErrorMeters + 0.00001f);
AssertQuaternionError(
result,
settings.QuaternionAngleErrorDegrees + 0.0001f);
AssertScalarError(
result.Times,
result.FieldOfView,
result.FieldOfViewIndices,
settings.FieldOfViewErrorDegrees + 0.00001f,
"FOV");
AssertScalarError(
result.Times,
result.Dutch,
result.DutchIndices,
settings.DutchErrorDegrees + 0.00001f,
"Dutch");
}
[Test]
public void EveryChannelPreservesEndpointsAndGlobalExtrema()
{
var samples = CreateSamples(181);
var result = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);
AssertEndpoints(result.PositionIndices, samples.Times.Length);
AssertEndpoints(result.RotationIndices, samples.Times.Length);
AssertEndpoints(result.FieldOfViewIndices, samples.Times.Length);
AssertEndpoints(result.DutchIndices, samples.Times.Length);
AssertExtrema(
result.PositionIndices,
result.Positions.Select(value => value.x).ToArray());
AssertExtrema(
result.PositionIndices,
result.Positions.Select(value => value.y).ToArray());
AssertExtrema(
result.PositionIndices,
result.Positions.Select(value => value.z).ToArray());
AssertExtrema(result.FieldOfViewIndices, result.FieldOfView);
AssertExtrema(result.DutchIndices, result.Dutch);
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.x).ToArray());
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.y).ToArray());
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.z).ToArray());
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.w).ToArray());
}
[Test]
public void QuaternionComponentsUseOneStrictlyIncreasingTimeIndexSet()
{
var samples = CreateSamples(151);
var result = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
Assert.That(result.RotationIndices, Is.Ordered.Ascending);
Assert.That(
result.RotationIndices.Distinct().Count(),
Is.EqualTo(result.RotationIndices.Length));
foreach (var index in result.RotationIndices)
{
Assert.That(index, Is.InRange(0, result.Times.Length - 1));
}
for (var index = 1; index < result.Rotations.Length; index++)
{
Assert.That(
Quaternion.Dot(
result.Rotations[index - 1],
result.Rotations[index]),
Is.GreaterThanOrEqualTo(0f));
}
}
[Test]
public void EditablePresetReducesSmoothCameraDataMoreThanExact()
{
var samples = CreateSamples(301);
var exact = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
var editable = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);
Assert.That(
editable.PositionIndices.Length,
Is.LessThan(exact.PositionIndices.Length));
Assert.That(
editable.RotationIndices.Length,
Is.LessThan(exact.RotationIndices.Length));
Assert.That(
editable.FieldOfViewIndices.Length,
Is.LessThan(exact.FieldOfViewIndices.Length));
Assert.That(
editable.DutchIndices.Length,
Is.LessThan(exact.DutchIndices.Length));
}
[Test]
public void ExplicitDirectorScopesSeparateSameNamedSongDirectors()
{
var firstObject = new GameObject("SongDirector");
var secondObject = new GameObject("SongDirector");
var firstTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
var secondTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var firstDirector = firstObject.AddComponent<PlayableDirector>();
var secondDirector = secondObject.AddComponent<PlayableDirector>();
firstDirector.playableAsset = firstTimeline;
secondDirector.playableAsset = secondTimeline;
var firstName = AICameraTimelinePreviewImporter
.GetScopedPreviewDirectorName(firstDirector);
var secondName = AICameraTimelinePreviewImporter
.GetScopedPreviewDirectorName(secondDirector);
StringAssert.StartsWith(
AICameraTimelinePreviewImporter.PreviewDirectorName + "__",
firstName);
Assert.That(secondName, Is.Not.EqualTo(firstName));
}
finally
{
UnityEngine.Object.DestroyImmediate(firstObject);
UnityEngine.Object.DestroyImmediate(secondObject);
UnityEngine.Object.DestroyImmediate(firstTimeline);
UnityEngine.Object.DestroyImmediate(secondTimeline);
}
}
[Test]
public void PreviewDirectorDetectionAcceptsScopedGeneratedPreviewOnly()
{
var previewObject = new GameObject(
AICameraTimelinePreviewImporter.PreviewDirectorName + "__Song_1234");
var ordinaryObject = new GameObject("Song");
var previewTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
var ordinaryTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
previewTimeline.CreateTrack<AnimationTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var preview = previewObject.AddComponent<PlayableDirector>();
var ordinary = ordinaryObject.AddComponent<PlayableDirector>();
preview.playableAsset = previewTimeline;
ordinary.playableAsset = ordinaryTimeline;
Assert.That(
AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(preview),
Is.True);
Assert.That(
AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(ordinary),
Is.False);
}
finally
{
UnityEngine.Object.DestroyImmediate(previewObject);
UnityEngine.Object.DestroyImmediate(ordinaryObject);
UnityEngine.Object.DestroyImmediate(previewTimeline);
UnityEngine.Object.DestroyImmediate(ordinaryTimeline);
}
}
[Test]
public void FinalAssetFolderMustStayBelowAssets()
{
Assert.That(
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets/Shows/AI_CameraFinal/SongA/"),
Is.EqualTo("Assets/Shows/AI_CameraFinal/SongA"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets/../Outside"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"C:/Outside"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets"));
}
[Test]
public void FinalizationClearsDisposablePreviewTrackBindings()
{
var directorObject = new GameObject("FinalDestinationDirector");
var animatorObject = new GameObject("DisposablePreviewAnimator");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
var animator = animatorObject.AddComponent<Animator>();
var track = timeline.CreateTrack<AnimationTrack>(
null,
"Disposable Preview Track");
director.playableAsset = timeline;
director.SetGenericBinding(track, animator);
Assert.That(director.GetGenericBinding(track), Is.SameAs(animator));
AICameraTimelinePreviewImporter
.ClearTimelineBindingsForFinalization(director, timeline);
Assert.That(director.GetGenericBinding(track), Is.Null);
}
finally
{
UnityEngine.Object.DestroyImmediate(directorObject);
UnityEngine.Object.DestroyImmediate(animatorObject);
UnityEngine.Object.DestroyImmediate(timeline);
}
}
private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
Simplify(
CameraSamples samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
{
return AICameraTimelinePreviewImporter.SimplifyCameraCurves(
samples.Times,
samples.Positions,
samples.Rotations,
samples.FieldOfView,
samples.Dutch,
preset);
}
private static CameraSamples CreateSamples(int count)
{
var times = new double[count];
var positions = new Vector3[count];
var rotations = new Quaternion[count];
var fieldOfView = new float[count];
var dutch = new float[count];
for (var index = 0; index < count; index++)
{
var time = index / 60.0;
times[index] = time;
positions[index] = new Vector3(
1.2f * Mathf.Sin((float)time * 0.8f),
1.6f + 0.25f * Mathf.Cos((float)time * 1.1f),
-3.2f + 0.7f * Mathf.Sin((float)time * 0.45f));
var rotation = Quaternion.Euler(
8f * Mathf.Sin((float)time * 0.7f),
35f * Mathf.Sin((float)time * 0.35f),
3f * Mathf.Cos((float)time * 0.9f));
rotations[index] = index % 11 == 0
? new Quaternion(
-rotation.x,
-rotation.y,
-rotation.z,
-rotation.w)
: rotation;
fieldOfView[index] =
42f + 7f * Mathf.Sin((float)time * 0.55f);
dutch[index] =
2.5f * Mathf.Cos((float)time * 0.75f);
}
return new CameraSamples(
times,
positions,
rotations,
fieldOfView,
dutch);
}
private static void AssertVectorError(
AICameraTimelinePreviewImporter.SimplifiedCameraCurves result,
float maximumError)
{
ForEverySegmentSample(
result.Times,
result.PositionIndices,
(sample, left, right, blend) =>
{
var reconstructed = Vector3.LerpUnclamped(
result.Positions[left],
result.Positions[right],
blend);
Assert.That(
Vector3.Distance(result.Positions[sample], reconstructed),
Is.LessThanOrEqualTo(maximumError),
$"Position error exceeded at sample {sample}.");
});
}
private static void AssertQuaternionError(
AICameraTimelinePreviewImporter.SimplifiedCameraCurves result,
float maximumError)
{
ForEverySegmentSample(
result.Times,
result.RotationIndices,
(sample, left, right, blend) =>
{
var reconstructed = Normalize(new Quaternion(
Mathf.LerpUnclamped(
result.Rotations[left].x,
result.Rotations[right].x,
blend),
Mathf.LerpUnclamped(
result.Rotations[left].y,
result.Rotations[right].y,
blend),
Mathf.LerpUnclamped(
result.Rotations[left].z,
result.Rotations[right].z,
blend),
Mathf.LerpUnclamped(
result.Rotations[left].w,
result.Rotations[right].w,
blend)));
Assert.That(
Quaternion.Angle(result.Rotations[sample], reconstructed),
Is.LessThanOrEqualTo(maximumError),
$"Quaternion angle error exceeded at sample {sample}.");
});
}
private static void AssertScalarError(
IReadOnlyList<double> times,
IReadOnlyList<float> values,
IReadOnlyList<int> indices,
float maximumError,
string label)
{
ForEverySegmentSample(
times,
indices,
(sample, left, right, blend) =>
{
var reconstructed = Mathf.LerpUnclamped(
values[left],
values[right],
blend);
Assert.That(
Mathf.Abs(values[sample] - reconstructed),
Is.LessThanOrEqualTo(maximumError),
$"{label} error exceeded at sample {sample}.");
});
}
private static void ForEverySegmentSample(
IReadOnlyList<double> times,
IReadOnlyList<int> indices,
Action<int, int, int, float> assertion)
{
for (var segmentIndex = 1;
segmentIndex < indices.Count;
segmentIndex++)
{
var left = indices[segmentIndex - 1];
var right = indices[segmentIndex];
var duration = times[right] - times[left];
for (var sample = left; sample <= right; sample++)
{
var blend = duration > double.Epsilon
? (float)((times[sample] - times[left]) / duration)
: 0f;
assertion(sample, left, right, blend);
}
}
}
private static void AssertEndpoints(IReadOnlyCollection<int> indices, int count)
{
Assert.That(indices, Does.Contain(0));
Assert.That(indices, Does.Contain(count - 1));
}
private static void AssertExtrema(
IReadOnlyCollection<int> indices,
IReadOnlyList<float> values)
{
var minimum = Enumerable.Range(0, values.Count)
.OrderBy(index => values[index])
.First();
var maximum = Enumerable.Range(0, values.Count)
.OrderByDescending(index => values[index])
.First();
Assert.That(indices, Does.Contain(minimum));
Assert.That(indices, Does.Contain(maximum));
}
private static Quaternion Normalize(Quaternion value)
{
var magnitude = Mathf.Sqrt(
value.x * value.x +
value.y * value.y +
value.z * value.z +
value.w * value.w);
return new Quaternion(
value.x / magnitude,
value.y / magnitude,
value.z / magnitude,
value.w / magnitude);
}
private readonly struct CameraSamples
{
public CameraSamples(
double[] times,
Vector3[] positions,
Quaternion[] rotations,
float[] fieldOfView,
float[] dutch)
{
Times = times;
Positions = positions;
Rotations = rotations;
FieldOfView = fieldOfView;
Dutch = dutch;
}
public double[] Times { get; }
public Vector3[] Positions { get; }
public Quaternion[] Rotations { get; }
public float[] FieldOfView { get; }
public float[] Dutch { get; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5856da1abe05460b9e98bed6d6d2536d
@@ -0,0 +1,426 @@
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1b88c37fe4bd4e48818660d18810ae43
@@ -0,0 +1,844 @@
using System;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using NUnit.Framework;
using UnityEngine;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraGeneratorWindowTests
{
[Serializable]
private sealed class DirectiveDocument
{
public string schemaVersion;
public DirectiveShot[] shots;
}
[Serializable]
private sealed class DirectiveShot
{
public int index;
public string shotSize;
public string motion;
public string composition;
public float distanceMeters;
public float motionIntensity;
public float bodyFollowStrength;
}
private static string HybridMetadataJson(
string generationMode = "hierarchical_hybrid_retarget_v12",
string plannerMode = "hierarchical",
double targetAspectRatio = 16d / 9d)
{
return "{" +
"\"schemaVersion\":\"3.6\"," +
$"\"generationMode\":\"{generationMode}\"," +
"\"songId\":\"song-id\"," +
"\"datasetId\":\"dataset-id\"," +
"\"frameCount\":6," +
"\"sampleRate\":60," +
"\"shotCount\":2," +
$"\"plannerMode\":\"{plannerMode}\"," +
"\"targetAspectRatio\":" +
targetAspectRatio.ToString(
"R",
CultureInfo.InvariantCulture) + "," +
"\"worldCameraFile\":\"generated_camera_world.f32\"," +
"\"timeFile\":\"time.f64\"," +
"\"shotsFile\":\"shots.json\"," +
"\"outputSha256\":{" +
"\"worldCamera\":\"camera-hash\"," +
"\"time\":\"time-hash\"," +
"\"shots\":\"shots-hash\"}}";
}
private static string HybridShotsJson(string schemaVersion = "1.2")
{
return "{" +
$"\"schemaVersion\":\"{schemaVersion}\"," +
"\"shots\":[" +
"{\"index\":0,\"startFrame\":0,\"endFrameExclusive\":3}," +
"{\"index\":1,\"startFrame\":3,\"endFrameExclusive\":6}]}";
}
[Test]
public void DefaultCwAiRootPrefersConfiguredThenSiblingRepository()
{
var root = Path.Combine(
Path.GetTempPath(),
"cw-ai-root-tests-" + Guid.NewGuid().ToString("N"));
var project = Path.Combine(root, "UnityProject");
var configured = Path.Combine(root, "ConfiguredCWAI");
var sibling = Path.Combine(root, "CW-AI");
Directory.CreateDirectory(project);
CreateCwAiRootMarker(configured);
CreateCwAiRootMarker(sibling);
try
{
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
configured),
Is.EqualTo(Path.GetFullPath(configured)));
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
string.Empty),
Is.EqualTo(Path.GetFullPath(sibling)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void DefaultCwAiRootSkipsProjectCopyWithoutTrainingIndex()
{
var root = Path.Combine(
Path.GetTempPath(),
"cw-ai-incomplete-root-tests-" + Guid.NewGuid().ToString("N"));
var project = Path.Combine(root, "UnityProject");
var sibling = Path.Combine(root, "CW-AI");
Directory.CreateDirectory(Path.Combine(project, "DatasetExports"));
Directory.CreateDirectory(Path.Combine(
project,
"MachineLearning",
"CameraDirector"));
CreateCwAiRootMarker(sibling);
try
{
Assert.That(
AICameraGeneratorWindow.IsGenerationLibraryRoot(project),
Is.False);
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
project),
Is.EqualTo(Path.GetFullPath(sibling)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void DefaultCwAiRootSkipsMalformedConfiguredPath()
{
var root = Path.Combine(
Path.GetTempPath(),
"cw-ai-malformed-root-tests-" + Guid.NewGuid().ToString("N"));
var project = Path.Combine(root, "UnityProject");
var sibling = Path.Combine(root, "CW-AI");
Directory.CreateDirectory(project);
CreateCwAiRootMarker(sibling);
try
{
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
"\0invalid"),
Is.EqualTo(Path.GetFullPath(sibling)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ProjectCacheRootDefaultsToWritableProjectLibrary()
{
var project = Path.Combine(
Path.GetTempPath(),
"cw-ai-cache-root-tests-" + Guid.NewGuid().ToString("N"));
var configured = Path.Combine(project, "ConfiguredCache");
Assert.That(
AICameraGeneratorWindow.ResolveProjectCacheRoot(
project,
string.Empty),
Is.EqualTo(Path.Combine(
Path.GetFullPath(project),
"Library",
"CWAI")));
Assert.That(
AICameraGeneratorWindow.ResolveProjectCacheRoot(
project,
configured),
Is.EqualTo(Path.GetFullPath(configured)));
}
private static void CreateCwAiRootMarker(string root)
{
Directory.CreateDirectory(Path.Combine(root, "DatasetExports"));
Directory.CreateDirectory(Path.Combine(
root,
"MachineLearning",
"CameraDirector"));
var reports = Directory.CreateDirectory(Path.Combine(root, "reports"));
File.WriteAllText(
Path.Combine(reports.FullName, "training_index.json"),
"{}");
var models = Directory.CreateDirectory(Path.Combine(root, "models"));
File.WriteAllText(
Path.Combine(models.FullName, "cut_ranker_v2.json"),
"{}");
}
[Test]
public void CliWorkerIsTheOnlyHighQualityBackend()
{
Assert.That(
AICameraGeneratorWindow.HybridQualityBackendLabel,
Does.Contain("CWCameraWorker"));
Assert.That(
AICameraGeneratorWindow.HybridQualityBackendDescription,
Does.Contain("별도 Python 설치가 필요하지 않습니다"));
}
[TestCase(0, "auto")]
[TestCase(1, "slow")]
[TestCase(2, "normal")]
[TestCase(3, "fast")]
public void CutRhythmUsesStableDataPlannerCliValues(
int value,
string expected)
{
Assert.That(
AICameraGeneratorWindow.CutDensityCliValueForTests(value),
Is.EqualTo(expected));
Assert.That(
AICameraGeneratorWindow.CutRhythmExplanation,
Does.Contain("음악"));
}
[TestCase("auto", "balanced")]
[TestCase("center", "centered")]
[TestCase("centered", "centered")]
[TestCase("left_third", "left_third")]
[TestCase("right-third", "right_third")]
public void HybridHorizontalFramingPreservesExplicitDirection(
string composition,
string expected)
{
Assert.That(
AICameraGeneratorWindow.HybridHorizontalFraming(composition),
Is.EqualTo(expected));
}
[Test]
public void FullGenerationIgnoresPersistedManualOverrides()
{
var unusedOutput = Path.Combine(
Path.GetTempPath(),
"cw-ai-full-scope-" + Guid.NewGuid().ToString("N"));
var scope = AICameraGeneratorWindow.CreateGenerationControlScope(
false,
12,
unusedOutput,
"close",
"orbit_right",
"left_third",
9.5f,
2f,
1f);
Assert.That(scope.ShotSize, Is.EqualTo("auto"));
Assert.That(scope.Motion, Is.EqualTo("auto"));
Assert.That(scope.Composition, Is.EqualTo("auto"));
Assert.That(scope.DistanceMeters, Is.Zero);
Assert.That(scope.MotionIntensity, Is.EqualTo(1f));
Assert.That(scope.BodyFollowStrength, Is.EqualTo(0.18f));
Assert.That(scope.DirectivesPath, Is.Empty);
Assert.That(scope.UsesSelectedShotOverrides, Is.False);
Assert.That(Directory.Exists(unusedOutput), Is.False);
}
[Test]
public void SelectedShotRegenerationSerializesOnlyThatShotsOverrides()
{
var output = Path.Combine(
Path.GetTempPath(),
"cw-ai-selected-scope-" + Guid.NewGuid().ToString("N"));
try
{
var scope =
AICameraGeneratorWindow.CreateGenerationControlScope(
true,
7,
output,
"medium",
"truck_left",
"right_third",
6.25f,
1.4f,
0.08f);
Assert.That(scope.ShotSize, Is.EqualTo("auto"));
Assert.That(scope.Motion, Is.EqualTo("auto"));
Assert.That(scope.Composition, Is.EqualTo("auto"));
Assert.That(scope.DistanceMeters, Is.Zero);
Assert.That(scope.MotionIntensity, Is.EqualTo(1f));
Assert.That(scope.BodyFollowStrength, Is.EqualTo(0.18f));
Assert.That(scope.UsesSelectedShotOverrides, Is.True);
Assert.That(File.Exists(scope.DirectivesPath), Is.True);
var document = JsonUtility.FromJson<DirectiveDocument>(
File.ReadAllText(scope.DirectivesPath));
Assert.That(
document.schemaVersion,
Is.EqualTo("camera-directives-v1"));
Assert.That(document.shots, Has.Length.EqualTo(1));
Assert.That(document.shots[0].index, Is.EqualTo(7));
Assert.That(document.shots[0].shotSize, Is.EqualTo("medium"));
Assert.That(document.shots[0].motion, Is.EqualTo("truck_left"));
Assert.That(
document.shots[0].composition,
Is.EqualTo("right_third"));
Assert.That(document.shots[0].distanceMeters, Is.EqualTo(6.25f));
Assert.That(document.shots[0].motionIntensity, Is.EqualTo(1.4f));
Assert.That(document.shots[0].bodyFollowStrength, Is.EqualTo(0.08f));
}
finally
{
if (Directory.Exists(output))
{
Directory.Delete(output, true);
}
}
}
[Test]
public void FullGenerationDoesNotEmitSelectedShotOneShotArguments()
{
Assert.That(
AICameraGeneratorWindow.BuildSelectedShotOneShotArguments(
-1,
string.Empty),
Is.Empty);
}
[Test]
public void SelectedShotOneShotArgumentsIncludeIndexAndBaseGeneration()
{
var baseGeneration = Path.Combine(
Path.GetTempPath(),
"cw ai base generation");
var arguments =
AICameraGeneratorWindow.BuildSelectedShotOneShotArguments(
7,
baseGeneration);
Assert.That(arguments, Does.Contain("--only-shot-index"));
Assert.That(arguments, Does.Contain("7"));
Assert.That(arguments, Does.Contain("--base-generation"));
Assert.That(
arguments,
Does.Contain(Path.GetFullPath(baseGeneration)));
}
[Test]
public void SelectedShotTimingUsesSeparateOneShotHistory()
{
var text =
AICameraGeneratorWindow.BuildSelectedShotGenerationTimingText(
4d,
10d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss);
Assert.That(text, Does.Contain("선택 카메라 1개만 계산 중"));
Assert.That(text, Does.Contain("최근 단일 샷 기준 총"));
Assert.That(text, Does.Contain("새 후보 풀 계산 중"));
}
[Test]
public void FailedImportPreservesPreviousGeneratedFolder()
{
var previous = Path.Combine(Path.GetTempPath(), "previous-base");
var failedCandidate = Path.Combine(
Path.GetTempPath(),
"failed-candidate");
Assert.That(
AICameraGeneratorWindow.GeneratedFolderAfterImport(
previous,
failedCandidate,
false),
Is.EqualTo(previous));
}
[Test]
public void SuccessfulImportAdvancesGeneratedFolder()
{
var candidate = Path.Combine(Path.GetTempPath(), "new-base");
Assert.That(
AICameraGeneratorWindow.GeneratedFolderAfterImport(
"previous-base",
candidate,
true),
Is.EqualTo(Path.GetFullPath(candidate)));
}
[Test]
public void HybridBaseContractAcceptsFullCompositeSchema()
{
Assert.DoesNotThrow(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(),
HybridShotsJson()));
}
[Test]
public void HybridBaseContractRejectsNonHybridOutputBeforeWorkerStarts()
{
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(
generationMode: "unity_native_fast",
plannerMode: string.Empty,
targetAspectRatio: 0d),
HybridShotsJson("1.3")));
Assert.That(exception.Message, Does.Contain("Hybrid"));
}
[Test]
public void HybridBaseContractRequiresTargetAspectRatio()
{
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(targetAspectRatio: 0d),
HybridShotsJson()));
Assert.That(exception.Message, Does.Contain("targetAspectRatio"));
}
[Test]
public void HybridBaseContractRequiresShotsSchema12()
{
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(),
HybridShotsJson("1.3")));
Assert.That(exception.Message, Does.Contain("schema 1.2"));
}
[Test]
public void WindowProvenanceRecoveryPrefersCwAiRelativePathAfterMove()
{
var newRoot = Path.Combine(
Path.GetTempPath(),
"cw-ai-moved-" + Guid.NewGuid().ToString("N"));
var relative = Path.Combine(
"examples",
"UnityGenerated",
"camera_result");
var movedFolder = Path.Combine(newRoot, relative);
Directory.CreateDirectory(movedFolder);
try
{
Assert.That(
AICameraGeneratorWindow
.ResolveGeneratedFolderFromProvenance(
newRoot,
relative.Replace('\\', '/'),
@"D:\OldPc\CW-AI\examples\UnityGenerated\camera_result"),
Is.EqualTo(Path.GetFullPath(movedFolder)));
}
finally
{
Directory.Delete(newRoot, true);
}
}
[Test]
public void WindowProvenanceRecoveryUsesAbsoluteFallback()
{
var fallback = Path.Combine(
Path.GetTempPath(),
"cw-ai-fallback-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(fallback);
try
{
Assert.That(
AICameraGeneratorWindow
.ResolveGeneratedFolderFromProvenance(
Path.Combine(fallback, "missing-root"),
"examples/UnityGenerated/missing",
fallback),
Is.EqualTo(Path.GetFullPath(fallback)));
}
finally
{
Directory.Delete(fallback, true);
}
}
[Test]
public void WindowProvenanceRejectsMetadataChangedBehindStoredHash()
{
var generatedFolder = Path.Combine(
Path.GetTempPath(),
"cw-ai-provenance-hash-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(generatedFolder);
var metadataPath = Path.Combine(generatedFolder, "metadata.json");
try
{
File.WriteAllText(metadataPath, "{\"generation\":1}");
string expected;
using (var algorithm = SHA256.Create())
{
expected = BitConverter.ToString(
algorithm.ComputeHash(File.ReadAllBytes(metadataPath)))
.Replace("-", string.Empty)
.ToLowerInvariant();
}
Assert.DoesNotThrow(() =>
AICameraGeneratorWindow.ValidateProvenanceMetadataSha256(
generatedFolder,
expected));
File.WriteAllText(metadataPath, "{\"generation\":2}");
Assert.That(
() => AICameraGeneratorWindow
.ValidateProvenanceMetadataSha256(
generatedFolder,
expected),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("does not match"));
}
finally
{
Directory.Delete(generatedFolder, true);
}
}
[Test]
public void ColdStartHybridEstimateIsBroadAndMarkedInitial()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
204.48d,
0d,
0,
-1d,
0d);
Assert.That(estimate.IsAvailable, Is.True);
Assert.That(estimate.IsInitial, Is.True);
Assert.That(
estimate.MinimumTotalSeconds,
Is.EqualTo(79.4112d).Within(1e-4));
Assert.That(
estimate.MaximumTotalSeconds,
Is.EqualTo(148.23424d).Within(1e-4));
Assert.That(
estimate.MinimumRemainingSeconds,
Is.EqualTo(79.4112d).Within(1e-4));
Assert.That(
estimate.MaximumRemainingSeconds,
Is.EqualTo(148.23424d).Within(1e-4));
}
[Test]
public void HistoricalEstimateUsesObservedRateAndElapsedTime()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d);
Assert.That(estimate.IsAvailable, Is.True);
Assert.That(estimate.IsInitial, Is.False);
Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(85d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(115d));
Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(65d));
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(95d));
}
[Test]
public void CandidateCacheHitReplacesGeneralHistoryWithShortTailBudget()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
10d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false);
Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(13d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(30d));
Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(3d));
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(20d));
}
[Test]
public void CandidateCacheHitTailCountsDownFromObservationTime()
{
var initial = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
10d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false,
10d);
var later = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
15d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false,
10d);
Assert.That(initial.MaximumRemainingSeconds, Is.EqualTo(20d));
Assert.That(later.MaximumRemainingSeconds, Is.EqualTo(15d));
Assert.That(
later.MaximumRemainingSeconds,
Is.LessThan(initial.MaximumRemainingSeconds));
Assert.That(later.MaximumTotalSeconds, Is.EqualTo(30d));
}
[Test]
public void PreparationCacheMissAddsColdPreparationPenalty()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Miss,
AICameraGeneratorWindow.CacheObservation.Miss,
2,
false);
Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(103d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(164d));
Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(83d));
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(144d));
}
[Test]
public void ImportStageBoundsStaleRemainingEstimate()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss,
4,
true);
Assert.That(estimate.MinimumRemainingSeconds, Is.Zero);
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(24d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(44d));
}
[Test]
public void ImportStageTailCountsDownFromObservationTime()
{
var initial = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss,
4,
true,
double.NaN,
20d);
var later = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
21d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss,
4,
true,
double.NaN,
20d);
Assert.That(initial.MaximumRemainingSeconds, Is.EqualTo(24d));
Assert.That(later.MaximumRemainingSeconds, Is.EqualTo(23d));
Assert.That(
later.MaximumRemainingSeconds,
Is.LessThan(initial.MaximumRemainingSeconds));
Assert.That(initial.MaximumTotalSeconds, Is.EqualTo(44d));
Assert.That(later.MaximumTotalSeconds, Is.EqualTo(44d));
}
[Test]
public void InvalidDurationDoesNotClaimAnEstimate()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
0d,
10d,
2,
0.5d,
0.1d);
Assert.That(estimate.IsAvailable, Is.False);
Assert.That(estimate.MinimumTotalSeconds, Is.Zero);
Assert.That(estimate.MaximumTotalSeconds, Is.Zero);
Assert.That(estimate.MinimumRemainingSeconds, Is.Zero);
Assert.That(estimate.MaximumRemainingSeconds, Is.Zero);
}
[Test]
public void TimingTextShowsElapsedTotalRemainingAndCacheState()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
10d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false);
var text = AICameraGeneratorWindow.BuildGenerationTimingText(
10d,
estimate,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit);
Assert.That(text, Does.Contain("경과 10초"));
Assert.That(text, Does.Contain("최근 실측 예상 총"));
Assert.That(text, Does.Contain("남은 시간"));
Assert.That(text, Does.Contain("안전 후보 캐시 사용 중"));
}
[TestCase("Candidate cache hit: abc", "candidate", 1)]
[TestCase("[candidate-cache] miss: none", "candidate", 2)]
[TestCase(
"Candidate cache runtime validation failed; falling back",
"candidate",
2)]
[TestCase("Preparation cache invalid: sha mismatch", "preparation", 2)]
[TestCase("unrelated message", "candidate", 0)]
public void CacheLogObservationParsesStableSignals(
string message,
string cacheName,
int expected)
{
Assert.That(
AICameraGeneratorWindow.ParseCacheObservation(
message,
cacheName),
Is.EqualTo(
(AICameraGeneratorWindow.CacheObservation)expected));
}
[Test]
public void CandidateCacheModeDefaultsToAutoAndFreshPoolUsesOff()
{
Assert.That(
AICameraGeneratorWindow.CandidateCacheMode(false),
Is.EqualTo("auto"));
Assert.That(
AICameraGeneratorWindow.CandidateCacheMode(true),
Is.EqualTo("off"));
}
[Test]
public void AppliedSeedSummarySeparatesCurrentResultFromNextGeneration()
{
var summary = AICameraGeneratorWindow.BuildAppliedSeedSummary(
20489696,
20489697,
false,
-1);
Assert.That(
summary,
Is.EqualTo(
"현재 전체 생성 결과 Seed: 20489696\n" +
"다음 생성에 사용할 Seed: 20489697"));
}
[Test]
public void AppliedSeedSummaryMarksSelectedShotAsMixedResult()
{
var summary = AICameraGeneratorWindow.BuildAppliedSeedSummary(
20489696,
20489697,
true,
8);
Assert.That(summary, Does.Contain("카메라: 008"));
Assert.That(summary, Does.Contain("해당 카메라 Seed: 20489696"));
Assert.That(summary, Does.Contain("나머지 카메라는 기존 결과"));
}
[Test]
public void SeedAndReapplyLabelsDescribeDifferentActions()
{
Assert.That(
AICameraGeneratorWindow.NextGenerationSeedLabel,
Does.Contain("다음 생성"));
Assert.That(
AICameraGeneratorWindow.SelectedCameraRegenerationButtonLabel,
Does.Contain("안전 후보"));
Assert.That(
AICameraGeneratorWindow.ListedCameraRegenerationButtonLabel,
Does.Contain("안전 후보"));
Assert.That(
AICameraGeneratorWindow.ReapplyCurrentResultButtonLabel,
Does.Contain("Seed 사용 안 함"));
Assert.That(
AICameraGeneratorWindow.ReapplyCurrentResultWarning,
Does.Contain("새 후보를 생성하지 않으며"));
Assert.That(
AICameraGeneratorWindow.SeedCandidateExplanation,
Does.Contain("안전 후보 풀을 재사용"));
Assert.That(
AICameraGeneratorWindow.FreshCandidatePoolOptionLabel,
Does.Contain("후보 풀 캐시 무시하고 재계산"));
Assert.That(
AICameraGeneratorWindow.FullAutonomousGenerationButtonLabel,
Does.Contain("자율 생성"));
Assert.That(
AICameraGeneratorWindow.FullGenerationAutonomyExplanation,
Does.Contain("전체 생성에 적용되지 않습니다"));
Assert.That(
AICameraGeneratorWindow.SelectedShotOverrideExplanation,
Does.Contain("선택한 카메라를 다시 생성할 때만"));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a3ea2034f8a4ce1b45f1673472e5e6f
@@ -0,0 +1,67 @@
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraGeneratorWindowUiTests
{
[Test]
public void UiAssetsLoadAndExposeTheCoreWorkflowContract()
{
var uxmlPath =
AICameraGeneratorWindow.ResolveUiAssetPathForTests(false);
var ussPath =
AICameraGeneratorWindow.ResolveUiAssetPathForTests(true);
Assert.That(uxmlPath, Is.Not.Empty);
Assert.That(ussPath, Is.Not.Empty);
var visualTree =
AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath);
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(ussPath);
Assert.That(visualTree, Is.Not.Null);
Assert.That(styleSheet, Is.Not.Null);
var root = visualTree.CloneTree();
Assert.That(
root.Q<DropdownField>("source-director-dropdown"),
Is.Not.Null);
Assert.That(root.Q<Button>("generate-full-button"), Is.Not.Null);
Assert.That(root.Q<VisualElement>("refinement-card"), Is.Not.Null);
Assert.That(
root.Q<DropdownField>("selected-shot-dropdown"),
Is.Not.Null);
Assert.That(root.Q<Button>("regenerate-shot-button"), Is.Not.Null);
Assert.That(root.Q<VisualElement>("final-card"), Is.Not.Null);
Assert.That(root.Q<Button>("save-final-scene-button"), Is.Not.Null);
Assert.That(root.Q<Foldout>("advanced-foldout"), Is.Not.Null);
Assert.That(root.Q<ProgressBar>("generation-progress"), Is.Not.Null);
Assert.That(root.Q<Button>("status-log-button"), Is.Not.Null);
Assert.That(root.Q<Button>("cancel-generation-button"), Is.Not.Null);
}
[Test]
public void CreateGuiCanRunTwiceWithoutDuplicatingTheWindowRoot()
{
var window = ScriptableObject.CreateInstance<AICameraGeneratorWindow>();
try
{
window.CreateGUI();
window.CreateGUI();
var roots = window.rootVisualElement
.Query<VisualElement>("camera-generator-root")
.ToList();
Assert.That(roots, Has.Count.EqualTo(1));
Assert.That(
window.rootVisualElement.Q<Button>("generate-full-button"),
Is.Not.Null);
}
finally
{
Object.DestroyImmediate(window);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ef03e2f61a24afab45e0e719a9098be
@@ -0,0 +1,867 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using Object = UnityEngine.Object;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraTimelinePreviewImporterTests
{
[Test]
public void BrainResolutionUsesOnlySceneBrainWithoutCinemachineTrack()
{
var directorObject = new GameObject("Director");
var cameraObject = new GameObject("Render Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Is.SameAs(brain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionPrefersUniqueMainCamera()
{
var directorObject = new GameObject("Director");
var mainCameraObject = new GameObject("Main Camera");
var secondaryCameraObject = new GameObject("Secondary Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
mainCameraObject.tag = "MainCamera";
mainCameraObject.AddComponent<Camera>();
var mainBrain = mainCameraObject.AddComponent<CinemachineBrain>();
secondaryCameraObject.AddComponent<Camera>();
var secondaryBrain =
secondaryCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { secondaryBrain, mainBrain }),
Is.SameAs(mainBrain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(mainCameraObject);
Object.DestroyImmediate(secondaryCameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionRejectsAmbiguousSceneBrains()
{
var directorObject = new GameObject("Director");
var firstCameraObject = new GameObject("First Camera");
var secondCameraObject = new GameObject("Second Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
firstCameraObject.AddComponent<Camera>();
var firstBrain = firstCameraObject.AddComponent<CinemachineBrain>();
secondCameraObject.AddComponent<Camera>();
var secondBrain = secondCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { firstBrain, secondBrain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("Multiple enabled CinemachineBrain"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(firstCameraObject);
Object.DestroyImmediate(secondCameraObject);
Object.DestroyImmediate(timeline);
}
}
[TestCase(false)]
[TestCase(true)]
public void GeneratedDirectorySupportsLegacyAndDeclaredHashes(
bool includeHashes)
{
var directory = CreateGeneratedDirectory(includeHashes);
try
{
Assert.That(
AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Does.Contain("1 frames, 1 shots at 60 FPS"));
}
finally
{
Directory.Delete(directory, true);
}
}
[TestCase("generated_camera_world.f32", "worldCamera")]
[TestCase("time.f64", "time")]
[TestCase("shots.json", "shots")]
public void GeneratedDirectoryRejectsTamperedDeclaredPayloadBeforeParsing(
string fileName,
string payloadName)
{
var directory = CreateGeneratedDirectory(true);
try
{
File.AppendAllText(
Path.Combine(directory, fileName),
"tampered",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
$"{payloadName} payload SHA-256 mismatch"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsAbsolutePayloadPath()
{
var directory = CreateGeneratedDirectory(true);
try
{
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
Path.Combine(directory, "generated_camera_world.f32"));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("must be relative"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsParentTraversalEvenWhenHashMatches()
{
var directory = CreateGeneratedDirectory(true);
var outsidePath = Path.Combine(
Path.GetDirectoryName(directory) ?? Path.GetTempPath(),
$"cw_ai_outside_{Guid.NewGuid():N}.f32");
try
{
File.Copy(
Path.Combine(directory, "generated_camera_world.f32"),
outsidePath);
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
"../" + Path.GetFileName(outsidePath));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("invalid segment"));
}
finally
{
Directory.Delete(directory, true);
if (File.Exists(outsidePath))
{
File.Delete(outsidePath);
}
}
}
[Test]
public void GeneratedDirectoryRejectsExplicitNullHashObject()
{
var directory = CreateGeneratedDirectory(false);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
File.WriteAllText(
metadataPath,
json.Substring(0, json.Length - 1) +
",\"outputSha256\":null}",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256 must be a JSON object"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsIncompleteDeclaredHashes()
{
var directory = CreateGeneratedDirectory(true);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var timePath = Path.Combine(directory, "time.f64");
var json = File.ReadAllText(metadataPath).Replace(
$",\"time\":\"{ComputeSha256(timePath)}\"",
string.Empty);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256.time is missing"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void PreviewProvenancePersistsHiddenRelativeAndAbsoluteFallback()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_root_{Guid.NewGuid():N}");
var generatedDirectory = Path.Combine(
cwAiRoot,
"examples",
"UnityGenerated",
"camera_test");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_Provenance_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var provenance = AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(
timelinePath,
ImportAssetOptions.ForceSynchronousImport);
var reloaded = AssetDatabase.LoadAssetAtPath<TimelineAsset>(
timelinePath);
Assert.That(reloaded, Is.Not.Null);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
reloaded,
out var info),
Is.True);
Assert.That(
info.CwAiRelativeGeneratedFolder,
Is.EqualTo("examples/UnityGenerated/camera_test"));
Assert.That(
info.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(generatedDirectory)));
Assert.That(
info.MetadataSha256,
Is.EqualTo(ComputeSha256(
Path.Combine(generatedDirectory, "metadata.json"))));
Assert.That(
provenance.hideFlags,
Is.EqualTo(
HideFlags.HideInHierarchy |
HideFlags.HideInInspector));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void PreviewProvenanceUndoRedoRestoresMaterializedBase()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_undo_{Guid.NewGuid():N}");
var first = Path.Combine(cwAiRoot, "outputs", "first");
var second = Path.Combine(cwAiRoot, "outputs", "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test AI Camera Provenance Undo");
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
cwAiRoot,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var changed),
Is.True);
Assert.That(
changed.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
Undo.PerformUndo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void FinalTimelineCleanupRemovesPreviewOnlyProvenanceSubasset()
{
var generatedDirectory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_final_{Guid.NewGuid():N}");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceFinal_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
string.Empty,
false);
AssetDatabase.SaveAssets();
AICameraTimelinePreviewImporter
.RemovePreviewGenerationProvenanceForTests(timeline);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out _),
Is.False);
Assert.That(
AssetDatabase.LoadAllAssetsAtPath(timelinePath)
.OfType<AICameraPreviewGenerationProvenance>(),
Is.Empty);
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(generatedDirectory, true);
}
}
[Test]
public void ShotDisplayNameAndProvenanceRestoreInOneUndoGroup()
{
var root = Path.Combine(
Path.GetTempPath(),
$"cw_ai_track_undo_{Guid.NewGuid():N}");
var first = Path.Combine(root, "first");
var second = Path.Combine(root, "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}");
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}");
var timelinePath =
$"Assets/__CWAI_TrackUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var track = timeline.CreateTrack<AnimationTrack>(null, "Shot");
var shot = track.CreateDefaultClip();
shot.displayName = "Before";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
root,
false);
EditorUtility.SetDirty(track);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test Shot and Provenance Undo");
Undo.RegisterCompleteObjectUndo(track, "Test Shot and Provenance Undo");
shot.displayName = "After";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
root,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Undo.PerformUndo();
Assert.That(shot.displayName, Is.EqualTo("Before"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(shot.displayName, Is.EqualTo("After"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(root, true);
}
}
[Test]
public void GeneratedMotionUsesOneAnimatorAndTrackPerCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
firstCamera.transform.SetLocalPositionAndRotation(
new Vector3(1.25f, 1.8f, -3.5f),
Quaternion.Euler(8f, 135f, 0f));
secondCamera.transform.SetLocalPositionAndRotation(
new Vector3(-2f, 0.9f, 6f),
Quaternion.Euler(-4f, 20f, 2f));
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var firstAnimator = firstCamera.GetComponent<Animator>();
var secondAnimator = secondCamera.GetComponent<Animator>();
Assert.That(cameraRoot.GetComponent<Animator>(), Is.Null);
Assert.That(firstAnimator, Is.Not.Null);
Assert.That(secondAnimator, Is.Not.Null);
Assert.That(firstAnimator, Is.Not.SameAs(secondAnimator));
Assert.That(
firstAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(
secondAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(director.GetGenericBinding(firstTrack), Is.SameAs(firstAnimator));
Assert.That(director.GetGenericBinding(secondTrack), Is.SameAs(secondAnimator));
Assert.That(firstTrack.parent, Is.SameAs(group));
Assert.That(secondTrack.parent, Is.SameAs(group));
Assert.That(
firstTrack.position,
Is.EqualTo(firstCamera.transform.localPosition));
Assert.That(
secondTrack.position,
Is.EqualTo(secondCamera.transform.localPosition));
Assert.That(
Quaternion.Angle(
firstTrack.rotation,
firstCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
Quaternion.Angle(
secondTrack.rotation,
secondCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
System.Linq.Enumerable.Count(firstTrack.GetClips()),
Is.EqualTo(1));
Assert.That(
System.Linq.Enumerable.Count(secondTrack.GetClips()),
Is.EqualTo(1));
StringAssert.StartsWith(
AICameraTimelinePreviewImporter.GeneratedAnimationTrackPrefix,
firstTrack.name);
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void ShotSelectionResolvesTimelineClipAssetAndCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var selectedTimelineClip = System.Linq.Enumerable.Single(
secondTrack.GetClips());
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
selectedTimelineClip,
null,
null,
out var timelineIndex),
Is.True);
Assert.That(timelineIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
null,
secondClip,
out var assetIndex),
Is.True);
Assert.That(assetIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
firstCamera,
null,
out var cameraIndex),
Is.True);
Assert.That(cameraIndex, Is.EqualTo(0));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void LegacySharedAnimationTrackStillResolvesShots()
{
var cameraRoot = new GameObject("Legacy AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var track = timeline.CreateTrack<AnimationTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTimelineClip = track.CreateClip(firstClip);
firstTimelineClip.start = 0;
firstTimelineClip.duration = 1;
var secondTimelineClip = track.CreateClip(secondClip);
secondTimelineClip.start = 1;
secondTimelineClip.duration = 1;
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
secondTimelineClip,
null,
null,
out var shotIndex),
Is.True);
Assert.That(shotIndex, Is.EqualTo(1));
}
finally
{
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
private static string CreateGeneratedDirectory(bool includeHashes)
{
var directory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_importer_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var cameraPath = Path.Combine(
directory,
"generated_camera_world.f32");
var timePath = Path.Combine(directory, "time.f64");
var shotsPath = Path.Combine(directory, "shots.json");
using (var writer = new BinaryWriter(File.Create(cameraPath)))
{
writer.Write(0f);
writer.Write(1.5f);
writer.Write(-3f);
writer.Write(0f);
writer.Write(0f);
writer.Write(0f);
writer.Write(1f);
writer.Write(60f);
writer.Write(0f);
}
using (var writer = new BinaryWriter(File.Create(timePath)))
{
writer.Write(0d);
}
File.WriteAllText(
shotsPath,
"{\"shots\":[{\"index\":0,\"cameraName\":\"Shot_000\"," +
"\"shotType\":\"medium\",\"startFrame\":0," +
"\"endFrameExclusive\":1}]}",
new UTF8Encoding(false));
var hashes = includeHashes
? ",\"outputSha256\":{" +
$"\"worldCamera\":\"{ComputeSha256(cameraPath)}\"," +
$"\"time\":\"{ComputeSha256(timePath)}\"," +
$"\"shots\":\"{ComputeSha256(shotsPath)}\"}}"
: string.Empty;
File.WriteAllText(
Path.Combine(directory, "metadata.json"),
"{\"songName\":\"Hash Validation Test\",\"sampleRate\":60," +
"\"frameCount\":1,\"duration\":0.016666666666666666," +
"\"worldCameraFile\":\"generated_camera_world.f32\"," +
"\"timeFile\":\"time.f64\",\"shotsFile\":\"shots.json\"," +
$"\"lookAtUsed\":false{hashes}}}",
new UTF8Encoding(false));
return directory;
}
private static void ReplaceMetadataPayloadPath(
string directory,
string propertyName,
string replacementPath)
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
var marker = $"\"{propertyName}\":\"";
var valueStart = json.IndexOf(marker, StringComparison.Ordinal);
Assert.That(valueStart, Is.GreaterThanOrEqualTo(0));
valueStart += marker.Length;
var valueEnd = json.IndexOf('"', valueStart);
Assert.That(valueEnd, Is.GreaterThan(valueStart));
var escapedReplacement = replacementPath
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
json = json.Substring(0, valueStart) +
escapedReplacement +
json.Substring(valueEnd);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
}
private static string ComputeSha256(string path)
{
using var algorithm = SHA256.Create();
return string.Concat(
algorithm.ComputeHash(File.ReadAllBytes(path))
.Select(value => value.ToString("x2")));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 74ed53c76a0a4c6c82ea504d79132c63
@@ -0,0 +1,24 @@
{
"name": "Mingle.CWAI.Editor.Tests",
"rootNamespace": "Streamingle.Editor.Tests",
"references": [
"Mingle.CWAI.Editor",
"Unity.Cinemachine",
"Unity.Recorder.Editor",
"Unity.Timeline"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false,
"optionalUnityReferences": [
"TestAssemblies"
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: fb8474ed574f4f70aabc37357da9fef1
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,551 @@
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 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<Animator>();
hipsObject.transform.SetParent(characterObject.transform, false);
headObject.transform.SetParent(hipsObject.transform, false);
headObject.transform.localPosition = Vector3.up;
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
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<AnimationTrack>();
var animationTimelineClip =
animationTrack.CreateClip<AnimationPlayableAsset>();
((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<AudioTrack>();
var audioTimelineClip = audioTrack.CreateClip<AudioPlayableAsset>();
((AudioPlayableAsset)audioTimelineClip.asset).clip = audioClip;
audioTimelineClip.duration = 1.0;
var director = directorObject.AddComponent<PlayableDirector>();
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<PlayableDirector>();
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<string>(),
sharedBones = new List<string> { "Hips", "Head" },
songs = new List<GenerationInputSongFixture> { song }
};
File.WriteAllText(
Path.Combine(songDirectory, "shots.json"),
JsonUtility.ToJson(new ShotFileFixture
{
timelineName = "Test",
shots = new List<ShotFixture>()
}, 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<string> skippedCameraTracks;
public List<string> sharedBones;
public List<GenerationInputSongFixture> 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<ShotFixture> shots;
}
[Serializable]
private sealed class ShotFixture
{
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b557e27541794769bd7798589e9c5a9b
timeCreated: 1785484800