1337 lines
46 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|