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

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.");
}
}
}
}