streamingle-unity-utilities/CameraAI~/Editor/AICameraGeneratorWindow.cs

4396 lines
167 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.SceneManagement;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
public sealed partial class AICameraGeneratorWindow : EditorWindow
{
private const string MenuPath =
"Tools/Streamingle/AI 카메라 생성";
private const string LegacyMenuPath =
"Tools/Streamingle/Timeline/AI Camera Generator";
private static readonly string DefaultCwAiRoot =
ResolveDefaultCwAiRoot(
Directory.GetParent(Application.dataPath)?.FullName ??
Environment.CurrentDirectory,
Environment.GetEnvironmentVariable("CWAI_ROOT"));
private const string DefaultSongId = "";
private const string DefaultDatasetId = "";
private const int DefaultSeed = 20260804;
private const int DefaultPreparationModelSeed = 20260729;
private const double DefaultTargetAspectRatio = 16d / 9d;
private const float AutonomousMotionIntensity = 1f;
private const float AutonomousBodyFollowStrength = 0.18f;
private const string SelectedShotDirectivesFileName =
"selected_shot_directives.json";
private const string RuntimeHistoryKeyPrefix =
"Streamingle.AICameraGenerator.Runtime.";
internal const string NextGenerationSeedLabel =
"다음 생성에 사용할 Seed";
internal const string SelectedCameraRegenerationButtonLabel =
"선택한 카메라만 다른 안전 후보로 재생성 (위 옵션 적용)";
internal const string ListedCameraRegenerationButtonLabel =
"목록의 카메라만 다른 안전 후보로 재생성 (위 옵션 적용)";
internal const string ReapplyCurrentResultButtonLabel =
"현재 결과 다시 적용 (Seed 사용 안 함)";
internal const string ReapplyCurrentResultWarning =
"'현재 결과 다시 적용'은 생성 결과 폴더를 Timeline에 다시 불러오기만 " +
"합니다. 새 후보를 생성하지 않으며, 위 Seed를 변경해도 결과는 " +
"달라지지 않습니다.";
internal const string SeedCandidateExplanation =
"Seed를 바꾸면 같은 입력에서 계산한 안전 후보 풀을 재사용하고, " +
"그 안에서 다른 카메라 조합을 선택합니다. 그래서 빠르지만 매번 " +
"완전히 새로운 후보 풀을 계산하는 것은 아닙니다.";
internal const string FreshCandidatePoolOptionLabel =
"다음 고품질 생성에서 후보 풀 캐시 무시하고 재계산 (1회)";
internal const string HybridQualityBackendLabel =
"고품질 생성 (CWCameraWorker · 학습 데이터 기반)";
internal const string HybridQualityBackendDescription =
"축적된 작업자 카메라 데이터를 참고해 새 카메라를 생성합니다. " +
"독립 실행형 CWCameraWorker가 생성하므로 별도 Python 설치가 필요하지 않습니다. " +
"첫 실행 후 캐시가 만들어지면 이후 실행이 빨라집니다.";
internal const string FullGenerationAutonomyExplanation =
"전체 생성은 작업자 데이터의 컷 리듬과 카메라 문법을 사용해 자유롭게 " +
"구성합니다. 아래 컷 리듬만 전체 결과에 적용됩니다. 거리·무빙·구도 " +
"옵션은 전체 생성에 적용되지 않습니다.";
internal const string CutRhythmExplanation =
"자동은 음악·동작·같은 음원의 작업자 컷과 학습된 샷 길이를 함께 " +
"분석합니다. 느리게/빠르게는 컷 수만 부드럽게 조절합니다.";
internal const string SelectedShotOverrideExplanation =
"아래 옵션은 선택한 카메라를 다시 생성할 때만 적용됩니다. " +
"고품질 생성도 선택 구간 하나만 계산합니다. 나머지 카메라는 그대로 " +
"유지되며 같은 카메라를 반복해서 다듬을 수 있습니다.";
internal const string FullAutonomousGenerationButtonLabel =
"전체 카메라 자율 생성";
private static readonly string[] SimpleShotSizeNames =
{ "자동", "풀샷", "미디엄", "클로즈업" };
private static readonly string[] SimpleMotionNames =
{
"자동", "고정", "앞으로 이동", "뒤로 이동", "왼쪽 회전",
"오른쪽 회전", "왼쪽 이동", "오른쪽 이동", "위로 이동", "아래로 이동"
};
private static readonly string[] SimpleCompositionNames =
{ "자동", "중앙", "왼쪽 1/3", "오른쪽 1/3" };
private static readonly string[] SimpleCutRhythmNames =
{ "자동 (권장)", "느리게", "보통", "빠르게" };
[SerializeField] private string _cwAiRoot = DefaultCwAiRoot;
[SerializeField] private string _generationInputRoot = string.Empty;
[SerializeField] private string _cliExecutable = string.Empty;
[SerializeField] private string _generatedFolder = string.Empty;
[SerializeField] private string _songId = DefaultSongId;
[SerializeField] private string _datasetId = DefaultDatasetId;
[SerializeField] private int _datasetSongIndex;
[SerializeField] private PlayableDirector _sourceDirector;
[SerializeField] private int _seed = DefaultSeed;
[SerializeField] private bool _forceFreshCandidatePool;
[SerializeField] private bool _hasLastAppliedSeed;
[SerializeField] private int _lastAppliedSeed;
[SerializeField] private bool _lastAppliedSeedWasSelectedShot;
[SerializeField] private int _lastAppliedSeedShotIndex = -1;
[SerializeField] private CutRhythmPreference _cutRhythm =
CutRhythmPreference.Auto;
[SerializeField] private AICameraTimelinePreviewImporter
.CurveSimplificationPreset _curvePreset =
AICameraTimelinePreviewImporter
.CurveSimplificationPreset.Balanced;
[SerializeField] private ShotSizePreference _shotSize =
ShotSizePreference.Auto;
[SerializeField] private MotionPreference _motion =
MotionPreference.Auto;
[SerializeField] private CompositionPreference _composition =
CompositionPreference.Auto;
[SerializeField] private float _distanceMeters;
[SerializeField, Range(0f, 2f)] private float _motionIntensity = 1f;
[SerializeField, Range(0f, 1f)] private float _bodyFollowStrength = 0.18f;
[SerializeField] private int _selectedShotIndex;
[SerializeField] private string _editorStyleId = string.Empty;
[SerializeField] private bool _showDirectionSettings;
[SerializeField] private bool _showAdvancedSettings;
[SerializeField] private string _status =
"모션과 음원이 들어 있는 Timeline을 선택하세요.";
[SerializeField] private string _log = string.Empty;
private AICameraCliRunner _cliRunner;
private ProcessCompletionAction _completionAction;
private string _runningOutputFolder = string.Empty;
private int _runningShotIndex = -1;
private int _runningSeed;
private bool _generationOperationActive;
private bool _runningSelectedShotOneShot;
private bool _runningOutputFilesDetected;
private double _generationStartedAt = -1d;
private double _generationSourceDurationSeconds;
private double _lastCompletedDurationSeconds = -1d;
private float _generationStageProgress = -1f;
private int _generationStageIndex;
private int _generationStageCount = 4;
private string _generationStage = string.Empty;
private string _runningJobId = string.Empty;
private double _workerEtaSeconds = -1d;
private CacheObservation _preparationCacheObservation;
private CacheObservation _candidateCacheObservation;
private double _candidateCacheObservedAtElapsedSeconds = -1d;
private double _importStageObservedAtElapsedSeconds = -1d;
private string[] _shotNames = Array.Empty<string>();
private string[] _datasetSongNames = Array.Empty<string>();
private DatasetSongSummary[] _datasetSongs =
Array.Empty<DatasetSongSummary>();
private Vector2 _scrollPosition;
private Vector2 _logScrollPosition;
private enum ProcessCompletionAction
{
None,
ImportFull,
ReplaceSelectedShot,
BuildPreparationCache
}
private enum CutRhythmPreference
{
Auto,
Slow,
Normal,
Fast
}
private enum ShotSizePreference
{
Auto,
Wide,
Medium,
Close
}
private enum MotionPreference
{
Auto,
Static,
DollyIn,
DollyOut,
OrbitLeft,
OrbitRight,
TruckLeft,
TruckRight,
CraneUp,
CraneDown
}
private enum CompositionPreference
{
Auto,
Center,
LeftThird,
RightThird
}
internal enum CacheObservation
{
Unknown,
Hit,
Miss
}
internal readonly struct RuntimeEstimate
{
internal RuntimeEstimate(
bool isAvailable,
bool isInitial,
double minimumTotalSeconds,
double maximumTotalSeconds,
double minimumRemainingSeconds,
double maximumRemainingSeconds)
{
IsAvailable = isAvailable;
IsInitial = isInitial;
MinimumTotalSeconds = minimumTotalSeconds;
MaximumTotalSeconds = maximumTotalSeconds;
MinimumRemainingSeconds = minimumRemainingSeconds;
MaximumRemainingSeconds = maximumRemainingSeconds;
}
internal bool IsAvailable { get; }
internal bool IsInitial { get; }
internal double MinimumTotalSeconds { get; }
internal double MaximumTotalSeconds { get; }
internal double MinimumRemainingSeconds { get; }
internal double MaximumRemainingSeconds { get; }
}
internal readonly struct GenerationControlScope
{
internal GenerationControlScope(
string shotSize,
string motion,
string composition,
float distanceMeters,
float motionIntensity,
float bodyFollowStrength,
string directivesPath,
bool usesSelectedShotOverrides)
{
ShotSize = shotSize;
Motion = motion;
Composition = composition;
DistanceMeters = distanceMeters;
MotionIntensity = motionIntensity;
BodyFollowStrength = bodyFollowStrength;
DirectivesPath = directivesPath;
UsesSelectedShotOverrides = usesSelectedShotOverrides;
}
internal string ShotSize { get; }
internal string Motion { get; }
internal string Composition { get; }
internal float DistanceMeters { get; }
internal float MotionIntensity { get; }
internal float BodyFollowStrength { get; }
internal string DirectivesPath { get; }
internal bool UsesSelectedShotOverrides { get; }
}
[Serializable]
private sealed class GeneratedCameraMetadata
{
public string schemaVersion;
public string generationMode;
public string songId;
public string datasetId;
public int frameCount;
public int sampleRate;
public int shotCount;
public string plannerMode;
public double targetAspectRatio;
public string worldCameraFile;
public string timeFile;
public string shotsFile;
public GeneratedOutputSha256 outputSha256;
}
[Serializable]
private sealed class GeneratedOutputSha256
{
public string worldCamera;
public string time;
public string shots;
}
[Serializable]
private sealed class HybridShotFile
{
public string schemaVersion;
public HybridShotBoundary[] shots;
}
[Serializable]
private sealed class HybridShotBoundary
{
public int index;
public int startFrame;
public int endFrameExclusive;
}
[Serializable]
private sealed class SelectedShotDirectiveDocument
{
public string schemaVersion = "camera-directives-v1";
public SelectedShotDirective[] shots;
}
[Serializable]
private sealed class SelectedShotDirective
{
public int index;
public string shotSize;
public string motion;
public string composition;
public float distanceMeters;
public float motionIntensity;
public float bodyFollowStrength;
}
[Serializable]
private sealed class DatasetManifestSummary
{
public DatasetSongSummary[] songs;
}
[Serializable]
private sealed class DatasetSongSummary
{
public string songName;
public string folderName;
}
[Serializable]
private sealed class DatasetCatalogSummary
{
public DatasetCatalogSongSummary[] songs;
}
[Serializable]
private sealed class DatasetCatalogSongSummary
{
public string id;
public string datasetId;
public string folder;
}
[MenuItem(MenuPath)]
public static void OpenWindow()
{
var window = GetWindow<AICameraGeneratorWindow>();
window.titleContent = new GUIContent("AI 카메라 생성");
window.minSize = new Vector2(520f, 480f);
window.Show();
}
[MenuItem(LegacyMenuPath)]
private static void OpenLegacyWindow()
{
OpenWindow();
}
private void OnEnable()
{
var repairedLibraryRoot = TryAutoRepairGenerationLibraryRoot();
if (string.IsNullOrWhiteSpace(_cwAiRoot))
{
_cwAiRoot = DefaultCwAiRoot;
}
if (repairedLibraryRoot &&
!string.IsNullOrWhiteSpace(_status) &&
_status.IndexOf(
"training_index",
StringComparison.OrdinalIgnoreCase) >= 0)
{
_status =
"참조 데이터 경로를 자동으로 복구했습니다. " +
"전체 생성을 다시 실행하세요.";
}
if (string.IsNullOrWhiteSpace(_generatedFolder))
{
_generatedFolder = Path.Combine(
Directory.GetParent(Application.dataPath)?.FullName
?? Environment.CurrentDirectory,
"GeneratedCameraOutputs");
}
if (string.IsNullOrWhiteSpace(_generationInputRoot))
{
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
_generationInputRoot = Path.Combine(
projectRoot ?? Environment.CurrentDirectory,
"DatasetExports");
}
if (string.IsNullOrWhiteSpace(_cliExecutable))
{
_cliExecutable = ResolveCliExecutable();
}
RefreshDatasetSongs(true);
ValidateOrAutoSelectSourceDirector();
RefreshShotNames();
Undo.undoRedoPerformed -= OnUndoRedoPerformed;
Undo.undoRedoPerformed += OnUndoRedoPerformed;
EditorApplication.update -= OnEditorUpdate;
if (_cliRunner != null)
{
EditorApplication.update += OnEditorUpdate;
}
}
private void OnDisable()
{
Undo.undoRedoPerformed -= OnUndoRedoPerformed;
// Keep polling an active worker even when the window is closed so its
// redirected streams are drained and the requested import can finish.
if (_cliRunner == null)
{
EditorApplication.update -= OnEditorUpdate;
}
}
private void OnFocus()
{
ValidateOrAutoSelectSourceDirector();
RefreshShotNames();
}
private void OnUndoRedoPerformed()
{
RefreshShotNames();
Repaint();
}
private void DrawLegacyGui()
{
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
EditorGUILayout.LabelField(
"AI 카메라 생성",
EditorStyles.largeLabel);
EditorGUILayout.HelpBox(
"생성할 Timeline을 고른 뒤 버튼 하나만 누르면 됩니다. " +
"데이터 추출, 카메라 생성, Timeline 적용을 자동으로 처리합니다.",
MessageType.Info);
DrawSimpleWorkflow();
EditorGUILayout.Space(12f);
_showAdvancedSettings = EditorGUILayout.Foldout(
_showAdvancedSettings,
"고급 설정 및 관리",
true);
if (_showAdvancedSettings)
{
EditorGUILayout.HelpBox(
"경로, CWCameraWorker, 내부 입력, Seed와 수동 가져오기는 " +
"개발·세부 조정이 필요할 때만 사용하세요.",
MessageType.None);
DrawPathSettings();
EditorGUILayout.Space(8f);
DrawTargetSettings();
EditorGUILayout.Space(8f);
DrawTimelineActions();
EditorGUILayout.Space(8f);
DrawGenerationActions();
EditorGUILayout.Space(8f);
DrawSceneCopyAction();
EditorGUILayout.Space(8f);
DrawStatusAndLog();
}
EditorGUILayout.EndScrollView();
}
private void DrawSimpleDirectionSettings()
{
EditorGUILayout.HelpBox(
SelectedShotOverrideExplanation,
MessageType.None);
_shotSize = (ShotSizePreference)EditorGUILayout.Popup(
"샷 거리",
(int)_shotSize,
SimpleShotSizeNames);
_motion = (MotionPreference)EditorGUILayout.Popup(
"카메라 움직임",
(int)_motion,
SimpleMotionNames);
_composition = (CompositionPreference)EditorGUILayout.Popup(
"인물 구도",
(int)_composition,
SimpleCompositionNames);
_motionIntensity = EditorGUILayout.Slider(
"움직임 강도",
_motionIntensity,
0f,
2f);
_distanceMeters = Mathf.Max(
0f,
EditorGUILayout.FloatField(
new GUIContent(
"직접 거리 (m)",
"0이면 선택한 샷 거리와 캐릭터 크기에 맞춰 자동 결정합니다."),
_distanceMeters));
_bodyFollowStrength = EditorGUILayout.Slider(
new GUIContent(
"몸 추종 강도",
"0이면 무대 기준으로 안정적이고, 1이면 캐릭터 이동을 강하게 따라갑니다."),
_bodyFollowStrength,
0f,
1f);
if (GUILayout.Button("선택 카메라 옵션을 자동으로 되돌리기"))
{
_shotSize = ShotSizePreference.Auto;
_motion = MotionPreference.Auto;
_composition = CompositionPreference.Auto;
_motionIntensity = 1f;
_distanceMeters = 0f;
_bodyFollowStrength = 0.18f;
}
}
private void DrawSimpleWorkflow()
{
EditorGUILayout.Space(6f);
EditorGUILayout.LabelField("1. 생성할 Timeline", EditorStyles.boldLabel);
DrawSimpleSourceDirectorSelector();
DrawGenerationInputSummary();
EditorGUILayout.Space(10f);
EditorGUILayout.LabelField("2. 전체 자율 생성", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
HybridQualityBackendDescription,
MessageType.None);
EditorGUILayout.HelpBox(
FullGenerationAutonomyExplanation,
MessageType.Info);
_cutRhythm = (CutRhythmPreference)EditorGUILayout.Popup(
"컷 리듬",
(int)_cutRhythm,
SimpleCutRhythmNames);
EditorGUILayout.HelpBox(
CutRhythmExplanation,
MessageType.None);
DrawCandidatePoolControls();
using (new EditorGUI.DisabledScope(
IsProcessRunning || !IsSimpleSourceDirectorReady()))
{
if (GUILayout.Button(
IsProcessRunning
? "생성 중..."
: FullAutonomousGenerationButtonLabel,
GUILayout.Height(48f)))
{
ExportGenerateAndImport();
}
}
if (!IsSimpleSourceDirectorReady())
{
EditorGUILayout.HelpBox(
_sourceDirector == null
? "생성할 Timeline을 먼저 선택하세요."
: "선택한 Timeline의 모션·캐릭터·음원 구성을 확인하세요.",
MessageType.Warning);
}
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("상태", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
string.IsNullOrWhiteSpace(_status) ? "준비됨" : _status,
IsProcessRunning ? MessageType.Info : MessageType.None);
DrawGenerationTiming();
if (IsProcessRunning)
{
using (new EditorGUI.DisabledScope(
_cliRunner?.CancellationRequested == true))
{
if (GUILayout.Button("생성 취소", GUILayout.Height(28f)))
{
CancelGeneration();
}
}
}
if (_hasLastAppliedSeed)
{
EditorGUILayout.HelpBox(
BuildAppliedSeedSummary(
_lastAppliedSeed,
_seed,
_lastAppliedSeedWasSelectedShot,
_lastAppliedSeedShotIndex),
MessageType.Info);
}
if (_shotNames.Length == 0)
{
return;
}
EditorGUILayout.Space(10f);
EditorGUILayout.LabelField("3. 결과 다듬기", EditorStyles.boldLabel);
var hasTimelineCameraSelection =
TrySyncSelectedShotFromEditor(out var selectedCameraName);
_selectedShotIndex = Mathf.Clamp(
_selectedShotIndex,
0,
_shotNames.Length - 1);
if (hasTimelineCameraSelection)
{
EditorGUILayout.HelpBox(
$"Timeline/Hierarchy 선택: {selectedCameraName} " +
$"(카메라 {_selectedShotIndex:D3})",
MessageType.Info);
}
else
{
EditorGUILayout.HelpBox(
"Timeline의 AI Cinemachine 클립 또는 Hierarchy의 AI 카메라를 " +
"선택하면 해당 카메라를 자동으로 인식합니다. 아래 목록으로도 " +
"직접 고를 수 있습니다.",
MessageType.None);
_selectedShotIndex = EditorGUILayout.Popup(
"카메라 직접 선택",
_selectedShotIndex,
_shotNames);
}
EditorGUILayout.Space(6f);
_showDirectionSettings = EditorGUILayout.Foldout(
_showDirectionSettings,
"선택 카메라 재생성 옵션",
true);
if (_showDirectionSettings)
{
using (new EditorGUI.DisabledScope(IsProcessRunning))
{
DrawSimpleDirectionSettings();
}
}
using (new EditorGUI.DisabledScope(IsProcessRunning))
{
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button(
hasTimelineCameraSelection
? SelectedCameraRegenerationButtonLabel
: ListedCameraRegenerationButtonLabel,
GUILayout.Height(34f)))
{
StartGeneration(
ProcessCompletionAction.ReplaceSelectedShot);
}
if (GUILayout.Button(
"생성 결과 지우기",
GUILayout.Height(34f)))
{
RunEditorAction(
"AI 카메라 결과를 제거하는 중...",
delegate
{
var result = RemoveScopedPreview();
RefreshShotNames();
return result;
});
}
}
EditorGUILayout.Space(10f);
EditorGUILayout.LabelField(
"4. 컷 수정 기록",
EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"카메라 재생성을 모두 마친 뒤 AI Generated Cinemachine " +
"트랙의 컷을 수정하고 저장하세요. 유지한 컷도 좋은 제안으로 " +
"기록됩니다. 입력한 ID는 로컬에서 SHA-256으로 익명화되며, " +
"씬과 Timeline은 저장하지 않고 외부 JSON만 생성합니다.",
MessageType.None);
_editorStyleId = EditorGUILayout.TextField(
"익명 편집 스타일 ID",
_editorStyleId);
using (new EditorGUI.DisabledScope(
string.IsNullOrWhiteSpace(_editorStyleId) ||
_sourceDirector == null))
{
if (GUILayout.Button(
"컷 수정 기록 저장",
GUILayout.Height(34f)))
{
RunEditorAction(
"컷 수정 기록을 저장하는 중...",
CaptureCutCorrectionSnapshot);
}
}
EditorGUILayout.Space(8f);
if (GUILayout.Button(
"완성본 씬으로 저장",
GUILayout.Height(38f)))
{
SaveActiveSceneCopy();
}
}
}
private void DrawCandidatePoolControls()
{
EditorGUILayout.HelpBox(
SeedCandidateExplanation,
MessageType.None);
_forceFreshCandidatePool = EditorGUILayout.ToggleLeft(
new GUIContent(
FreshCandidatePoolOptionLabel,
"후보 캐시를 읽지 않고 이번 생성의 후보를 처음부터 다시 계산합니다. " +
"평소보다 오래 걸리며, 생성 시작 후 자동으로 해제됩니다."),
_forceFreshCandidatePool);
if (_forceFreshCandidatePool)
{
EditorGUILayout.HelpBox(
"이번 생성은 후보 캐시를 사용하지 않아 시간이 더 오래 걸립니다.",
MessageType.Warning);
}
}
private void DrawSimpleSourceDirectorSelector()
{
var directors = GetAvailableSourceDirectors();
if (directors.Length == 0)
{
_sourceDirector = null;
EditorGUILayout.HelpBox(
"현재 씬에서 사용할 수 있는 Timeline을 찾지 못했습니다.",
MessageType.Error);
return;
}
if (_sourceDirector == null && directors.Length == 1)
{
_sourceDirector = directors[0];
RefreshShotNames();
}
var currentIndex = Array.IndexOf(directors, _sourceDirector);
var labels = new string[directors.Length + 1];
labels[0] = "Timeline을 선택하세요";
for (var index = 0; index < directors.Length; index++)
{
labels[index + 1] = GetSourceDirectorLabel(directors[index]);
}
var selectedIndex = EditorGUILayout.Popup(
"원본 Timeline",
currentIndex + 1,
labels);
if (selectedIndex - 1 != currentIndex)
{
_sourceDirector = selectedIndex <= 0
? null
: directors[selectedIndex - 1];
RefreshShotNames();
}
if (_sourceDirector != null)
{
EditorGUILayout.LabelField(
"선택됨",
GetSourceDirectorLabel(_sourceDirector),
EditorStyles.miniLabel);
}
}
private static PlayableDirector[] GetAvailableSourceDirectors()
{
var scene = SceneManager.GetActiveScene();
if (!scene.IsValid() || !scene.isLoaded)
{
return Array.Empty<PlayableDirector>();
}
return Resources.FindObjectsOfTypeAll<PlayableDirector>()
.Where(value =>
value != null &&
value.gameObject.scene == scene &&
value.playableAsset is TimelineAsset &&
!IsAuxiliaryMotionDirector(value) &&
!string.Equals(
value.name,
"Timeline_AI_Final",
StringComparison.OrdinalIgnoreCase) &&
!AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(value))
.OrderBy(value => GetHierarchyPath(value.transform),
StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static bool IsAuxiliaryMotionDirector(
PlayableDirector director)
{
if (director == null ||
!string.Equals(
director.name,
"Motion",
StringComparison.OrdinalIgnoreCase))
{
return false;
}
return director.transform.parent != null &&
director.transform.parent.GetComponent<PlayableDirector>()
?.playableAsset is TimelineAsset;
}
private static string GetSourceDirectorLabel(PlayableDirector director)
{
if (director == null)
{
return "(선택 안 됨)";
}
var timelineName =
(director.playableAsset as TimelineAsset)?.name ??
"TimelineAsset 없음";
return $"{GetHierarchyPath(director.transform)} · {timelineName}";
}
private void DrawGenerationInputSummary()
{
if (_sourceDirector == null)
{
return;
}
var summary = TimelineCameraDatasetExporter
.GetGenerationInputSummaryForEditor(_sourceDirector);
if (!summary.isValid)
{
EditorGUILayout.HelpBox(
string.IsNullOrWhiteSpace(summary.error)
? "캐릭터 모션과 음원을 확인할 수 없습니다."
: summary.error,
MessageType.Error);
return;
}
EditorGUILayout.HelpBox(
$"캐릭터: {summary.characterName}\n" +
$"음원: {summary.audioClipName}\n" +
$"길이: {summary.duration:F1}초\n" +
"기존 카메라는 필요하지 않으며 생성 입력으로 사용하지 않습니다.",
MessageType.None);
}
private bool IsSimpleSourceDirectorReady()
{
var scene = SceneManager.GetActiveScene();
if (_sourceDirector == null ||
!scene.IsValid() ||
!scene.isLoaded ||
_sourceDirector.gameObject.scene != scene ||
_sourceDirector.playableAsset is not TimelineAsset ||
AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(_sourceDirector))
{
return false;
}
return TimelineCameraDatasetExporter
.GetGenerationInputSummaryForEditor(_sourceDirector)
.isValid;
}
private void DrawPathSettings()
{
EditorGUILayout.LabelField("경로", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
_cwAiRoot = EditorGUILayout.TextField(
"참조 데이터 루트",
_cwAiRoot);
if (GUILayout.Button("찾기", GUILayout.Width(54f)))
{
var selected = EditorUtility.OpenFolderPanel(
"CW-AI data library",
ExistingDirectoryOrFallback(
_cwAiRoot,
Directory.GetParent(Application.dataPath)?.FullName),
string.Empty);
if (!string.IsNullOrWhiteSpace(selected))
{
_cwAiRoot = selected;
}
}
}
using (new EditorGUILayout.HorizontalScope())
{
_cliExecutable = EditorGUILayout.TextField(
"CWCameraWorker",
_cliExecutable);
if (GUILayout.Button("자동", GUILayout.Width(54f)))
{
_cliExecutable = ResolveCliExecutable(true);
SetStatus(string.IsNullOrWhiteSpace(_cliExecutable)
? "CWCameraWorker.exe를 자동으로 찾지 못했습니다."
: $"CWCameraWorker: {_cliExecutable}");
}
if (GUILayout.Button("찾기", GUILayout.Width(54f)))
{
var selected = EditorUtility.OpenFilePanel(
"CWCameraWorker executable",
ExistingDirectoryOrFallback(
Path.GetDirectoryName(_cliExecutable),
_cwAiRoot),
"exe");
if (!string.IsNullOrWhiteSpace(selected))
{
_cliExecutable = selected;
}
}
}
using (new EditorGUILayout.HorizontalScope())
{
_generatedFolder = EditorGUILayout.TextField(
"생성 결과 폴더",
_generatedFolder);
if (GUILayout.Button("찾기", GUILayout.Width(54f)))
{
var selected = EditorUtility.OpenFolderPanel(
"Generated AI camera directory",
ExistingDirectoryOrFallback(
_generatedFolder,
Path.Combine(_cwAiRoot, "examples")),
string.Empty);
if (!string.IsNullOrWhiteSpace(selected))
{
_generatedFolder = selected;
}
}
}
using (new EditorGUILayout.HorizontalScope())
{
_generationInputRoot = EditorGUILayout.TextField(
new GUIContent(
"입력 저장 폴더",
"선택한 Timeline의 모션·음원 생성 입력을 저장하고 읽을 폴더입니다."),
_generationInputRoot);
if (GUILayout.Button("찾기", GUILayout.Width(54f)))
{
var selected = EditorUtility.OpenFolderPanel(
"Generation input DatasetExports directory",
ExistingDirectoryOrFallback(
_generationInputRoot,
Directory.GetParent(Application.dataPath)?.FullName),
string.Empty);
if (!string.IsNullOrWhiteSpace(selected))
{
_generationInputRoot = selected;
RefreshDatasetSongs(true);
}
}
}
}
private void DrawTargetSettings()
{
EditorGUILayout.LabelField(
"개발용 입력 데이터",
EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
EditorGUI.BeginChangeCheck();
_datasetId = EditorGUILayout.TextField(
"입력 묶음 ID",
_datasetId);
if (EditorGUI.EndChangeCheck())
{
RefreshDatasetSongs(true);
}
if (GUILayout.Button("목록", GUILayout.Width(64f)))
{
RefreshDatasetSongs(true);
}
}
if (_datasetSongNames.Length > 0)
{
_datasetSongIndex = Mathf.Clamp(
_datasetSongIndex,
0,
_datasetSongNames.Length - 1);
var selectedSongIndex = EditorGUILayout.Popup(
"입력 레코드",
_datasetSongIndex,
_datasetSongNames);
if (selectedSongIndex != _datasetSongIndex)
{
_datasetSongIndex = selectedSongIndex;
_songId = _datasetSongs[_datasetSongIndex].songName;
TrySelectSourceDirectorForSong(_songId, true);
}
}
EditorGUI.BeginChangeCheck();
_songId = EditorGUILayout.TextField("입력 ID / 이름", _songId);
if (EditorGUI.EndChangeCheck())
{
var matchingIndex = Array.FindIndex(
_datasetSongs,
value => string.Equals(
value.songName?.Trim(),
_songId.Trim(),
StringComparison.Ordinal));
if (matchingIndex < 0 &&
IsSongId(_songId))
{
matchingIndex = FindSongIndexFromCatalog(_songId.Trim());
}
if (matchingIndex >= 0)
{
_datasetSongIndex = matchingIndex;
TrySelectSourceDirectorForSong(
_datasetSongs[matchingIndex].songName,
true);
}
}
using (new EditorGUILayout.HorizontalScope())
{
_sourceDirector = (PlayableDirector)EditorGUILayout.ObjectField(
new GUIContent(
"원본 PlayableDirector",
"TimeLine/<곡> 구조에서는 선택한 곡의 원본 Director를 명시합니다."),
_sourceDirector,
typeof(PlayableDirector),
true);
if (GUILayout.Button("자동 찾기", GUILayout.Width(74f)))
{
TrySelectSourceDirectorForSong(GetSelectedSongName(), true);
}
}
if (_sourceDirector != null)
{
EditorGUILayout.LabelField(
"Timeline",
(_sourceDirector.playableAsset as TimelineAsset)?.name ??
"(TimelineAsset 없음)",
EditorStyles.miniLabel);
}
_seed = EditorGUILayout.IntField(
new GUIContent(
NextGenerationSeedLabel,
"같은 안전 후보 풀에서 다음 카메라 조합을 선택할 때 사용할 값입니다."),
_seed);
_curvePreset =
(AICameraTimelinePreviewImporter.CurveSimplificationPreset)
EditorGUILayout.EnumPopup("키 단순화", _curvePreset);
EditorGUILayout.HelpBox(
FullGenerationAutonomyExplanation + " 선택 카메라용 옵션은 " +
"위의 '결과 다듬기' 영역에서 설정합니다.",
MessageType.Info);
EditorGUILayout.HelpBox(
SeedCandidateExplanation + " 생성이 성공하면 Seed는 다음 선택을 " +
"위해 자동으로 1 증가합니다. '현재 결과 다시 적용'은 Seed를 " +
"사용하지 않습니다.",
MessageType.None);
EditorGUILayout.HelpBox(
HybridQualityBackendDescription,
MessageType.None);
}
private void DrawTimelineActions()
{
EditorGUILayout.LabelField("Timeline", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
ReapplyCurrentResultWarning,
MessageType.Warning);
using (new EditorGUI.DisabledScope(IsProcessRunning))
{
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("현재 Timeline 데이터 추출", GUILayout.Height(30f)))
{
RunEditorAction(
"Timeline 데이터를 추출하는 중...",
delegate
{
var output =
TimelineCameraDatasetExporter
.ExportAll60FpsForCli();
ConfigureExportedDataset(
output,
_songId.Trim());
return $"추출 완료: {output}\n" +
$"곡 {_datasetSongNames.Length:N0}개를 찾았습니다.";
});
}
if (GUILayout.Button(
new GUIContent(
ReapplyCurrentResultButtonLabel,
ReapplyCurrentResultWarning),
GUILayout.Height(30f)))
{
ImportFullResult(_generatedFolder);
}
if (GUILayout.Button("결과 제거", GUILayout.Height(30f)))
{
RunEditorAction(
"AI 카메라 결과를 제거하는 중...",
delegate
{
var result =
RemoveScopedPreview();
RefreshShotNames();
return result;
});
}
}
}
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.PrefixLabel("선택 카메라");
if (_shotNames.Length == 0)
{
EditorGUILayout.LabelField(
"가져온 AI 카메라가 없습니다.",
EditorStyles.miniLabel);
}
else
{
_selectedShotIndex = Mathf.Clamp(
_selectedShotIndex,
0,
_shotNames.Length - 1);
_selectedShotIndex = EditorGUILayout.Popup(
_selectedShotIndex,
_shotNames);
}
if (GUILayout.Button("새로고침", GUILayout.Width(74f)))
{
RefreshShotNames();
}
}
using (new EditorGUI.DisabledScope(
IsProcessRunning || _shotNames.Length == 0))
{
if (GUILayout.Button(
new GUIContent(
"현재 결과로 선택 카메라만 교체 (Seed 사용 안 함)",
ReapplyCurrentResultWarning),
GUILayout.Height(28f)))
{
ImportSelectedShot(_generatedFolder, _selectedShotIndex);
}
}
}
private void DrawGenerationActions()
{
EditorGUILayout.LabelField(
"안전 후보 선택 및 생성 (비동기)",
EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(IsProcessRunning))
{
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button(
"전체 자율 생성 후 가져오기",
GUILayout.Height(34f)))
{
StartGeneration(ProcessCompletionAction.ImportFull);
}
using (new EditorGUI.DisabledScope(_shotNames.Length == 0))
{
if (GUILayout.Button(
"선택 카메라 재생성 (옵션 적용)",
GUILayout.Height(34f)))
{
StartGeneration(
ProcessCompletionAction.ReplaceSelectedShot);
}
}
}
if (GUILayout.Button(
"고품질 생성 준비 캐시 만들기 / 갱신",
GUILayout.Height(28f)))
{
StartPreparationCacheBuild();
}
}
if (IsProcessRunning)
{
EditorGUILayout.HelpBox(
$"생성 중 (Seed {_runningSeed})\n{_runningOutputFolder}",
MessageType.Info);
using (new EditorGUI.DisabledScope(
_cliRunner?.CancellationRequested == true))
{
if (GUILayout.Button("생성 취소", GUILayout.Height(28f)))
{
CancelGeneration();
}
}
}
}
private void DrawSceneCopyAction()
{
EditorGUILayout.LabelField("완성본 보존", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"현재 씬 상태를 원본과 별개의 *_AI_Camera_Final.unity 파일로 " +
"저장합니다. 현재 열린 씬 경로와 저장 여부는 바뀌지 않습니다.",
MessageType.None);
using (new EditorGUI.DisabledScope(IsProcessRunning))
{
if (GUILayout.Button(
"현재 씬을 AI Camera Final 복사본으로 저장",
GUILayout.Height(30f)))
{
SaveActiveSceneCopy();
}
}
}
private void DrawStatusAndLog()
{
EditorGUILayout.LabelField("상태", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
string.IsNullOrWhiteSpace(_status) ? "준비됨" : _status,
MessageType.None);
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.LabelField("로그", EditorStyles.boldLabel);
if (GUILayout.Button("지우기", GUILayout.Width(54f)))
{
_log = string.Empty;
}
}
_logScrollPosition = EditorGUILayout.BeginScrollView(
_logScrollPosition,
GUILayout.MinHeight(150f),
GUILayout.MaxHeight(280f));
EditorGUILayout.SelectableLabel(
string.IsNullOrWhiteSpace(_log)
? "아직 로그가 없습니다."
: _log,
EditorStyles.textArea,
GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
}
private bool IsProcessRunning
{
get { return _cliRunner != null; }
}
private void DrawGenerationTiming()
{
if (_generationOperationActive)
{
var stageText = _generationStageIndex > 0
? $"{_generationStageIndex}/{_generationStageCount}단계 · " +
_generationStage
: _generationStage;
if (_generationStageProgress >= 0f)
{
var progressRect = GUILayoutUtility.GetRect(
18f,
18f,
GUILayout.ExpandWidth(true));
EditorGUI.ProgressBar(
progressRect,
Mathf.Clamp01(_generationStageProgress),
stageText);
}
else
{
EditorGUILayout.LabelField(
stageText,
EditorStyles.miniBoldLabel);
}
EditorGUILayout.LabelField(
BuildRunningTimingText(),
EditorStyles.miniLabel);
return;
}
var recentDuration = _lastCompletedDurationSeconds > 0d
? _lastCompletedDurationSeconds
: EditorPrefs.GetFloat(
RuntimeHistoryKey("LastSeconds"),
-1f);
if (recentDuration > 0d)
{
EditorGUILayout.LabelField(
$"최근 완료 소요시간: " +
$"{FormatObservedDuration(recentDuration)}",
EditorStyles.miniLabel);
}
}
private void BeginGenerationOperation(
string stage,
int stageIndex)
{
_generationOperationActive = true;
_generationStartedAt = EditorApplication.timeSinceStartup;
_generationSourceDurationSeconds = GetSourceDurationSeconds();
_generationStageProgress = -1f;
_generationStageIndex = stageIndex;
_generationStageCount = 4;
_generationStage = stage;
_runningOutputFilesDetected = false;
_preparationCacheObservation = CacheObservation.Unknown;
_candidateCacheObservation = CacheObservation.Unknown;
_candidateCacheObservedAtElapsedSeconds = -1d;
_importStageObservedAtElapsedSeconds = stageIndex >= 4 ? 0d : -1d;
_workerEtaSeconds = -1d;
Repaint();
}
private string BuildRunningTimingText()
{
var elapsed = Math.Max(
0d,
EditorApplication.timeSinceStartup - _generationStartedAt);
if (_workerEtaSeconds >= 0d)
{
return $"경과 {FormatObservedDuration(elapsed)} · " +
$"예상 남은 시간 {FormatObservedDuration(_workerEtaSeconds)}";
}
if (_runningSelectedShotOneShot)
{
return BuildSelectedShotGenerationTimingText(
elapsed,
EditorPrefs.GetFloat(
RuntimeHistoryKey("SelectedShot.LastSeconds"),
-1f),
_preparationCacheObservation,
_candidateCacheObservation);
}
return BuildGenerationTimingText(
elapsed,
GetCurrentRuntimeEstimate(elapsed),
_preparationCacheObservation,
_candidateCacheObservation);
}
private void UpdateGenerationProgress(
string stage,
int stageIndex,
float stageProgress = -1f)
{
if (!_generationOperationActive)
{
return;
}
_generationStage = stage;
_generationStageIndex = Mathf.Clamp(
stageIndex,
1,
_generationStageCount);
if (_generationStageIndex >= 4 &&
_importStageObservedAtElapsedSeconds < 0d)
{
_importStageObservedAtElapsedSeconds = Math.Max(
0d,
EditorApplication.timeSinceStartup - _generationStartedAt);
}
_generationStageProgress = stageProgress < 0f
? -1f
: Mathf.Clamp01(stageProgress);
Repaint();
}
private void CompleteGenerationOperation(bool recordObservation)
{
if (!_generationOperationActive)
{
return;
}
var elapsed = Math.Max(
0d,
EditorApplication.timeSinceStartup - _generationStartedAt);
if (recordObservation && elapsed > 0d)
{
_lastCompletedDurationSeconds = elapsed;
if (_runningSelectedShotOneShot)
{
EditorPrefs.SetFloat(
RuntimeHistoryKey("SelectedShot.LastSeconds"),
(float)elapsed);
}
else
{
RecordRuntimeObservation(
elapsed,
_generationSourceDurationSeconds,
_preparationCacheObservation,
_candidateCacheObservation);
}
AppendLog(
$"[timing] completed in " +
$"{FormatObservedDuration(elapsed)}");
}
_generationOperationActive = false;
_generationStageProgress = -1f;
_generationStageIndex = 0;
_generationStage = string.Empty;
_runningSelectedShotOneShot = false;
_runningOutputFilesDetected = false;
_candidateCacheObservedAtElapsedSeconds = -1d;
_importStageObservedAtElapsedSeconds = -1d;
Repaint();
}
private double GetSourceDurationSeconds()
{
if (_sourceDirector == null)
{
return 0d;
}
var duration = _sourceDirector.duration;
if (double.IsNaN(duration) ||
double.IsInfinity(duration) ||
duration <= 0d)
{
duration = _sourceDirector.playableAsset?.duration ?? 0d;
}
return double.IsNaN(duration) ||
double.IsInfinity(duration) ||
duration <= 0d
? 0d
: duration;
}
private RuntimeEstimate GetCurrentRuntimeEstimate(
double elapsedSeconds)
{
var profileSuffix = RuntimeProfileSuffix(
_preparationCacheObservation,
_candidateCacheObservation);
var profilePrefix = RuntimeHistoryKey(
profileSuffix);
var sampleCount = EditorPrefs.GetInt(profilePrefix + "Count", 0);
var historyMatchesCacheState = sampleCount > 0;
var keyPrefix = historyMatchesCacheState
? profilePrefix
: RuntimeHistoryKey(string.Empty);
sampleCount = EditorPrefs.GetInt(keyPrefix + "Count", 0);
var meanRate = EditorPrefs.GetFloat(
keyPrefix + "SecondsPerTimelineSecond",
-1f);
var deviationRate = EditorPrefs.GetFloat(
keyPrefix + "Deviation",
Math.Max(0f, meanRate) * 0.25f);
return CalculateRuntimeEstimate(
_generationSourceDurationSeconds,
elapsedSeconds,
sampleCount,
meanRate,
deviationRate,
_preparationCacheObservation,
_candidateCacheObservation,
_generationStageIndex,
historyMatchesCacheState,
_candidateCacheObservedAtElapsedSeconds,
_importStageObservedAtElapsedSeconds);
}
internal static RuntimeEstimate CalculateRuntimeEstimate(
double sourceDurationSeconds,
double elapsedSeconds,
int sampleCount,
double meanSecondsPerTimelineSecond,
double deviationSecondsPerTimelineSecond,
CacheObservation preparationCache = CacheObservation.Unknown,
CacheObservation candidateCache = CacheObservation.Unknown,
int stageIndex = 0,
bool historyMatchesCacheState = false,
double candidateCacheObservedAtElapsedSeconds = double.NaN,
double importStageObservedAtElapsedSeconds = double.NaN)
{
if (double.IsNaN(sourceDurationSeconds) ||
double.IsInfinity(sourceDurationSeconds) ||
sourceDurationSeconds <= 0d)
{
return new RuntimeEstimate(
false,
false,
0d,
0d,
0d,
0d);
}
elapsedSeconds = Math.Max(0d, elapsedSeconds);
var hasHistory = sampleCount > 0 &&
meanSecondsPerTimelineSecond > 0d &&
!double.IsNaN(meanSecondsPerTimelineSecond) &&
!double.IsInfinity(meanSecondsPerTimelineSecond);
double minimumTotal;
double maximumTotal;
var initialEstimate = !hasHistory;
if (initialEstimate)
{
// Conservative cold-start ranges measured on this production
// workstation. They are intentionally broad and are replaced
// by observed worker timings after the first completed run.
var baselineTotal = 20d + sourceDurationSeconds * 0.42d;
minimumTotal = baselineTotal * 0.75d;
maximumTotal = baselineTotal * 1.40d;
}
else
{
var safeDeviation = double.IsNaN(
deviationSecondsPerTimelineSecond) ||
double.IsInfinity(
deviationSecondsPerTimelineSecond)
? meanSecondsPerTimelineSecond * 0.25d
: Math.Max(0d, deviationSecondsPerTimelineSecond);
var estimatedTotal =
meanSecondsPerTimelineSecond * sourceDurationSeconds;
var spread = Math.Max(
estimatedTotal * (sampleCount == 1 ? 0.30d : 0.15d),
safeDeviation * sourceDurationSeconds * 1.5d);
minimumTotal = Math.Max(0d, estimatedTotal - spread);
maximumTotal = Math.Max(minimumTotal, estimatedTotal + spread);
}
// Until an observation exists for this exact cache profile, use
// conservative measured penalties/budgets to correct the general
// backend history as soon as the Python stage log reveals a hit or miss.
if (!historyMatchesCacheState)
{
if (candidateCache == CacheObservation.Hit)
{
var minimumAfterHit = 2d + sourceDurationSeconds * 0.005d;
var maximumAfterHit = 12d + sourceDurationSeconds * 0.04d;
var observationElapsed =
double.IsNaN(candidateCacheObservedAtElapsedSeconds) ||
double.IsInfinity(candidateCacheObservedAtElapsedSeconds) ||
candidateCacheObservedAtElapsedSeconds < 0d
? elapsedSeconds
: Math.Min(
elapsedSeconds,
candidateCacheObservedAtElapsedSeconds);
minimumTotal = observationElapsed + minimumAfterHit;
maximumTotal = observationElapsed + maximumAfterHit;
}
else if (preparationCache == CacheObservation.Miss)
{
minimumTotal += 10d + sourceDurationSeconds * 0.04d;
maximumTotal += 25d + sourceDurationSeconds * 0.12d;
}
}
var minimumRemaining = Math.Max(0d, minimumTotal - elapsedSeconds);
var maximumRemaining = Math.Max(
minimumRemaining,
maximumTotal - elapsedSeconds);
// Once Unity begins validating/importing output, generation itself
// is complete. Bound stale historical estimates to the observed
// import tail instead of showing a long, misleading countdown.
if (stageIndex >= 4)
{
minimumRemaining = 0d;
var observedImportStart =
double.IsNaN(importStageObservedAtElapsedSeconds) ||
double.IsInfinity(importStageObservedAtElapsedSeconds) ||
importStageObservedAtElapsedSeconds < 0d
? elapsedSeconds
: Math.Min(
elapsedSeconds,
importStageObservedAtElapsedSeconds);
var importTailBudget =
20d + sourceDurationSeconds * 0.02d;
var importTailRemaining = Math.Max(
0d,
observedImportStart + importTailBudget - elapsedSeconds);
maximumRemaining = Math.Min(
maximumRemaining,
importTailRemaining);
}
minimumTotal = Math.Max(elapsedSeconds, elapsedSeconds + minimumRemaining);
maximumTotal = Math.Max(minimumTotal, elapsedSeconds + maximumRemaining);
return new RuntimeEstimate(
true,
initialEstimate,
minimumTotal,
maximumTotal,
minimumRemaining,
maximumRemaining);
}
internal static string BuildGenerationTimingText(
double elapsedSeconds,
RuntimeEstimate estimate,
CacheObservation preparationCache,
CacheObservation candidateCache)
{
var elapsedText = $"경과 {FormatObservedDuration(elapsedSeconds)}";
var cacheText = BuildCacheStatusText(
preparationCache,
candidateCache);
if (!estimate.IsAvailable)
{
return $"{elapsedText} · {cacheText} · " +
"첫 완료 기록을 수집 중";
}
var estimateLabel = estimate.IsInitial
? "초기 추정 총"
: "최근 실측 예상 총";
var totalText = FormatDurationRange(
estimate.MinimumTotalSeconds,
estimate.MaximumTotalSeconds);
var remainingText = estimate.MaximumRemainingSeconds <= 0d
? estimate.IsInitial
? "초기 추정 범위를 넘어 작업 중"
: "최근 실측 범위를 넘어 작업 중"
: "남은 시간 " + FormatDurationRange(
estimate.MinimumRemainingSeconds,
estimate.MaximumRemainingSeconds);
return $"{elapsedText} · {estimateLabel} {totalText} · " +
$"{remainingText} · {cacheText}";
}
internal static string BuildSelectedShotGenerationTimingText(
double elapsedSeconds,
double lastCompletedSeconds,
CacheObservation preparationCache,
CacheObservation candidateCache)
{
elapsedSeconds = Math.Max(0d, elapsedSeconds);
var elapsedText = $"경과 {FormatObservedDuration(elapsedSeconds)}";
var cacheText = BuildCacheStatusText(
preparationCache,
candidateCache);
if (double.IsNaN(lastCompletedSeconds) ||
double.IsInfinity(lastCompletedSeconds) ||
lastCompletedSeconds <= 0d)
{
return $"{elapsedText} · 선택 카메라 1개만 계산 중 · " +
$"첫 단일 샷 완료 기록을 수집 중 · {cacheText}";
}
var minimumTotal = Math.Max(1d, lastCompletedSeconds * 0.65d);
var maximumTotal = Math.Max(
minimumTotal,
lastCompletedSeconds * 1.35d);
var minimumRemaining = Math.Max(
0d,
minimumTotal - elapsedSeconds);
var maximumRemaining = Math.Max(
minimumRemaining,
maximumTotal - elapsedSeconds);
var remainingText = maximumRemaining <= 0d
? "최근 단일 샷 시간을 넘어 작업 중"
: "예상 남은 시간 " + FormatDurationRange(
minimumRemaining,
maximumRemaining);
return $"{elapsedText} · 선택 카메라 1개만 계산 중 · " +
$"최근 단일 샷 기준 총 " +
$"{FormatDurationRange(minimumTotal, maximumTotal)} · " +
$"{remainingText} · {cacheText}";
}
internal static string BuildCacheStatusText(
CacheObservation preparationCache,
CacheObservation candidateCache)
{
if (candidateCache == CacheObservation.Hit)
{
return "안전 후보 캐시 사용 중";
}
if (candidateCache == CacheObservation.Miss)
{
return preparationCache == CacheObservation.Miss
? "준비 캐시 구성 후 새 후보 풀 계산 중"
: "새 후보 풀 계산 중";
}
if (preparationCache == CacheObservation.Hit)
{
return "준비 캐시 사용 · 후보 캐시 확인 중";
}
if (preparationCache == CacheObservation.Miss)
{
return "준비 캐시 구성 중";
}
return "캐시 상태 확인 중";
}
internal static CacheObservation ParseCacheObservation(
string message,
string cacheName)
{
if (string.IsNullOrWhiteSpace(message) ||
string.IsNullOrWhiteSpace(cacheName))
{
return CacheObservation.Unknown;
}
var normalized = message
.ToLowerInvariant()
.Replace('_', ' ')
.Replace('-', ' ');
var normalizedCacheName = cacheName.Trim().ToLowerInvariant();
if (normalized.IndexOf(
normalizedCacheName + " cache",
StringComparison.Ordinal) < 0)
{
return CacheObservation.Unknown;
}
if (normalized.IndexOf("hit", StringComparison.Ordinal) >= 0)
{
return CacheObservation.Hit;
}
return normalized.IndexOf("miss", StringComparison.Ordinal) >= 0 ||
normalized.IndexOf("invalid", StringComparison.Ordinal) >= 0 ||
normalized.IndexOf("disabled", StringComparison.Ordinal) >= 0 ||
normalized.IndexOf("bypass", StringComparison.Ordinal) >= 0 ||
normalized.IndexOf("failed", StringComparison.Ordinal) >= 0 ||
normalized.IndexOf("mode off", StringComparison.Ordinal) >= 0
? CacheObservation.Miss
: CacheObservation.Unknown;
}
internal static string BuildAppliedSeedSummary(
int appliedSeed,
int nextSeed,
bool selectedShot,
int shotIndex)
{
if (selectedShot)
{
var cameraLabel = shotIndex >= 0
? $"{shotIndex:D3}"
: "선택 카메라";
return $"마지막으로 안전 후보를 적용한 카메라: {cameraLabel}\n" +
$"해당 카메라 Seed: {appliedSeed}\n" +
"나머지 카메라는 기존 결과를 유지합니다.\n" +
$"다음 생성에 사용할 Seed: {nextSeed}";
}
return $"현재 전체 생성 결과 Seed: {appliedSeed}\n" +
$"다음 생성에 사용할 Seed: {nextSeed}";
}
private static void RecordRuntimeObservation(
double elapsedSeconds,
double sourceDurationSeconds,
CacheObservation preparationCache,
CacheObservation candidateCache)
{
if (elapsedSeconds <= 0d || sourceDurationSeconds <= 0d)
{
return;
}
RecordRuntimeObservationAtKey(
RuntimeHistoryKey(string.Empty),
elapsedSeconds,
sourceDurationSeconds);
EditorPrefs.SetFloat(
RuntimeHistoryKey("LastSeconds"),
(float)elapsedSeconds);
if (preparationCache != CacheObservation.Unknown ||
candidateCache != CacheObservation.Unknown)
{
RecordRuntimeObservationAtKey(
RuntimeHistoryKey(
RuntimeProfileSuffix(
preparationCache,
candidateCache)),
elapsedSeconds,
sourceDurationSeconds);
}
}
private static void RecordRuntimeObservationAtKey(
string keyPrefix,
double elapsedSeconds,
double sourceDurationSeconds)
{
var sampleRate = elapsedSeconds / sourceDurationSeconds;
var count = EditorPrefs.GetInt(keyPrefix + "Count", 0);
var previousMean = EditorPrefs.GetFloat(
keyPrefix + "SecondsPerTimelineSecond",
(float)sampleRate);
var previousDeviation = EditorPrefs.GetFloat(
keyPrefix + "Deviation",
(float)(sampleRate * 0.25d));
var blend = count <= 0 ? 1d : 0.35d;
var updatedMean = previousMean * (1d - blend) +
sampleRate * blend;
var updatedDeviation = previousDeviation * (1d - blend) +
Math.Abs(sampleRate - previousMean) * blend;
EditorPrefs.SetInt(keyPrefix + "Count", Math.Min(1000, count + 1));
EditorPrefs.SetFloat(
keyPrefix + "SecondsPerTimelineSecond",
(float)updatedMean);
EditorPrefs.SetFloat(
keyPrefix + "Deviation",
(float)updatedDeviation);
}
private static string RuntimeProfileSuffix(
CacheObservation preparationCache,
CacheObservation candidateCache)
{
return $"Profile.Preparation{preparationCache}." +
$"Candidate{candidateCache}.";
}
private static string RuntimeHistoryKey(string suffix)
{
return RuntimeHistoryKeyPrefix + "CliHybridQuality." + suffix;
}
private static string FormatObservedDuration(double seconds)
{
var rounded = Math.Max(0, (int)Math.Round(seconds));
if (rounded < 60)
{
return $"{rounded}초";
}
return $"{rounded / 60}분 {rounded % 60}초";
}
private static string FormatDurationRange(
double minimumSeconds,
double maximumSeconds)
{
var minimum = RoundEstimateSeconds(minimumSeconds);
var maximum = Math.Max(minimum, RoundEstimateSeconds(maximumSeconds));
if (minimum == maximum)
{
return FormatCoarseDuration(maximum);
}
return $"{FormatCoarseDuration(minimum)}~" +
FormatCoarseDuration(maximum);
}
private static int RoundEstimateSeconds(double seconds)
{
var interval = seconds >= 60d ? 10d : 5d;
return Math.Max(
0,
(int)(Math.Round(seconds / interval) * interval));
}
private static string FormatCoarseDuration(int seconds)
{
if (seconds < 60)
{
return $"{seconds}초";
}
var minutes = seconds / 60;
var remaining = seconds % 60;
return remaining == 0
? $"{minutes}분"
: $"{minutes}분 {remaining}초";
}
private bool ImportFullResult(string folder)
{
var importSucceeded = RunEditorAction(
"전체 AI 카메라 결과를 가져오는 중...",
delegate
{
ValidateGeneratedFolder(folder);
AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(folder);
if (GetScopedShotNames().Length > 0)
{
RemoveScopedPreview();
}
var result =
CreateScopedPreview(folder);
return result;
});
ApplyImportedGeneratedFolder(folder, importSucceeded);
return importSucceeded;
}
private bool ImportSelectedShot(string folder, int shotIndex)
{
var importSucceeded = RunEditorAction(
$"카메라 {shotIndex:D3}을(를) 교체하는 중...",
delegate
{
ValidateGeneratedFolder(folder);
var result =
RegenerateScopedShot(folder, shotIndex);
return result;
});
ApplyImportedGeneratedFolder(folder, importSucceeded);
return importSucceeded;
}
private void ApplyImportedGeneratedFolder(
string folder,
bool importSucceeded)
{
_generatedFolder = GeneratedFolderAfterImport(
_generatedFolder,
folder,
importSucceeded);
if (!importSucceeded)
{
return;
}
_hasLastAppliedSeed = false;
_lastAppliedSeedWasSelectedShot = false;
_lastAppliedSeedShotIndex = -1;
RefreshShotNames();
}
internal static string GeneratedFolderAfterImport(
string currentFolder,
string candidateFolder,
bool importSucceeded)
{
if (!importSucceeded)
{
return currentFolder;
}
if (string.IsNullOrWhiteSpace(candidateFolder))
{
throw new ArgumentException(
"The imported generation folder is required.",
nameof(candidateFolder));
}
return Path.GetFullPath(candidateFolder);
}
private string ResolveSelectedShotBaseGenerationFolder()
{
if (_sourceDirector == null ||
!AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForEditor(
_sourceDirector,
out var provenance))
{
return _generatedFolder;
}
var resolved = ResolveGeneratedFolderFromProvenance(
_cwAiRoot,
provenance.CwAiRelativeGeneratedFolder,
provenance.AbsoluteGeneratedFolder);
ValidateProvenanceMetadataSha256(
resolved,
provenance.MetadataSha256);
_generatedFolder = resolved;
return resolved;
}
private string CaptureCutCorrectionSnapshot()
{
var generatedDirectory =
ResolveSelectedShotBaseGenerationFolder();
var outputDirectory = Path.Combine(
Path.GetFullPath(_cwAiRoot),
"UserCorrectionLogs",
"Cuts");
var outputPath = AICameraCutCorrectionRecorder.CaptureForCli(
_sourceDirector,
generatedDirectory,
_editorStyleId,
outputDirectory);
return $"컷 수정 기록 저장 완료: {outputPath}";
}
private void RestoreGeneratedFolderFromPreviewProvenance()
{
if (_sourceDirector == null)
{
return;
}
try
{
if (!AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForEditor(
_sourceDirector,
out var provenance))
{
return;
}
var resolved = ResolveGeneratedFolderFromProvenance(
_cwAiRoot,
provenance.CwAiRelativeGeneratedFolder,
provenance.AbsoluteGeneratedFolder);
ValidateProvenanceMetadataSha256(
resolved,
provenance.MetadataSha256);
_generatedFolder = resolved;
}
catch (Exception exception)
{
AppendLog("[preview provenance] " + exception.Message);
}
}
internal static string ResolveGeneratedFolderFromProvenance(
string cwAiRoot,
string cwAiRelativeGeneratedFolder,
string absoluteGeneratedFolder)
{
string relativeCandidate = null;
if (!string.IsNullOrWhiteSpace(cwAiRoot) &&
!string.IsNullOrWhiteSpace(cwAiRelativeGeneratedFolder))
{
var relative = cwAiRelativeGeneratedFolder
.Trim()
.Replace('/', Path.DirectorySeparatorChar)
.Replace(Path.AltDirectorySeparatorChar,
Path.DirectorySeparatorChar);
if (Path.IsPathRooted(relative) ||
relative.Split(Path.DirectorySeparatorChar)
.Any(segment =>
string.IsNullOrWhiteSpace(segment) ||
segment == "." ||
segment == ".."))
{
throw new InvalidDataException(
"Preview provenance contains an invalid CW-AI relative path.");
}
var fullRoot = Path.GetFullPath(cwAiRoot)
.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar);
relativeCandidate = Path.GetFullPath(
Path.Combine(fullRoot, relative));
if (!relativeCandidate.StartsWith(
fullRoot + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
"Preview provenance resolves outside the configured CW-AI root.");
}
if (Directory.Exists(relativeCandidate))
{
return relativeCandidate;
}
}
if (!string.IsNullOrWhiteSpace(absoluteGeneratedFolder))
{
var absoluteCandidate = Path.GetFullPath(
absoluteGeneratedFolder);
if (Directory.Exists(absoluteCandidate))
{
return absoluteCandidate;
}
if (relativeCandidate == null)
{
relativeCandidate = absoluteCandidate;
}
}
if (!string.IsNullOrWhiteSpace(relativeCandidate))
{
return relativeCandidate;
}
throw new InvalidDataException(
"Preview provenance does not contain a generated folder path.");
}
internal static void ValidateProvenanceMetadataSha256(
string generatedFolder,
string expectedSha256)
{
if (string.IsNullOrWhiteSpace(expectedSha256) ||
expectedSha256.Trim().Length != 64 ||
expectedSha256.Any(character => !Uri.IsHexDigit(character)))
{
throw new InvalidDataException(
"Preview provenance metadata SHA-256 is missing or invalid.");
}
var metadataPath = Path.Combine(
Path.GetFullPath(generatedFolder),
"metadata.json");
if (!File.Exists(metadataPath))
{
throw new FileNotFoundException(
"Preview provenance metadata.json was not found.",
metadataPath);
}
using var algorithm = SHA256.Create();
var actualSha256 = string.Concat(
algorithm.ComputeHash(File.ReadAllBytes(metadataPath))
.Select(value => value.ToString("x2")));
if (!string.Equals(
actualSha256,
expectedSha256.Trim(),
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
"Preview provenance metadata SHA-256 does not match the " +
"materialized generated folder.");
}
}
private void StartGeneration(ProcessCompletionAction completionAction)
{
if (IsProcessRunning)
{
SetStatus("이미 카메라 생성 작업이 실행 중입니다.");
return;
}
try
{
ValidateGenerationSettings(completionAction);
var selectedShotOneShot =
completionAction ==
ProcessCompletionAction.ReplaceSelectedShot;
var baseGenerationFolder = selectedShotOneShot
? ValidateSelectedShotBaseGeneration(
ResolveSelectedShotBaseGenerationFolder())
: string.Empty;
if (selectedShotOneShot && _sourceDirector != null)
{
AICameraTimelinePreviewImporter
.EnsurePreviewGenerationProvenanceForEditor(
_sourceDirector,
baseGenerationFolder,
_cwAiRoot);
}
_runningSelectedShotOneShot = selectedShotOneShot;
if (!_generationOperationActive)
{
BeginGenerationOperation(
"생성 준비",
2);
}
var outputFolder = CreateUniqueOutputFolderPath();
if (_forceFreshCandidatePool)
{
_candidateCacheObservation = CacheObservation.Miss;
_candidateCacheObservedAtElapsedSeconds = Math.Max(
0d,
EditorApplication.timeSinceStartup -
_generationStartedAt);
}
var datasetRoot = Path.Combine(
Path.GetFullPath(_cwAiRoot),
"DatasetExports");
var trainingIndex = Path.Combine(
Path.GetFullPath(_cwAiRoot),
"reports",
"training_index.json");
var controls = CreateGenerationControlScope(
completionAction ==
ProcessCompletionAction.ReplaceSelectedShot,
_selectedShotIndex,
outputFolder,
ToCliValue(_shotSize),
ToCliValue(_motion),
ToCliValue(_composition),
_distanceMeters,
_motionIntensity,
_bodyFollowStrength);
var arguments = BuildGeneratorArguments(
datasetRoot,
trainingIndex,
outputFolder,
_seed,
controls,
selectedShotOneShot ? _selectedShotIndex : -1,
baseGenerationFolder).ToList();
_runningJobId = "unity-" + Guid.NewGuid().ToString("N");
var cancellationFile = CreateCancellationFilePath(_runningJobId);
AppendOption(arguments, "--event-format", "jsonl");
AppendOption(arguments, "--job-id", _runningJobId);
AppendOption(arguments, "--cancel-file", cancellationFile);
_completionAction = completionAction;
_runningOutputFolder = outputFolder;
_runningShotIndex = _selectedShotIndex;
_runningSeed = _seed;
_log = string.Empty;
UpdateGenerationProgress(
selectedShotOneShot
? "선택 카메라 입력 및 캐시 확인"
: "입력 분석 및 준비 캐시 확인",
2);
SetStatus(
(controls.UsesSelectedShotOverrides
? $"선택 카메라 {_runningShotIndex:D3} 단일 샷 재생성 " +
$"시작 (옵션 적용) — Seed {_runningSeed}\n"
: $"전체 카메라 자율 생성 시작 — Seed {_runningSeed}\n") +
$"{_runningOutputFolder}");
AppendLog(
$"[start] {_cliExecutable} " +
AICameraCliRunner.JoinArguments(arguments));
_cliRunner = new AICameraCliRunner();
_cliRunner.Start(new AICameraCliRunner.ProcessRequest(
_cliExecutable.Trim(),
Path.GetFullPath(_cwAiRoot),
arguments,
cancellationFile));
// This is deliberately a one-shot escape hatch. Normal Seed
// changes should return to the fast, reusable candidate pool.
_forceFreshCandidatePool = false;
EditorApplication.update -= OnEditorUpdate;
EditorApplication.update += OnEditorUpdate;
}
catch (Exception exception)
{
CleanupProcess();
CompleteGenerationOperation(false);
ReportException("카메라 생성을 시작하지 못했습니다.", exception);
}
}
private void StartPreparationCacheBuild()
{
if (IsProcessRunning)
{
SetStatus("이미 카메라 생성 작업이 실행 중입니다.");
return;
}
try
{
ValidateGenerationSettings(
ProcessCompletionAction.BuildPreparationCache);
BeginGenerationOperation(
"고품질 생성 준비 캐시 구성",
2);
var repositoryRoot = Path.GetFullPath(_cwAiRoot);
var arguments = BuildPreparationCacheArguments(
Path.Combine(repositoryRoot, "DatasetExports"),
Path.Combine(repositoryRoot, "reports", "training_index.json"))
.ToList();
_runningJobId = "unity-cache-" + Guid.NewGuid().ToString("N");
var cancellationFile = CreateCancellationFilePath(_runningJobId);
AppendOption(arguments, "--event-format", "jsonl");
AppendOption(arguments, "--job-id", _runningJobId);
AppendOption(arguments, "--cancel-file", cancellationFile);
_completionAction = ProcessCompletionAction.BuildPreparationCache;
_runningOutputFolder = Path.Combine(
ResolveProjectCacheRoot(),
"hybrid_preparation");
_runningSeed = _seed;
_log = string.Empty;
SetStatus("고품질 생성 준비 캐시를 만드는 중...");
AppendLog(
$"[cache-build] {_cliExecutable} " +
AICameraCliRunner.JoinArguments(arguments));
_cliRunner = new AICameraCliRunner();
_cliRunner.Start(new AICameraCliRunner.ProcessRequest(
_cliExecutable.Trim(),
repositoryRoot,
arguments,
cancellationFile));
EditorApplication.update -= OnEditorUpdate;
EditorApplication.update += OnEditorUpdate;
}
catch (Exception exception)
{
CleanupProcess();
CompleteGenerationOperation(false);
ReportException("준비 캐시 생성을 시작하지 못했습니다.", exception);
}
}
private void ExportGenerateAndImport()
{
if (IsProcessRunning)
{
SetStatus("이미 카메라 생성 작업이 실행 중입니다.");
return;
}
try
{
if (!IsSimpleSourceDirectorReady())
{
throw new InvalidOperationException(
"모션과 음원이 들어 있는 원본 Timeline을 선택하세요.");
}
BeginGenerationOperation(
"Timeline 모션·음원 추출",
1);
SetStatus("선택한 Timeline에서 모션과 음원을 추출하는 중...");
var selectedSourceDirector = _sourceDirector;
EditorUtility.DisplayProgressBar(
"AI 카메라 생성",
"Timeline 모션·음원을 추출하는 중...",
0.02f);
string inputDirectory;
try
{
inputDirectory = TimelineCameraDatasetExporter
.ExportGenerationInput60FpsForCli(
selectedSourceDirector);
}
finally
{
EditorUtility.ClearProgressBar();
}
ConfigureExportedDataset(inputDirectory, string.Empty);
_sourceDirector = selectedSourceDirector;
if (_datasetSongs.Length != 1)
{
throw new InvalidDataException(
"생성 입력은 정확히 하나의 Timeline 레코드여야 합니다.");
}
_datasetSongIndex = 0;
_songId = _datasetSongs[0].songName.Trim();
AppendLog($"[generation-input] {inputDirectory}");
StartGeneration(ProcessCompletionAction.ImportFull);
}
catch (Exception exception)
{
EditorUtility.ClearProgressBar();
CompleteGenerationOperation(false);
ReportException("새 카메라 생성을 시작하지 못했습니다.", exception);
}
}
private void OnEditorUpdate()
{
DrainProcessMessages();
DetectGeneratedOutputFiles();
if (this != null)
{
Repaint();
}
if (_cliRunner == null)
{
return;
}
int exitCode;
try
{
if (!_cliRunner.TryGetExitCode(out exitCode))
{
return;
}
}
catch (Exception exception)
{
ReportException(
"CWCameraWorker 상태를 확인하지 못했습니다.",
exception);
CleanupProcess();
CompleteGenerationOperation(false);
return;
}
DrainProcessMessages();
var completedAction = _completionAction;
var completedFolder = _runningOutputFolder;
var completedShotIndex = _runningShotIndex;
var completedSeed = _runningSeed;
var wasCancelled = _cliRunner.CancellationRequested;
CleanupProcess();
if (wasCancelled)
{
CompleteGenerationOperation(false);
SetStatus("카메라 생성이 취소되었습니다.");
AppendLog("[cancelled] CWCameraWorker stopped by user request");
return;
}
if (exitCode != 0)
{
CompleteGenerationOperation(false);
SetStatus(
$"생성 실패 — CWCameraWorker 종료 코드 {exitCode}. 로그를 확인하세요.");
AppendLog($"[failed] exit code {exitCode}");
return;
}
if (completedAction == ProcessCompletionAction.BuildPreparationCache)
{
// Cache-build time is not a camera-generation observation and
// must not distort the generation ETA history.
CompleteGenerationOperation(false);
SetStatus("고품질 생성 준비 캐시가 최신 상태입니다.");
AppendLog($"[cache-ready] {completedFolder}");
return;
}
try
{
UpdateGenerationProgress("생성 결과 검증 및 Timeline 적용", 4);
ValidateGeneratedFolder(completedFolder);
AppendLog($"[complete] {completedFolder}");
var importSucceeded = true;
if (completedAction == ProcessCompletionAction.ImportFull)
{
importSucceeded = ImportFullResult(completedFolder);
}
else if (completedAction ==
ProcessCompletionAction.ReplaceSelectedShot)
{
importSucceeded = ImportSelectedShot(
completedFolder,
completedShotIndex);
}
if (!importSucceeded)
{
CompleteGenerationOperation(false);
return;
}
_seed = completedSeed == int.MaxValue
? 1
: completedSeed + 1;
_lastAppliedSeed = completedSeed;
_hasLastAppliedSeed = true;
_lastAppliedSeedWasSelectedShot =
completedAction ==
ProcessCompletionAction.ReplaceSelectedShot;
_lastAppliedSeedShotIndex =
_lastAppliedSeedWasSelectedShot
? completedShotIndex
: -1;
CompleteGenerationOperation(true);
}
catch (Exception exception)
{
CompleteGenerationOperation(false);
ReportException(
"생성은 완료됐지만 Unity Timeline 반영에 실패했습니다. " +
"생성 결과 폴더는 유지됩니다.",
exception);
}
}
private void DrainProcessMessages()
{
if (_cliRunner == null)
{
return;
}
while (_cliRunner.TryDequeueMessage(out var processMessage))
{
if (processMessage.IsEvent)
{
ApplyCliEvent(processMessage.Event);
continue;
}
UpdateProgressFromProcessMessage(processMessage.Text);
AppendLog(
processMessage.IsError
? "[stderr] " + processMessage.Text
: processMessage.Text);
}
}
private void ApplyCliEvent(AICameraCliRunner.CliEvent cliEvent)
{
if (!string.IsNullOrWhiteSpace(cliEvent.ProtocolVersion) &&
!string.Equals(
cliEvent.ProtocolVersion,
AICameraCliRunner.EventProtocolVersion,
StringComparison.Ordinal))
{
AppendLog(
$"[worker protocol] expected " +
$"{AICameraCliRunner.EventProtocolVersion}, received " +
cliEvent.ProtocolVersion);
}
if (!string.IsNullOrWhiteSpace(cliEvent.JobId) &&
!string.Equals(
cliEvent.JobId,
_runningJobId,
StringComparison.Ordinal))
{
AppendLog(
"[worker event ignored] unexpected jobId " +
cliEvent.JobId);
return;
}
if (cliEvent.EtaSeconds >= 0d)
{
_workerEtaSeconds = cliEvent.EtaSeconds;
}
var eventType = cliEvent.EventType.ToLowerInvariant();
var display = !string.IsNullOrWhiteSpace(cliEvent.Message)
? cliEvent.Message
: !string.IsNullOrWhiteSpace(cliEvent.Phase)
? cliEvent.Phase
: cliEvent.EventType;
if (eventType == "progress" || eventType == "stage")
{
UpdateGenerationProgress(
display,
CliStageIndex(cliEvent.Phase),
cliEvent.Progress);
}
else if (eventType == "result" || eventType == "complete")
{
UpdateGenerationProgress("생성 결과 저장 및 검증", 3, 1f);
}
else if (eventType == "cancelled")
{
SetStatus("CWCameraWorker가 취소 요청을 처리하는 중입니다.");
}
else if (eventType == "error")
{
AppendLog(
"[worker error] " +
(!string.IsNullOrWhiteSpace(cliEvent.Error)
? cliEvent.Error
: display));
}
if (!string.IsNullOrWhiteSpace(cliEvent.Message))
{
UpdateProgressFromProcessMessage(cliEvent.Message);
}
AppendLog($"[worker:{cliEvent.EventType}] {display}");
}
private static int CliStageIndex(string phase)
{
var normalized = (phase ?? string.Empty).Trim().ToLowerInvariant();
if (normalized.Contains("candidate") ||
normalized.Contains("retarget") ||
normalized.Contains("generate") ||
normalized.Contains("output") ||
normalized.Contains("publish") ||
normalized.Contains("result"))
{
return 3;
}
return 2;
}
private void UpdateProgressFromProcessMessage(string message)
{
if (!_generationOperationActive || string.IsNullOrWhiteSpace(message))
{
return;
}
var candidateObservation = ParseCacheObservation(
message,
"candidate");
if (candidateObservation != CacheObservation.Unknown)
{
if (candidateObservation != _candidateCacheObservation ||
_candidateCacheObservedAtElapsedSeconds < 0d)
{
_candidateCacheObservedAtElapsedSeconds = Math.Max(
0d,
EditorApplication.timeSinceStartup -
_generationStartedAt);
}
_candidateCacheObservation = candidateObservation;
UpdateGenerationProgress(
candidateObservation == CacheObservation.Hit
? _runningSelectedShotOneShot
? "선택 카메라 안전 후보 캐시 사용"
: "안전 후보 캐시 사용 · Seed로 카메라 조합 선택"
: _runningSelectedShotOneShot
? "선택 카메라 안전 후보 계산 중"
: "새 안전 후보 풀 계산 중",
3);
return;
}
var preparationObservation = ParseCacheObservation(
message,
"preparation");
if (preparationObservation != CacheObservation.Unknown)
{
_preparationCacheObservation = preparationObservation;
UpdateGenerationProgress(
preparationObservation == CacheObservation.Hit
? "준비 캐시 사용 · 후보 캐시 확인"
: "참조 카메라 분석 및 생성 모델 준비 (첫 실행은 오래 걸릴 수 있음)",
2);
}
else if (message.IndexOf(
"Hybrid camera saved to:",
StringComparison.OrdinalIgnoreCase) >= 0)
{
UpdateGenerationProgress("생성 결과 저장 및 검증", 3);
}
}
private void DetectGeneratedOutputFiles()
{
if (!_generationOperationActive ||
_cliRunner == null ||
_runningOutputFilesDetected ||
string.IsNullOrWhiteSpace(_runningOutputFolder))
{
return;
}
if (File.Exists(Path.Combine(
_runningOutputFolder,
"generated_camera_world.f32")) &&
File.Exists(Path.Combine(
_runningOutputFolder,
"shots.json")))
{
_runningOutputFilesDetected = true;
UpdateGenerationProgress("생성 결과 저장 및 검증", 3);
}
}
private void CleanupProcess()
{
if (_cliRunner != null)
{
try
{
if (_cliRunner.TryGetExitCode(out _))
{
_cliRunner.CompleteAndDispose();
}
else
{
_cliRunner.Dispose();
}
}
catch (InvalidOperationException)
{
_cliRunner.Dispose();
}
_cliRunner = null;
}
_completionAction = ProcessCompletionAction.None;
_runningOutputFolder = string.Empty;
_runningShotIndex = -1;
_runningSeed = 0;
_runningJobId = string.Empty;
_workerEtaSeconds = -1d;
EditorApplication.update -= OnEditorUpdate;
}
private void CancelGeneration()
{
if (_cliRunner == null || !_cliRunner.IsRunning)
{
SetStatus("실행 중인 생성 작업이 없습니다.");
return;
}
try
{
if (_cliRunner.RequestCancellation())
{
SetStatus("카메라 생성 취소를 요청했습니다. 현재 단계를 정리하는 중입니다.");
AppendLog("[cancel-requested] CWCameraWorker");
}
}
catch (Exception exception)
{
ReportException("카메라 생성 취소를 요청하지 못했습니다.", exception);
}
}
private void ValidateGenerationSettings(
ProcessCompletionAction completionAction)
{
TryAutoRepairGenerationLibraryRoot();
var selectedShotRegeneration =
completionAction ==
ProcessCompletionAction.ReplaceSelectedShot;
if (string.IsNullOrWhiteSpace(_cwAiRoot) ||
!Directory.Exists(_cwAiRoot))
{
throw new DirectoryNotFoundException(
$"CW-AI 루트를 찾을 수 없습니다: {_cwAiRoot}");
}
var datasetRoot = Path.Combine(
Path.GetFullPath(_cwAiRoot),
"DatasetExports");
if (!Directory.Exists(datasetRoot))
{
throw new DirectoryNotFoundException(
$"DatasetExports를 찾을 수 없습니다: {datasetRoot}");
}
var trainingIndex = Path.Combine(
Path.GetFullPath(_cwAiRoot),
"reports",
"training_index.json");
if (!File.Exists(trainingIndex))
{
throw new FileNotFoundException(
"학습 참조 인덱스를 찾지 못했습니다. " +
"고급·진단에서 참조 데이터 루트를 확인하세요.\n" +
trainingIndex,
trainingIndex);
}
var cutRankerModel = Path.Combine(
Path.GetFullPath(_cwAiRoot),
"models",
"cut_ranker_v2.json");
if (!File.Exists(cutRankerModel))
{
throw new FileNotFoundException(
"컷 타이밍 모델을 찾지 못했습니다. " +
"고급·진단에서 참조 데이터 루트를 확인하세요.\n" +
cutRankerModel,
cutRankerModel);
}
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
!File.Exists(_cliExecutable))
{
_cliExecutable = ResolveCliExecutable();
}
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
!File.Exists(_cliExecutable))
{
throw new FileNotFoundException(
"CWCameraWorker.exe를 찾을 수 없습니다. 고급 설정에서 실행 파일을 지정하세요.",
_cliExecutable);
}
ValidateCommonGenerationControls(selectedShotRegeneration);
}
private void ValidateCommonGenerationControls(
bool selectedShotRegeneration)
{
if (string.IsNullOrWhiteSpace(_songId))
{
throw new InvalidOperationException(
"생성 입력 레코드를 찾지 못했습니다. Timeline을 다시 추출하세요.");
}
if (string.IsNullOrWhiteSpace(_datasetId))
{
throw new InvalidOperationException(
"생성 입력 묶음을 찾지 못했습니다. Timeline을 다시 추출하세요.");
}
if (!selectedShotRegeneration)
{
return;
}
if (_shotNames.Length == 0 ||
_selectedShotIndex < 0 ||
_selectedShotIndex >= _shotNames.Length)
{
throw new InvalidOperationException(
"재생성할 AI 카메라를 먼저 선택하세요.");
}
if (_distanceMeters < 0f ||
float.IsNaN(_distanceMeters) ||
float.IsInfinity(_distanceMeters))
{
throw new InvalidOperationException(
"직접 거리는 0 이상의 유한한 값이어야 합니다.");
}
if (_motionIntensity < 0f ||
float.IsNaN(_motionIntensity) ||
float.IsInfinity(_motionIntensity))
{
throw new InvalidOperationException(
"무빙 강도는 0 이상의 유한한 값이어야 합니다.");
}
if (_bodyFollowStrength < 0f ||
_bodyFollowStrength > 1f ||
float.IsNaN(_bodyFollowStrength) ||
float.IsInfinity(_bodyFollowStrength))
{
throw new InvalidOperationException(
"몸 추종 강도는 0에서 1 사이여야 합니다.");
}
}
private static GeneratedCameraMetadata ValidateGeneratedFolder(
string folder)
{
if (string.IsNullOrWhiteSpace(folder))
{
throw new ArgumentException(
"생성 결과 폴더를 지정하세요.",
nameof(folder));
}
var fullFolder = Path.GetFullPath(folder);
if (!Directory.Exists(fullFolder))
{
throw new DirectoryNotFoundException(
$"생성 결과 폴더를 찾을 수 없습니다: {fullFolder}");
}
var metadataPath = Path.Combine(fullFolder, "metadata.json");
if (!File.Exists(metadataPath))
{
throw new FileNotFoundException(
"metadata.json을 찾을 수 없습니다.",
metadataPath);
}
var metadata = JsonUtility.FromJson<GeneratedCameraMetadata>(
File.ReadAllText(metadataPath));
if (metadata == null ||
metadata.frameCount <= 0 ||
metadata.sampleRate <= 0)
{
throw new InvalidDataException(
"metadata.json의 frameCount 또는 sampleRate가 올바르지 않습니다.");
}
ValidateGeneratedFile(
fullFolder,
metadata.worldCameraFile,
"world camera");
ValidateGeneratedFile(fullFolder, metadata.timeFile, "time");
ValidateGeneratedFile(fullFolder, metadata.shotsFile, "shots");
return metadata;
}
private string ValidateSelectedShotBaseGeneration(
string folder)
{
try
{
var metadata = ValidateGeneratedFolder(folder);
var shotsPath = Path.Combine(
Path.GetFullPath(folder),
metadata.shotsFile);
var shots = JsonUtility.FromJson<HybridShotFile>(
File.ReadAllText(shotsPath));
ValidateHybridBaseGenerationContract(
metadata,
shots,
DefaultTargetAspectRatio);
if (!string.Equals(
metadata.datasetId,
(_datasetId ?? string.Empty).Trim(),
StringComparison.Ordinal))
{
throw new InvalidDataException(
"기준 결과 datasetId가 현재 Timeline 입력과 다릅니다.");
}
var requestedSong = (_songId ?? string.Empty).Trim();
if (IsSongId(requestedSong) &&
!string.Equals(
metadata.songId,
requestedSong,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
"기준 결과 songId가 현재 Timeline 입력과 다릅니다.");
}
if (_selectedShotIndex < 0 ||
_selectedShotIndex >= metadata.shotCount)
{
throw new InvalidDataException(
"선택 카메라 인덱스가 기준 결과의 컷 범위를 벗어났습니다.");
}
var sourceDuration = GetSourceDurationSeconds();
var expectedFrameCount = sourceDuration > 0d
? (int)Math.Round(sourceDuration * metadata.sampleRate)
: metadata.frameCount;
if (expectedFrameCount != metadata.frameCount)
{
throw new InvalidDataException(
"기준 결과 frameCount가 현재 Timeline 길이와 다릅니다.");
}
AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(folder);
return Path.GetFullPath(folder);
}
catch (Exception exception)
{
throw new InvalidOperationException(
"현재 결과를 선택 카메라 고품질 재생성의 기준으로 " +
$"사용할 수 없습니다: {exception.Message} " +
"먼저 고품질 방식으로 전체 카메라 자율 생성을 실행하세요.",
exception);
}
}
internal static void ValidateHybridBaseGenerationContractForTests(
string metadataJson,
string shotsJson)
{
var metadata = JsonUtility.FromJson<GeneratedCameraMetadata>(
metadataJson);
var shots = JsonUtility.FromJson<HybridShotFile>(shotsJson);
ValidateHybridBaseGenerationContract(
metadata,
shots,
DefaultTargetAspectRatio);
}
private static void ValidateHybridBaseGenerationContract(
GeneratedCameraMetadata metadata,
HybridShotFile shotFile,
double expectedAspectRatio)
{
if (metadata == null)
{
throw new InvalidDataException(
"기준 결과 metadata.json이 올바른 JSON 객체가 아닙니다.");
}
if (string.IsNullOrWhiteSpace(metadata.schemaVersion) ||
!metadata.schemaVersion.StartsWith(
"3.",
StringComparison.Ordinal))
{
throw new InvalidDataException(
"기준 결과 metadata schema 3.x가 필요합니다.");
}
if (string.IsNullOrWhiteSpace(metadata.generationMode) ||
metadata.generationMode.IndexOf(
"hybrid",
StringComparison.OrdinalIgnoreCase) < 0)
{
throw new InvalidDataException(
"기준 결과는 Hybrid composite가 아닙니다 " +
"(generationMode 확인 필요). 다른 생성 방식의 결과는 사용할 수 없습니다.");
}
if (!string.Equals(
metadata.plannerMode,
"hierarchical",
StringComparison.Ordinal))
{
throw new InvalidDataException(
"기준 결과 plannerMode는 hierarchical이어야 합니다.");
}
if (double.IsNaN(metadata.targetAspectRatio) ||
double.IsInfinity(metadata.targetAspectRatio) ||
metadata.targetAspectRatio <= 0d ||
Math.Abs(metadata.targetAspectRatio - expectedAspectRatio) >
1e-9d)
{
throw new InvalidDataException(
$"기준 결과 targetAspectRatio는 {expectedAspectRatio:R}이어야 합니다.");
}
if (metadata.frameCount <= 0 ||
metadata.shotCount <= 0 ||
string.IsNullOrWhiteSpace(metadata.songId) ||
string.IsNullOrWhiteSpace(metadata.datasetId))
{
throw new InvalidDataException(
"기준 결과의 songId, datasetId, frameCount 또는 shotCount가 " +
"누락되었습니다.");
}
if (!IsDirectGeneratedPayloadName(metadata.worldCameraFile) ||
!IsDirectGeneratedPayloadName(metadata.timeFile) ||
!IsDirectGeneratedPayloadName(metadata.shotsFile))
{
throw new InvalidDataException(
"기준 결과 payload 파일은 결과 폴더의 직접 구성원이어야 합니다.");
}
if (metadata.outputSha256 == null ||
string.IsNullOrWhiteSpace(metadata.outputSha256.worldCamera) ||
string.IsNullOrWhiteSpace(metadata.outputSha256.time) ||
string.IsNullOrWhiteSpace(metadata.outputSha256.shots))
{
throw new InvalidDataException(
"기준 결과 outputSha256 무결성 정보가 필요합니다.");
}
if (shotFile == null ||
!string.Equals(
shotFile.schemaVersion,
"1.2",
StringComparison.Ordinal) ||
shotFile.shots == null)
{
throw new InvalidDataException(
"기준 결과 shots.json schema 1.2가 필요합니다.");
}
if (shotFile.shots.Length != metadata.shotCount)
{
throw new InvalidDataException(
"기준 결과 shotCount와 shots.json 행 수가 일치하지 않습니다.");
}
var previousEnd = 0;
for (var index = 0; index < shotFile.shots.Length; index++)
{
var shot = shotFile.shots[index];
if (shot == null ||
shot.index != index ||
shot.startFrame != previousEnd ||
shot.startFrame >= shot.endFrameExclusive ||
shot.endFrameExclusive > metadata.frameCount)
{
throw new InvalidDataException(
$"기준 결과 shots.json의 {index}번 컷 경계가 올바르지 않습니다.");
}
previousEnd = shot.endFrameExclusive;
}
if (previousEnd != metadata.frameCount)
{
throw new InvalidDataException(
"기준 결과 컷이 전체 frameCount를 연속해서 덮지 않습니다.");
}
}
private static bool IsDirectGeneratedPayloadName(string value)
{
return !string.IsNullOrWhiteSpace(value) &&
!Path.IsPathRooted(value) &&
string.Equals(
value,
Path.GetFileName(value),
StringComparison.Ordinal);
}
private static void ValidateGeneratedFile(
string folder,
string relativePath,
string label)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new InvalidDataException(
$"metadata.json에 {label} 파일명이 없습니다.");
}
var path = Path.Combine(folder, relativePath);
if (!File.Exists(path))
{
throw new FileNotFoundException(
$"{label} 파일을 찾을 수 없습니다.",
path);
}
}
private IReadOnlyList<string> BuildGeneratorArguments(
string datasetRoot,
string trainingIndex,
string outputFolder,
int seed,
GenerationControlScope controls,
int onlyShotIndex,
string baseGenerationFolder)
{
var arguments = new List<string> { "generate" };
AppendCurrentInputSelector(arguments);
AppendOption(arguments, "--dataset-id", _datasetId.Trim());
AppendOption(arguments, "--output", outputFolder);
AppendOption(
arguments,
"--seed",
seed.ToString(CultureInfo.InvariantCulture));
AppendOption(arguments, "--data", datasetRoot);
var generationInputRoot = string.IsNullOrWhiteSpace(
_generationInputRoot)
? string.Empty
: Path.GetFullPath(_generationInputRoot);
if (!string.IsNullOrWhiteSpace(generationInputRoot) &&
!string.Equals(
generationInputRoot.TrimEnd('\\', '/'),
Path.GetFullPath(datasetRoot).TrimEnd('\\', '/'),
StringComparison.OrdinalIgnoreCase))
{
AppendArgument(arguments, generationInputRoot);
}
AppendOption(arguments, "--training-index", trainingIndex);
AppendOption(arguments, "--library-split", "all");
AppendOption(arguments, "--planner", "hierarchical");
AppendOption(arguments, "--cut-planner", "data_driven");
AppendOption(arguments, "--cut-density", ToCliValue(_cutRhythm));
AppendOption(
arguments,
"--cut-ranker-model",
Path.Combine(
Path.GetFullPath(_cwAiRoot),
"models",
"cut_ranker_v2.json"));
var compositionValue = controls.Composition;
AppendOption(
arguments,
"--horizontal-framing",
HybridHorizontalFraming(compositionValue));
AppendOption(
arguments,
"--aspect-ratio",
DefaultTargetAspectRatio.ToString(
"R",
CultureInfo.InvariantCulture));
AppendOption(arguments, "--shot-size", controls.ShotSize);
AppendOption(arguments, "--motion", controls.Motion);
AppendOption(
arguments,
"--composition",
compositionValue);
AppendOption(
arguments,
"--distance-meters",
controls.DistanceMeters.ToString(
"R",
CultureInfo.InvariantCulture));
AppendOption(
arguments,
"--motion-intensity",
controls.MotionIntensity.ToString(
"R",
CultureInfo.InvariantCulture));
AppendOption(
arguments,
"--body-follow-strength",
controls.BodyFollowStrength.ToString(
"R",
CultureInfo.InvariantCulture));
if (!string.IsNullOrWhiteSpace(controls.DirectivesPath))
{
AppendOption(
arguments,
"--directives-json",
Path.GetFullPath(controls.DirectivesPath));
}
AppendSelectedShotOneShotArguments(
arguments,
onlyShotIndex,
baseGenerationFolder);
AppendOption(
arguments,
"--cache-dir",
Path.Combine(
ResolveProjectCacheRoot(),
"camera_director"));
AppendOption(
arguments,
"--preparation-cache-root",
Path.Combine(
ResolveProjectCacheRoot(),
"hybrid_preparation"));
AppendOption(arguments, "--preparation-cache-mode", "auto");
AppendOption(
arguments,
"--candidate-cache-root",
Path.Combine(
ResolveProjectCacheRoot(),
"hybrid_candidates"));
AppendOption(
arguments,
"--candidate-cache-mode",
CandidateCacheMode(_forceFreshCandidatePool));
AppendOption(
arguments,
"--preparation-model-seed",
DefaultPreparationModelSeed.ToString(
CultureInfo.InvariantCulture));
return arguments;
}
internal static string BuildSelectedShotOneShotArguments(
int onlyShotIndex,
string baseGenerationFolder)
{
var arguments = new List<string>();
AppendSelectedShotOneShotArguments(
arguments,
onlyShotIndex,
baseGenerationFolder);
return AICameraCliRunner.JoinArguments(arguments);
}
private static void AppendSelectedShotOneShotArguments(
ICollection<string> arguments,
int onlyShotIndex,
string baseGenerationFolder)
{
if (onlyShotIndex < 0)
{
return;
}
if (string.IsNullOrWhiteSpace(baseGenerationFolder))
{
throw new ArgumentException(
"A base generation folder is required for one-shot regeneration.",
nameof(baseGenerationFolder));
}
AppendOption(
arguments,
"--only-shot-index",
onlyShotIndex.ToString(CultureInfo.InvariantCulture));
AppendOption(
arguments,
"--base-generation",
Path.GetFullPath(baseGenerationFolder));
}
private IReadOnlyList<string> BuildPreparationCacheArguments(
string datasetRoot,
string trainingIndex)
{
var arguments = new List<string> { "prepare-cache" };
AppendCurrentInputSelector(arguments);
AppendOption(arguments, "--dataset-id", _datasetId.Trim());
AppendOption(arguments, "--data", datasetRoot);
var generationInputRoot = string.IsNullOrWhiteSpace(
_generationInputRoot)
? string.Empty
: Path.GetFullPath(_generationInputRoot);
if (!string.IsNullOrWhiteSpace(generationInputRoot) &&
!string.Equals(
generationInputRoot.TrimEnd('\\', '/'),
Path.GetFullPath(datasetRoot).TrimEnd('\\', '/'),
StringComparison.OrdinalIgnoreCase))
{
AppendArgument(arguments, generationInputRoot);
}
AppendOption(arguments, "--training-index", trainingIndex);
AppendOption(arguments, "--library-split", "all");
AppendOption(
arguments,
"--preparation-model-seed",
DefaultPreparationModelSeed.ToString(
CultureInfo.InvariantCulture));
AppendOption(arguments, "--beam-width", "6");
AppendOption(
arguments,
"--cache-dir",
Path.Combine(
ResolveProjectCacheRoot(),
"camera_director"));
AppendOption(
arguments,
"--output-root",
Path.Combine(
ResolveProjectCacheRoot(),
"hybrid_preparation"));
return arguments;
}
private static string ToCliValue(Enum value)
{
var name = value.ToString();
var builder = new StringBuilder(name.Length + 4);
for (var index = 0; index < name.Length; index++)
{
var character = name[index];
if (index > 0 && char.IsUpper(character))
{
builder.Append('_');
}
builder.Append(char.ToLowerInvariant(character));
}
return builder.ToString();
}
internal static string CutDensityCliValueForTests(int value)
{
if (!Enum.IsDefined(typeof(CutRhythmPreference), value))
{
throw new ArgumentOutOfRangeException(nameof(value));
}
return ToCliValue((CutRhythmPreference)value);
}
internal static string CandidateCacheMode(
bool forceFreshCandidatePool)
{
return forceFreshCandidatePool ? "off" : "auto";
}
internal static GenerationControlScope CreateGenerationControlScope(
bool selectedShotRegeneration,
int selectedShotIndex,
string outputFolder,
string shotSize,
string motion,
string composition,
float distanceMeters,
float motionIntensity,
float bodyFollowStrength)
{
// Full generation is intentionally autonomous. Serialized values
// from a previous per-shot edit must never leak into a new result.
if (!selectedShotRegeneration)
{
return new GenerationControlScope(
"auto",
"auto",
"auto",
0f,
AutonomousMotionIntensity,
AutonomousBodyFollowStrength,
string.Empty,
false);
}
if (selectedShotIndex < 0)
{
throw new ArgumentOutOfRangeException(
nameof(selectedShotIndex),
"Selected-shot regeneration requires a valid shot index.");
}
if (string.IsNullOrWhiteSpace(outputFolder))
{
throw new ArgumentException(
"Selected-shot regeneration requires an output folder.",
nameof(outputFolder));
}
var fullOutputFolder = Path.GetFullPath(outputFolder);
Directory.CreateDirectory(fullOutputFolder);
var directivesPath = Path.Combine(
fullOutputFolder,
SelectedShotDirectivesFileName);
var document = new SelectedShotDirectiveDocument
{
shots = new[]
{
new SelectedShotDirective
{
index = selectedShotIndex,
shotSize = shotSize,
motion = motion,
composition = composition,
distanceMeters = distanceMeters,
motionIntensity = motionIntensity,
bodyFollowStrength = bodyFollowStrength
}
}
};
File.WriteAllText(
directivesPath,
JsonUtility.ToJson(document, true),
new UTF8Encoding(false));
// The global controls remain autonomous; only the selected row in
// the directive document receives the editor overrides.
return new GenerationControlScope(
"auto",
"auto",
"auto",
0f,
AutonomousMotionIntensity,
AutonomousBodyFollowStrength,
directivesPath,
true);
}
internal static string HybridHorizontalFraming(string composition)
{
var normalized = (composition ?? string.Empty)
.Trim()
.ToLowerInvariant()
.Replace('-', '_');
switch (normalized)
{
case "auto":
return "balanced";
case "center":
case "centered":
return "centered";
case "left_third":
return "left_third";
case "right_third":
return "right_third";
default:
throw new ArgumentException(
$"Unsupported composition: {composition}",
nameof(composition));
}
}
internal static string PythonSongSelectorOption(string selector)
{
return IsSongId(selector)
? "--song-id"
: "--song";
}
internal static bool IsSongId(string selector)
{
var normalized = (selector ?? string.Empty).Trim();
return normalized.Length == 16 && normalized.All(Uri.IsHexDigit);
}
private void AppendCurrentInputSelector(ICollection<string> arguments)
{
var normalized = (_songId ?? string.Empty).Trim();
var isGenerationInput = !string.IsNullOrWhiteSpace(_datasetId) &&
_datasetId.Trim().StartsWith(
"GenerationInput_",
StringComparison.OrdinalIgnoreCase);
AppendOption(
arguments,
isGenerationInput
? "--song"
: PythonSongSelectorOption(normalized),
normalized);
}
private string CreateUniqueOutputFolderPath()
{
var parent = Path.Combine(
Directory.GetParent(Application.dataPath)?.FullName
?? Environment.CurrentDirectory,
"GeneratedCameraOutputs");
Directory.CreateDirectory(parent);
var baseName =
$"camera_{DateTime.Now:yyyyMMdd_HHmmss}_seed_{_seed}";
var candidate = Path.Combine(parent, baseName);
var suffix = 1;
while (Directory.Exists(candidate) || File.Exists(candidate))
{
candidate = Path.Combine(parent, $"{baseName}_{suffix:D2}");
suffix++;
}
return candidate;
}
private void SaveActiveSceneCopy()
{
RunEditorAction(
"AI Camera Final 씬 복사본을 저장하는 중...",
delegate
{
var scene = SceneManager.GetActiveScene();
if (!scene.IsValid() || !scene.isLoaded)
{
throw new InvalidOperationException(
"활성 씬이 올바르게 로드되지 않았습니다.");
}
if (string.IsNullOrWhiteSpace(scene.path))
{
throw new InvalidOperationException(
"먼저 현재 씬을 프로젝트에 저장하세요.");
}
ValidateOrAutoSelectSourceDirector();
var sourceDirector = _sourceDirector ??
Resources.FindObjectsOfTypeAll<PlayableDirector>()
.SingleOrDefault(value =>
value.gameObject.scene == scene &&
value.name ==
AICameraTimelinePreviewImporter.OriginalDirectorName &&
value.playableAsset is TimelineAsset)
?? throw new InvalidOperationException(
"완성본을 만들 원본 PlayableDirector를 선택하세요.");
var sourcePreviewDirector =
Resources.FindObjectsOfTypeAll<PlayableDirector>()
.Where(value =>
value.gameObject.scene == scene &&
AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(value))
.SingleOrDefault(value =>
value.name ==
AICameraTimelinePreviewImporter
.GetScopedPreviewDirectorName(sourceDirector)) ??
Resources.FindObjectsOfTypeAll<PlayableDirector>()
.Where(value =>
value.gameObject.scene == scene &&
AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(value))
.SingleOrDefault();
if (sourcePreviewDirector == null)
{
throw new InvalidOperationException(
"선택한 원본 Director의 AI camera preview가 없습니다.");
}
var directory = Path.GetDirectoryName(scene.path);
var uniquePath = GetNextFinalScenePath(scene.path);
var finalAssetFolder =
$"{directory?.Replace('\\', '/')}/AI_CameraFinal/" +
Path.GetFileNameWithoutExtension(uniquePath);
var activeSceneBefore = SceneManager.GetActiveScene();
var sourcePathBefore = scene.path;
var sourceDirtyBefore = scene.isDirty;
var sourceTimelineBefore = sourceDirector.playableAsset;
var sourceTimeBefore = sourceDirector.time;
var previewTimelineBefore = sourcePreviewDirector.playableAsset;
var previewTimeBefore = sourcePreviewDirector.time;
Scene destinationScene = default;
var sceneAssetCreated = false;
try
{
if (!EditorSceneManager.SaveScene(scene, uniquePath, true))
{
throw new IOException(
$"씬 복사본을 저장하지 못했습니다: {uniquePath}");
}
sceneAssetCreated = true;
destinationScene = EditorSceneManager.OpenScene(
uniquePath,
OpenSceneMode.Additive);
var destinationDirector =
Resources.FindObjectsOfTypeAll<PlayableDirector>()
.SingleOrDefault(value =>
value.gameObject.scene == destinationScene &&
value.name == sourcePreviewDirector.name)
?? throw new InvalidOperationException(
"복사된 씬에서 AI preview Director를 찾지 못했습니다.");
var result = AICameraTimelinePreviewImporter.SaveFinalForEditor(
sourceDirector,
destinationDirector,
finalAssetFolder);
var disposablePreviewRoots =
Resources.FindObjectsOfTypeAll<GameObject>()
.Where(value =>
value != null &&
value.scene == destinationScene &&
value != result.CameraRoot &&
(value.name ==
AICameraTimelinePreviewImporter
.PreviewCameraName ||
value.name.StartsWith(
AICameraTimelinePreviewImporter
.PreviewCameraName + "__",
StringComparison.Ordinal)))
.ToArray();
foreach (var previewRoot in disposablePreviewRoots)
{
UnityEngine.Object.DestroyImmediate(previewRoot);
}
destinationDirector.name = "Timeline_AI_Final";
EditorUtility.SetDirty(destinationDirector);
EditorSceneManager.MarkSceneDirty(destinationScene);
if (!EditorSceneManager.SaveScene(destinationScene))
{
throw new IOException(
$"독립 Final 씬을 저장하지 못했습니다: {uniquePath}");
}
AssetDatabase.SaveAssets();
return string.Join(
Environment.NewLine,
$"독립 Final 씬 저장 완료: {uniquePath}",
$"Final assets: {result.AssetFolderPath}",
$"Timeline: {AssetDatabase.GetAssetPath(result.Timeline)}",
$"Animation clips: {result.AnimationClips.Count:N0}");
}
catch
{
if (destinationScene.IsValid() && destinationScene.isLoaded)
{
EditorSceneManager.CloseScene(destinationScene, true);
}
if (sceneAssetCreated)
{
AssetDatabase.DeleteAsset(uniquePath);
}
throw;
}
finally
{
if (destinationScene.IsValid() && destinationScene.isLoaded)
{
EditorSceneManager.CloseScene(destinationScene, true);
}
if (activeSceneBefore.IsValid() && activeSceneBefore.isLoaded &&
SceneManager.GetActiveScene() != activeSceneBefore)
{
SceneManager.SetActiveScene(activeSceneBefore);
}
if (scene.path != sourcePathBefore ||
sourceDirector.playableAsset != sourceTimelineBefore ||
sourcePreviewDirector.playableAsset != previewTimelineBefore ||
Math.Abs(sourceDirector.time - sourceTimeBefore) > double.Epsilon ||
Math.Abs(sourcePreviewDirector.time - previewTimeBefore) >
double.Epsilon ||
scene.isDirty != sourceDirtyBefore)
{
throw new InvalidOperationException(
"Final 저장 후 원본 scene/path/Timeline 상태가 달라졌습니다.");
}
}
});
}
internal static string GetNextFinalScenePath(string sourceScenePath)
{
var normalized = (sourceScenePath ?? string.Empty)
.Replace('\\', '/');
var directory = Path.GetDirectoryName(normalized)
?.Replace('\\', '/');
var sourceName = Path.GetFileNameWithoutExtension(normalized);
if (string.IsNullOrWhiteSpace(directory) ||
string.IsNullOrWhiteSpace(sourceName) ||
!normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
"Source scene must be an Assets-relative .unity path.",
nameof(sourceScenePath));
}
const string marker = "_AI_Camera_Final";
var markerIndex = sourceName.LastIndexOf(
marker,
StringComparison.Ordinal);
string finalBaseName;
var version = 0;
if (markerIndex >= 0)
{
finalBaseName = sourceName.Substring(0, markerIndex) + marker;
var suffix = sourceName.Substring(markerIndex + marker.Length);
if (suffix.StartsWith("_v", StringComparison.OrdinalIgnoreCase) &&
int.TryParse(
suffix.Substring(2),
NumberStyles.None,
CultureInfo.InvariantCulture,
out var parsedVersion))
{
version = Math.Max(1, parsedVersion + 1);
}
else
{
version = 2;
}
}
else
{
finalBaseName = sourceName + marker;
}
while (true)
{
var fileName = version > 0
? $"{finalBaseName}_v{version}.unity"
: $"{finalBaseName}.unity";
var candidate = $"{directory}/{fileName}";
if (AssetDatabase.LoadAssetAtPath<SceneAsset>(candidate) == null &&
!File.Exists(Path.GetFullPath(candidate)))
{
return candidate;
}
version = Math.Max(2, version + 1);
}
}
private void RefreshShotNames()
{
try
{
RestoreGeneratedFolderFromPreviewProvenance();
_shotNames =
GetScopedShotNames() ??
Array.Empty<string>();
if (_shotNames.Length == 0)
{
_selectedShotIndex = 0;
}
else
{
_selectedShotIndex = Mathf.Clamp(
_selectedShotIndex,
0,
_shotNames.Length - 1);
}
}
catch (Exception exception)
{
_shotNames = Array.Empty<string>();
AppendLog("[shot list] " + exception.Message);
}
}
private bool TrySyncSelectedShotFromEditor(out string cameraName)
{
cameraName = string.Empty;
if (_sourceDirector == null || _shotNames.Length == 0)
{
return false;
}
try
{
if (!AICameraTimelinePreviewImporter
.TryGetSelectedGeneratedShotForEditor(
_sourceDirector,
out var shotIndex,
out cameraName) ||
shotIndex < 0 ||
shotIndex >= _shotNames.Length)
{
cameraName = string.Empty;
return false;
}
_selectedShotIndex = shotIndex;
return true;
}
catch
{
// Timeline selection is optional. Keep the explicit popup as a
// stable fallback when no Timeline window/preview is available.
cameraName = string.Empty;
return false;
}
}
private bool ConfigureExportedDataset(
string datasetDirectory,
string preferredSong)
{
var datasetInfo = new DirectoryInfo(datasetDirectory);
_generationInputRoot = datasetInfo.Parent?.FullName
?? throw new InvalidOperationException(
"내보낸 데이터셋의 상위 폴더를 확인할 수 없습니다.");
_datasetId = datasetInfo.Name;
_songId = preferredSong ?? string.Empty;
return RefreshDatasetSongs(true);
}
private string[] GetScopedShotNames()
{
return _sourceDirector != null
? AICameraTimelinePreviewImporter.GetGeneratedShotNamesForEditor(
_sourceDirector)
: AICameraTimelinePreviewImporter.GetGeneratedShotNamesForEditor();
}
private string RemoveScopedPreview()
{
return _sourceDirector != null
? AICameraTimelinePreviewImporter.RemovePreviewForCli(_sourceDirector)
: AICameraTimelinePreviewImporter.RemovePreviewForCli();
}
private string CreateScopedPreview(string folder)
{
return _sourceDirector != null
? AICameraTimelinePreviewImporter.CreatePreviewForCli(
folder,
_curvePreset,
_sourceDirector,
_cwAiRoot)
: AICameraTimelinePreviewImporter.CreatePreviewForCli(
folder,
_curvePreset);
}
private string RegenerateScopedShot(string folder, int shotIndex)
{
return _sourceDirector != null
? AICameraTimelinePreviewImporter.RegenerateShotForCli(
folder,
shotIndex,
_curvePreset,
_sourceDirector,
_cwAiRoot)
: AICameraTimelinePreviewImporter.RegenerateShotForCli(
folder,
shotIndex,
_curvePreset);
}
private bool RefreshDatasetSongs(bool selectFirstWhenMissing)
{
_datasetSongNames = Array.Empty<string>();
_datasetSongs = Array.Empty<DatasetSongSummary>();
if (string.IsNullOrWhiteSpace(_generationInputRoot) ||
string.IsNullOrWhiteSpace(_datasetId))
{
_datasetSongIndex = 0;
return false;
}
var manifestPath = Path.Combine(
Path.GetFullPath(_generationInputRoot),
_datasetId.Trim(),
"dataset_manifest.json");
if (!File.Exists(manifestPath))
{
_datasetSongIndex = 0;
return false;
}
var manifest = JsonUtility.FromJson<DatasetManifestSummary>(
File.ReadAllText(manifestPath));
_datasetSongs = (manifest?.songs ?? Array.Empty<DatasetSongSummary>())
.Where(value => value != null &&
!string.IsNullOrWhiteSpace(value.songName))
.ToArray();
_datasetSongNames = _datasetSongs
.Select((value, index) =>
{
var duplicate = _datasetSongs.Count(other =>
string.Equals(
other.songName?.Trim(),
value.songName?.Trim(),
StringComparison.Ordinal)) > 1;
return duplicate
? $"{value.songName.Trim()} [{value.folderName ?? index.ToString()}]"
: value.songName.Trim();
})
.ToArray();
if (_datasetSongNames.Length == 0)
{
_datasetSongIndex = 0;
return false;
}
var currentSong = (_songId ?? string.Empty).Trim();
var matchingIndex = Array.FindIndex(
_datasetSongs,
value => string.Equals(
value.songName?.Trim(),
currentSong,
StringComparison.Ordinal));
if (matchingIndex < 0 && IsSongId(currentSong))
{
matchingIndex = FindSongIndexFromCatalog(currentSong);
}
if (matchingIndex >= 0)
{
_datasetSongIndex = matchingIndex;
// Preserve an explicit catalog ID. A popup selection intentionally
// switches back to the human-readable song-name selector.
if (!IsSongId(currentSong))
{
_songId = _datasetSongs[matchingIndex].songName.Trim();
}
TrySelectSourceDirectorForSong(
_datasetSongs[matchingIndex].songName,
false);
return true;
}
_datasetSongIndex = 0;
if (selectFirstWhenMissing)
{
_songId = _datasetSongs[0].songName.Trim();
TrySelectSourceDirectorForSong(_songId, true);
}
return false;
}
private int FindSongIndexFromCatalog(string songId)
{
var catalogPath = Path.Combine(
Path.GetFullPath(_generationInputRoot),
"dataset_catalog.json");
if (!File.Exists(catalogPath))
{
return -1;
}
try
{
var catalog = JsonUtility.FromJson<DatasetCatalogSummary>(
File.ReadAllText(catalogPath));
var entry = catalog?.songs?.FirstOrDefault(value =>
value != null &&
string.Equals(value.id, songId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(value.datasetId, _datasetId.Trim(),
StringComparison.Ordinal));
var folderName = string.IsNullOrWhiteSpace(entry?.folder)
? string.Empty
: entry.folder.Replace('\\', '/').Split('/').Last();
return Array.FindIndex(
_datasetSongs,
value => string.Equals(
value.folderName,
folderName,
StringComparison.OrdinalIgnoreCase));
}
catch (Exception exception)
{
AppendLog("[song catalog] " + exception.Message);
return -1;
}
}
private string GetSelectedSongName()
{
return _datasetSongs.Length > 0 &&
_datasetSongIndex >= 0 &&
_datasetSongIndex < _datasetSongs.Length
? _datasetSongs[_datasetSongIndex].songName
: (_songId ?? string.Empty).Trim();
}
private void ValidateOrAutoSelectSourceDirector()
{
var scene = SceneManager.GetActiveScene();
if (_sourceDirector != null &&
_sourceDirector.gameObject.scene == scene &&
_sourceDirector.playableAsset is TimelineAsset)
{
return;
}
_sourceDirector = null;
TrySelectSourceDirectorForSong(GetSelectedSongName(), false);
}
private void TrySelectSourceDirectorForSong(
string songName,
bool replaceExisting)
{
var scene = SceneManager.GetActiveScene();
if (!scene.IsValid() || !scene.isLoaded)
{
return;
}
if (!replaceExisting && _sourceDirector != null &&
_sourceDirector.gameObject.scene == scene &&
_sourceDirector.playableAsset is TimelineAsset)
{
return;
}
var normalizedSong = (songName ?? string.Empty).Trim();
var directors = Resources.FindObjectsOfTypeAll<PlayableDirector>()
.Where(value =>
value != null &&
value.gameObject.scene == scene &&
value.playableAsset is TimelineAsset &&
!AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(value))
.ToArray();
if (directors.Length == 0)
{
_sourceDirector = null;
return;
}
var matches = directors
.Select(value => new
{
Director = value,
Score = ScoreDirectorForSong(value, normalizedSong)
})
.Where(value => value.Score > 0)
.OrderByDescending(value => value.Score)
.ToArray();
if (matches.Length > 0 &&
(matches.Length == 1 || matches[0].Score > matches[1].Score))
{
_sourceDirector = matches[0].Director;
return;
}
// Backward compatibility: the original single-Timeline scene still
// auto-selects its only usable director.
_sourceDirector = directors.Length == 1 ? directors[0] : null;
}
private static int ScoreDirectorForSong(
PlayableDirector director,
string songName)
{
if (director == null || string.IsNullOrWhiteSpace(songName))
{
return 0;
}
var score = 0;
if (string.Equals(director.name, songName,
StringComparison.OrdinalIgnoreCase))
{
score += 100;
}
if (string.Equals(director.transform.parent?.name, songName,
StringComparison.OrdinalIgnoreCase))
{
score += 80;
}
if (director.playableAsset is TimelineAsset timeline &&
timeline.name.IndexOf(songName,
StringComparison.OrdinalIgnoreCase) >= 0)
{
score += 40;
}
var path = GetHierarchyPath(director.transform);
if (path.IndexOf($"TimeLine/{songName}",
StringComparison.OrdinalIgnoreCase) >= 0)
{
score += 120;
}
return score;
}
private static string GetHierarchyPath(Transform transform)
{
var parts = new System.Collections.Generic.Stack<string>();
for (var current = transform; current != null; current = current.parent)
{
parts.Push(current.name);
}
return string.Join("/", parts);
}
private bool RunEditorAction(
string runningStatus,
Func<string> action)
{
try
{
SetStatus(runningStatus);
var result = action();
SetStatus(result);
AppendLog(result);
return true;
}
catch (Exception exception)
{
ReportException(runningStatus + " 실패", exception);
return false;
}
}
private void ReportException(string context, Exception exception)
{
SetStatus($"{context}\n{exception.Message}");
AppendLog("[error] " + exception);
UnityEngine.Debug.LogException(exception);
}
private void SetStatus(string value)
{
_status = value;
if (this != null)
{
Repaint();
}
}
private void AppendLog(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!string.IsNullOrEmpty(_log))
{
_log += Environment.NewLine;
}
_log += value;
const int maximumLogCharacters = 40000;
if (_log.Length > maximumLogCharacters)
{
_log = _log.Substring(_log.Length - maximumLogCharacters);
}
_logScrollPosition.y = float.MaxValue;
}
private string ResolveCliExecutable(bool ignoreConfigured = false)
{
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
?? Environment.CurrentDirectory;
return AICameraCliRunner.ResolveExecutable(
ignoreConfigured ? string.Empty : _cliExecutable,
_cwAiRoot,
projectRoot);
}
internal static string ResolveDefaultCwAiRoot(
string projectRoot,
string configuredRoot)
{
var normalizedProjectRoot = string.IsNullOrWhiteSpace(projectRoot)
? Environment.CurrentDirectory
: Path.GetFullPath(projectRoot);
var candidates = new[]
{
configuredRoot,
// Self-contained package install: RuntimeData~ is shipped
// beside the editor scripts, so artists do not need a
// separate CW-AI checkout.
Path.Combine(
normalizedProjectRoot,
"Packages",
"com.mingle.cw-ai",
"RuntimeData~"),
normalizedProjectRoot,
Path.Combine(
Directory.GetParent(normalizedProjectRoot)?.FullName ??
normalizedProjectRoot,
"CW-AI"),
Environment.CurrentDirectory
};
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
string fullPath;
try
{
fullPath = Path.GetFullPath(candidate);
}
catch (Exception)
{
continue;
}
if (IsGenerationLibraryRoot(fullPath))
{
return fullPath;
}
}
return Path.Combine(
Directory.GetParent(normalizedProjectRoot)?.FullName ??
normalizedProjectRoot,
"CW-AI");
}
private static string ResolveProjectCacheRoot()
{
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
?? Environment.CurrentDirectory;
return ResolveProjectCacheRoot(
projectRoot,
Environment.GetEnvironmentVariable("CWAI_CACHE_ROOT"));
}
internal static string ResolveProjectCacheRoot(
string projectRoot,
string configuredRoot)
{
if (!string.IsNullOrWhiteSpace(configuredRoot))
{
return Path.GetFullPath(configuredRoot);
}
var normalizedProjectRoot = string.IsNullOrWhiteSpace(projectRoot)
? Environment.CurrentDirectory
: Path.GetFullPath(projectRoot);
return Path.Combine(normalizedProjectRoot, "Library", "CWAI");
}
internal static bool IsGenerationLibraryRoot(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
try
{
var fullPath = Path.GetFullPath(candidate);
return Directory.Exists(Path.Combine(fullPath, "DatasetExports")) &&
File.Exists(Path.Combine(
fullPath,
"reports",
"training_index.json")) &&
File.Exists(Path.Combine(
fullPath,
"models",
"cut_ranker_v2.json"));
}
catch (Exception)
{
return false;
}
}
private bool TryAutoRepairGenerationLibraryRoot()
{
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
?? Environment.CurrentDirectory;
var resolvedRoot = ResolveDefaultCwAiRoot(projectRoot, _cwAiRoot);
var currentRoot = string.Empty;
if (!string.IsNullOrWhiteSpace(_cwAiRoot))
{
try
{
currentRoot = Path.GetFullPath(_cwAiRoot);
}
catch (Exception)
{
currentRoot = string.Empty;
}
}
if (string.Equals(
currentRoot.TrimEnd('\\', '/'),
resolvedRoot.TrimEnd('\\', '/'),
StringComparison.OrdinalIgnoreCase))
{
return false;
}
var previousRoot = _cwAiRoot;
_cwAiRoot = resolvedRoot;
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
!File.Exists(_cliExecutable))
{
_cliExecutable = ResolveCliExecutable(true);
}
AppendLog(
"[library-root-auto-repair] " +
$"{previousRoot} -> {_cwAiRoot}");
return true;
}
private static string ExistingDirectoryOrFallback(
string candidate,
string fallback)
{
if (!string.IsNullOrWhiteSpace(candidate))
{
if (Directory.Exists(candidate))
{
return candidate;
}
var parent = Path.GetDirectoryName(candidate);
if (!string.IsNullOrWhiteSpace(parent) &&
Directory.Exists(parent))
{
return parent;
}
}
return !string.IsNullOrWhiteSpace(fallback) &&
Directory.Exists(fallback)
? fallback
: Application.dataPath;
}
private static string CreateCancellationFilePath(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId) ||
jobId.Any(character =>
!(char.IsLetterOrDigit(character) ||
character == '-' || character == '_')))
{
throw new ArgumentException(
"The camera worker job id is invalid.",
nameof(jobId));
}
var projectRoot = Directory.GetParent(Application.dataPath)?.FullName
?? Environment.CurrentDirectory;
return Path.Combine(
projectRoot,
"Library",
"CWAI",
"Jobs",
jobId + ".cancel");
}
private static void AppendOption(
ICollection<string> arguments,
string option,
string value)
{
if (arguments == null)
{
throw new ArgumentNullException(nameof(arguments));
}
arguments.Add(option);
AppendArgument(arguments, value);
}
private static void AppendArgument(
ICollection<string> arguments,
string value)
{
if (arguments == null)
{
throw new ArgumentNullException(nameof(arguments));
}
arguments.Add(value ?? string.Empty);
}
}
}