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 audio = new(); public List 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 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( 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(string path) { if (!File.Exists(path)) { return default; } return JsonUtility.FromJson(File.ReadAllText(path)); } internal static void WriteJsonAtomic(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."); } } } }