feat: add Camera Work AI package
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user