3641 lines
135 KiB
C#
3641 lines
135 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Unity.Cinemachine;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.Playables;
|
|
using UnityEngine.SceneManagement;
|
|
using UnityEngine.Timeline;
|
|
|
|
namespace Streamingle.Editor
|
|
{
|
|
public static class TimelineCameraDatasetExporter
|
|
{
|
|
private const int SampleRate = 60;
|
|
private const string TimelineRootName = "TimeLine";
|
|
private const string MotionDirectorName = "Motion";
|
|
private const string SchemaVersion = "1.3";
|
|
private const string GenerationInputKind = "generation_input";
|
|
internal const string SourceFingerprintSchema =
|
|
"cw-ai-camera-source-v2-dependency-hash";
|
|
internal const string LegacySourceFingerprintSchema =
|
|
"cw-ai-camera-source-v1-semantic";
|
|
private const string GenerationInputDirectoryPrefix =
|
|
"GenerationInput_60fps_";
|
|
|
|
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
|
|
|
|
[Serializable]
|
|
public sealed class GenerationInputSummary
|
|
{
|
|
public bool isValid;
|
|
public string error;
|
|
public string timelineName;
|
|
public string characterName;
|
|
public string characterPath;
|
|
public string audioClipName;
|
|
public string audioAssetPath;
|
|
public double duration;
|
|
public bool usesStructuredMotionDirector;
|
|
}
|
|
|
|
[MenuItem("Tools/Streamingle/Timeline/Export Camera Dataset (60 FPS)")]
|
|
public static void ExportFromMenu()
|
|
{
|
|
try
|
|
{
|
|
var outputPath = ExportAll60FpsForCli();
|
|
Debug.Log($"Timeline camera dataset export complete: {outputPath}");
|
|
EditorUtility.RevealInFinder(outputPath);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Debug.LogException(exception);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Entry point intended for UniCLI Eval.
|
|
/// Returns the absolute dataset directory.
|
|
/// </summary>
|
|
public static string ExportAll60FpsForCli()
|
|
{
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
|
|
?? throw new InvalidOperationException("Unable to resolve the Unity project root.");
|
|
var outputRoot = Path.Combine(
|
|
projectRoot,
|
|
"DatasetExports",
|
|
$"TimelineCamera_60fps_{DateTime.Now:yyyyMMdd_HHmmss}");
|
|
return ExportActiveScene60FpsToForCli(outputRoot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports one selected performance Timeline as a camera-generation input.
|
|
/// The Timeline only needs character animation and audio; authored camera
|
|
/// tracks and Cinemachine shots are deliberately ignored.
|
|
/// </summary>
|
|
public static string ExportGenerationInput60FpsForCli(
|
|
PlayableDirector sourceDirector)
|
|
{
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
|
|
?? throw new InvalidOperationException(
|
|
"Unable to resolve the Unity project root.");
|
|
var datasetRoot = Path.Combine(projectRoot, "DatasetExports");
|
|
if (TryComputeGenerationInputSourceFingerprint(
|
|
sourceDirector,
|
|
out var sourceFingerprint,
|
|
out var fingerprintError))
|
|
{
|
|
if (TryFindReusableGenerationInputExport(
|
|
datasetRoot,
|
|
sourceFingerprint,
|
|
out var reusableExport,
|
|
out _))
|
|
{
|
|
Debug.Log(
|
|
$"[CW-AI] Reusing unchanged generation input: " +
|
|
$"{reusableExport}");
|
|
return reusableExport;
|
|
}
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(fingerprintError))
|
|
{
|
|
Debug.Log(
|
|
$"[CW-AI] Generation input reuse skipped: " +
|
|
$"{fingerprintError}");
|
|
}
|
|
|
|
var outputRoot = Path.Combine(
|
|
datasetRoot,
|
|
$"{GenerationInputDirectoryPrefix}" +
|
|
$"{DateTime.Now:yyyyMMdd_HHmmss_fff}");
|
|
return ExportGenerationInput60FpsToForCli(
|
|
sourceDirector,
|
|
outputRoot,
|
|
true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read-only probe for a simple generator UI. It never evaluates the
|
|
/// Timeline or writes files.
|
|
/// </summary>
|
|
public static GenerationInputSummary GetGenerationInputSummaryForEditor(
|
|
PlayableDirector sourceDirector)
|
|
{
|
|
var summary = new GenerationInputSummary();
|
|
try
|
|
{
|
|
if (sourceDirector == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(sourceDirector));
|
|
}
|
|
|
|
if (sourceDirector.playableAsset is not TimelineAsset timeline)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The selected PlayableDirector must reference a TimelineAsset.");
|
|
}
|
|
|
|
if (!sourceDirector.gameObject.scene.IsValid() ||
|
|
!sourceDirector.gameObject.scene.isLoaded)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The selected PlayableDirector must belong to a loaded scene.");
|
|
}
|
|
|
|
var song = BuildGenerationInputContext(sourceDirector);
|
|
var duration = ResolveGenerationInputDuration(song);
|
|
var audio = FindActiveAudio(song.MainTimeline, 0, duration);
|
|
ValidateAudioSource(audio);
|
|
summary.isValid = true;
|
|
summary.error = string.Empty;
|
|
summary.timelineName = timeline.name;
|
|
summary.characterName = song.CharacterAnimator.name;
|
|
summary.characterPath = GetHierarchyPath(
|
|
song.CharacterAnimator.transform);
|
|
summary.audioClipName = audio.Clip?.name ?? string.Empty;
|
|
summary.audioAssetPath = audio.AssetPath;
|
|
summary.duration = duration;
|
|
summary.usesStructuredMotionDirector =
|
|
song.MotionDirector != song.MainDirector;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
summary.isValid = false;
|
|
summary.error = exception.Message;
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Explicit-output overload used by editor automation and tests.
|
|
/// Produces a single-entry manifest even when other Directors exist in
|
|
/// the open scene.
|
|
/// </summary>
|
|
public static string ExportGenerationInput60FpsToForCli(
|
|
PlayableDirector sourceDirector,
|
|
string outputDirectory,
|
|
bool requireAudio = true)
|
|
{
|
|
if (EditorApplication.isPlayingOrWillChangePlaymode)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Generation input export must run in Edit Mode.");
|
|
}
|
|
|
|
if (sourceDirector == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(sourceDirector));
|
|
}
|
|
|
|
if (sourceDirector.playableAsset is not TimelineAsset)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The selected PlayableDirector must reference a TimelineAsset.");
|
|
}
|
|
|
|
if (!sourceDirector.gameObject.scene.IsValid() ||
|
|
!sourceDirector.gameObject.scene.isLoaded)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The selected PlayableDirector must belong to a loaded scene.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(outputDirectory))
|
|
{
|
|
throw new ArgumentException(
|
|
"An output directory is required.",
|
|
nameof(outputDirectory));
|
|
}
|
|
|
|
var outputRoot = Path.GetFullPath(outputDirectory);
|
|
if (Directory.Exists(outputRoot) &&
|
|
Directory.EnumerateFileSystemEntries(outputRoot).Any())
|
|
{
|
|
throw new IOException(
|
|
$"The output directory is not empty: '{outputRoot}'.");
|
|
}
|
|
|
|
var song = BuildGenerationInputContext(sourceDirector);
|
|
TryComputeGenerationInputSourceFingerprint(
|
|
song,
|
|
out var sourceFingerprint,
|
|
out _);
|
|
var sourceScene = sourceDirector.gameObject.scene;
|
|
var sourceSceneWasDirty = sourceScene.isDirty;
|
|
var songs = new List<SongContext> { song };
|
|
var sharedJoints = FindSharedJoints(songs);
|
|
if (sharedJoints.Count == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The selected character has no exportable joints.");
|
|
}
|
|
|
|
Directory.CreateDirectory(outputRoot);
|
|
var activationObjects = BuildActivationChain(
|
|
song.Root,
|
|
song.MotionDirector.transform);
|
|
var activationStates = activationObjects
|
|
.Select(gameObject => gameObject.activeSelf)
|
|
.ToArray();
|
|
var activationChanged = new bool[activationObjects.Count];
|
|
SongMetadata exportedSong;
|
|
try
|
|
{
|
|
for (var index = 0; index < activationObjects.Count; index++)
|
|
{
|
|
if (activationObjects[index].activeSelf)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
activationObjects[index].SetActive(true);
|
|
activationChanged[index] = true;
|
|
}
|
|
|
|
exportedSong = ExportSong(
|
|
song,
|
|
0,
|
|
1,
|
|
sharedJoints,
|
|
outputRoot,
|
|
requireAudio,
|
|
false,
|
|
GenerationInputKind);
|
|
}
|
|
finally
|
|
{
|
|
for (var index = activationObjects.Count - 1;
|
|
index >= 0;
|
|
index--)
|
|
{
|
|
if (!activationChanged[index])
|
|
{
|
|
continue;
|
|
}
|
|
|
|
activationObjects[index].SetActive(activationStates[index]);
|
|
}
|
|
|
|
if (!sourceSceneWasDirty && sourceScene.isDirty)
|
|
{
|
|
foreach (var gameObject in activationObjects)
|
|
{
|
|
EditorUtility.ClearDirty(gameObject);
|
|
}
|
|
}
|
|
|
|
EditorUtility.ClearProgressBar();
|
|
}
|
|
|
|
var scene = sourceScene;
|
|
var manifest = new DatasetManifest
|
|
{
|
|
schemaVersion = SchemaVersion,
|
|
role = GenerationInputKind,
|
|
inputKind = GenerationInputKind,
|
|
hasAuthoredCamera = false,
|
|
createdUtc = DateTime.UtcNow.ToString("O", Invariant),
|
|
unityVersion = Application.unityVersion,
|
|
scenePath = scene.path,
|
|
sampleRate = SampleRate,
|
|
byteOrder = "little-endian",
|
|
floatFormat = "IEEE-754 float32",
|
|
timeFormat = "IEEE-754 float64",
|
|
jointLayout = "[frame, bone, xyz]",
|
|
cameraLayout =
|
|
"[frame, position.xyz, rotation.xyzw, fieldOfViewDegrees, dutchDegrees]",
|
|
rootLayout =
|
|
"[frame, characterRoot.position.xyz, characterRoot.rotation.xyzw, " +
|
|
"hips.position.xyz, hips.rotation.xyzw]",
|
|
audioFeatureLayout = "[frame, rms, onset]",
|
|
cameraSamplingPolicy =
|
|
"No authored camera. camera.f32 contains NaN placeholders, " +
|
|
"shot_index.i32 contains -1, and shots.json is empty.",
|
|
sourceFingerprint = sourceFingerprint,
|
|
discoveredCameraTrackCount = 0,
|
|
exportedCameraTrackCount = 0,
|
|
skippedCameraTracks = new List<SkippedCameraTrack>(),
|
|
sharedBones = sharedJoints.Select(joint => joint.Name).ToList(),
|
|
songs = new List<SongMetadata> { exportedSong }
|
|
};
|
|
WriteJson(Path.Combine(outputRoot, "dataset_manifest.json"), manifest);
|
|
return outputRoot;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports the active scene to an explicit directory.
|
|
/// Intended for resumable cross-project batch collection.
|
|
/// </summary>
|
|
public static string ExportActiveScene60FpsToForCli(string outputDirectory)
|
|
{
|
|
return ExportActiveScene60FpsToForCli(outputDirectory, true);
|
|
}
|
|
|
|
public static string ExportActiveScene60FpsToForCli(
|
|
string outputDirectory,
|
|
bool requireAudio)
|
|
{
|
|
return ExportActiveScene60FpsToForCli(
|
|
outputDirectory,
|
|
requireAudio,
|
|
false);
|
|
}
|
|
|
|
public static string ExportActiveScene60FpsToForCli(
|
|
string outputDirectory,
|
|
bool requireAudio,
|
|
bool includeMutedCameraTracks)
|
|
{
|
|
if (EditorApplication.isPlayingOrWillChangePlaymode)
|
|
{
|
|
throw new InvalidOperationException("Dataset export must run in Edit Mode.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(outputDirectory))
|
|
{
|
|
throw new ArgumentException(
|
|
"An output directory is required.",
|
|
nameof(outputDirectory));
|
|
}
|
|
|
|
var outputRoot = Path.GetFullPath(outputDirectory);
|
|
if (Directory.Exists(outputRoot) &&
|
|
Directory.EnumerateFileSystemEntries(outputRoot).Any())
|
|
{
|
|
throw new IOException(
|
|
$"The output directory is not empty: '{outputRoot}'.");
|
|
}
|
|
|
|
var scene = SceneManager.GetActiveScene();
|
|
var sourceFingerprint =
|
|
ComputeSceneCameraSourceFingerprint(scene);
|
|
var skippedCameraTracks = new List<SkippedCameraTrack>();
|
|
var timelineRoot = TryFindTimelineRoot(scene);
|
|
var songs = timelineRoot != null
|
|
? BuildSongContexts(
|
|
timelineRoot,
|
|
includeMutedCameraTracks,
|
|
skippedCameraTracks)
|
|
: new List<SongContext>();
|
|
var usesSongActivationIsolation = songs.Count > 0;
|
|
if (songs.Count == 0)
|
|
{
|
|
songs = BuildCombinedTimelineContexts(
|
|
scene,
|
|
includeMutedCameraTracks,
|
|
skippedCameraTracks);
|
|
}
|
|
|
|
if (songs.Count == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Scene '{scene.name}' does not contain a supported camera Timeline.");
|
|
}
|
|
|
|
var sharedJoints = FindSharedJoints(songs);
|
|
if (sharedJoints.Count == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The song Animators do not share any exportable joints.");
|
|
}
|
|
|
|
Directory.CreateDirectory(outputRoot);
|
|
|
|
var originalTimelineRootActive =
|
|
timelineRoot != null && timelineRoot.gameObject.activeSelf;
|
|
var originalSongActiveStates = usesSongActivationIsolation
|
|
? songs.Select(song => song.Root.gameObject.activeSelf).ToArray()
|
|
: Array.Empty<bool>();
|
|
var exportedSongs = new List<SongMetadata>();
|
|
|
|
try
|
|
{
|
|
if (usesSongActivationIsolation)
|
|
{
|
|
// Only the song currently being sampled should be active.
|
|
foreach (var song in songs)
|
|
{
|
|
song.Root.gameObject.SetActive(false);
|
|
}
|
|
|
|
timelineRoot.gameObject.SetActive(true);
|
|
}
|
|
|
|
for (var songIndex = 0; songIndex < songs.Count; songIndex++)
|
|
{
|
|
var song = songs[songIndex];
|
|
if (usesSongActivationIsolation)
|
|
{
|
|
song.Root.gameObject.SetActive(true);
|
|
}
|
|
|
|
try
|
|
{
|
|
exportedSongs.Add(ExportSong(
|
|
song,
|
|
songIndex,
|
|
songs.Count,
|
|
sharedJoints,
|
|
outputRoot,
|
|
requireAudio));
|
|
}
|
|
finally
|
|
{
|
|
if (usesSongActivationIsolation)
|
|
{
|
|
song.Root.gameObject.SetActive(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (usesSongActivationIsolation)
|
|
{
|
|
for (var index = 0; index < songs.Count; index++)
|
|
{
|
|
songs[index].Root.gameObject.SetActive(originalSongActiveStates[index]);
|
|
}
|
|
|
|
timelineRoot.gameObject.SetActive(originalTimelineRootActive);
|
|
}
|
|
|
|
EditorUtility.ClearProgressBar();
|
|
}
|
|
|
|
var manifest = new DatasetManifest
|
|
{
|
|
schemaVersion = SchemaVersion,
|
|
role = "training_dataset",
|
|
inputKind = "authored_camera_timeline",
|
|
hasAuthoredCamera = true,
|
|
createdUtc = DateTime.UtcNow.ToString("O", Invariant),
|
|
unityVersion = Application.unityVersion,
|
|
scenePath = scene.path,
|
|
sampleRate = SampleRate,
|
|
byteOrder = "little-endian",
|
|
floatFormat = "IEEE-754 float32",
|
|
timeFormat = "IEEE-754 float64",
|
|
jointLayout = "[frame, bone, xyz]",
|
|
cameraLayout =
|
|
"[frame, position.xyz, rotation.xyzw, fieldOfViewDegrees, dutchDegrees]",
|
|
rootLayout =
|
|
"[frame, characterRoot.position.xyz, characterRoot.rotation.xyzw, " +
|
|
"hips.position.xyz, hips.rotation.xyzw]",
|
|
audioFeatureLayout = string.Empty,
|
|
cameraSamplingPolicy =
|
|
"Per authored Cinemachine track; muted variants included by batch; " +
|
|
"procedural CameraState sampled deterministically; overlaps blended.",
|
|
sourceFingerprintSchema = SourceFingerprintSchema,
|
|
sourceFingerprint = sourceFingerprint,
|
|
discoveredCameraTrackCount =
|
|
songs.Count + skippedCameraTracks.Count,
|
|
exportedCameraTrackCount = songs.Count,
|
|
skippedCameraTracks = skippedCameraTracks,
|
|
sharedBones = sharedJoints.Select(joint => joint.Name).ToList(),
|
|
songs = exportedSongs
|
|
};
|
|
|
|
WriteJson(Path.Combine(outputRoot, "dataset_manifest.json"), manifest);
|
|
return outputRoot;
|
|
}
|
|
|
|
private static SongMetadata ExportSong(
|
|
SongContext song,
|
|
int songIndex,
|
|
int songCount,
|
|
IReadOnlyList<JointDefinition> sharedJoints,
|
|
string outputRoot,
|
|
bool requireAudio,
|
|
bool hasAuthoredCamera = true,
|
|
string inputKind = "authored_camera_timeline")
|
|
{
|
|
var folderName = $"{songIndex + 1:D2}_{SanitizeFileName(song.SongName)}";
|
|
var songDirectory = Path.Combine(outputRoot, folderName);
|
|
Directory.CreateDirectory(songDirectory);
|
|
|
|
var shots = hasAuthoredCamera
|
|
? BuildShots(song)
|
|
: new List<ShotRuntime>();
|
|
if (hasAuthoredCamera && shots.Count == 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"'{song.SongName}' does not contain Cinemachine shots.");
|
|
}
|
|
|
|
var sampleStart = hasAuthoredCamera
|
|
? Math.Max(0.0, shots.Min(shot => shot.Start))
|
|
: 0.0;
|
|
var sampleEnd = hasAuthoredCamera
|
|
? Math.Min(
|
|
song.MainDirector.duration,
|
|
shots.Max(shot => shot.End))
|
|
: ResolveGenerationInputDuration(song);
|
|
if (sampleEnd <= sampleStart)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"'{song.SongName}' has an invalid sampling interval.");
|
|
}
|
|
|
|
var audio = FindActiveAudio(
|
|
song.MainTimeline,
|
|
sampleStart,
|
|
sampleEnd);
|
|
var audioSourcePath = ResolveAudioSource(audio, requireAudio);
|
|
|
|
// The end is exclusive so every exported frame has a valid timeline time.
|
|
var frameCount = (int)Math.Ceiling(
|
|
(sampleEnd - sampleStart) * SampleRate - 1e-8);
|
|
|
|
var boundAnimators = song.MotionTimeline
|
|
.GetOutputTracks()
|
|
.OfType<AnimationTrack>()
|
|
.Where(track => !track.mutedInHierarchy)
|
|
.Select(track => song.MotionDirector.GetGenericBinding(track) as Animator)
|
|
.Where(animator => animator != null)
|
|
.Distinct()
|
|
.ToArray();
|
|
var originalCullingModes = boundAnimators
|
|
.Select(animator => animator.cullingMode)
|
|
.ToArray();
|
|
var cameraTracks = song.MotionTimeline
|
|
.GetOutputTracks()
|
|
.OfType<CinemachineTrack>()
|
|
.ToArray();
|
|
var originalCameraTrackMutes = cameraTracks
|
|
.Select(track => track.muted)
|
|
.ToArray();
|
|
var originalMotionTime = song.MotionDirector.time;
|
|
var originalCurrentTimeOverride = CinemachineCore.CurrentTimeOverride;
|
|
var originalDeltaTimeOverride = CinemachineCore.UniformDeltaTimeOverride;
|
|
|
|
var jointsPath = Path.Combine(songDirectory, "joints_world.f32");
|
|
var cameraPath = Path.Combine(songDirectory, "camera.f32");
|
|
var rootPath = Path.Combine(songDirectory, "root.f32");
|
|
var shotIndexPath = Path.Combine(songDirectory, "shot_index.i32");
|
|
var timePath = Path.Combine(songDirectory, "time.f64");
|
|
var audioFeaturesPath = Path.Combine(
|
|
songDirectory,
|
|
"audio_features.f32");
|
|
var previewRows = new List<string>
|
|
{
|
|
"frame,time,shotIndex,shotName,camX,camY,camZ,fov,dutch," +
|
|
"hipsX,hipsY,hipsZ,headX,headY,headZ"
|
|
};
|
|
var previewFrames = BuildPreviewFrameSet(frameCount);
|
|
var missingCameraFrames = 0;
|
|
var missingJointSamples = 0;
|
|
var missingRootSamples = 0;
|
|
var previouslyActiveCameras =
|
|
new HashSet<CinemachineVirtualCameraBase>();
|
|
var exportedShots = new List<ShotInfo>();
|
|
ShotRuntime previousDominantShot = null;
|
|
ShotInfo currentShotInfo = null;
|
|
|
|
try
|
|
{
|
|
foreach (var animator in boundAnimators)
|
|
{
|
|
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
|
|
}
|
|
|
|
foreach (var cameraTrack in cameraTracks)
|
|
{
|
|
cameraTrack.muted = true;
|
|
}
|
|
|
|
song.MotionDirector.RebuildGraph();
|
|
|
|
using var jointsWriter = CreateBinaryWriter(jointsPath);
|
|
using var cameraWriter = CreateBinaryWriter(cameraPath);
|
|
using var rootWriter = CreateBinaryWriter(rootPath);
|
|
using var shotIndexWriter = CreateBinaryWriter(shotIndexPath);
|
|
using var timeWriter = CreateBinaryWriter(timePath);
|
|
|
|
for (var frame = 0; frame < frameCount; frame++)
|
|
{
|
|
if ((frame & 255) == 0)
|
|
{
|
|
var songProgress = frame / (float)Math.Max(1, frameCount);
|
|
EditorUtility.DisplayProgressBar(
|
|
"Timeline Camera Dataset Export",
|
|
$"{song.SongName}: {frame:N0} / {frameCount:N0} frames",
|
|
(songIndex + songProgress) / songCount);
|
|
}
|
|
|
|
var time = sampleStart + frame / (double)SampleRate;
|
|
CinemachineCore.CurrentTimeOverride = (float)time;
|
|
CinemachineCore.UniformDeltaTimeOverride = 1f / SampleRate;
|
|
song.MotionDirector.time = time;
|
|
song.MotionDirector.Evaluate();
|
|
timeWriter.Write(time);
|
|
|
|
WriteJointFrame(
|
|
jointsWriter,
|
|
song.CharacterAnimator,
|
|
sharedJoints,
|
|
ref missingJointSamples);
|
|
WriteRootFrame(
|
|
rootWriter,
|
|
song.CharacterAnimator,
|
|
sharedJoints,
|
|
ref missingRootSamples);
|
|
|
|
var cameraSample = hasAuthoredCamera
|
|
? EvaluateCameraSample(
|
|
shots,
|
|
time,
|
|
song.CameraBrain != null
|
|
? song.CameraBrain.DefaultWorldUp
|
|
: Vector3.up,
|
|
previouslyActiveCameras)
|
|
: new CameraSample();
|
|
if (cameraSample.DominantShot != previousDominantShot)
|
|
{
|
|
if (currentShotInfo != null)
|
|
{
|
|
CloseShotInfo(currentShotInfo, time);
|
|
}
|
|
|
|
currentShotInfo = cameraSample.IsValid
|
|
? CreateShotInfo(
|
|
cameraSample.DominantShot,
|
|
exportedShots.Count,
|
|
time)
|
|
: null;
|
|
if (currentShotInfo != null)
|
|
{
|
|
exportedShots.Add(currentShotInfo);
|
|
}
|
|
|
|
previousDominantShot = cameraSample.DominantShot;
|
|
}
|
|
|
|
if (!cameraSample.IsValid || currentShotInfo == null)
|
|
{
|
|
missingCameraFrames++;
|
|
shotIndexWriter.Write(-1);
|
|
WriteInvalidCameraFrame(cameraWriter);
|
|
}
|
|
else
|
|
{
|
|
shotIndexWriter.Write(currentShotInfo.index);
|
|
WriteCameraFrame(cameraWriter, cameraSample.State);
|
|
}
|
|
|
|
if (previewFrames.Contains(frame))
|
|
{
|
|
previewRows.Add(BuildPreviewRow(
|
|
frame,
|
|
time,
|
|
currentShotInfo?.index ?? -1,
|
|
currentShotInfo?.cameraName ?? string.Empty,
|
|
cameraSample.IsValid
|
|
? cameraSample.State
|
|
: (CameraState?)null,
|
|
song.CharacterAnimator,
|
|
sharedJoints));
|
|
}
|
|
}
|
|
|
|
if (currentShotInfo != null)
|
|
{
|
|
CloseShotInfo(currentShotInfo, sampleEnd);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
CinemachineCore.CurrentTimeOverride = originalCurrentTimeOverride;
|
|
CinemachineCore.UniformDeltaTimeOverride =
|
|
originalDeltaTimeOverride;
|
|
|
|
for (var index = 0; index < boundAnimators.Length; index++)
|
|
{
|
|
boundAnimators[index].cullingMode = originalCullingModes[index];
|
|
}
|
|
|
|
for (var index = 0; index < cameraTracks.Length; index++)
|
|
{
|
|
cameraTracks[index].muted =
|
|
originalCameraTrackMutes[index];
|
|
}
|
|
|
|
try
|
|
{
|
|
song.MotionDirector.RebuildGraph();
|
|
song.MotionDirector.time = originalMotionTime;
|
|
song.MotionDirector.Evaluate();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Debug.LogWarning(
|
|
$"[CW-AI] Timeline restore warning for " +
|
|
$"'{song.SongName}': {exception}");
|
|
}
|
|
}
|
|
|
|
File.WriteAllLines(
|
|
Path.Combine(songDirectory, "preview.csv"),
|
|
previewRows);
|
|
|
|
var audioFeaturesStatus = string.Empty;
|
|
if (!hasAuthoredCamera)
|
|
{
|
|
audioFeaturesStatus = WriteAudioFeatures(
|
|
audioFeaturesPath,
|
|
audio,
|
|
sampleStart,
|
|
frameCount,
|
|
song.SongName);
|
|
}
|
|
|
|
var audioFile = CopyAudioSource(audioSourcePath, songDirectory);
|
|
var copiedAudioPath = string.IsNullOrWhiteSpace(audioFile)
|
|
? string.Empty
|
|
: Path.Combine(songDirectory, audioFile);
|
|
var audioFingerprint = File.Exists(copiedAudioPath)
|
|
? ComputeFileSha256(copiedAudioPath)
|
|
: string.Empty;
|
|
var jointsFingerprint = hasAuthoredCamera
|
|
? string.Empty
|
|
: ComputeFileSha256(jointsPath);
|
|
var cameraFingerprint = hasAuthoredCamera
|
|
? string.Empty
|
|
: ComputeFileSha256(cameraPath);
|
|
var rootFingerprint = hasAuthoredCamera
|
|
? string.Empty
|
|
: ComputeFileSha256(rootPath);
|
|
var shotIndexFingerprint = hasAuthoredCamera
|
|
? string.Empty
|
|
: ComputeFileSha256(shotIndexPath);
|
|
var timeFingerprint = hasAuthoredCamera
|
|
? string.Empty
|
|
: ComputeFileSha256(timePath);
|
|
var audioFeaturesFingerprint = !hasAuthoredCamera &&
|
|
File.Exists(audioFeaturesPath)
|
|
? ComputeFileSha256(audioFeaturesPath)
|
|
: string.Empty;
|
|
var shotFile = new ShotFile
|
|
{
|
|
timelineName = song.SongName,
|
|
shots = exportedShots
|
|
};
|
|
WriteJson(Path.Combine(songDirectory, "shots.json"), shotFile);
|
|
|
|
var metadata = new SongMetadata
|
|
{
|
|
songName = song.SongName,
|
|
role = hasAuthoredCamera ? "training_dataset" : GenerationInputKind,
|
|
inputKind = inputKind,
|
|
hasAuthoredCamera = hasAuthoredCamera,
|
|
folderName = folderName,
|
|
mainTimelineAssetPath = AssetDatabase.GetAssetPath(song.MainTimeline),
|
|
motionTimelineAssetPath = AssetDatabase.GetAssetPath(song.MotionTimeline),
|
|
characterPath = GetHierarchyPath(song.CharacterAnimator.transform),
|
|
cameraTrackName = song.CameraTrack?.name ?? string.Empty,
|
|
cameraTrackIndex = hasAuthoredCamera
|
|
? song.CameraTrackIndex
|
|
: -1,
|
|
cameraTrackMuted = hasAuthoredCamera &&
|
|
song.CameraTrack != null &&
|
|
song.CameraTrack.mutedInHierarchy,
|
|
audioAssetPath = audio.AssetPath,
|
|
audioFile = audioFile,
|
|
audioFingerprint = audioFingerprint,
|
|
audioStart = audio.Start,
|
|
audioDuration = audio.Duration,
|
|
mainDuration = song.MainDirector.duration,
|
|
motionDuration = song.MotionDirector.duration,
|
|
sampleRate = SampleRate,
|
|
sampleStart = sampleStart,
|
|
sampleEndExclusive = sampleEnd,
|
|
frameCount = frameCount,
|
|
sharedBoneCount = sharedJoints.Count,
|
|
skeletonType = song.CharacterAnimator.isHuman ? "Humanoid" : "Generic",
|
|
shotCount = exportedShots.Count,
|
|
missingCameraFrames = missingCameraFrames,
|
|
missingJointSamples = missingJointSamples,
|
|
missingRootSamples = missingRootSamples,
|
|
jointsFile = "joints_world.f32",
|
|
jointsFingerprint = jointsFingerprint,
|
|
cameraFile = "camera.f32",
|
|
cameraFingerprint = cameraFingerprint,
|
|
rootFile = "root.f32",
|
|
rootFingerprint = rootFingerprint,
|
|
shotIndexFile = "shot_index.i32",
|
|
shotIndexFingerprint = shotIndexFingerprint,
|
|
timeFile = "time.f64",
|
|
timeFingerprint = timeFingerprint,
|
|
shotsFile = "shots.json",
|
|
previewFile = "preview.csv",
|
|
audioFeaturesFile = hasAuthoredCamera
|
|
? string.Empty
|
|
: "audio_features.f32",
|
|
audioFeaturesFingerprint = audioFeaturesFingerprint,
|
|
audioFeatureLayout = hasAuthoredCamera
|
|
? string.Empty
|
|
: "[frame, rms, onset]",
|
|
audioFeaturesStatus = audioFeaturesStatus
|
|
};
|
|
WriteJson(Path.Combine(songDirectory, "metadata.json"), metadata);
|
|
return metadata;
|
|
}
|
|
|
|
private static string CopyAudioSource(
|
|
string sourcePath,
|
|
string songDirectory)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sourcePath))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var extension = Path.GetExtension(sourcePath).ToLowerInvariant();
|
|
var fileName = $"audio_source{extension}";
|
|
File.Copy(
|
|
sourcePath,
|
|
Path.Combine(songDirectory, fileName),
|
|
true);
|
|
return fileName;
|
|
}
|
|
|
|
private static string ResolveAudioSource(
|
|
AudioInfo audio,
|
|
bool requireAudio)
|
|
{
|
|
if (requireAudio)
|
|
{
|
|
return ValidateAudioSource(audio);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(audio.AssetPath))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
try
|
|
{
|
|
return ValidateAudioSource(audio);
|
|
}
|
|
catch (FileNotFoundException)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
private static string ValidateAudioSource(AudioInfo audio)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(audio.AssetPath))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The active Timeline audio clip has no AssetDatabase path.");
|
|
}
|
|
|
|
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
|
|
?? throw new InvalidOperationException(
|
|
"Unable to resolve the Unity project root.");
|
|
var sourcePath = Path.GetFullPath(
|
|
Path.Combine(projectRoot, audio.AssetPath));
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
throw new FileNotFoundException(
|
|
"The Timeline source audio file was not found.",
|
|
sourcePath);
|
|
}
|
|
|
|
return sourcePath;
|
|
}
|
|
|
|
private static void WriteJointFrame(
|
|
BinaryWriter writer,
|
|
Animator animator,
|
|
IReadOnlyList<JointDefinition> joints,
|
|
ref int missingJointSamples)
|
|
{
|
|
foreach (var joint in joints)
|
|
{
|
|
var transform = ResolveJoint(animator, joint);
|
|
if (transform == null)
|
|
{
|
|
missingJointSamples++;
|
|
writer.Write(float.NaN);
|
|
writer.Write(float.NaN);
|
|
writer.Write(float.NaN);
|
|
continue;
|
|
}
|
|
|
|
WriteVector3(writer, transform.position);
|
|
}
|
|
}
|
|
|
|
private static void WriteRootFrame(
|
|
BinaryWriter writer,
|
|
Animator animator,
|
|
IReadOnlyList<JointDefinition> joints,
|
|
ref int missingRootSamples)
|
|
{
|
|
var characterRoot = animator.transform;
|
|
var hips = FindSemanticJoint(animator, joints, "Hips", "Pelvis");
|
|
WriteVector3(writer, characterRoot.position);
|
|
WriteQuaternion(writer, characterRoot.rotation);
|
|
|
|
if (hips == null)
|
|
{
|
|
missingRootSamples++;
|
|
WriteInvalidVector3(writer);
|
|
WriteInvalidQuaternion(writer);
|
|
return;
|
|
}
|
|
|
|
WriteVector3(writer, hips.position);
|
|
WriteQuaternion(writer, hips.rotation);
|
|
}
|
|
|
|
private static void WriteCameraFrame(
|
|
BinaryWriter writer,
|
|
CameraState state)
|
|
{
|
|
WriteVector3(writer, state.GetCorrectedPosition());
|
|
WriteQuaternion(writer, state.GetCorrectedOrientation());
|
|
writer.Write(state.Lens.FieldOfView);
|
|
writer.Write(state.Lens.Dutch);
|
|
}
|
|
|
|
private static void WriteInvalidCameraFrame(BinaryWriter writer)
|
|
{
|
|
for (var index = 0; index < 9; index++)
|
|
{
|
|
writer.Write(float.NaN);
|
|
}
|
|
}
|
|
|
|
private static string BuildPreviewRow(
|
|
int frame,
|
|
double time,
|
|
int shotIndex,
|
|
string cameraName,
|
|
CameraState? cameraState,
|
|
Animator animator,
|
|
IReadOnlyList<JointDefinition> joints)
|
|
{
|
|
var cameraPosition = cameraState.HasValue
|
|
? cameraState.Value.GetCorrectedPosition()
|
|
: new Vector3(float.NaN, float.NaN, float.NaN);
|
|
var lens = cameraState?.Lens ?? LensSettings.Default;
|
|
var hips = FindSemanticJoint(animator, joints, "Hips", "Pelvis");
|
|
var head = FindSemanticJoint(animator, joints, "Head");
|
|
|
|
return string.Join(",",
|
|
frame.ToString(Invariant),
|
|
time.ToString("R", Invariant),
|
|
shotIndex.ToString(Invariant),
|
|
EscapeCsv(cameraName),
|
|
cameraPosition.x.ToString("R", Invariant),
|
|
cameraPosition.y.ToString("R", Invariant),
|
|
cameraPosition.z.ToString("R", Invariant),
|
|
lens.FieldOfView.ToString("R", Invariant),
|
|
lens.Dutch.ToString("R", Invariant),
|
|
(hips?.position.x ?? float.NaN).ToString("R", Invariant),
|
|
(hips?.position.y ?? float.NaN).ToString("R", Invariant),
|
|
(hips?.position.z ?? float.NaN).ToString("R", Invariant),
|
|
(head?.position.x ?? float.NaN).ToString("R", Invariant),
|
|
(head?.position.y ?? float.NaN).ToString("R", Invariant),
|
|
(head?.position.z ?? float.NaN).ToString("R", Invariant));
|
|
}
|
|
|
|
private static HashSet<int> BuildPreviewFrameSet(int frameCount)
|
|
{
|
|
return new HashSet<int>
|
|
{
|
|
0,
|
|
Math.Max(0, frameCount / 4),
|
|
Math.Max(0, frameCount / 2),
|
|
Math.Max(0, frameCount * 3 / 4),
|
|
Math.Max(0, frameCount - 1)
|
|
};
|
|
}
|
|
|
|
private static List<ShotRuntime> BuildShots(SongContext song)
|
|
{
|
|
var track = song.CameraTrack;
|
|
if (track == null)
|
|
{
|
|
return new List<ShotRuntime>();
|
|
}
|
|
|
|
var result = new List<ShotRuntime>();
|
|
var orderedClips = track.GetClips().OrderBy(clip => clip.start).ToArray();
|
|
foreach (var timelineClip in orderedClips)
|
|
{
|
|
if (timelineClip.end <= timelineClip.start + 1e-8 ||
|
|
timelineClip.asset is not CinemachineShot shotAsset)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var virtualCamera =
|
|
shotAsset.VirtualCamera.Resolve(song.MotionDirector);
|
|
result.Add(new ShotRuntime
|
|
{
|
|
Clip = timelineClip,
|
|
Start = timelineClip.start,
|
|
End = timelineClip.end,
|
|
VirtualCamera = virtualCamera,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static CameraSample EvaluateCameraSample(
|
|
IReadOnlyList<ShotRuntime> shots,
|
|
double time,
|
|
Vector3 worldUp,
|
|
HashSet<CinemachineVirtualCameraBase> previouslyActiveCameras)
|
|
{
|
|
var activeShots = shots
|
|
.Where(shot =>
|
|
time >= shot.Start - 1e-7 &&
|
|
time < shot.End - 1e-7 &&
|
|
shot.VirtualCamera != null)
|
|
.Take(2)
|
|
.ToArray();
|
|
var activeCameras = new HashSet<CinemachineVirtualCameraBase>(
|
|
activeShots.Select(shot => shot.VirtualCamera));
|
|
foreach (var virtualCamera in activeCameras)
|
|
{
|
|
var isCameraCut =
|
|
!previouslyActiveCameras.Contains(virtualCamera);
|
|
if (isCameraCut)
|
|
{
|
|
virtualCamera.PreviousStateIsValid = false;
|
|
}
|
|
|
|
virtualCamera.InternalUpdateCameraState(
|
|
worldUp,
|
|
isCameraCut ? -1f : 1f / SampleRate);
|
|
}
|
|
|
|
previouslyActiveCameras.Clear();
|
|
previouslyActiveCameras.UnionWith(activeCameras);
|
|
if (activeShots.Length == 0)
|
|
{
|
|
return new CameraSample();
|
|
}
|
|
|
|
if (activeShots.Length == 1)
|
|
{
|
|
return new CameraSample
|
|
{
|
|
IsValid = true,
|
|
DominantShot = activeShots[0],
|
|
State = activeShots[0].VirtualCamera.State
|
|
};
|
|
}
|
|
|
|
var outgoing = activeShots[0];
|
|
var incoming = activeShots[1];
|
|
if (incoming.Start < outgoing.Start)
|
|
{
|
|
(outgoing, incoming) = (incoming, outgoing);
|
|
}
|
|
|
|
var incomingWeight = Mathf.Clamp01(
|
|
incoming.Clip.EvaluateMixIn(time) *
|
|
incoming.Clip.EvaluateMixOut(time));
|
|
return new CameraSample
|
|
{
|
|
IsValid = true,
|
|
DominantShot = incomingWeight >= 0.5f
|
|
? incoming
|
|
: outgoing,
|
|
State = CameraState.Lerp(
|
|
outgoing.VirtualCamera.State,
|
|
incoming.VirtualCamera.State,
|
|
incomingWeight)
|
|
};
|
|
}
|
|
|
|
private static ShotInfo CreateShotInfo(
|
|
ShotRuntime source,
|
|
int index,
|
|
double start)
|
|
{
|
|
return new ShotInfo
|
|
{
|
|
index = index,
|
|
clipName = source.Clip.displayName,
|
|
cameraName = source.VirtualCamera.name,
|
|
cameraPath = GetHierarchyPath(source.VirtualCamera.transform),
|
|
start = start,
|
|
end = start,
|
|
duration = 0,
|
|
easeInDuration = source.Clip.easeInDuration,
|
|
easeOutDuration = source.Clip.easeOutDuration
|
|
};
|
|
}
|
|
|
|
private static void CloseShotInfo(ShotInfo shot, double end)
|
|
{
|
|
shot.end = end;
|
|
shot.duration = Math.Max(0, end - shot.start);
|
|
}
|
|
|
|
private static AudioInfo FindActiveAudio(
|
|
TimelineAsset timeline,
|
|
double sampleStart,
|
|
double sampleEnd)
|
|
{
|
|
var best = timeline
|
|
.GetOutputTracks()
|
|
.OfType<AudioTrack>()
|
|
.Where(track => !track.mutedInHierarchy)
|
|
.SelectMany(track => track.GetClips())
|
|
.Select(clip => new
|
|
{
|
|
Clip = clip,
|
|
Asset = clip.asset as AudioPlayableAsset,
|
|
Overlap = Math.Max(
|
|
0,
|
|
Math.Min(sampleEnd, clip.end) -
|
|
Math.Max(sampleStart, clip.start))
|
|
})
|
|
.Where(item => item.Asset?.clip != null)
|
|
.OrderByDescending(item => item.Overlap)
|
|
.ThenByDescending(item => item.Clip.duration)
|
|
.ThenBy(item => item.Clip.start)
|
|
.FirstOrDefault();
|
|
return best == null
|
|
? new AudioInfo
|
|
{
|
|
Clip = null,
|
|
AssetPath = string.Empty,
|
|
Start = 0,
|
|
Duration = 0,
|
|
ClipIn = 0,
|
|
TimeScale = 1
|
|
}
|
|
: new AudioInfo
|
|
{
|
|
Clip = best.Asset.clip,
|
|
AssetPath = AssetDatabase.GetAssetPath(best.Asset.clip),
|
|
Start = best.Clip.start,
|
|
Duration = best.Clip.duration,
|
|
ClipIn = best.Clip.clipIn,
|
|
TimeScale = best.Clip.timeScale
|
|
};
|
|
}
|
|
|
|
private static SongContext BuildGenerationInputContext(
|
|
PlayableDirector sourceDirector)
|
|
{
|
|
var mainTimeline = sourceDirector.playableAsset as TimelineAsset
|
|
?? throw new InvalidOperationException(
|
|
"The selected PlayableDirector has no TimelineAsset.");
|
|
|
|
var motionDirector = sourceDirector;
|
|
var motionTimeline = mainTimeline;
|
|
var characterAnimator = FindCharacterAnimator(
|
|
motionTimeline,
|
|
motionDirector);
|
|
|
|
// Existing performance scenes commonly keep audio/activation on the
|
|
// song root Director and character animation on a child named Motion.
|
|
var structuredMotionDirector = sourceDirector.transform
|
|
.Find(MotionDirectorName)
|
|
?.GetComponent<PlayableDirector>();
|
|
if (structuredMotionDirector?.playableAsset is TimelineAsset
|
|
structuredMotionTimeline)
|
|
{
|
|
var structuredAnimator = FindCharacterAnimator(
|
|
structuredMotionTimeline,
|
|
structuredMotionDirector);
|
|
if (structuredAnimator != null)
|
|
{
|
|
motionDirector = structuredMotionDirector;
|
|
motionTimeline = structuredMotionTimeline;
|
|
characterAnimator = structuredAnimator;
|
|
}
|
|
}
|
|
|
|
if (characterAnimator == null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"No character Animator is bound to an active AnimationTrack " +
|
|
"on the selected Timeline or its Motion child Timeline.");
|
|
}
|
|
|
|
var duration = ResolveFiniteDuration(sourceDirector, mainTimeline);
|
|
var audio = FindActiveAudio(mainTimeline, 0, duration);
|
|
var audioName = string.IsNullOrWhiteSpace(audio.AssetPath)
|
|
? string.Empty
|
|
: Path.GetFileNameWithoutExtension(audio.AssetPath);
|
|
var songName = string.IsNullOrWhiteSpace(audioName)
|
|
? mainTimeline.name
|
|
: audioName;
|
|
return new SongContext
|
|
{
|
|
SongName = songName,
|
|
Root = sourceDirector.transform,
|
|
MainDirector = sourceDirector,
|
|
MotionDirector = motionDirector,
|
|
MainTimeline = mainTimeline,
|
|
MotionTimeline = motionTimeline,
|
|
CharacterAnimator = characterAnimator,
|
|
CameraTrack = null,
|
|
CameraTrackIndex = -1,
|
|
CameraBrain = null
|
|
};
|
|
}
|
|
|
|
private static double ResolveGenerationInputDuration(SongContext song)
|
|
{
|
|
return ResolveFiniteDuration(song.MainDirector, song.MainTimeline);
|
|
}
|
|
|
|
private static double ResolveFiniteDuration(
|
|
PlayableDirector director,
|
|
TimelineAsset timeline)
|
|
{
|
|
var duration = director.duration;
|
|
if (double.IsNaN(duration) || double.IsInfinity(duration) ||
|
|
duration <= 0)
|
|
{
|
|
duration = timeline.duration;
|
|
}
|
|
|
|
if (double.IsNaN(duration) || double.IsInfinity(duration) ||
|
|
duration <= 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Timeline '{timeline.name}' has no finite positive duration.");
|
|
}
|
|
|
|
return duration;
|
|
}
|
|
|
|
private static List<GameObject> BuildActivationChain(
|
|
params Transform[] transforms)
|
|
{
|
|
var values = new HashSet<GameObject>();
|
|
foreach (var transform in transforms.Where(value => value != null))
|
|
{
|
|
var current = transform;
|
|
while (current != null)
|
|
{
|
|
values.Add(current.gameObject);
|
|
current = current.parent;
|
|
}
|
|
}
|
|
|
|
return values
|
|
.OrderBy(value => GetHierarchyDepth(value.transform))
|
|
.ThenBy(value => GetHierarchyPath(value.transform),
|
|
StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
|
|
private static int GetHierarchyDepth(Transform transform)
|
|
{
|
|
var depth = 0;
|
|
while (transform != null)
|
|
{
|
|
depth++;
|
|
transform = transform.parent;
|
|
}
|
|
|
|
return depth;
|
|
}
|
|
|
|
private static string WriteAudioFeatures(
|
|
string path,
|
|
AudioInfo audio,
|
|
double sampleStart,
|
|
int frameCount,
|
|
string songName)
|
|
{
|
|
var features = new float[checked(frameCount * 2)];
|
|
var clip = audio?.Clip;
|
|
if (clip == null)
|
|
{
|
|
WriteFloatArray(path, features);
|
|
Debug.LogWarning(
|
|
$"[CW-AI] '{songName}' has no readable AudioClip. " +
|
|
"audio_features.f32 contains zero placeholders.");
|
|
return "missing_audio_clip_zero_fallback";
|
|
}
|
|
|
|
var channelCount = Math.Max(1, clip.channels);
|
|
var valueCount = (long)clip.samples * channelCount;
|
|
if (valueCount <= 0 || valueCount > int.MaxValue)
|
|
{
|
|
WriteFloatArray(path, features);
|
|
Debug.LogWarning(
|
|
$"[CW-AI] AudioClip '{clip.name}' is too large or empty. " +
|
|
"audio_features.f32 contains zero placeholders.");
|
|
return "invalid_audio_size_zero_fallback";
|
|
}
|
|
|
|
try
|
|
{
|
|
var samples = new float[(int)valueCount];
|
|
if (!clip.GetData(samples, 0))
|
|
{
|
|
WriteFloatArray(path, features);
|
|
Debug.LogWarning(
|
|
$"[CW-AI] AudioClip.GetData failed for '{clip.name}'. " +
|
|
"Use Decompress On Load when the importer does not expose " +
|
|
"PCM samples. audio_features.f32 contains zeros.");
|
|
return "get_data_failed_zero_fallback";
|
|
}
|
|
|
|
var previousRms = 0f;
|
|
var frequency = Math.Max(1, clip.frequency);
|
|
var timeScale = Math.Max(1e-6, Math.Abs(audio.TimeScale));
|
|
var windowSamples = Math.Max(
|
|
1,
|
|
(int)Math.Ceiling(frequency * timeScale / SampleRate));
|
|
for (var frame = 0; frame < frameCount; frame++)
|
|
{
|
|
var timelineTime = sampleStart + frame / (double)SampleRate;
|
|
var rms = 0f;
|
|
if (timelineTime >= audio.Start &&
|
|
timelineTime < audio.Start + audio.Duration)
|
|
{
|
|
var localTime = audio.ClipIn +
|
|
(timelineTime - audio.Start) *
|
|
audio.TimeScale;
|
|
var firstSample = (int)Math.Floor(localTime * frequency);
|
|
if (firstSample >= 0 && firstSample < clip.samples)
|
|
{
|
|
var lastSample = Math.Min(
|
|
clip.samples,
|
|
firstSample + windowSamples);
|
|
double squareSum = 0;
|
|
long scalarCount = 0;
|
|
for (var sample = firstSample;
|
|
sample < lastSample;
|
|
sample++)
|
|
{
|
|
var scalarOffset = sample * channelCount;
|
|
for (var channel = 0;
|
|
channel < channelCount;
|
|
channel++)
|
|
{
|
|
var value = samples[scalarOffset + channel];
|
|
squareSum += value * value;
|
|
scalarCount++;
|
|
}
|
|
}
|
|
|
|
if (scalarCount > 0)
|
|
{
|
|
rms = (float)Math.Sqrt(squareSum / scalarCount);
|
|
}
|
|
}
|
|
}
|
|
|
|
features[frame * 2] = rms;
|
|
features[frame * 2 + 1] = Math.Max(0f, rms - previousRms);
|
|
previousRms = rms;
|
|
}
|
|
|
|
WriteFloatArray(path, features);
|
|
return "ok";
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
WriteFloatArray(path, features);
|
|
Debug.LogWarning(
|
|
$"[CW-AI] Audio feature extraction failed for '{clip.name}': " +
|
|
$"{exception.Message}. audio_features.f32 contains zeros.");
|
|
return "exception_zero_fallback";
|
|
}
|
|
}
|
|
|
|
private static void WriteFloatArray(string path, IReadOnlyList<float> values)
|
|
{
|
|
using var writer = CreateBinaryWriter(path);
|
|
foreach (var value in values)
|
|
{
|
|
writer.Write(value);
|
|
}
|
|
}
|
|
|
|
private static List<SongContext> BuildSongContexts(
|
|
Transform timelineRoot,
|
|
bool includeMutedCameraTracks,
|
|
List<SkippedCameraTrack> skippedCameraTracks)
|
|
{
|
|
var result = new List<SongContext>();
|
|
foreach (Transform child in timelineRoot)
|
|
{
|
|
var mainDirector = child.GetComponent<PlayableDirector>();
|
|
var motionTransform = child.Find(MotionDirectorName);
|
|
var motionDirector = motionTransform != null
|
|
? motionTransform.GetComponent<PlayableDirector>()
|
|
: null;
|
|
if (mainDirector?.playableAsset is not TimelineAsset mainTimeline ||
|
|
motionDirector?.playableAsset is not TimelineAsset motionTimeline)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var characterAnimator = FindCharacterAnimator(
|
|
motionTimeline,
|
|
motionDirector);
|
|
if (characterAnimator == null || !characterAnimator.isHuman)
|
|
{
|
|
RecordSkippedCameraTracks(
|
|
motionTimeline,
|
|
includeMutedCameraTracks,
|
|
"No bound Humanoid character Animator was found.",
|
|
skippedCameraTracks);
|
|
Debug.LogWarning(
|
|
$"[CW-AI] Skipping '{child.name}': no bound Humanoid " +
|
|
"character Animator was found.");
|
|
continue;
|
|
}
|
|
|
|
var cameraTracks = SelectCameraTracks(
|
|
motionTimeline,
|
|
motionDirector,
|
|
includeMutedCameraTracks,
|
|
skippedCameraTracks);
|
|
for (var trackIndex = 0;
|
|
trackIndex < cameraTracks.Count;
|
|
trackIndex++)
|
|
{
|
|
var cameraTrack = cameraTracks[trackIndex];
|
|
result.Add(new SongContext
|
|
{
|
|
SongName = BuildCameraVariantName(
|
|
child.name,
|
|
cameraTrack,
|
|
trackIndex,
|
|
cameraTracks.Count),
|
|
Root = child,
|
|
MainDirector = mainDirector,
|
|
MotionDirector = motionDirector,
|
|
MainTimeline = mainTimeline,
|
|
MotionTimeline = motionTimeline,
|
|
CharacterAnimator = characterAnimator,
|
|
CameraTrack = cameraTrack,
|
|
CameraTrackIndex = trackIndex,
|
|
CameraBrain =
|
|
motionDirector.GetGenericBinding(cameraTrack)
|
|
as CinemachineBrain
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static List<SongContext> BuildCombinedTimelineContexts(
|
|
Scene scene,
|
|
bool includeMutedCameraTracks,
|
|
List<SkippedCameraTrack> skippedCameraTracks)
|
|
{
|
|
var result = new List<SongContext>();
|
|
var directors = Resources.FindObjectsOfTypeAll<PlayableDirector>()
|
|
.Where(director =>
|
|
director.gameObject.scene == scene &&
|
|
director.playableAsset is TimelineAsset)
|
|
.OrderBy(director => GetHierarchyPath(director.transform));
|
|
|
|
foreach (var director in directors)
|
|
{
|
|
var timeline = director.playableAsset as TimelineAsset;
|
|
var cameraTracks = SelectCameraTracks(
|
|
timeline,
|
|
director,
|
|
includeMutedCameraTracks,
|
|
skippedCameraTracks);
|
|
if (cameraTracks.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var characterAnimator = FindCharacterAnimator(
|
|
timeline,
|
|
director);
|
|
if (characterAnimator == null)
|
|
{
|
|
RecordSkippedCameraTracks(
|
|
timeline,
|
|
includeMutedCameraTracks,
|
|
"No exportable character Animator was found.",
|
|
skippedCameraTracks,
|
|
cameraTracks);
|
|
continue;
|
|
}
|
|
|
|
var audio = FindActiveAudio(
|
|
timeline,
|
|
0,
|
|
timeline.duration);
|
|
var audioName = string.IsNullOrWhiteSpace(audio.AssetPath)
|
|
? string.Empty
|
|
: Path.GetFileNameWithoutExtension(audio.AssetPath);
|
|
var baseName = string.IsNullOrWhiteSpace(audioName)
|
|
? timeline.name
|
|
: audioName;
|
|
for (var trackIndex = 0;
|
|
trackIndex < cameraTracks.Count;
|
|
trackIndex++)
|
|
{
|
|
var cameraTrack = cameraTracks[trackIndex];
|
|
result.Add(new SongContext
|
|
{
|
|
SongName = BuildCameraVariantName(
|
|
baseName,
|
|
cameraTrack,
|
|
trackIndex,
|
|
cameraTracks.Count),
|
|
Root = director.transform,
|
|
MainDirector = director,
|
|
MotionDirector = director,
|
|
MainTimeline = timeline,
|
|
MotionTimeline = timeline,
|
|
CharacterAnimator = characterAnimator,
|
|
CameraTrack = cameraTrack,
|
|
CameraTrackIndex = trackIndex,
|
|
CameraBrain =
|
|
director.GetGenericBinding(cameraTrack)
|
|
as CinemachineBrain
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static List<CinemachineTrack> SelectCameraTracks(
|
|
TimelineAsset timeline,
|
|
PlayableDirector director,
|
|
bool includeMutedCameraTracks,
|
|
List<SkippedCameraTrack> skippedCameraTracks)
|
|
{
|
|
var candidates = timeline
|
|
.GetOutputTracks()
|
|
.OfType<CinemachineTrack>()
|
|
.Where(track =>
|
|
includeMutedCameraTracks || !track.mutedInHierarchy)
|
|
.Where(track => track
|
|
.GetClips()
|
|
.Any(clip => clip.asset is CinemachineShot))
|
|
.ToList();
|
|
var result = new List<CinemachineTrack>();
|
|
foreach (var track in candidates)
|
|
{
|
|
var clips = track
|
|
.GetClips()
|
|
.Where(clip => clip.asset is CinemachineShot)
|
|
.OrderBy(clip => clip.start)
|
|
.ToArray();
|
|
var unresolvedCount = clips.Count(clip =>
|
|
((CinemachineShot)clip.asset)
|
|
.VirtualCamera.Resolve(director) == null);
|
|
var coverageEnd = clips[0].end;
|
|
var hasCoverageGap = false;
|
|
foreach (var clip in clips.Skip(1))
|
|
{
|
|
if (clip.start > coverageEnd + 1e-7)
|
|
{
|
|
hasCoverageGap = true;
|
|
break;
|
|
}
|
|
|
|
coverageEnd = Math.Max(coverageEnd, clip.end);
|
|
}
|
|
|
|
if (unresolvedCount > 0 || hasCoverageGap)
|
|
{
|
|
skippedCameraTracks.Add(new SkippedCameraTrack
|
|
{
|
|
timelineAssetPath =
|
|
AssetDatabase.GetAssetPath(timeline),
|
|
trackName = track.name,
|
|
muted = track.mutedInHierarchy,
|
|
reason =
|
|
$"unresolvedShots={unresolvedCount}; " +
|
|
$"coverageGap={hasCoverageGap}"
|
|
});
|
|
Debug.LogWarning(
|
|
$"[CW-AI] Skipping camera track '{track.name}' in " +
|
|
$"'{timeline.name}': unresolvedShots={unresolvedCount}, " +
|
|
$"coverageGap={hasCoverageGap}.");
|
|
continue;
|
|
}
|
|
|
|
result.Add(track);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static void RecordSkippedCameraTracks(
|
|
TimelineAsset timeline,
|
|
bool includeMutedCameraTracks,
|
|
string reason,
|
|
List<SkippedCameraTrack> skippedCameraTracks,
|
|
IReadOnlyCollection<CinemachineTrack> tracks = null)
|
|
{
|
|
var candidates = tracks ?? timeline
|
|
.GetOutputTracks()
|
|
.OfType<CinemachineTrack>()
|
|
.Where(track =>
|
|
includeMutedCameraTracks || !track.mutedInHierarchy)
|
|
.Where(track => track
|
|
.GetClips()
|
|
.Any(clip => clip.asset is CinemachineShot))
|
|
.ToArray();
|
|
foreach (var track in candidates)
|
|
{
|
|
skippedCameraTracks.Add(new SkippedCameraTrack
|
|
{
|
|
timelineAssetPath = AssetDatabase.GetAssetPath(timeline),
|
|
trackName = track.name,
|
|
muted = track.mutedInHierarchy,
|
|
reason = reason
|
|
});
|
|
}
|
|
}
|
|
|
|
private static string BuildCameraVariantName(
|
|
string baseName,
|
|
CinemachineTrack track,
|
|
int trackIndex,
|
|
int trackCount)
|
|
{
|
|
if (trackCount <= 1)
|
|
{
|
|
return baseName;
|
|
}
|
|
|
|
var mutedSuffix = track.mutedInHierarchy ? " [muted]" : string.Empty;
|
|
return $"{baseName} - Camera {trackIndex + 1:D2} " +
|
|
$"({track.name}){mutedSuffix}";
|
|
}
|
|
|
|
private static Animator FindCharacterAnimator(
|
|
TimelineAsset timeline,
|
|
PlayableDirector director)
|
|
{
|
|
return timeline
|
|
.GetOutputTracks()
|
|
.OfType<AnimationTrack>()
|
|
.Where(track => !track.mutedInHierarchy)
|
|
.Select(track => director.GetGenericBinding(track) as Animator)
|
|
.Where(IsExportableCharacterAnimator)
|
|
.Distinct()
|
|
.OrderByDescending(animator => animator.isHuman)
|
|
.ThenByDescending(animator =>
|
|
animator.avatar != null && animator.avatar.isValid)
|
|
.ThenByDescending(animator =>
|
|
animator.GetComponentsInChildren<Transform>(true).Length)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
private static bool IsExportableCharacterAnimator(Animator animator)
|
|
{
|
|
if (animator == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var path = $"/{GetHierarchyPath(animator.transform)}/";
|
|
if (path.IndexOf(
|
|
"/Cam/",
|
|
StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
path.IndexOf(
|
|
"/Cams/",
|
|
StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
path.IndexOf(
|
|
"/Camera/",
|
|
StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
animator.GetComponent<CinemachineVirtualCameraBase>() != null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (path.IndexOf(
|
|
"Missing Prefab with guid:",
|
|
StringComparison.OrdinalIgnoreCase) >= 0 ||
|
|
animator.name.IndexOf(
|
|
"Placeholder for referenced Animator",
|
|
StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return animator.isHuman ||
|
|
(animator.avatar != null && animator.avatar.isValid) ||
|
|
animator.GetComponentsInChildren<Transform>(true).Length > 1;
|
|
}
|
|
|
|
private static List<JointDefinition> FindSharedJoints(
|
|
IReadOnlyList<SongContext> songs)
|
|
{
|
|
if (songs.All(song => song.CharacterAnimator.isHuman))
|
|
{
|
|
return Enum.GetValues(typeof(HumanBodyBones))
|
|
.Cast<HumanBodyBones>()
|
|
.Where(bone => bone != HumanBodyBones.LastBone)
|
|
.Where(bone => songs.All(song =>
|
|
song.CharacterAnimator.GetBoneTransform(bone) != null))
|
|
.Select(bone => new JointDefinition
|
|
{
|
|
Name = bone.ToString(),
|
|
HumanBone = bone
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
if (songs.Any(song => song.CharacterAnimator.isHuman))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Mixing Humanoid and Generic skeletons in one export is not supported.");
|
|
}
|
|
|
|
var sharedPaths = FindAnimatedTransformPaths(songs[0]);
|
|
foreach (var song in songs.Skip(1))
|
|
{
|
|
var songPaths = new HashSet<string>(
|
|
FindAnimatedTransformPaths(song),
|
|
StringComparer.Ordinal);
|
|
sharedPaths.RemoveAll(path => !songPaths.Contains(path));
|
|
}
|
|
|
|
return sharedPaths
|
|
.Where(path => ResolveRelativePath(
|
|
songs[0].CharacterAnimator.transform,
|
|
path) != null)
|
|
.Select(path => new JointDefinition
|
|
{
|
|
Name = path,
|
|
RelativePath = path
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static List<string> FindAnimatedTransformPaths(SongContext song)
|
|
{
|
|
var paths = new HashSet<string>(StringComparer.Ordinal);
|
|
var tracks = song.MotionTimeline
|
|
.GetOutputTracks()
|
|
.OfType<AnimationTrack>()
|
|
.Where(track =>
|
|
!track.mutedInHierarchy &&
|
|
song.MotionDirector.GetGenericBinding(track) ==
|
|
song.CharacterAnimator);
|
|
|
|
foreach (var track in tracks)
|
|
{
|
|
var clips = track.GetClips()
|
|
.Select(clip => clip.asset as AnimationPlayableAsset)
|
|
.Where(asset => asset?.clip != null)
|
|
.Select(asset => asset.clip)
|
|
.ToList();
|
|
if (track.infiniteClip != null)
|
|
{
|
|
clips.Add(track.infiniteClip);
|
|
}
|
|
|
|
foreach (var clip in clips.Distinct())
|
|
{
|
|
foreach (var binding in AnimationUtility.GetCurveBindings(clip))
|
|
{
|
|
if (binding.type == typeof(Transform))
|
|
{
|
|
paths.Add(binding.path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return paths
|
|
.OrderBy(path => string.IsNullOrEmpty(path)
|
|
? 0
|
|
: path.Count(character => character == '/') + 1)
|
|
.ThenBy(path => path, StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
|
|
private static Transform ResolveJoint(
|
|
Animator animator,
|
|
JointDefinition joint)
|
|
{
|
|
return joint.HumanBone.HasValue
|
|
? animator.GetBoneTransform(joint.HumanBone.Value)
|
|
: ResolveRelativePath(animator.transform, joint.RelativePath);
|
|
}
|
|
|
|
private static Transform ResolveRelativePath(Transform root, string path)
|
|
{
|
|
return string.IsNullOrEmpty(path) ? root : root.Find(path);
|
|
}
|
|
|
|
private static Transform FindSemanticJoint(
|
|
Animator animator,
|
|
IReadOnlyList<JointDefinition> joints,
|
|
params string[] names)
|
|
{
|
|
foreach (var name in names)
|
|
{
|
|
var exact = joints.FirstOrDefault(joint =>
|
|
string.Equals(joint.Name, name, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(
|
|
Path.GetFileName(joint.Name),
|
|
name,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
if (exact != null)
|
|
{
|
|
return ResolveJoint(animator, exact);
|
|
}
|
|
}
|
|
|
|
foreach (var name in names)
|
|
{
|
|
var partial = joints.FirstOrDefault(joint =>
|
|
Path.GetFileName(joint.Name)
|
|
.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0);
|
|
if (partial != null)
|
|
{
|
|
return ResolveJoint(animator, partial);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static Transform TryFindTimelineRoot(Scene scene)
|
|
{
|
|
var root = Resources.FindObjectsOfTypeAll<GameObject>()
|
|
.Where(gameObject => gameObject.scene == scene)
|
|
.FirstOrDefault(gameObject => gameObject.name == TimelineRootName);
|
|
return root != null ? root.transform : null;
|
|
}
|
|
|
|
internal static string ComputeSceneCameraSourceFingerprint(Scene scene)
|
|
{
|
|
return ComputeSceneCameraSourceFingerprint(
|
|
scene,
|
|
out _);
|
|
}
|
|
|
|
internal static string ComputeSceneCameraSourceFingerprint(
|
|
Scene scene,
|
|
out string legacyFingerprint)
|
|
{
|
|
legacyFingerprint =
|
|
ComputeLegacySceneCameraSourceFingerprint(scene);
|
|
var dependencyRecords = AssetDatabase
|
|
.GetDependencies(scene.path, true)
|
|
.Where(IsCameraSourceDependency)
|
|
.Select(assetPath =>
|
|
assetPath.Replace('\\', '/') + "|" +
|
|
AssetDatabase
|
|
.GetAssetDependencyHash(assetPath)
|
|
.ToString())
|
|
.ToArray();
|
|
return ComposeSceneCameraSourceFingerprint(
|
|
legacyFingerprint,
|
|
dependencyRecords);
|
|
}
|
|
|
|
internal static string ComputeLegacySceneCameraSourceFingerprint(
|
|
Scene scene)
|
|
{
|
|
var lines = new List<string>();
|
|
var directors = Resources.FindObjectsOfTypeAll<PlayableDirector>()
|
|
.Where(director =>
|
|
director.gameObject.scene == scene &&
|
|
director.playableAsset is TimelineAsset)
|
|
.OrderBy(director => GetHierarchyPath(director.transform));
|
|
foreach (var director in directors)
|
|
{
|
|
var timeline = (TimelineAsset)director.playableAsset;
|
|
var timelinePath = AssetDatabase.GetAssetPath(timeline);
|
|
var outputTracks = timeline.GetOutputTracks().ToArray();
|
|
lines.Add(
|
|
$"director|{GetHierarchyPath(director.transform)}|" +
|
|
$"{timelinePath}");
|
|
for (var trackIndex = 0;
|
|
trackIndex < outputTracks.Length;
|
|
trackIndex++)
|
|
{
|
|
if (outputTracks[trackIndex] is not CinemachineTrack track)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
lines.Add(
|
|
$"track|{trackIndex}|{track.name}|" +
|
|
$"{track.mutedInHierarchy}|{track.TrackPriority}");
|
|
foreach (var clip in track
|
|
.GetClips()
|
|
.OrderBy(item => item.start)
|
|
.ThenBy(item => item.end))
|
|
{
|
|
var shot = clip.asset as CinemachineShot;
|
|
var virtualCamera =
|
|
shot?.VirtualCamera.Resolve(director);
|
|
var virtualCameraPath = virtualCamera != null
|
|
? GetHierarchyPath(virtualCamera.transform)
|
|
: "<unresolved>";
|
|
lines.Add(
|
|
$"clip|{clip.displayName}|" +
|
|
$"{clip.start.ToString("R", Invariant)}|" +
|
|
$"{clip.end.ToString("R", Invariant)}|" +
|
|
virtualCameraPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = sha256.ComputeHash(
|
|
Encoding.UTF8.GetBytes(string.Join("\n", lines)));
|
|
return string.Concat(bytes.Select(item => item.ToString("x2")));
|
|
}
|
|
|
|
internal static string ComposeSceneCameraSourceFingerprint(
|
|
string semanticFingerprint,
|
|
IEnumerable<string> dependencyRecords)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(semanticFingerprint))
|
|
{
|
|
throw new ArgumentException(
|
|
"A semantic source fingerprint is required.",
|
|
nameof(semanticFingerprint));
|
|
}
|
|
|
|
var records = (dependencyRecords ?? Array.Empty<string>())
|
|
.Where(record => !string.IsNullOrWhiteSpace(record))
|
|
.Select(record => record.Trim().Replace('\\', '/'))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.OrderBy(record => record, StringComparer.Ordinal);
|
|
var payload = string.Join(
|
|
"\n",
|
|
new[]
|
|
{
|
|
"schema|" + SourceFingerprintSchema,
|
|
"semantic|" + semanticFingerprint.Trim()
|
|
}.Concat(records.Select(record => "dependency|" + record)));
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
|
return string.Concat(bytes.Select(item => item.ToString("x2")));
|
|
}
|
|
|
|
internal static bool TryMatchSceneCameraSourceFingerprint(
|
|
string storedSchema,
|
|
string storedFingerprint,
|
|
string currentStrongFingerprint,
|
|
string currentLegacyFingerprint,
|
|
out bool legacyFingerprintAccepted,
|
|
out string error)
|
|
{
|
|
legacyFingerprintAccepted = false;
|
|
if (string.IsNullOrWhiteSpace(storedFingerprint))
|
|
{
|
|
error = "The manifest source fingerprint is missing.";
|
|
return false;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(storedSchema) ||
|
|
string.Equals(
|
|
storedSchema,
|
|
LegacySourceFingerprintSchema,
|
|
StringComparison.Ordinal))
|
|
{
|
|
if (!string.Equals(
|
|
storedFingerprint,
|
|
currentLegacyFingerprint,
|
|
StringComparison.Ordinal))
|
|
{
|
|
error =
|
|
"The loaded scene legacy semantic fingerprint does " +
|
|
"not match the manifest.";
|
|
return false;
|
|
}
|
|
|
|
legacyFingerprintAccepted = true;
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
if (!string.Equals(
|
|
storedSchema,
|
|
SourceFingerprintSchema,
|
|
StringComparison.Ordinal))
|
|
{
|
|
error =
|
|
$"Unsupported source fingerprint schema '{storedSchema}'.";
|
|
return false;
|
|
}
|
|
|
|
if (!string.Equals(
|
|
storedFingerprint,
|
|
currentStrongFingerprint,
|
|
StringComparison.Ordinal))
|
|
{
|
|
error =
|
|
"The loaded scene strong dependency fingerprint does " +
|
|
"not match the manifest.";
|
|
return false;
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool IsCameraSourceDependency(string assetPath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(assetPath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch (Path.GetExtension(assetPath).ToLowerInvariant())
|
|
{
|
|
case ".unity":
|
|
case ".playable":
|
|
case ".anim":
|
|
case ".prefab":
|
|
case ".controller":
|
|
case ".overridecontroller":
|
|
case ".mask":
|
|
case ".fbx":
|
|
case ".dae":
|
|
case ".obj":
|
|
case ".blend":
|
|
case ".wav":
|
|
case ".mp3":
|
|
case ".flac":
|
|
case ".ogg":
|
|
return true;
|
|
case ".asset":
|
|
var assetType =
|
|
AssetDatabase.GetMainAssetTypeAtPath(assetPath);
|
|
var fullName = assetType?.FullName ?? string.Empty;
|
|
return assetType != null &&
|
|
(typeof(TimelineAsset).IsAssignableFrom(assetType) ||
|
|
typeof(AnimationClip).IsAssignableFrom(assetType) ||
|
|
typeof(RuntimeAnimatorController)
|
|
.IsAssignableFrom(assetType) ||
|
|
typeof(AvatarMask).IsAssignableFrom(assetType) ||
|
|
fullName.StartsWith(
|
|
"Unity.Cinemachine.",
|
|
StringComparison.Ordinal));
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
internal static bool TryComputeGenerationInputSourceFingerprint(
|
|
PlayableDirector sourceDirector,
|
|
out string fingerprint,
|
|
out string error)
|
|
{
|
|
fingerprint = string.Empty;
|
|
error = string.Empty;
|
|
try
|
|
{
|
|
if (sourceDirector == null)
|
|
{
|
|
error = "The source PlayableDirector is missing.";
|
|
return false;
|
|
}
|
|
|
|
var scene = sourceDirector.gameObject.scene;
|
|
if (!scene.IsValid() || !scene.isLoaded)
|
|
{
|
|
error = "The source PlayableDirector is not in a loaded scene.";
|
|
return false;
|
|
}
|
|
|
|
if (scene.isDirty)
|
|
{
|
|
error =
|
|
"the source scene has unsaved changes; a fresh export is safer";
|
|
return false;
|
|
}
|
|
|
|
return TryComputeGenerationInputSourceFingerprint(
|
|
BuildGenerationInputContext(sourceDirector),
|
|
out fingerprint,
|
|
out error);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
fingerprint = string.Empty;
|
|
error = exception.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryComputeGenerationInputSourceFingerprint(
|
|
SongContext song,
|
|
out string fingerprint,
|
|
out string error)
|
|
{
|
|
fingerprint = string.Empty;
|
|
error = string.Empty;
|
|
try
|
|
{
|
|
var scene = song.MainDirector.gameObject.scene;
|
|
if (!scene.IsValid() || !scene.isLoaded || scene.isDirty)
|
|
{
|
|
error =
|
|
"the source scene is unsaved or dirty; a fresh export is required";
|
|
return false;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(scene.path))
|
|
{
|
|
error = "the source scene has not been saved";
|
|
return false;
|
|
}
|
|
|
|
var lines = new List<string>();
|
|
if (!TryAppendAssetPathFingerprint(
|
|
lines,
|
|
"scene",
|
|
scene.path,
|
|
out error) ||
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
"mainTimeline",
|
|
song.MainTimeline,
|
|
out error) ||
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
"motionTimeline",
|
|
song.MotionTimeline,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (EditorUtility.IsDirty(song.MainDirector) ||
|
|
EditorUtility.IsDirty(song.MotionDirector) ||
|
|
EditorUtility.IsDirty(song.CharacterAnimator))
|
|
{
|
|
error =
|
|
"a Director or Animator has unsaved changes";
|
|
return false;
|
|
}
|
|
|
|
AppendDirectorFingerprint(lines, "mainDirector", song.MainDirector);
|
|
AppendDirectorFingerprint(
|
|
lines,
|
|
"motionDirector",
|
|
song.MotionDirector);
|
|
if (!TryAppendTimelineFingerprint(
|
|
lines,
|
|
"main",
|
|
song.MainTimeline,
|
|
song.MainDirector,
|
|
scene,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (song.MotionTimeline != song.MainTimeline &&
|
|
!TryAppendTimelineFingerprint(
|
|
lines,
|
|
"motion",
|
|
song.MotionTimeline,
|
|
song.MotionDirector,
|
|
scene,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryAppendRigFingerprint(
|
|
lines,
|
|
song.CharacterAnimator,
|
|
scene,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lines.Add(
|
|
$"duration|" +
|
|
ResolveGenerationInputDuration(song)
|
|
.ToString("R", Invariant));
|
|
fingerprint = ComputeTextSha256(lines);
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
fingerprint = string.Empty;
|
|
error = exception.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryAppendTimelineFingerprint(
|
|
List<string> lines,
|
|
string label,
|
|
TimelineAsset timeline,
|
|
PlayableDirector director,
|
|
Scene sourceScene,
|
|
out string error)
|
|
{
|
|
var rootTracks = timeline.GetRootTracks().ToArray();
|
|
for (var index = 0; index < rootTracks.Length; index++)
|
|
{
|
|
if (!TryAppendTrackFingerprint(
|
|
lines,
|
|
$"{label}/track[{index}]",
|
|
rootTracks[index],
|
|
director,
|
|
sourceScene,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryAppendTrackFingerprint(
|
|
List<string> lines,
|
|
string label,
|
|
TrackAsset track,
|
|
PlayableDirector director,
|
|
Scene sourceScene,
|
|
out string error)
|
|
{
|
|
if (track == null)
|
|
{
|
|
error = $"{label} is missing";
|
|
return false;
|
|
}
|
|
|
|
var trackType = track.GetType();
|
|
var isGroup = trackType == typeof(GroupTrack);
|
|
var isMarker = trackType == typeof(MarkerTrack);
|
|
var isAnimation = trackType == typeof(AnimationTrack);
|
|
var isAudio = trackType == typeof(AudioTrack);
|
|
var isCinemachine = trackType == typeof(CinemachineTrack);
|
|
var isControl = trackType == typeof(ControlTrack);
|
|
var isRecorder = trackType ==
|
|
typeof(UnityEditor.Recorder.Timeline.RecorderTrack);
|
|
// This project track only writes facial blendshape weights, which
|
|
// are not part of the joint/root generation input. Its serialized
|
|
// track, clips, binding and scene dependencies are still hashed.
|
|
var isBlendshapeGaze = string.Equals(
|
|
trackType.FullName,
|
|
"Streamingle.Gaze.BlendshapeGazeTrack",
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
trackType.Assembly.GetName().Name,
|
|
"Streamingle.Gaze.Runtime",
|
|
StringComparison.Ordinal);
|
|
var isSubtitle = string.Equals(
|
|
trackType.FullName,
|
|
"Streamingle.Subtitles.SubtitleTrack",
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
trackType.Assembly.GetName().Name,
|
|
"Streamingle.Subtitles.Runtime",
|
|
StringComparison.Ordinal);
|
|
if (!isGroup && !isMarker && !isAnimation && !isAudio &&
|
|
!isCinemachine && !isControl && !isRecorder &&
|
|
!isBlendshapeGaze && !isSubtitle)
|
|
{
|
|
error =
|
|
$"custom Timeline track '{trackType.FullName}' cannot be " +
|
|
"fingerprinted safely";
|
|
return false;
|
|
}
|
|
|
|
if (EditorUtility.IsDirty(track))
|
|
{
|
|
error = $"Timeline track '{track.name}' has unsaved changes";
|
|
return false;
|
|
}
|
|
|
|
if (track.GetMarkers().Any())
|
|
{
|
|
error =
|
|
$"Timeline track '{track.name}' contains notifications or " +
|
|
"custom markers";
|
|
return false;
|
|
}
|
|
|
|
lines.Add(
|
|
$"{label}|{trackType.FullName}|{track.name}|" +
|
|
$"muted={track.muted}|mutedInHierarchy={track.mutedInHierarchy}|" +
|
|
$"locked={track.locked}|lockedInHierarchy={track.lockedInHierarchy}");
|
|
|
|
if (!isGroup && !isMarker)
|
|
{
|
|
var binding = director.GetGenericBinding(track);
|
|
if (isAnimation && binding != null && binding is not Animator)
|
|
{
|
|
error =
|
|
$"Animation track '{track.name}' has an unsupported binding";
|
|
return false;
|
|
}
|
|
|
|
if (isAudio && binding != null && binding is not AudioSource)
|
|
{
|
|
error =
|
|
$"Audio track '{track.name}' has an unsupported binding";
|
|
return false;
|
|
}
|
|
|
|
if (!TryAppendBindingFingerprint(
|
|
lines,
|
|
$"{label}/binding",
|
|
binding,
|
|
sourceScene,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var clips = track.GetClips()
|
|
.OrderBy(value => value.start)
|
|
.ThenBy(value => value.end)
|
|
.ThenBy(value => value.displayName, StringComparer.Ordinal)
|
|
.ToArray();
|
|
for (var clipIndex = 0; clipIndex < clips.Length; clipIndex++)
|
|
{
|
|
if (!TryAppendTimelineClipFingerprint(
|
|
lines,
|
|
$"{label}/clip[{clipIndex}]",
|
|
clips[clipIndex],
|
|
isAnimation,
|
|
isAudio,
|
|
isCinemachine,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (isAnimation)
|
|
{
|
|
var animationTrack = (AnimationTrack)track;
|
|
lines.Add(
|
|
$"{label}/animation|trackOffset={animationTrack.trackOffset}");
|
|
if (animationTrack.infiniteClip != null &&
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
$"{label}/infiniteClip",
|
|
animationTrack.infiniteClip,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (animationTrack.avatarMask != null &&
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
$"{label}/avatarMask",
|
|
animationTrack.avatarMask,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var childTracks = track.GetChildTracks().ToArray();
|
|
for (var childIndex = 0;
|
|
childIndex < childTracks.Length;
|
|
childIndex++)
|
|
{
|
|
if (!TryAppendTrackFingerprint(
|
|
lines,
|
|
$"{label}/child[{childIndex}]",
|
|
childTracks[childIndex],
|
|
director,
|
|
sourceScene,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryAppendTimelineClipFingerprint(
|
|
List<string> lines,
|
|
string label,
|
|
TimelineClip clip,
|
|
bool isAnimationTrack,
|
|
bool isAudioTrack,
|
|
bool isCinemachineTrack,
|
|
out string error)
|
|
{
|
|
var asset = clip.asset;
|
|
if (asset == null)
|
|
{
|
|
error = $"Timeline clip '{clip.displayName}' has no PlayableAsset";
|
|
return false;
|
|
}
|
|
|
|
if ((isAnimationTrack &&
|
|
asset.GetType() != typeof(AnimationPlayableAsset)) ||
|
|
(isAudioTrack &&
|
|
asset.GetType() != typeof(AudioPlayableAsset)) ||
|
|
(isCinemachineTrack && asset.GetType() != typeof(CinemachineShot)))
|
|
{
|
|
error =
|
|
$"custom PlayableAsset '{asset.GetType().FullName}' cannot be " +
|
|
"fingerprinted safely";
|
|
return false;
|
|
}
|
|
|
|
lines.Add(
|
|
$"{label}|{clip.displayName}|" +
|
|
$"start={clip.start.ToString("R", Invariant)}|" +
|
|
$"duration={clip.duration.ToString("R", Invariant)}|" +
|
|
$"end={clip.end.ToString("R", Invariant)}|" +
|
|
$"clipIn={clip.clipIn.ToString("R", Invariant)}|" +
|
|
$"timeScale={clip.timeScale.ToString("R", Invariant)}|" +
|
|
$"easeIn={clip.easeInDuration.ToString("R", Invariant)}|" +
|
|
$"easeOut={clip.easeOutDuration.ToString("R", Invariant)}|" +
|
|
$"blendIn={clip.blendInDuration.ToString("R", Invariant)}|" +
|
|
$"blendOut={clip.blendOutDuration.ToString("R", Invariant)}|" +
|
|
$"preExtrapolation={clip.preExtrapolationMode}|" +
|
|
$"postExtrapolation={clip.postExtrapolationMode}");
|
|
if (!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
$"{label}/playable",
|
|
asset,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (clip.curves != null &&
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
$"{label}/curves",
|
|
clip.curves,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (asset is AnimationPlayableAsset animationAsset)
|
|
{
|
|
if (animationAsset.clip == null ||
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
$"{label}/animationClip",
|
|
animationAsset.clip,
|
|
out error))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(error))
|
|
{
|
|
error =
|
|
$"Animation clip '{clip.displayName}' has no source clip";
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
lines.Add(
|
|
$"{label}/animationSettings|" +
|
|
$"position={FormatVector3(animationAsset.position)}|" +
|
|
$"euler={FormatVector3(animationAsset.eulerAngles)}|" +
|
|
$"removeStartOffset={animationAsset.removeStartOffset}|" +
|
|
$"applyFootIK={animationAsset.applyFootIK}|" +
|
|
$"loop={animationAsset.loop}|" +
|
|
$"useTrackMatchFields={animationAsset.useTrackMatchFields}|" +
|
|
$"matchTargetFields={animationAsset.matchTargetFields}");
|
|
}
|
|
else if (asset is AudioPlayableAsset audioAsset)
|
|
{
|
|
if (audioAsset.clip == null ||
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
$"{label}/audioClip",
|
|
audioAsset.clip,
|
|
out error))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(error))
|
|
{
|
|
error = $"Audio clip '{clip.displayName}' has no source clip";
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
lines.Add($"{label}/audioSettings|loop={audioAsset.loop}");
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryAppendBindingFingerprint(
|
|
List<string> lines,
|
|
string label,
|
|
UnityEngine.Object binding,
|
|
Scene sourceScene,
|
|
out string error)
|
|
{
|
|
if (binding == null)
|
|
{
|
|
lines.Add($"{label}|<null>");
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
GameObject gameObject = null;
|
|
if (binding is GameObject boundGameObject)
|
|
{
|
|
gameObject = boundGameObject;
|
|
}
|
|
else if (binding is Component component)
|
|
{
|
|
gameObject = component.gameObject;
|
|
}
|
|
|
|
if (gameObject != null)
|
|
{
|
|
if (gameObject.scene != sourceScene)
|
|
{
|
|
error =
|
|
$"Timeline binding '{binding.name}' belongs to another scene";
|
|
return false;
|
|
}
|
|
|
|
lines.Add(
|
|
$"{label}|{binding.GetType().FullName}|" +
|
|
GetHierarchyPath(gameObject.transform));
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
if (EditorUtility.IsPersistent(binding))
|
|
{
|
|
return TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
label,
|
|
binding,
|
|
out error);
|
|
}
|
|
|
|
error =
|
|
$"Timeline binding '{binding.name}' cannot be fingerprinted safely";
|
|
return false;
|
|
}
|
|
|
|
private static bool TryAppendRigFingerprint(
|
|
List<string> lines,
|
|
Animator animator,
|
|
Scene sourceScene,
|
|
out string error)
|
|
{
|
|
if (animator == null || animator.gameObject.scene != sourceScene)
|
|
{
|
|
error = "The character rig is not part of the source scene.";
|
|
return false;
|
|
}
|
|
|
|
lines.Add(
|
|
$"character|{GetHierarchyPath(animator.transform)}|" +
|
|
$"isHuman={animator.isHuman}|applyRootMotion={animator.applyRootMotion}|" +
|
|
$"updateMode={animator.updateMode}|" +
|
|
$"worldPosition={FormatVector3(animator.transform.position)}|" +
|
|
$"worldRotation={FormatQuaternion(animator.transform.rotation)}|" +
|
|
$"worldScale={FormatVector3(animator.transform.lossyScale)}");
|
|
|
|
var hierarchyPaths = animator
|
|
.GetComponentsInChildren<Transform>(true)
|
|
.Select(transform =>
|
|
GetRelativeHierarchyPath(animator.transform, transform))
|
|
.OrderBy(value => value, StringComparer.Ordinal);
|
|
foreach (var hierarchyPath in hierarchyPaths)
|
|
{
|
|
lines.Add($"rigNode|{hierarchyPath}");
|
|
}
|
|
|
|
var ancestor = animator.transform.parent;
|
|
while (ancestor != null)
|
|
{
|
|
lines.Add(
|
|
$"rigAncestor|{GetHierarchyPath(ancestor)}|" +
|
|
$"position={FormatVector3(ancestor.localPosition)}|" +
|
|
$"rotation={FormatQuaternion(ancestor.localRotation)}|" +
|
|
$"scale={FormatVector3(ancestor.localScale)}|" +
|
|
$"active={ancestor.gameObject.activeSelf}");
|
|
ancestor = ancestor.parent;
|
|
}
|
|
|
|
if (animator.avatar != null &&
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
"character/avatar",
|
|
animator.avatar,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (animator.runtimeAnimatorController != null &&
|
|
!TryAppendPersistentAssetFingerprint(
|
|
lines,
|
|
"character/controller",
|
|
animator.runtimeAnimatorController,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static void AppendDirectorFingerprint(
|
|
List<string> lines,
|
|
string label,
|
|
PlayableDirector director)
|
|
{
|
|
lines.Add(
|
|
$"{label}|{GetHierarchyPath(director.transform)}|" +
|
|
$"playOnAwake={director.playOnAwake}|" +
|
|
$"timeUpdateMode={director.timeUpdateMode}|" +
|
|
$"extrapolationMode={director.extrapolationMode}");
|
|
}
|
|
|
|
private static bool TryAppendPersistentAssetFingerprint(
|
|
List<string> lines,
|
|
string label,
|
|
UnityEngine.Object asset,
|
|
out string error)
|
|
{
|
|
if (asset == null)
|
|
{
|
|
error = $"Asset '{label}' is missing.";
|
|
return false;
|
|
}
|
|
|
|
if (EditorUtility.IsDirty(asset))
|
|
{
|
|
error = $"Asset '{asset.name}' has unsaved changes.";
|
|
return false;
|
|
}
|
|
|
|
var assetPath = AssetDatabase.GetAssetPath(asset);
|
|
if (string.IsNullOrWhiteSpace(assetPath))
|
|
{
|
|
error =
|
|
$"Asset '{asset.name}' is not persistent and cannot be " +
|
|
"fingerprinted safely.";
|
|
return false;
|
|
}
|
|
|
|
return TryAppendAssetPathFingerprint(
|
|
lines,
|
|
label,
|
|
assetPath,
|
|
out error);
|
|
}
|
|
|
|
private static bool TryAppendAssetPathFingerprint(
|
|
List<string> lines,
|
|
string label,
|
|
string assetPath,
|
|
out string error)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(assetPath) ||
|
|
AssetDatabase.LoadMainAssetAtPath(assetPath) == null)
|
|
{
|
|
error = $"Asset path '{assetPath}' is not available.";
|
|
return false;
|
|
}
|
|
|
|
var dependencyHash =
|
|
AssetDatabase.GetAssetDependencyHash(assetPath).ToString();
|
|
if (string.IsNullOrWhiteSpace(dependencyHash) ||
|
|
dependencyHash.All(character => character == '0'))
|
|
{
|
|
error =
|
|
$"Asset dependency hash for '{assetPath}' is unavailable.";
|
|
return false;
|
|
}
|
|
|
|
lines.Add($"{label}|{assetPath}|{dependencyHash}");
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static string GetRelativeHierarchyPath(
|
|
Transform root,
|
|
Transform value)
|
|
{
|
|
if (value == root)
|
|
{
|
|
return ".";
|
|
}
|
|
|
|
var names = new List<string>();
|
|
var current = value;
|
|
while (current != null && current != root)
|
|
{
|
|
names.Add(current.name);
|
|
current = current.parent;
|
|
}
|
|
|
|
if (current != root)
|
|
{
|
|
return $"<outside>/{GetHierarchyPath(value)}";
|
|
}
|
|
|
|
names.Reverse();
|
|
return string.Join("/", names);
|
|
}
|
|
|
|
private static string FormatVector3(Vector3 value)
|
|
{
|
|
return $"{value.x.ToString("R", Invariant)}," +
|
|
$"{value.y.ToString("R", Invariant)}," +
|
|
value.z.ToString("R", Invariant);
|
|
}
|
|
|
|
private static string FormatQuaternion(Quaternion value)
|
|
{
|
|
return $"{value.x.ToString("R", Invariant)}," +
|
|
$"{value.y.ToString("R", Invariant)}," +
|
|
$"{value.z.ToString("R", Invariant)}," +
|
|
value.w.ToString("R", Invariant);
|
|
}
|
|
|
|
private static string ComputeTextSha256(IEnumerable<string> lines)
|
|
{
|
|
using var sha256 = SHA256.Create();
|
|
var bytes = sha256.ComputeHash(
|
|
Encoding.UTF8.GetBytes(string.Join("\n", lines)));
|
|
return string.Concat(bytes.Select(item => item.ToString("x2")));
|
|
}
|
|
|
|
internal static bool TryFindReusableGenerationInputExport(
|
|
string datasetRoot,
|
|
string sourceFingerprint,
|
|
out string exportDirectory,
|
|
out string error)
|
|
{
|
|
exportDirectory = string.Empty;
|
|
error = string.Empty;
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(datasetRoot) ||
|
|
string.IsNullOrWhiteSpace(sourceFingerprint) ||
|
|
!Directory.Exists(datasetRoot))
|
|
{
|
|
error = "No reusable generation input directory is available.";
|
|
return false;
|
|
}
|
|
|
|
var candidates = Directory
|
|
.EnumerateDirectories(
|
|
Path.GetFullPath(datasetRoot),
|
|
$"{GenerationInputDirectoryPrefix}*",
|
|
SearchOption.TopDirectoryOnly)
|
|
.OrderByDescending(
|
|
Path.GetFileName,
|
|
StringComparer.Ordinal)
|
|
.ToArray();
|
|
foreach (var candidate in candidates)
|
|
{
|
|
if (!TryValidateReusableGenerationInputExport(
|
|
candidate,
|
|
sourceFingerprint,
|
|
out _))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
exportDirectory = Path.GetFullPath(candidate);
|
|
return true;
|
|
}
|
|
|
|
error =
|
|
"No complete generation input matches the current source.";
|
|
return false;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
exportDirectory = string.Empty;
|
|
error = exception.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
internal static bool TryValidateReusableGenerationInputExport(
|
|
string candidateDirectory,
|
|
string sourceFingerprint,
|
|
out string error)
|
|
{
|
|
error = string.Empty;
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(candidateDirectory) ||
|
|
string.IsNullOrWhiteSpace(sourceFingerprint) ||
|
|
!Directory.Exists(candidateDirectory))
|
|
{
|
|
error = "The candidate directory or source fingerprint is invalid.";
|
|
return false;
|
|
}
|
|
|
|
var candidateRoot = Path.GetFullPath(candidateDirectory);
|
|
var manifestPath = Path.Combine(
|
|
candidateRoot,
|
|
"dataset_manifest.json");
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
error = "dataset_manifest.json is missing.";
|
|
return false;
|
|
}
|
|
|
|
var manifest = JsonUtility.FromJson<DatasetManifest>(
|
|
File.ReadAllText(manifestPath, Encoding.UTF8));
|
|
if (manifest == null ||
|
|
!string.Equals(
|
|
manifest.schemaVersion,
|
|
SchemaVersion,
|
|
StringComparison.Ordinal) ||
|
|
!string.Equals(
|
|
manifest.role,
|
|
GenerationInputKind,
|
|
StringComparison.Ordinal) ||
|
|
!string.Equals(
|
|
manifest.inputKind,
|
|
GenerationInputKind,
|
|
StringComparison.Ordinal) ||
|
|
manifest.hasAuthoredCamera ||
|
|
manifest.sampleRate != SampleRate ||
|
|
manifest.discoveredCameraTrackCount != 0 ||
|
|
manifest.exportedCameraTrackCount != 0 ||
|
|
!string.Equals(
|
|
manifest.sourceFingerprint,
|
|
sourceFingerprint,
|
|
StringComparison.Ordinal) ||
|
|
manifest.sharedBones == null ||
|
|
manifest.sharedBones.Count == 0 ||
|
|
manifest.songs == null ||
|
|
manifest.songs.Count != 1)
|
|
{
|
|
error =
|
|
"The manifest is not a matching generation input export.";
|
|
return false;
|
|
}
|
|
|
|
if (manifest.skippedCameraTracks != null &&
|
|
manifest.skippedCameraTracks.Count != 0)
|
|
{
|
|
error = "The generation input manifest contains camera tracks.";
|
|
return false;
|
|
}
|
|
|
|
var song = manifest.songs[0];
|
|
if (!ValidateGenerationInputSongMetadata(
|
|
song,
|
|
manifest.sharedBones.Count,
|
|
out error) ||
|
|
!TryResolveChildPath(
|
|
candidateRoot,
|
|
song.folderName,
|
|
out var songDirectory))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(error))
|
|
{
|
|
error = "The song directory path is invalid.";
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (!ValidateFileSize(
|
|
songDirectory,
|
|
song.jointsFile,
|
|
checked((long)song.frameCount *
|
|
song.sharedBoneCount * 3 * sizeof(float)),
|
|
out error) ||
|
|
!ValidateFileSize(
|
|
songDirectory,
|
|
song.cameraFile,
|
|
checked((long)song.frameCount * 9 * sizeof(float)),
|
|
out error) ||
|
|
!ValidateFileSize(
|
|
songDirectory,
|
|
song.rootFile,
|
|
checked((long)song.frameCount * 14 * sizeof(float)),
|
|
out error) ||
|
|
!ValidateFileSize(
|
|
songDirectory,
|
|
song.shotIndexFile,
|
|
checked((long)song.frameCount * sizeof(int)),
|
|
out error) ||
|
|
!ValidateFileSize(
|
|
songDirectory,
|
|
song.timeFile,
|
|
checked((long)song.frameCount * sizeof(double)),
|
|
out error) ||
|
|
!ValidateFileSize(
|
|
songDirectory,
|
|
song.audioFeaturesFile,
|
|
checked((long)song.frameCount * 2 * sizeof(float)),
|
|
out error) ||
|
|
!ValidateRequiredFile(
|
|
songDirectory,
|
|
song.previewFile,
|
|
out error) ||
|
|
!ValidateRequiredFile(
|
|
songDirectory,
|
|
song.shotsFile,
|
|
out error) ||
|
|
!ValidateRequiredFile(
|
|
songDirectory,
|
|
"metadata.json",
|
|
out error) ||
|
|
!ValidateRequiredFile(
|
|
songDirectory,
|
|
song.audioFile,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!ValidateFileSha256(
|
|
songDirectory,
|
|
song.jointsFile,
|
|
song.jointsFingerprint,
|
|
out error) ||
|
|
!ValidateFileSha256(
|
|
songDirectory,
|
|
song.cameraFile,
|
|
song.cameraFingerprint,
|
|
out error) ||
|
|
!ValidateFileSha256(
|
|
songDirectory,
|
|
song.rootFile,
|
|
song.rootFingerprint,
|
|
out error) ||
|
|
!ValidateFileSha256(
|
|
songDirectory,
|
|
song.shotIndexFile,
|
|
song.shotIndexFingerprint,
|
|
out error) ||
|
|
!ValidateFileSha256(
|
|
songDirectory,
|
|
song.timeFile,
|
|
song.timeFingerprint,
|
|
out error) ||
|
|
!ValidateFileSha256(
|
|
songDirectory,
|
|
song.audioFeaturesFile,
|
|
song.audioFeaturesFingerprint,
|
|
out error) ||
|
|
!ValidateFileSha256(
|
|
songDirectory,
|
|
song.audioFile,
|
|
song.audioFingerprint,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryResolveChildPath(
|
|
songDirectory,
|
|
"metadata.json",
|
|
out var metadataPath) ||
|
|
!TryResolveChildPath(
|
|
songDirectory,
|
|
song.shotsFile,
|
|
out var shotsPath))
|
|
{
|
|
error = "A generation input file path is invalid.";
|
|
return false;
|
|
}
|
|
|
|
var metadata = JsonUtility.FromJson<SongMetadata>(
|
|
File.ReadAllText(metadataPath, Encoding.UTF8));
|
|
if (!GenerationInputMetadataMatches(song, metadata))
|
|
{
|
|
error =
|
|
"metadata.json does not match dataset_manifest.json.";
|
|
return false;
|
|
}
|
|
|
|
var shots = JsonUtility.FromJson<ShotFile>(
|
|
File.ReadAllText(shotsPath, Encoding.UTF8));
|
|
if (shots?.shots == null || shots.shots.Count != 0)
|
|
{
|
|
error = "A generation input must contain no authored shots.";
|
|
return false;
|
|
}
|
|
|
|
if (!ValidateGenerationInputPlaceholders(
|
|
songDirectory,
|
|
song,
|
|
out error))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
error = exception.Message;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool ValidateGenerationInputSongMetadata(
|
|
SongMetadata song,
|
|
int manifestBoneCount,
|
|
out string error)
|
|
{
|
|
if (song == null ||
|
|
!string.Equals(
|
|
song.role,
|
|
GenerationInputKind,
|
|
StringComparison.Ordinal) ||
|
|
!string.Equals(
|
|
song.inputKind,
|
|
GenerationInputKind,
|
|
StringComparison.Ordinal) ||
|
|
song.hasAuthoredCamera ||
|
|
song.sampleRate != SampleRate ||
|
|
song.frameCount <= 0 ||
|
|
song.sharedBoneCount <= 0 ||
|
|
song.sharedBoneCount != manifestBoneCount ||
|
|
song.shotCount != 0 ||
|
|
song.missingCameraFrames != song.frameCount ||
|
|
song.missingJointSamples != 0 ||
|
|
song.missingRootSamples != 0 ||
|
|
string.IsNullOrWhiteSpace(song.folderName) ||
|
|
string.IsNullOrWhiteSpace(song.jointsFile) ||
|
|
string.IsNullOrWhiteSpace(song.cameraFile) ||
|
|
string.IsNullOrWhiteSpace(song.rootFile) ||
|
|
string.IsNullOrWhiteSpace(song.shotIndexFile) ||
|
|
string.IsNullOrWhiteSpace(song.timeFile) ||
|
|
string.IsNullOrWhiteSpace(song.shotsFile) ||
|
|
string.IsNullOrWhiteSpace(song.previewFile) ||
|
|
string.IsNullOrWhiteSpace(song.audioFeaturesFile) ||
|
|
string.IsNullOrWhiteSpace(song.audioFile) ||
|
|
!IsSha256(song.audioFingerprint) ||
|
|
!IsSha256(song.jointsFingerprint) ||
|
|
!IsSha256(song.cameraFingerprint) ||
|
|
!IsSha256(song.rootFingerprint) ||
|
|
!IsSha256(song.shotIndexFingerprint) ||
|
|
!IsSha256(song.timeFingerprint) ||
|
|
!IsSha256(song.audioFeaturesFingerprint))
|
|
{
|
|
error = "The generation input song metadata is incomplete.";
|
|
return false;
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool GenerationInputMetadataMatches(
|
|
SongMetadata manifestSong,
|
|
SongMetadata metadata)
|
|
{
|
|
return metadata != null &&
|
|
string.Equals(
|
|
metadata.role,
|
|
GenerationInputKind,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.inputKind,
|
|
GenerationInputKind,
|
|
StringComparison.Ordinal) &&
|
|
!metadata.hasAuthoredCamera &&
|
|
metadata.sampleRate == manifestSong.sampleRate &&
|
|
metadata.frameCount == manifestSong.frameCount &&
|
|
metadata.sharedBoneCount == manifestSong.sharedBoneCount &&
|
|
metadata.shotCount == manifestSong.shotCount &&
|
|
metadata.missingCameraFrames ==
|
|
manifestSong.missingCameraFrames &&
|
|
metadata.missingJointSamples ==
|
|
manifestSong.missingJointSamples &&
|
|
metadata.missingRootSamples == manifestSong.missingRootSamples &&
|
|
string.Equals(
|
|
metadata.folderName,
|
|
manifestSong.folderName,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.jointsFile,
|
|
manifestSong.jointsFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.jointsFingerprint,
|
|
manifestSong.jointsFingerprint,
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(
|
|
metadata.cameraFile,
|
|
manifestSong.cameraFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.cameraFingerprint,
|
|
manifestSong.cameraFingerprint,
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(
|
|
metadata.rootFile,
|
|
manifestSong.rootFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.rootFingerprint,
|
|
manifestSong.rootFingerprint,
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(
|
|
metadata.shotIndexFile,
|
|
manifestSong.shotIndexFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.shotIndexFingerprint,
|
|
manifestSong.shotIndexFingerprint,
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(
|
|
metadata.timeFile,
|
|
manifestSong.timeFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.timeFingerprint,
|
|
manifestSong.timeFingerprint,
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(
|
|
metadata.shotsFile,
|
|
manifestSong.shotsFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.previewFile,
|
|
manifestSong.previewFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.audioFeaturesFile,
|
|
manifestSong.audioFeaturesFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.audioFeaturesFingerprint,
|
|
manifestSong.audioFeaturesFingerprint,
|
|
StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(
|
|
metadata.audioFile,
|
|
manifestSong.audioFile,
|
|
StringComparison.Ordinal) &&
|
|
string.Equals(
|
|
metadata.audioFingerprint,
|
|
manifestSong.audioFingerprint,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool ValidateGenerationInputPlaceholders(
|
|
string songDirectory,
|
|
SongMetadata song,
|
|
out string error)
|
|
{
|
|
if (!TryResolveChildPath(
|
|
songDirectory,
|
|
song.cameraFile,
|
|
out var cameraPath) ||
|
|
!TryResolveChildPath(
|
|
songDirectory,
|
|
song.shotIndexFile,
|
|
out var shotIndexPath))
|
|
{
|
|
error = "A placeholder file path is invalid.";
|
|
return false;
|
|
}
|
|
|
|
using (var cameraReader = new BinaryReader(File.OpenRead(cameraPath)))
|
|
{
|
|
for (var index = 0; index < 9; index++)
|
|
{
|
|
if (!float.IsNaN(cameraReader.ReadSingle()))
|
|
{
|
|
error =
|
|
"camera.f32 is not a generation-input placeholder.";
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
using (var shotReader = new BinaryReader(File.OpenRead(shotIndexPath)))
|
|
{
|
|
if (shotReader.ReadInt32() != -1)
|
|
{
|
|
error =
|
|
"shot_index.i32 is not a generation-input placeholder.";
|
|
return false;
|
|
}
|
|
|
|
if (song.frameCount > 1)
|
|
{
|
|
shotReader.BaseStream.Seek(-sizeof(int), SeekOrigin.End);
|
|
if (shotReader.ReadInt32() != -1)
|
|
{
|
|
error =
|
|
"shot_index.i32 has an invalid final placeholder.";
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool ValidateFileSize(
|
|
string directory,
|
|
string relativePath,
|
|
long expectedBytes,
|
|
out string error)
|
|
{
|
|
if (!TryResolveChildPath(
|
|
directory,
|
|
relativePath,
|
|
out var fullPath) ||
|
|
!File.Exists(fullPath))
|
|
{
|
|
error = $"Required file '{relativePath}' is missing.";
|
|
return false;
|
|
}
|
|
|
|
var actualBytes = new FileInfo(fullPath).Length;
|
|
if (actualBytes != expectedBytes)
|
|
{
|
|
error =
|
|
$"File '{relativePath}' has {actualBytes} bytes; " +
|
|
$"expected {expectedBytes}.";
|
|
return false;
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool ValidateRequiredFile(
|
|
string directory,
|
|
string relativePath,
|
|
out string error)
|
|
{
|
|
if (!TryResolveChildPath(
|
|
directory,
|
|
relativePath,
|
|
out var fullPath) ||
|
|
!File.Exists(fullPath) ||
|
|
new FileInfo(fullPath).Length == 0)
|
|
{
|
|
error = $"Required file '{relativePath}' is missing or empty.";
|
|
return false;
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool ValidateFileSha256(
|
|
string directory,
|
|
string relativePath,
|
|
string expectedFingerprint,
|
|
out string error)
|
|
{
|
|
if (!IsSha256(expectedFingerprint) ||
|
|
!TryResolveChildPath(
|
|
directory,
|
|
relativePath,
|
|
out var fullPath) ||
|
|
!File.Exists(fullPath))
|
|
{
|
|
error =
|
|
$"Required fingerprint for '{relativePath}' is missing.";
|
|
return false;
|
|
}
|
|
|
|
var actualFingerprint = ComputeFileSha256(fullPath);
|
|
if (!string.Equals(
|
|
actualFingerprint,
|
|
expectedFingerprint,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
error = $"File fingerprint for '{relativePath}' does not match.";
|
|
return false;
|
|
}
|
|
|
|
error = string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryResolveChildPath(
|
|
string directory,
|
|
string relativePath,
|
|
out string fullPath)
|
|
{
|
|
fullPath = string.Empty;
|
|
if (string.IsNullOrWhiteSpace(directory) ||
|
|
string.IsNullOrWhiteSpace(relativePath) ||
|
|
Path.IsPathRooted(relativePath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var root = Path.GetFullPath(directory)
|
|
.TrimEnd(
|
|
Path.DirectorySeparatorChar,
|
|
Path.AltDirectorySeparatorChar) +
|
|
Path.DirectorySeparatorChar;
|
|
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
|
|
var comparison = Path.DirectorySeparatorChar == '\\'
|
|
? StringComparison.OrdinalIgnoreCase
|
|
: StringComparison.Ordinal;
|
|
if (!candidate.StartsWith(root, comparison))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
fullPath = candidate;
|
|
return true;
|
|
}
|
|
|
|
private static bool IsSha256(string value)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(value) &&
|
|
value.Length == 64 &&
|
|
value.All(character =>
|
|
(character >= '0' && character <= '9') ||
|
|
(character >= 'a' && character <= 'f') ||
|
|
(character >= 'A' && character <= 'F'));
|
|
}
|
|
|
|
private static BinaryWriter CreateBinaryWriter(string path)
|
|
{
|
|
return new BinaryWriter(new FileStream(
|
|
path,
|
|
FileMode.Create,
|
|
FileAccess.Write,
|
|
FileShare.None,
|
|
1024 * 1024,
|
|
FileOptions.SequentialScan));
|
|
}
|
|
|
|
private static string ComputeFileSha256(string path)
|
|
{
|
|
using var stream = new FileStream(
|
|
path,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.Read,
|
|
1024 * 1024,
|
|
FileOptions.SequentialScan);
|
|
using var sha256 = SHA256.Create();
|
|
return string.Concat(
|
|
sha256.ComputeHash(stream)
|
|
.Select(value => value.ToString("x2")));
|
|
}
|
|
|
|
private static void WriteJson<T>(string path, T value)
|
|
{
|
|
File.WriteAllText(path, JsonUtility.ToJson(value, true));
|
|
}
|
|
|
|
private static void WriteVector3(BinaryWriter writer, Vector3 value)
|
|
{
|
|
writer.Write(value.x);
|
|
writer.Write(value.y);
|
|
writer.Write(value.z);
|
|
}
|
|
|
|
private static void WriteQuaternion(BinaryWriter writer, Quaternion value)
|
|
{
|
|
writer.Write(value.x);
|
|
writer.Write(value.y);
|
|
writer.Write(value.z);
|
|
writer.Write(value.w);
|
|
}
|
|
|
|
private static void WriteInvalidVector3(BinaryWriter writer)
|
|
{
|
|
for (var index = 0; index < 3; index++)
|
|
{
|
|
writer.Write(float.NaN);
|
|
}
|
|
}
|
|
|
|
private static void WriteInvalidQuaternion(BinaryWriter writer)
|
|
{
|
|
for (var index = 0; index < 4; index++)
|
|
{
|
|
writer.Write(float.NaN);
|
|
}
|
|
}
|
|
|
|
private static string GetHierarchyPath(Transform transform)
|
|
{
|
|
var names = new List<string>();
|
|
while (transform != null)
|
|
{
|
|
names.Add(transform.name);
|
|
transform = transform.parent;
|
|
}
|
|
|
|
names.Reverse();
|
|
return string.Join("/", names);
|
|
}
|
|
|
|
private static string SanitizeFileName(string value)
|
|
{
|
|
var invalidCharacters = Path.GetInvalidFileNameChars();
|
|
var sanitized = new string(value
|
|
.Select(character => invalidCharacters.Contains(character) ? '_' : character)
|
|
.ToArray());
|
|
return string.IsNullOrWhiteSpace(sanitized) ? "Song" : sanitized;
|
|
}
|
|
|
|
private static string EscapeCsv(string value)
|
|
{
|
|
if (!value.Contains(",") && !value.Contains("\"") &&
|
|
!value.Contains("\r") && !value.Contains("\n"))
|
|
{
|
|
return value;
|
|
}
|
|
|
|
return $"\"{value.Replace("\"", "\"\"")}\"";
|
|
}
|
|
|
|
private sealed class SongContext
|
|
{
|
|
public string SongName;
|
|
public Transform Root;
|
|
public PlayableDirector MainDirector;
|
|
public PlayableDirector MotionDirector;
|
|
public TimelineAsset MainTimeline;
|
|
public TimelineAsset MotionTimeline;
|
|
public Animator CharacterAnimator;
|
|
public CinemachineTrack CameraTrack;
|
|
public int CameraTrackIndex;
|
|
public CinemachineBrain CameraBrain;
|
|
}
|
|
|
|
private sealed class JointDefinition
|
|
{
|
|
public string Name;
|
|
public HumanBodyBones? HumanBone;
|
|
public string RelativePath;
|
|
}
|
|
|
|
private sealed class ShotRuntime
|
|
{
|
|
public TimelineClip Clip;
|
|
public double Start;
|
|
public double End;
|
|
public CinemachineVirtualCameraBase VirtualCamera;
|
|
}
|
|
|
|
private sealed class CameraSample
|
|
{
|
|
public bool IsValid;
|
|
public ShotRuntime DominantShot;
|
|
public CameraState State;
|
|
}
|
|
|
|
private sealed class AudioInfo
|
|
{
|
|
public AudioClip Clip;
|
|
public string AssetPath;
|
|
public double Start;
|
|
public double Duration;
|
|
public double ClipIn;
|
|
public double TimeScale;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class SkippedCameraTrack
|
|
{
|
|
public string timelineAssetPath;
|
|
public string trackName;
|
|
public bool muted;
|
|
public string reason;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class DatasetManifest
|
|
{
|
|
public string schemaVersion;
|
|
public string role;
|
|
public string inputKind;
|
|
public bool hasAuthoredCamera;
|
|
public string createdUtc;
|
|
public string unityVersion;
|
|
public string scenePath;
|
|
public int sampleRate;
|
|
public string byteOrder;
|
|
public string floatFormat;
|
|
public string timeFormat;
|
|
public string jointLayout;
|
|
public string cameraLayout;
|
|
public string rootLayout;
|
|
public string audioFeatureLayout;
|
|
public string cameraSamplingPolicy;
|
|
public string sourceFingerprintSchema;
|
|
public string sourceFingerprint;
|
|
public int discoveredCameraTrackCount;
|
|
public int exportedCameraTrackCount;
|
|
public List<SkippedCameraTrack> skippedCameraTracks;
|
|
public List<string> sharedBones;
|
|
public List<SongMetadata> songs;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class SongMetadata
|
|
{
|
|
public string songName;
|
|
public string role;
|
|
public string inputKind;
|
|
public bool hasAuthoredCamera;
|
|
public string folderName;
|
|
public string mainTimelineAssetPath;
|
|
public string motionTimelineAssetPath;
|
|
public string characterPath;
|
|
public string cameraTrackName;
|
|
public int cameraTrackIndex;
|
|
public bool cameraTrackMuted;
|
|
public string audioAssetPath;
|
|
public string audioFile;
|
|
public string audioFingerprint;
|
|
public double audioStart;
|
|
public double audioDuration;
|
|
public double mainDuration;
|
|
public double motionDuration;
|
|
public int sampleRate;
|
|
public double sampleStart;
|
|
public double sampleEndExclusive;
|
|
public int frameCount;
|
|
public int sharedBoneCount;
|
|
public string skeletonType;
|
|
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;
|
|
public string audioFeatureLayout;
|
|
public string audioFeaturesStatus;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class ShotFile
|
|
{
|
|
public string timelineName;
|
|
public List<ShotInfo> shots;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class ShotInfo
|
|
{
|
|
public int index;
|
|
public string clipName;
|
|
public string cameraName;
|
|
public string cameraPath;
|
|
public double start;
|
|
public double end;
|
|
public double duration;
|
|
public double easeInDuration;
|
|
public double easeOutDuration;
|
|
}
|
|
}
|
|
}
|