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() .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(); 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(); 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() .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 { candidateA, candidateB } }; return result; } private static List CollectAudioProvenance( TimelineAsset timeline) { var result = new List(); foreach (var track in timeline.GetOutputTracks() .OfType() .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() .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(); 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; } } }