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

1337 lines
46 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using Unity.Cinemachine.Editor;
using UnityEditor;
using UnityEditor.Recorder;
using UnityEditor.Recorder.Encoder;
using UnityEditor.Recorder.Input;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
[InitializeOnLoad]
public static class AICameraABRenderRunner
{
internal const string PhaseBuildPreview = "build_preview";
internal const string PhaseEnterPlayMode = "enter_play_mode";
internal const string PhaseRecording = "recording";
internal const string PhaseFinalizeCandidate = "finalize_candidate";
internal const string PhaseComplete = "complete";
internal const string PhaseFailed = "failed";
internal const string PhaseCancelled = "cancelled";
private const string ManifestFileName = "manifest.json";
private const string ActiveJobFileName = "active_job.json";
private const double OutputFinalizationTimeoutSeconds = 60;
private const double OutputQuietPeriodSeconds = 0.25;
private const int RequiredStableOutputChecks = 2;
private static AICameraABRenderJobState job;
private static AICameraABRenderManifest manifest;
private static RecorderController recorderController;
private static RecorderControllerSettings controllerSettings;
private static MovieRecorderSettings movieSettings;
private static bool updateInProgress;
static AICameraABRenderRunner()
{
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
EditorApplication.update += OnEditorUpdate;
EditorApplication.delayCall += ResumePersistedJob;
}
public static string StartABRenderForCli(
string candidateADirectory,
string candidateBDirectory,
string outputRoot,
int startFrame,
int endFrameExclusive,
string codec,
int width,
int height)
{
var request = new AICameraABRenderRequest
{
candidateADirectory = candidateADirectory,
candidateBDirectory = candidateBDirectory,
outputRoot = outputRoot,
startFrame = startFrame,
endFrameExclusive = endFrameExclusive,
codec = AICameraABRenderUtility.ParseCodec(codec),
width = width,
height = height
};
var startedJob = Start(request);
return JsonUtility.ToJson(
new StartResponse
{
jobId = startedJob.jobId,
manifestPath = startedJob.manifestPath,
runDirectory = startedJob.runDirectory
},
true);
}
public static string GetABRenderStatusForCli(string jobId)
{
LoadPersistedState();
if (job == null ||
!string.Equals(job.jobId, jobId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"A/B render job '{jobId}' is not the active job.");
}
if (!File.Exists(job.manifestPath))
{
throw new FileNotFoundException(
"A/B render manifest was not found.",
job.manifestPath);
}
return File.ReadAllText(job.manifestPath);
}
public static string CancelABRenderForCli(string jobId)
{
LoadPersistedState();
if (job == null ||
!string.Equals(job.jobId, jobId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"A/B render job '{jobId}' is not the active job.");
}
if (AICameraABRenderUtility.IsTerminalPhase(job.phase))
{
return $"A/B render job '{jobId}' is already {job.phase}.";
}
job.cancelRequested = true;
SaveJob();
return $"Cancellation requested for A/B render job '{jobId}'.";
}
public static string RetryFailedABRenderForCli(string jobId)
{
EnsureEditMode();
LoadPersistedState();
if (job == null ||
!string.Equals(job.jobId, jobId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"A/B render job '{jobId}' is not the active job.");
}
if (job.phase != PhaseFailed ||
job.cleanupPending ||
manifest == null ||
job.candidateIndex < 0 ||
job.candidateIndex >= manifest.candidates.Count)
{
throw new InvalidOperationException(
$"A/B render job '{jobId}' cannot be retried from its " +
$"current state.");
}
RestoreCinemachineSaveDuringPlay();
if (!job.cinemachineSaveDuringPlayStateRestored)
{
throw new InvalidOperationException(
"Cinemachine Save During Play must be restored before " +
"retrying a failed render.");
}
EnsureCleanSavedScenes();
for (var index = 0; index < job.candidateIndex; index++)
{
var completed = manifest.candidates[index];
if (completed.status != "complete" ||
!File.Exists(completed.outputPath) ||
new FileInfo(completed.outputPath).Length !=
completed.outputBytes ||
!string.Equals(
AICameraABRenderUtility.ComputeSha256(
completed.outputPath),
completed.outputSha256,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"Completed candidate {completed.label} no longer " +
"matches its manifest.");
}
}
var candidate = GetCurrentCandidate();
var currentInput = AICameraABRenderUtility.ReadCandidate(
candidate.label,
candidate.generatedDirectory).Result;
if (!string.Equals(
currentInput.metadataSha256,
candidate.metadataSha256,
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(
currentInput.cameraSha256,
candidate.cameraSha256,
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(
currentInput.timeSha256,
candidate.timeSha256,
StringComparison.OrdinalIgnoreCase) ||
!string.Equals(
currentInput.shotsSha256,
candidate.shotsSha256,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"Candidate {candidate.label} inputs changed after the " +
"original render attempt.");
}
if (File.Exists(candidate.outputPath))
{
throw new InvalidOperationException(
$"Candidate {candidate.label} has a partial output. " +
"Move or remove it before retrying: " +
$"'{candidate.outputPath}'.");
}
candidate.status = "queued";
candidate.startedUtc = string.Empty;
candidate.completedUtc = string.Empty;
candidate.outputBytes = 0;
candidate.outputSha256 = string.Empty;
candidate.error = string.Empty;
manifest.state = "running";
manifest.completedUtc = string.Empty;
manifest.error = string.Empty;
job.phase = PhaseBuildPreview;
job.cancelRequested = false;
job.cleanupPending = false;
job.stableOutputChecks = 0;
job.lastObservedOutputBytes = -1;
job.finalizationStartedUtc = string.Empty;
job.error = string.Empty;
job.cinemachineSaveDuringPlayStateCaptured = true;
job.cinemachineSaveDuringPlayOriginalEnabled =
SaveDuringPlay.Enabled;
job.cinemachineSaveDuringPlayStateRestored = false;
SaveManifest();
SaveJob();
EnsureCinemachineSaveDuringPlayDisabled();
EditorApplication.delayCall += ContinueInCurrentMode;
return JsonUtility.ToJson(
new StartResponse
{
jobId = job.jobId,
manifestPath = job.manifestPath,
runDirectory = job.runDirectory
},
true);
}
internal static bool TryGetActiveStatus(
out string jobId,
out string phase,
out string manifestPath)
{
LoadPersistedState();
if (job == null)
{
jobId = string.Empty;
phase = string.Empty;
manifestPath = string.Empty;
return false;
}
jobId = job.jobId;
phase = job.phase;
manifestPath = job.manifestPath;
return true;
}
internal static AICameraABRenderJobState Start(
AICameraABRenderRequest request)
{
EnsureEditMode();
ClearPreviousTerminalJob();
EnsureNoActiveJob();
EnsureCleanSavedScenes();
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(request.outputRoot))
{
request.outputRoot = GetDefaultOutputRoot();
}
var candidateA = AICameraABRenderUtility.ReadCandidate(
"A",
request.candidateADirectory);
var candidateB = AICameraABRenderUtility.ReadCandidate(
"B",
request.candidateBDirectory);
var fairness = AICameraABRenderUtility.ValidateCandidatePair(
candidateA,
candidateB,
request);
var activeScene = SceneManager.GetActiveScene();
var originalDirector = FindDirector(
activeScene,
AICameraTimelinePreviewImporter.OriginalDirectorName);
var originalTimeline = originalDirector.playableAsset as TimelineAsset
?? throw new InvalidOperationException(
$"'{AICameraTimelinePreviewImporter.OriginalDirectorName}' " +
"is not bound to a TimelineAsset.");
var timelineFrameRate = originalTimeline.editorSettings.frameRate;
if (Math.Abs(timelineFrameRate - candidateA.Result.sampleRate) > 0.001)
{
throw new InvalidDataException(
$"Timeline is {timelineFrameRate:F3} FPS but generated " +
$"camera data is {candidateA.Result.sampleRate} FPS.");
}
var runIdSource =
$"{DateTime.UtcNow:yyyyMMdd_HHmmss_ffff}_{Guid.NewGuid():N}";
var runId = runIdSource.Substring(0, 37);
var runDirectory = Path.Combine(
Path.GetFullPath(request.outputRoot),
$"cw_ai_ab_{runId}");
if (Directory.Exists(runDirectory))
{
throw new IOException(
$"A/B render directory already exists: '{runDirectory}'.");
}
var extension = AICameraABRenderUtility.GetExtension(request.codec);
ConfigureCandidateOutput(
candidateA.Result,
runDirectory,
extension);
ConfigureCandidateOutput(
candidateB.Result,
runDirectory,
extension);
var manifestPath = Path.Combine(runDirectory, ManifestFileName);
manifest = BuildManifest(
request,
activeScene,
originalTimeline,
candidateA.Result,
candidateB.Result,
fairness,
runId);
if (manifest.audio.Count == 0)
{
throw new InvalidOperationException(
"The original Timeline has no active AudioClip to use " +
"for the A/B render.");
}
Directory.CreateDirectory(runDirectory);
job = new AICameraABRenderJobState
{
jobId = runId,
phase = PhaseBuildPreview,
manifestPath = manifestPath,
runDirectory = runDirectory,
candidateIndex = 0,
request = request,
sceneSetup = CaptureSceneSetup(),
cinemachineSaveDuringPlayStateCaptured = true,
cinemachineSaveDuringPlayOriginalEnabled =
SaveDuringPlay.Enabled
};
SaveManifest();
SaveJob();
EnsureCinemachineSaveDuringPlayDisabled();
EditorApplication.delayCall += ContinueInCurrentMode;
return job;
}
private static void ResumePersistedJob()
{
LoadPersistedState();
if (job == null)
{
return;
}
EnsureCinemachineSaveDuringPlayDisabled();
if (AICameraABRenderUtility.IsTerminalPhase(job.phase) &&
!job.cleanupPending &&
job.cinemachineSaveDuringPlayStateRestored)
{
return;
}
EditorApplication.delayCall += ContinueInCurrentMode;
}
private static void OnEditorUpdate()
{
if (updateInProgress)
{
return;
}
LoadPersistedState();
if (job == null)
{
return;
}
updateInProgress = true;
try
{
ContinueInCurrentMode();
}
catch (Exception exception)
{
FailJob(exception);
}
finally
{
updateInProgress = false;
}
}
private static void ContinueInCurrentMode()
{
if (job == null)
{
return;
}
EnsureCinemachineSaveDuringPlayDisabled();
if (Application.isPlaying)
{
ContinueInPlayMode();
return;
}
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
return;
}
ContinueInEditMode();
}
private static void ContinueInEditMode()
{
if (job.cleanupPending)
{
CleanupPreviewAndRestoreScene();
job.cleanupPending = false;
SaveJob();
}
if (AICameraABRenderUtility.IsTerminalPhase(job.phase))
{
RestoreCinemachineSaveDuringPlay();
return;
}
if (job.cancelRequested &&
!AICameraABRenderUtility.IsTerminalPhase(job.phase))
{
CancelJobInEditMode();
return;
}
switch (job.phase)
{
case PhaseBuildPreview:
BuildPreviewAndEnterPlayMode();
break;
case PhaseEnterPlayMode:
EditorApplication.isPlaying = true;
break;
case PhaseRecording:
throw new InvalidOperationException(
"Play Mode exited before the Recorder completed.");
case PhaseFinalizeCandidate:
TryFinalizeCandidate();
break;
case PhaseComplete:
case PhaseFailed:
case PhaseCancelled:
break;
}
}
private static void ContinueInPlayMode()
{
if (job.cancelRequested)
{
CancelJobInPlayMode();
return;
}
switch (job.phase)
{
case PhaseEnterPlayMode:
StartRecordingInPlayMode();
break;
case PhaseRecording:
MonitorRecordingInPlayMode();
break;
case PhaseFinalizeCandidate:
EditorApplication.isPlaying = false;
break;
case PhaseFailed:
case PhaseCancelled:
EditorApplication.isPlaying = false;
break;
}
}
private static void BuildPreviewAndEnterPlayMode()
{
RestoreCleanSceneSetup();
var candidate = GetCurrentCandidate();
candidate.status = "building_preview";
SaveManifest();
var activeScene = SceneManager.GetActiveScene();
var hasExistingPreview = Resources
.FindObjectsOfTypeAll<PlayableDirector>()
.Any(director =>
director.gameObject.scene == activeScene &&
director.name ==
AICameraTimelinePreviewImporter.PreviewDirectorName);
if (hasExistingPreview)
{
AICameraTimelinePreviewImporter.RemovePreviewForCli();
}
AICameraTimelinePreviewImporter.CreatePreviewForCli(
candidate.generatedDirectory);
activeScene = SceneManager.GetActiveScene();
var previewDirector = FindDirector(
activeScene,
AICameraTimelinePreviewImporter.PreviewDirectorName);
if (previewDirector.playableAsset is not TimelineAsset previewTimeline)
{
throw new InvalidOperationException(
"AI preview Director is not bound to a TimelineAsset.");
}
AICameraTimelinePreviewImporter.MuteCopiedRecorderTracks(
previewTimeline);
EditorUtility.SetDirty(previewTimeline);
AssetDatabase.SaveAssets();
candidate.status = "waiting_for_play_mode";
job.phase = PhaseEnterPlayMode;
SaveManifest();
SaveJob();
EditorApplication.isPlaying = true;
}
private static void StartRecordingInPlayMode()
{
if (recorderController != null)
{
return;
}
var activeScene = SceneManager.GetActiveScene();
var previewDirector = FindDirector(
activeScene,
AICameraTimelinePreviewImporter.PreviewDirectorName);
if (Camera.main == null)
{
throw new InvalidOperationException(
"The active scene has no enabled Camera tagged MainCamera.");
}
var candidate = GetCurrentCandidate();
var request = job.request;
var cameraInput = new CameraInputSettings
{
Source = ImageSource.MainCamera,
CaptureUI = false,
OutputWidth = request.width,
OutputHeight = request.height
};
movieSettings = ScriptableObject.CreateInstance<MovieRecorderSettings>();
movieSettings.name = $"CW-AI A/B {candidate.label}";
movieSettings.Enabled = true;
movieSettings.EncoderSettings = new CoreEncoderSettings
{
Codec = request.codec == AICameraABRenderCodec.MP4
? CoreEncoderSettings.OutputCodec.MP4
: CoreEncoderSettings.OutputCodec.WEBM,
EncodingQuality =
CoreEncoderSettings.VideoEncodingQuality.High
};
movieSettings.CaptureAlpha = false;
movieSettings.CaptureAudio = true;
movieSettings.ImageInputSettings = cameraInput;
movieSettings.OutputFile =
candidate.outputBasePath.Replace('\\', '/');
controllerSettings =
ScriptableObject.CreateInstance<RecorderControllerSettings>();
controllerSettings.AddRecorderSettings(movieSettings);
controllerSettings.FrameRate = candidate.sampleRate;
controllerSettings.FrameRatePlayback =
FrameRatePlayback.Constant;
controllerSettings.CapFrameRate = true;
controllerSettings.ExitPlayMode = false;
controllerSettings.SetRecordModeToFrameInterval(
request.startFrame,
AICameraABRenderUtility.ToInclusiveEndFrame(
request.startFrame,
request.endFrameExclusive,
candidate.frameCount));
recorderController = new RecorderController(controllerSettings);
previewDirector.time = 0;
previewDirector.RebuildGraph();
previewDirector.Evaluate();
RecorderOptions.VerboseMode = false;
recorderController.PrepareRecording();
if (!recorderController.StartRecording())
{
throw new InvalidOperationException(
"Unity Recorder could not start the movie recording.");
}
previewDirector.Play();
candidate.status = "recording";
candidate.startedUtc = AICameraABRenderUtility.UtcNow();
job.phase = PhaseRecording;
manifest.state = "recording";
SaveManifest();
SaveJob();
}
private static void MonitorRecordingInPlayMode()
{
if (recorderController == null)
{
throw new InvalidOperationException(
"Recorder state was lost during Play Mode.");
}
if (recorderController.IsRecording())
{
return;
}
recorderController.StopRecording();
ReleaseRecorderObjects();
var candidate = GetCurrentCandidate();
candidate.status = "finalizing";
job.phase = PhaseFinalizeCandidate;
job.finalizationStartedUtc = AICameraABRenderUtility.UtcNow();
job.stableOutputChecks = 0;
job.lastObservedOutputBytes = -1;
manifest.state = "finalizing";
SaveManifest();
SaveJob();
EditorApplication.isPlaying = false;
}
private static void TryFinalizeCandidate()
{
var candidate = GetCurrentCandidate();
var outputPath = candidate.outputPath;
var started = ParseUtcOrNow(job.finalizationStartedUtc);
if (DateTime.UtcNow - started >
TimeSpan.FromSeconds(OutputFinalizationTimeoutSeconds))
{
throw new TimeoutException(
$"Recorder output did not finalize within " +
$"{OutputFinalizationTimeoutSeconds:F0} seconds: " +
$"'{outputPath}'.");
}
if (!File.Exists(outputPath))
{
return;
}
var file = new FileInfo(outputPath);
if (file.Length <= 0)
{
return;
}
if (file.Length == job.lastObservedOutputBytes &&
DateTime.UtcNow - file.LastWriteTimeUtc >
TimeSpan.FromSeconds(OutputQuietPeriodSeconds))
{
job.stableOutputChecks++;
}
else
{
job.stableOutputChecks = 0;
job.lastObservedOutputBytes = file.Length;
}
SaveJob();
if (job.stableOutputChecks < RequiredStableOutputChecks)
{
return;
}
try
{
candidate.outputSha256 =
AICameraABRenderUtility.ComputeSha256(outputPath);
}
catch (IOException)
{
job.stableOutputChecks = 0;
SaveJob();
return;
}
candidate.outputBytes = file.Length;
candidate.completedUtc = AICameraABRenderUtility.UtcNow();
candidate.status = "complete";
job.cleanupPending = true;
SaveManifest();
CleanupPreviewAndRestoreScene();
job.cleanupPending = false;
job.candidateIndex++;
job.stableOutputChecks = 0;
job.lastObservedOutputBytes = -1;
job.finalizationStartedUtc = string.Empty;
if (job.candidateIndex < manifest.candidates.Count)
{
job.phase = PhaseBuildPreview;
manifest.state = "running";
SaveManifest();
SaveJob();
EditorApplication.delayCall += ContinueInCurrentMode;
return;
}
job.phase = PhaseComplete;
manifest.state = "complete";
manifest.completedUtc = AICameraABRenderUtility.UtcNow();
SaveManifest();
RestoreCinemachineSaveDuringPlay();
SaveJob();
Debug.Log(
$"CW-AI A/B render complete. Manifest: {job.manifestPath}");
}
private static void CancelJobInPlayMode()
{
try
{
recorderController?.StopRecording();
}
finally
{
ReleaseRecorderObjects();
}
SetCancelledState();
job.cleanupPending = true;
SaveManifest();
SaveJob();
EditorApplication.isPlaying = false;
}
private static void CancelJobInEditMode()
{
SetCancelledState();
job.cleanupPending = true;
SaveManifest();
CleanupPreviewAndRestoreScene();
job.cleanupPending = false;
RestoreCinemachineSaveDuringPlay();
SaveJob();
}
private static void SetCancelledState()
{
job.phase = PhaseCancelled;
job.error = "Cancelled by user.";
manifest.state = "cancelled";
manifest.error = job.error;
manifest.completedUtc = AICameraABRenderUtility.UtcNow();
if (job.candidateIndex >= 0 &&
job.candidateIndex < manifest.candidates.Count)
{
var candidate = GetCurrentCandidate();
if (candidate.status != "complete")
{
candidate.status = "cancelled";
candidate.error = job.error;
}
}
}
private static void FailJob(Exception exception)
{
try
{
LoadPersistedState();
if (job == null)
{
Debug.LogException(exception);
return;
}
try
{
recorderController?.StopRecording();
}
catch (Exception stopException)
{
Debug.LogException(stopException);
}
finally
{
ReleaseRecorderObjects();
}
job.phase = PhaseFailed;
job.error = exception.ToString();
job.cleanupPending = true;
if (manifest != null)
{
manifest.state = "failed";
manifest.error = job.error;
manifest.completedUtc = AICameraABRenderUtility.UtcNow();
if (job.candidateIndex >= 0 &&
job.candidateIndex < manifest.candidates.Count)
{
var candidate = GetCurrentCandidate();
if (candidate.status != "complete")
{
candidate.status = "failed";
candidate.error = exception.Message;
}
}
SaveManifest();
}
SaveJob();
if (Application.isPlaying ||
EditorApplication.isPlayingOrWillChangePlaymode)
{
EditorApplication.isPlaying = false;
}
else
{
CleanupPreviewAndRestoreScene();
job.cleanupPending = false;
RestoreCinemachineSaveDuringPlay();
SaveJob();
}
}
finally
{
Debug.LogException(exception);
}
}
private static void CleanupPreviewAndRestoreScene()
{
if (Application.isPlaying ||
EditorApplication.isPlayingOrWillChangePlaymode)
{
return;
}
var activeScene = SceneManager.GetActiveScene();
var hasPreview = Resources.FindObjectsOfTypeAll<PlayableDirector>()
.Any(director =>
director.gameObject.scene == activeScene &&
director.name ==
AICameraTimelinePreviewImporter.PreviewDirectorName);
if (hasPreview)
{
AICameraTimelinePreviewImporter.RemovePreviewForCli();
}
RestoreCleanSceneSetup();
}
private static void RestoreCleanSceneSetup()
{
if (job?.sceneSetup == null ||
string.IsNullOrWhiteSpace(job.sceneSetup.activeScenePath))
{
throw new InvalidOperationException(
"The original Scene setup is unavailable.");
}
var entries = job.sceneSetup.scenes
.Where(entry => !string.IsNullOrWhiteSpace(entry.path))
.Select(entry => new SceneSetup
{
path = entry.path,
isLoaded = entry.isLoaded,
isActive = entry.isActive
})
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
"The original Scene setup contains no saved Scenes.");
}
EditorSceneManager.OpenScene(
job.sceneSetup.activeScenePath,
OpenSceneMode.Single);
EditorSceneManager.RestoreSceneManagerSetup(entries);
}
private static AICameraABRenderManifest BuildManifest(
AICameraABRenderRequest request,
Scene activeScene,
TimelineAsset timeline,
AICameraABCandidateResult candidateA,
AICameraABCandidateResult candidateB,
AICameraABFairnessInfo fairness,
string runId)
{
var timelinePath = AssetDatabase.GetAssetPath(timeline);
var result = new AICameraABRenderManifest
{
jobId = runId,
state = "running",
createdUtc = AICameraABRenderUtility.UtcNow(),
environment = new AICameraABEnvironmentInfo
{
unityVersion = Application.unityVersion,
recorderVersion =
GetPackageVersion(typeof(RecorderController).Assembly),
cwAiVersion =
GetPackageVersion(typeof(AICameraABRenderRunner).Assembly),
operatingSystem = SystemInfo.operatingSystem
},
scene = new AICameraABSceneInfo
{
scenePath = activeScene.path,
sceneGuid = AssetDatabase.AssetPathToGUID(activeScene.path),
timelinePath = timelinePath,
timelineGuid = AssetDatabase.AssetPathToGUID(timelinePath),
timelineFrameRate = timeline.editorSettings.frameRate
},
frameRange = new AICameraABFrameRange
{
startFrame = request.startFrame,
endFrameExclusive = request.endFrameExclusive,
expectedFrameCount =
request.endFrameExclusive - request.startFrame,
startSeconds =
request.startFrame / (double)candidateA.sampleRate,
endSecondsExclusive =
request.endFrameExclusive /
(double)candidateA.sampleRate
},
movie = new AICameraABMovieSettings
{
codec = request.codec.ToString(),
encoder = request.codec == AICameraABRenderCodec.MP4
? "H.264"
: "VP8",
quality = "High",
width = request.width,
height = request.height,
aspectRatio = request.width / (double)request.height,
frameRate = candidateA.sampleRate,
cameraSource = "MainCamera",
captureAudio = true,
captureAlpha = false
},
fairness = fairness,
audio = CollectAudioProvenance(timeline),
candidates = new List<AICameraABCandidateResult>
{
candidateA,
candidateB
}
};
return result;
}
private static List<AICameraABAudioClipInfo> CollectAudioProvenance(
TimelineAsset timeline)
{
var result = new List<AICameraABAudioClipInfo>();
foreach (var track in timeline.GetOutputTracks()
.OfType<AudioTrack>()
.Where(track => !track.mutedInHierarchy))
{
foreach (var timelineClip in track.GetClips())
{
if (timelineClip.asset is not AudioPlayableAsset audioAsset ||
audioAsset.clip == null)
{
continue;
}
var assetPath = AssetDatabase.GetAssetPath(audioAsset.clip);
var absolutePath = string.IsNullOrWhiteSpace(assetPath)
? string.Empty
: Path.GetFullPath(Path.Combine(
GetProjectRoot(),
assetPath));
result.Add(new AICameraABAudioClipInfo
{
trackName = track.name,
clipName = audioAsset.clip.name,
assetPath = assetPath,
assetGuid = string.IsNullOrWhiteSpace(assetPath)
? string.Empty
: AssetDatabase.AssetPathToGUID(assetPath),
assetSha256 =
!string.IsNullOrWhiteSpace(absolutePath) &&
File.Exists(absolutePath)
? AICameraABRenderUtility.ComputeSha256(
absolutePath)
: string.Empty,
timelineStart = timelineClip.start,
clipIn = timelineClip.clipIn,
duration = timelineClip.duration
});
}
}
return result;
}
private static AICameraABSceneSetupSnapshot CaptureSceneSetup()
{
var result = new AICameraABSceneSetupSnapshot
{
activeScenePath = SceneManager.GetActiveScene().path
};
foreach (var scene in EditorSceneManager.GetSceneManagerSetup())
{
result.scenes.Add(new AICameraABSceneEntry
{
path = scene.path,
isLoaded = scene.isLoaded,
isActive = scene.isActive
});
}
return result;
}
private static void ConfigureCandidateOutput(
AICameraABCandidateResult candidate,
string runDirectory,
string extension)
{
var directoryName = new DirectoryInfo(
candidate.generatedDirectory)
.Name;
var safeName = AICameraABRenderUtility.SanitizeFileName(
directoryName);
candidate.outputBasePath = Path.Combine(
runDirectory,
$"{candidate.label}_{safeName}");
candidate.outputPath =
$"{candidate.outputBasePath}.{extension}";
if (File.Exists(candidate.outputPath))
{
throw new IOException(
$"Recorder output already exists: '{candidate.outputPath}'.");
}
}
private static AICameraABCandidateResult GetCurrentCandidate()
{
if (manifest?.candidates == null ||
job.candidateIndex < 0 ||
job.candidateIndex >= manifest.candidates.Count)
{
throw new InvalidOperationException(
"A/B render candidate index is invalid.");
}
return manifest.candidates[job.candidateIndex];
}
private static PlayableDirector FindDirector(Scene scene, string name)
{
return Resources.FindObjectsOfTypeAll<PlayableDirector>()
.Where(director =>
director.gameObject.scene == scene &&
director.name == name)
.SingleOrDefault()
?? throw new InvalidOperationException(
$"Scene '{scene.name}' does not contain one " +
$"'{name}' PlayableDirector.");
}
private static void OnPlayModeStateChanged(
PlayModeStateChange state)
{
LoadPersistedState();
if (job == null)
{
return;
}
switch (state)
{
case PlayModeStateChange.EnteredPlayMode:
case PlayModeStateChange.EnteredEditMode:
EditorApplication.delayCall += ContinueInCurrentMode;
break;
}
}
private static void LoadPersistedState()
{
if (job == null)
{
job = AICameraABRenderUtility.ReadJson<
AICameraABRenderJobState>(GetActiveJobPath());
}
if (job != null &&
manifest == null &&
!string.IsNullOrWhiteSpace(job.manifestPath))
{
manifest = AICameraABRenderUtility.ReadJson<
AICameraABRenderManifest>(job.manifestPath);
}
}
private static void SaveJob()
{
if (job != null)
{
AICameraABRenderUtility.WriteJsonAtomic(
GetActiveJobPath(),
job);
}
}
private static void SaveManifest()
{
if (job != null && manifest != null)
{
AICameraABRenderUtility.WriteJsonAtomic(
job.manifestPath,
manifest);
}
}
private static void ReleaseRecorderObjects()
{
recorderController = null;
if (controllerSettings != null)
{
UnityEngine.Object.DestroyImmediate(controllerSettings);
controllerSettings = null;
}
if (movieSettings != null)
{
UnityEngine.Object.DestroyImmediate(movieSettings);
movieSettings = null;
}
}
private static void EnsureEditMode()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
throw new InvalidOperationException(
"A/B rendering must be started in Edit Mode.");
}
}
private static void EnsureNoActiveJob()
{
LoadPersistedState();
if (job != null)
{
throw new InvalidOperationException(
$"A/B render job '{job.jobId}' is already {job.phase}.");
}
}
private static void ClearPreviousTerminalJob()
{
LoadPersistedState();
if (job == null)
{
return;
}
if (!AICameraABRenderUtility.IsTerminalPhase(job.phase) ||
job.cleanupPending)
{
return;
}
EnsureCinemachineSaveDuringPlayDisabled();
RestoreCinemachineSaveDuringPlay();
if (!job.cinemachineSaveDuringPlayStateRestored)
{
throw new InvalidOperationException(
"Cinemachine Save During Play could not be restored.");
}
var path = GetActiveJobPath();
if (File.Exists(path))
{
File.Delete(path);
}
job = null;
manifest = null;
}
private static void EnsureCinemachineSaveDuringPlayDisabled()
{
if (job == null ||
job.cinemachineSaveDuringPlayStateRestored)
{
return;
}
if (!job.cinemachineSaveDuringPlayStateCaptured)
{
job.cinemachineSaveDuringPlayOriginalEnabled =
SaveDuringPlay.Enabled;
job.cinemachineSaveDuringPlayStateCaptured = true;
SaveJob();
}
SaveDuringPlay.Enabled = false;
}
private static void RestoreCinemachineSaveDuringPlay()
{
if (job == null ||
job.cinemachineSaveDuringPlayStateRestored ||
!job.cinemachineSaveDuringPlayStateCaptured ||
job.cleanupPending ||
!AICameraABRenderUtility.IsTerminalPhase(job.phase) ||
Application.isPlaying ||
EditorApplication.isPlayingOrWillChangePlaymode)
{
return;
}
SaveDuringPlay.Enabled =
job.cinemachineSaveDuringPlayOriginalEnabled;
job.cinemachineSaveDuringPlayStateRestored = true;
SaveJob();
}
private static void EnsureCleanSavedScenes()
{
var sceneCount = SceneManager.sceneCount;
var dirtyScenes = new List<string>();
for (var index = 0; index < sceneCount; index++)
{
var scene = SceneManager.GetSceneAt(index);
if (string.IsNullOrWhiteSpace(scene.path))
{
throw new InvalidOperationException(
$"Scene '{scene.name}' must be saved before A/B rendering.");
}
if (scene.isDirty)
{
dirtyScenes.Add(scene.path);
}
}
if (dirtyScenes.Count > 0)
{
throw new InvalidOperationException(
"Save or discard dirty Scenes before A/B rendering: " +
string.Join(", ", dirtyScenes));
}
}
private static string GetPackageVersion(Assembly assembly)
{
try
{
return UnityEditor.PackageManager.PackageInfo
.FindForAssembly(assembly)?.version ??
assembly.GetName().Version?.ToString() ??
"unknown";
}
catch
{
return assembly.GetName().Version?.ToString() ?? "unknown";
}
}
private static DateTime ParseUtcOrNow(string value)
{
return DateTime.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind,
out var parsed)
? parsed.ToUniversalTime()
: DateTime.UtcNow;
}
private static string GetProjectRoot()
{
return Directory.GetParent(Application.dataPath)?.FullName
?? throw new InvalidOperationException(
"Unable to resolve the Unity project root.");
}
private static string GetDefaultOutputRoot()
{
return Path.Combine(
GetProjectRoot(),
"Recordings",
"CWAI_AB");
}
private static string GetActiveJobPath()
{
return Path.Combine(
GetProjectRoot(),
"Library",
"CWAI",
"ABRender",
ActiveJobFileName);
}
[Serializable]
private sealed class StartResponse
{
public string jobId;
public string manifestPath;
public string runDirectory;
}
}
}