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