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(); private string[] _datasetSongNames = Array.Empty(); private DatasetSongSummary[] _datasetSongs = Array.Empty(); 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(); 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(); } return Resources.FindObjectsOfTypeAll() .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() ?.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( 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( 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( metadataJson); var shots = JsonUtility.FromJson(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 BuildGeneratorArguments( string datasetRoot, string trainingIndex, string outputFolder, int seed, GenerationControlScope controls, int onlyShotIndex, string baseGenerationFolder) { var arguments = new List { "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(); AppendSelectedShotOneShotArguments( arguments, onlyShotIndex, baseGenerationFolder); return AICameraCliRunner.JoinArguments(arguments); } private static void AppendSelectedShotOneShotArguments( ICollection 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 BuildPreparationCacheArguments( string datasetRoot, string trainingIndex) { var arguments = new List { "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 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() .SingleOrDefault(value => value.gameObject.scene == scene && value.name == AICameraTimelinePreviewImporter.OriginalDirectorName && value.playableAsset is TimelineAsset) ?? throw new InvalidOperationException( "완성본을 만들 원본 PlayableDirector를 선택하세요."); var sourcePreviewDirector = Resources.FindObjectsOfTypeAll() .Where(value => value.gameObject.scene == scene && AICameraTimelinePreviewImporter .IsPreviewDirectorForEditor(value)) .SingleOrDefault(value => value.name == AICameraTimelinePreviewImporter .GetScopedPreviewDirectorName(sourceDirector)) ?? Resources.FindObjectsOfTypeAll() .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() .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() .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(candidate) == null && !File.Exists(Path.GetFullPath(candidate))) { return candidate; } version = Math.Max(2, version + 1); } } private void RefreshShotNames() { try { RestoreGeneratedFolderFromPreviewProvenance(); _shotNames = GetScopedShotNames() ?? Array.Empty(); if (_shotNames.Length == 0) { _selectedShotIndex = 0; } else { _selectedShotIndex = Mathf.Clamp( _selectedShotIndex, 0, _shotNames.Length - 1); } } catch (Exception exception) { _shotNames = Array.Empty(); 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(); _datasetSongs = Array.Empty(); 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( File.ReadAllText(manifestPath)); _datasetSongs = (manifest?.songs ?? Array.Empty()) .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( 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() .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(); for (var current = transform; current != null; current = current.parent) { parts.Push(current.name); } return string.Join("/", parts); } private bool RunEditorAction( string runningStatus, Func 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 ResolveCliExecutablePath( ignoreConfigured ? string.Empty : _cliExecutable, _cwAiRoot, projectRoot, ResolvePhysicalPackageRoot()); } internal static string ResolveCliExecutablePath( string configuredExecutable, string cwAiRoot, string projectRoot, string packageRoot) { var normalizedConfigured = TryGetFullPath(configuredExecutable); if (!string.IsNullOrWhiteSpace(normalizedConfigured) && !IsCameraAiPackageCachePath(normalizedConfigured) && File.Exists(normalizedConfigured)) { // A valid path outside PackageCache was explicitly selected by // the user and must remain authoritative. return normalizedConfigured; } var normalizedPackageRoot = TryGetFullPath(packageRoot); if (!string.IsNullOrWhiteSpace(normalizedPackageRoot)) { var packagedWorker = Path.Combine( normalizedPackageRoot, "Tools~", "CWCameraWorker", AICameraCliRunner.ExecutableName); if (File.Exists(packagedWorker)) { return Path.GetFullPath(packagedWorker); } packagedWorker = Path.Combine( normalizedPackageRoot, "Tools~", AICameraCliRunner.ExecutableName); if (File.Exists(packagedWorker)) { return Path.GetFullPath(packagedWorker); } } // Never fall back to a serialized worker or data root from an old // Git package revision merely because Unity has not evicted that // PackageCache directory yet. var safeConfigured = IsCameraAiPackageCachePath(configuredExecutable) ? string.Empty : normalizedConfigured; var safeCwAiRoot = IsCameraAiPackageCachePath(cwAiRoot) ? string.Empty : TryGetFullPath(cwAiRoot); return AICameraCliRunner.ResolveExecutable( safeConfigured, safeCwAiRoot, projectRoot, normalizedPackageRoot); } internal static string ResolveDefaultCwAiRoot( string projectRoot, string configuredRoot) { return ResolveDefaultCwAiRoot( projectRoot, configuredRoot, ResolvePhysicalPackageRoot()); } internal static string ResolveDefaultCwAiRoot( string projectRoot, string configuredRoot, string packageRoot) { var normalizedProjectRoot = string.IsNullOrWhiteSpace(projectRoot) ? Environment.CurrentDirectory : Path.GetFullPath(projectRoot); var normalizedConfigured = TryGetFullPath(configuredRoot); if (!string.IsNullOrWhiteSpace(normalizedConfigured) && !IsCameraAiPackageCachePath(normalizedConfigured) && IsGenerationLibraryRoot(normalizedConfigured)) { // Preserve a valid repository or data library that the user // explicitly selected outside Unity's PackageCache. return normalizedConfigured; } var candidates = new List(); var normalizedPackageRoot = TryGetFullPath(packageRoot); if (!string.IsNullOrWhiteSpace(normalizedPackageRoot)) { candidates.Add(Path.Combine( normalizedPackageRoot, "RuntimeData~")); } // This logical package path supports embedded/local packages when // FileUtil cannot resolve a physical path during early domain load. candidates.Add(Path.Combine( normalizedProjectRoot, "Packages", "com.mingle.cw-ai", "RuntimeData~")); candidates.AddRange(new[] { 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 ResolvePhysicalPackageRoot() { try { return TryGetFullPath(FileUtil.GetPhysicalPath( "Packages/com.mingle.cw-ai")); } catch (Exception) { return string.Empty; } } private static bool IsCameraAiPackageCachePath(string candidate) { var fullPath = TryGetFullPath(candidate); if (string.IsNullOrWhiteSpace(fullPath)) { return false; } var normalized = fullPath.Replace('\\', '/'); return normalized.IndexOf( "/Library/PackageCache/com.mingle.cw-ai@", StringComparison.OrdinalIgnoreCase) >= 0; } private static string TryGetFullPath(string candidate) { if (string.IsNullOrWhiteSpace(candidate)) { return string.Empty; } try { return Path.GetFullPath(candidate.Trim()); } catch (Exception) { return string.Empty; } } 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; } } var previousRoot = _cwAiRoot; var repairedLibraryRoot = !string.Equals( currentRoot.TrimEnd('\\', '/'), resolvedRoot.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase); if (repairedLibraryRoot) { _cwAiRoot = resolvedRoot; AppendLog( "[library-root-auto-repair] " + $"{previousRoot} -> {_cwAiRoot}"); } var previousExecutable = _cliExecutable; var resolvedExecutable = ResolveCliExecutable(); if (!string.Equals( TryGetFullPath(previousExecutable), TryGetFullPath(resolvedExecutable), StringComparison.OrdinalIgnoreCase)) { _cliExecutable = resolvedExecutable; AppendLog( "[worker-path-auto-repair] " + $"{previousExecutable} -> {_cliExecutable}"); } return repairedLibraryRoot; } 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 arguments, string option, string value) { if (arguments == null) { throw new ArgumentNullException(nameof(arguments)); } arguments.Add(option); AppendArgument(arguments, value); } private static void AppendArgument( ICollection arguments, string value) { if (arguments == null) { throw new ArgumentNullException(nameof(arguments)); } arguments.Add(value ?? string.Empty); } } }