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

552 lines
21 KiB
C#

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
{
}
}
}