634 lines
21 KiB
C#
634 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using UnityEngine;
|
|
|
|
namespace Streamingle.Editor
|
|
{
|
|
public enum AICameraABRenderCodec
|
|
{
|
|
MP4,
|
|
WEBM
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABRenderRequest
|
|
{
|
|
public string candidateADirectory;
|
|
public string candidateBDirectory;
|
|
public string outputRoot;
|
|
public int startFrame;
|
|
public int endFrameExclusive;
|
|
public AICameraABRenderCodec codec = AICameraABRenderCodec.MP4;
|
|
public int width = 1920;
|
|
public int height = 1080;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABRenderManifest
|
|
{
|
|
public string schemaVersion = "1.0";
|
|
public string jobId;
|
|
public string state;
|
|
public string createdUtc;
|
|
public string completedUtc;
|
|
public string error;
|
|
public AICameraABEnvironmentInfo environment;
|
|
public AICameraABSceneInfo scene;
|
|
public AICameraABFrameRange frameRange;
|
|
public AICameraABMovieSettings movie;
|
|
public AICameraABFairnessInfo fairness;
|
|
public List<AICameraABAudioClipInfo> audio = new();
|
|
public List<AICameraABCandidateResult> candidates = new();
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABEnvironmentInfo
|
|
{
|
|
public string unityVersion;
|
|
public string recorderVersion;
|
|
public string cwAiVersion;
|
|
public string operatingSystem;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABSceneInfo
|
|
{
|
|
public string scenePath;
|
|
public string sceneGuid;
|
|
public string timelinePath;
|
|
public string timelineGuid;
|
|
public double timelineFrameRate;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABFrameRange
|
|
{
|
|
public int startFrame;
|
|
public int endFrameExclusive;
|
|
public int expectedFrameCount;
|
|
public double startSeconds;
|
|
public double endSecondsExclusive;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABMovieSettings
|
|
{
|
|
public string codec;
|
|
public string encoder;
|
|
public string quality;
|
|
public int width;
|
|
public int height;
|
|
public double aspectRatio;
|
|
public int frameRate;
|
|
public string cameraSource;
|
|
public bool captureAudio;
|
|
public bool captureAlpha;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABFairnessInfo
|
|
{
|
|
public bool sameSampleRate;
|
|
public bool sameFrameCount;
|
|
public bool sameTimeSha256;
|
|
public bool sameSongId;
|
|
public bool sameDatasetId;
|
|
public bool sameTargetAspectRatio;
|
|
public bool sameSceneAndTimeline;
|
|
public string sharedTimeSha256;
|
|
public string sharedSongId;
|
|
public string targetEvaluationGroupId;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABAudioClipInfo
|
|
{
|
|
public string trackName;
|
|
public string clipName;
|
|
public string assetPath;
|
|
public string assetGuid;
|
|
public string assetSha256;
|
|
public double timelineStart;
|
|
public double clipIn;
|
|
public double duration;
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AICameraABCandidateResult
|
|
{
|
|
public string label;
|
|
public string generatedDirectory;
|
|
public string horizontalFramingPolicy;
|
|
public string metadataSha256;
|
|
public string cameraSha256;
|
|
public string timeSha256;
|
|
public string shotsSha256;
|
|
public string declaredCameraSha256;
|
|
public string declaredTimeSha256;
|
|
public string declaredShotsSha256;
|
|
public string songName;
|
|
public string songId;
|
|
public string datasetId;
|
|
public string targetEvaluationGroupId;
|
|
public double targetAspectRatio;
|
|
public int sampleRate;
|
|
public int frameCount;
|
|
public double duration;
|
|
public string outputBasePath;
|
|
public string outputPath;
|
|
public string status;
|
|
public string startedUtc;
|
|
public string completedUtc;
|
|
public long outputBytes;
|
|
public string outputSha256;
|
|
public string error;
|
|
}
|
|
|
|
[Serializable]
|
|
internal sealed class AICameraABRenderJobState
|
|
{
|
|
public string schemaVersion = "1.0";
|
|
public string jobId;
|
|
public string phase;
|
|
public string manifestPath;
|
|
public string runDirectory;
|
|
public int candidateIndex;
|
|
public bool cancelRequested;
|
|
public bool cleanupPending;
|
|
public bool cinemachineSaveDuringPlayStateCaptured;
|
|
public bool cinemachineSaveDuringPlayOriginalEnabled;
|
|
public bool cinemachineSaveDuringPlayStateRestored;
|
|
public int stableOutputChecks;
|
|
public long lastObservedOutputBytes = -1;
|
|
public string finalizationStartedUtc;
|
|
public string error;
|
|
public AICameraABRenderRequest request;
|
|
public AICameraABSceneSetupSnapshot sceneSetup;
|
|
}
|
|
|
|
[Serializable]
|
|
internal sealed class AICameraABSceneSetupSnapshot
|
|
{
|
|
public string activeScenePath;
|
|
public List<AICameraABSceneEntry> scenes = new();
|
|
}
|
|
|
|
[Serializable]
|
|
internal sealed class AICameraABSceneEntry
|
|
{
|
|
public string path;
|
|
public bool isLoaded;
|
|
public bool isActive;
|
|
}
|
|
|
|
[Serializable]
|
|
internal sealed class AICameraABGeneratedCameraMetadata
|
|
{
|
|
public string songName;
|
|
public string songId;
|
|
public string datasetId;
|
|
public string targetEvaluationGroupId;
|
|
public string horizontalFramingPolicy;
|
|
public double targetAspectRatio;
|
|
public double aspectRatioOverride;
|
|
public int sampleRate;
|
|
public int frameCount;
|
|
public double duration;
|
|
public string worldCameraFile;
|
|
public string timeFile;
|
|
public string shotsFile;
|
|
public AICameraABGeneratedOutputSha256 outputSha256;
|
|
}
|
|
|
|
[Serializable]
|
|
internal sealed class AICameraABGeneratedOutputSha256
|
|
{
|
|
public string worldCamera;
|
|
public string time;
|
|
public string shots;
|
|
}
|
|
|
|
internal sealed class AICameraABCandidateProbe
|
|
{
|
|
public AICameraABGeneratedCameraMetadata Metadata;
|
|
public AICameraABCandidateResult Result;
|
|
}
|
|
|
|
internal static class AICameraABRenderUtility
|
|
{
|
|
private static readonly StringComparison PathComparison =
|
|
Application.platform == RuntimePlatform.WindowsEditor
|
|
? StringComparison.OrdinalIgnoreCase
|
|
: StringComparison.Ordinal;
|
|
|
|
internal static string UtcNow()
|
|
{
|
|
return DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
internal static AICameraABRenderCodec ParseCodec(string value)
|
|
{
|
|
if (Enum.TryParse(value, true, out AICameraABRenderCodec codec))
|
|
{
|
|
return codec;
|
|
}
|
|
|
|
throw new ArgumentException(
|
|
$"Unsupported movie codec '{value}'. Use MP4 or WEBM.",
|
|
nameof(value));
|
|
}
|
|
|
|
internal static string GetExtension(AICameraABRenderCodec codec)
|
|
{
|
|
return codec == AICameraABRenderCodec.MP4 ? "mp4" : "webm";
|
|
}
|
|
|
|
internal static int ToInclusiveEndFrame(
|
|
int startFrame,
|
|
int endFrameExclusive,
|
|
int frameCount)
|
|
{
|
|
ValidateFrameRange(startFrame, endFrameExclusive, frameCount);
|
|
return endFrameExclusive - 1;
|
|
}
|
|
|
|
internal static void ValidateFrameRange(
|
|
int startFrame,
|
|
int endFrameExclusive,
|
|
int frameCount)
|
|
{
|
|
if (frameCount <= 0 ||
|
|
startFrame < 0 ||
|
|
endFrameExclusive <= startFrame ||
|
|
endFrameExclusive > frameCount)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(endFrameExclusive),
|
|
$"Frame range [{startFrame}, {endFrameExclusive}) is invalid " +
|
|
$"for a {frameCount}-frame camera.");
|
|
}
|
|
}
|
|
|
|
internal static void ValidateOutputSettings(
|
|
AICameraABRenderCodec codec,
|
|
int width,
|
|
int height)
|
|
{
|
|
if (width <= 0 || height <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(width),
|
|
"Movie width and height must both be positive.");
|
|
}
|
|
|
|
if (codec == AICameraABRenderCodec.MP4 &&
|
|
(width % 2 != 0 || height % 2 != 0))
|
|
{
|
|
throw new ArgumentException(
|
|
"MP4/H.264 output requires even width and height.");
|
|
}
|
|
}
|
|
|
|
internal static AICameraABCandidateProbe ReadCandidate(
|
|
string label,
|
|
string generatedDirectory)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(generatedDirectory))
|
|
{
|
|
throw new ArgumentException(
|
|
$"Candidate {label} directory is empty.",
|
|
nameof(generatedDirectory));
|
|
}
|
|
|
|
var canonicalDirectory = Path.GetFullPath(generatedDirectory);
|
|
var metadataPath = ResolveInputFile(
|
|
canonicalDirectory,
|
|
"metadata.json",
|
|
true);
|
|
var metadata = JsonUtility.FromJson<AICameraABGeneratedCameraMetadata>(
|
|
File.ReadAllText(metadataPath));
|
|
if (metadata == null ||
|
|
metadata.sampleRate <= 0 ||
|
|
metadata.frameCount <= 0 ||
|
|
metadata.duration <= 0 ||
|
|
string.IsNullOrWhiteSpace(metadata.worldCameraFile) ||
|
|
string.IsNullOrWhiteSpace(metadata.timeFile))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Candidate {label} metadata is incomplete.");
|
|
}
|
|
|
|
var cameraPath = ResolveInputFile(
|
|
canonicalDirectory,
|
|
metadata.worldCameraFile,
|
|
true);
|
|
var timePath = ResolveInputFile(
|
|
canonicalDirectory,
|
|
metadata.timeFile,
|
|
true);
|
|
var shotsPath = string.IsNullOrWhiteSpace(metadata.shotsFile)
|
|
? string.Empty
|
|
: ResolveInputFile(
|
|
canonicalDirectory,
|
|
metadata.shotsFile,
|
|
true);
|
|
|
|
var result = new AICameraABCandidateResult
|
|
{
|
|
label = label,
|
|
generatedDirectory = canonicalDirectory,
|
|
horizontalFramingPolicy = metadata.horizontalFramingPolicy,
|
|
metadataSha256 = ComputeSha256(metadataPath),
|
|
cameraSha256 = ComputeSha256(cameraPath),
|
|
timeSha256 = ComputeSha256(timePath),
|
|
shotsSha256 = string.IsNullOrWhiteSpace(shotsPath)
|
|
? string.Empty
|
|
: ComputeSha256(shotsPath),
|
|
declaredCameraSha256 =
|
|
metadata.outputSha256?.worldCamera ?? string.Empty,
|
|
declaredTimeSha256 =
|
|
metadata.outputSha256?.time ?? string.Empty,
|
|
declaredShotsSha256 =
|
|
metadata.outputSha256?.shots ?? string.Empty,
|
|
songName = metadata.songName,
|
|
songId = metadata.songId,
|
|
datasetId = metadata.datasetId,
|
|
targetEvaluationGroupId = metadata.targetEvaluationGroupId,
|
|
targetAspectRatio = GetTargetAspectRatio(metadata),
|
|
sampleRate = metadata.sampleRate,
|
|
frameCount = metadata.frameCount,
|
|
duration = metadata.duration,
|
|
status = "queued"
|
|
};
|
|
|
|
ValidateDeclaredSha(
|
|
label,
|
|
"camera",
|
|
result.declaredCameraSha256,
|
|
result.cameraSha256);
|
|
ValidateDeclaredSha(
|
|
label,
|
|
"time",
|
|
result.declaredTimeSha256,
|
|
result.timeSha256);
|
|
ValidateDeclaredSha(
|
|
label,
|
|
"shots",
|
|
result.declaredShotsSha256,
|
|
result.shotsSha256);
|
|
|
|
return new AICameraABCandidateProbe
|
|
{
|
|
Metadata = metadata,
|
|
Result = result
|
|
};
|
|
}
|
|
|
|
internal static AICameraABFairnessInfo ValidateCandidatePair(
|
|
AICameraABCandidateProbe candidateA,
|
|
AICameraABCandidateProbe candidateB,
|
|
AICameraABRenderRequest request)
|
|
{
|
|
if (candidateA == null || candidateB == null)
|
|
{
|
|
throw new ArgumentNullException(
|
|
candidateA == null ? nameof(candidateA) : nameof(candidateB));
|
|
}
|
|
|
|
var a = candidateA.Result;
|
|
var b = candidateB.Result;
|
|
var sameSongId = string.Equals(
|
|
a.songId ?? string.Empty,
|
|
b.songId ?? string.Empty,
|
|
StringComparison.Ordinal);
|
|
var sameEvaluationGroup = string.Equals(
|
|
a.targetEvaluationGroupId ?? string.Empty,
|
|
b.targetEvaluationGroupId ?? string.Empty,
|
|
StringComparison.Ordinal);
|
|
var sameDatasetId = string.Equals(
|
|
a.datasetId ?? string.Empty,
|
|
b.datasetId ?? string.Empty,
|
|
StringComparison.Ordinal);
|
|
var sameAspect = Math.Abs(
|
|
a.targetAspectRatio - b.targetAspectRatio) < 0.0001;
|
|
var fairness = new AICameraABFairnessInfo
|
|
{
|
|
sameSampleRate = a.sampleRate == b.sampleRate,
|
|
sameFrameCount = a.frameCount == b.frameCount,
|
|
sameTimeSha256 = string.Equals(
|
|
a.timeSha256,
|
|
b.timeSha256,
|
|
StringComparison.OrdinalIgnoreCase),
|
|
sameSongId = sameSongId,
|
|
sameDatasetId = sameDatasetId,
|
|
sameTargetAspectRatio = sameAspect,
|
|
sameSceneAndTimeline = true,
|
|
sharedTimeSha256 = a.timeSha256,
|
|
sharedSongId = a.songId,
|
|
targetEvaluationGroupId = a.targetEvaluationGroupId
|
|
};
|
|
|
|
if (!fairness.sameSampleRate ||
|
|
!fairness.sameFrameCount ||
|
|
!fairness.sameTimeSha256 ||
|
|
!sameSongId ||
|
|
!sameDatasetId ||
|
|
!sameEvaluationGroup ||
|
|
!sameAspect)
|
|
{
|
|
throw new InvalidDataException(
|
|
"A/B candidates must share sample rate, frame count, " +
|
|
"time.f64, songId, datasetId, evaluation group, and " +
|
|
"target aspect ratio.");
|
|
}
|
|
|
|
ValidateFrameRange(
|
|
request.startFrame,
|
|
request.endFrameExclusive,
|
|
a.frameCount);
|
|
ValidateOutputSettings(request.codec, request.width, request.height);
|
|
|
|
if (a.targetAspectRatio > 0)
|
|
{
|
|
var outputAspect = request.width / (double)request.height;
|
|
if (Math.Abs(outputAspect - a.targetAspectRatio) > 0.01)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Output aspect ratio {outputAspect:F4} does not match " +
|
|
$"candidate target aspect {a.targetAspectRatio:F4}.");
|
|
}
|
|
}
|
|
|
|
return fairness;
|
|
}
|
|
|
|
internal static string ComputeSha256(string path)
|
|
{
|
|
using var stream = new FileStream(
|
|
path,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.Read,
|
|
1024 * 1024,
|
|
FileOptions.SequentialScan);
|
|
using var sha = SHA256.Create();
|
|
return BitConverter.ToString(sha.ComputeHash(stream))
|
|
.Replace("-", string.Empty)
|
|
.ToLowerInvariant();
|
|
}
|
|
|
|
internal static string SanitizeFileName(string value)
|
|
{
|
|
var source = string.IsNullOrWhiteSpace(value) ? "candidate" : value;
|
|
var invalid = Path.GetInvalidFileNameChars();
|
|
var builder = new StringBuilder(source.Length);
|
|
foreach (var character in source)
|
|
{
|
|
builder.Append(
|
|
Array.IndexOf(invalid, character) >= 0 ? '_' : character);
|
|
}
|
|
|
|
return builder.ToString().Trim().TrimEnd('.');
|
|
}
|
|
|
|
internal static T ReadJson<T>(string path)
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
return default;
|
|
}
|
|
|
|
return JsonUtility.FromJson<T>(File.ReadAllText(path));
|
|
}
|
|
|
|
internal static void WriteJsonAtomic<T>(string path, T value)
|
|
{
|
|
const int maximumReplaceAttempts = 8;
|
|
var canonicalPath = Path.GetFullPath(path);
|
|
var directory = Path.GetDirectoryName(canonicalPath)
|
|
?? throw new InvalidOperationException(
|
|
$"Path '{canonicalPath}' has no parent directory.");
|
|
Directory.CreateDirectory(directory);
|
|
|
|
var temporaryPath =
|
|
$"{canonicalPath}.{Guid.NewGuid():N}.tmp";
|
|
try
|
|
{
|
|
File.WriteAllText(
|
|
temporaryPath,
|
|
JsonUtility.ToJson(value, true),
|
|
new UTF8Encoding(false));
|
|
for (var attempt = 0; ; attempt++)
|
|
{
|
|
try
|
|
{
|
|
if (File.Exists(canonicalPath))
|
|
{
|
|
File.Replace(temporaryPath, canonicalPath, null);
|
|
}
|
|
else
|
|
{
|
|
File.Move(temporaryPath, canonicalPath);
|
|
}
|
|
|
|
return;
|
|
}
|
|
catch (IOException)
|
|
when (attempt + 1 < maximumReplaceAttempts)
|
|
{
|
|
Thread.Sleep(10 * (attempt + 1));
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
when (attempt + 1 < maximumReplaceAttempts)
|
|
{
|
|
Thread.Sleep(10 * (attempt + 1));
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(temporaryPath))
|
|
{
|
|
File.Delete(temporaryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static bool IsTerminalPhase(string phase)
|
|
{
|
|
return phase == AICameraABRenderRunner.PhaseComplete ||
|
|
phase == AICameraABRenderRunner.PhaseFailed ||
|
|
phase == AICameraABRenderRunner.PhaseCancelled;
|
|
}
|
|
|
|
private static double GetTargetAspectRatio(
|
|
AICameraABGeneratedCameraMetadata metadata)
|
|
{
|
|
return metadata.targetAspectRatio > 0
|
|
? metadata.targetAspectRatio
|
|
: metadata.aspectRatioOverride;
|
|
}
|
|
|
|
private static string ResolveInputFile(
|
|
string rootDirectory,
|
|
string relativePath,
|
|
bool requireFile)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(relativePath) ||
|
|
Path.IsPathRooted(relativePath))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Generated input path '{relativePath}' must be relative.");
|
|
}
|
|
|
|
var root = Path.GetFullPath(rootDirectory);
|
|
var resolved = Path.GetFullPath(Path.Combine(root, relativePath));
|
|
var prefix = root.TrimEnd(
|
|
Path.DirectorySeparatorChar,
|
|
Path.AltDirectorySeparatorChar) +
|
|
Path.DirectorySeparatorChar;
|
|
if (!resolved.StartsWith(prefix, PathComparison))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Generated input '{relativePath}' escapes its directory.");
|
|
}
|
|
|
|
if (requireFile && !File.Exists(resolved))
|
|
{
|
|
throw new FileNotFoundException(
|
|
"Generated camera input was not found.",
|
|
resolved);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
private static void ValidateDeclaredSha(
|
|
string label,
|
|
string inputName,
|
|
string declared,
|
|
string actual)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(declared))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(actual) ||
|
|
!string.Equals(
|
|
declared,
|
|
actual,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Candidate {label} {inputName} SHA256 does not match metadata.");
|
|
}
|
|
}
|
|
}
|
|
}
|