552 lines
21 KiB
C#
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
|
|
{
|
|
}
|
|
}
|
|
}
|