feat: add Camera Work AI package

This commit is contained in:
KINDNICK
2026-08-03 04:42:44 +09:00
parent 26c578416f
commit 02d76bc87c
2187 changed files with 119610 additions and 1 deletions
@@ -0,0 +1,633 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using UnityEngine;
namespace Streamingle.Editor
{
public enum AICameraABRenderCodec
{
MP4,
WEBM
}
[Serializable]
public sealed class AICameraABRenderRequest
{
public string candidateADirectory;
public string candidateBDirectory;
public string outputRoot;
public int startFrame;
public int endFrameExclusive;
public AICameraABRenderCodec codec = AICameraABRenderCodec.MP4;
public int width = 1920;
public int height = 1080;
}
[Serializable]
public sealed class AICameraABRenderManifest
{
public string schemaVersion = "1.0";
public string jobId;
public string state;
public string createdUtc;
public string completedUtc;
public string error;
public AICameraABEnvironmentInfo environment;
public AICameraABSceneInfo scene;
public AICameraABFrameRange frameRange;
public AICameraABMovieSettings movie;
public AICameraABFairnessInfo fairness;
public List<AICameraABAudioClipInfo> audio = new();
public List<AICameraABCandidateResult> candidates = new();
}
[Serializable]
public sealed class AICameraABEnvironmentInfo
{
public string unityVersion;
public string recorderVersion;
public string cwAiVersion;
public string operatingSystem;
}
[Serializable]
public sealed class AICameraABSceneInfo
{
public string scenePath;
public string sceneGuid;
public string timelinePath;
public string timelineGuid;
public double timelineFrameRate;
}
[Serializable]
public sealed class AICameraABFrameRange
{
public int startFrame;
public int endFrameExclusive;
public int expectedFrameCount;
public double startSeconds;
public double endSecondsExclusive;
}
[Serializable]
public sealed class AICameraABMovieSettings
{
public string codec;
public string encoder;
public string quality;
public int width;
public int height;
public double aspectRatio;
public int frameRate;
public string cameraSource;
public bool captureAudio;
public bool captureAlpha;
}
[Serializable]
public sealed class AICameraABFairnessInfo
{
public bool sameSampleRate;
public bool sameFrameCount;
public bool sameTimeSha256;
public bool sameSongId;
public bool sameDatasetId;
public bool sameTargetAspectRatio;
public bool sameSceneAndTimeline;
public string sharedTimeSha256;
public string sharedSongId;
public string targetEvaluationGroupId;
}
[Serializable]
public sealed class AICameraABAudioClipInfo
{
public string trackName;
public string clipName;
public string assetPath;
public string assetGuid;
public string assetSha256;
public double timelineStart;
public double clipIn;
public double duration;
}
[Serializable]
public sealed class AICameraABCandidateResult
{
public string label;
public string generatedDirectory;
public string horizontalFramingPolicy;
public string metadataSha256;
public string cameraSha256;
public string timeSha256;
public string shotsSha256;
public string declaredCameraSha256;
public string declaredTimeSha256;
public string declaredShotsSha256;
public string songName;
public string songId;
public string datasetId;
public string targetEvaluationGroupId;
public double targetAspectRatio;
public int sampleRate;
public int frameCount;
public double duration;
public string outputBasePath;
public string outputPath;
public string status;
public string startedUtc;
public string completedUtc;
public long outputBytes;
public string outputSha256;
public string error;
}
[Serializable]
internal sealed class AICameraABRenderJobState
{
public string schemaVersion = "1.0";
public string jobId;
public string phase;
public string manifestPath;
public string runDirectory;
public int candidateIndex;
public bool cancelRequested;
public bool cleanupPending;
public bool cinemachineSaveDuringPlayStateCaptured;
public bool cinemachineSaveDuringPlayOriginalEnabled;
public bool cinemachineSaveDuringPlayStateRestored;
public int stableOutputChecks;
public long lastObservedOutputBytes = -1;
public string finalizationStartedUtc;
public string error;
public AICameraABRenderRequest request;
public AICameraABSceneSetupSnapshot sceneSetup;
}
[Serializable]
internal sealed class AICameraABSceneSetupSnapshot
{
public string activeScenePath;
public List<AICameraABSceneEntry> scenes = new();
}
[Serializable]
internal sealed class AICameraABSceneEntry
{
public string path;
public bool isLoaded;
public bool isActive;
}
[Serializable]
internal sealed class AICameraABGeneratedCameraMetadata
{
public string songName;
public string songId;
public string datasetId;
public string targetEvaluationGroupId;
public string horizontalFramingPolicy;
public double targetAspectRatio;
public double aspectRatioOverride;
public int sampleRate;
public int frameCount;
public double duration;
public string worldCameraFile;
public string timeFile;
public string shotsFile;
public AICameraABGeneratedOutputSha256 outputSha256;
}
[Serializable]
internal sealed class AICameraABGeneratedOutputSha256
{
public string worldCamera;
public string time;
public string shots;
}
internal sealed class AICameraABCandidateProbe
{
public AICameraABGeneratedCameraMetadata Metadata;
public AICameraABCandidateResult Result;
}
internal static class AICameraABRenderUtility
{
private static readonly StringComparison PathComparison =
Application.platform == RuntimePlatform.WindowsEditor
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
internal static string UtcNow()
{
return DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
}
internal static AICameraABRenderCodec ParseCodec(string value)
{
if (Enum.TryParse(value, true, out AICameraABRenderCodec codec))
{
return codec;
}
throw new ArgumentException(
$"Unsupported movie codec '{value}'. Use MP4 or WEBM.",
nameof(value));
}
internal static string GetExtension(AICameraABRenderCodec codec)
{
return codec == AICameraABRenderCodec.MP4 ? "mp4" : "webm";
}
internal static int ToInclusiveEndFrame(
int startFrame,
int endFrameExclusive,
int frameCount)
{
ValidateFrameRange(startFrame, endFrameExclusive, frameCount);
return endFrameExclusive - 1;
}
internal static void ValidateFrameRange(
int startFrame,
int endFrameExclusive,
int frameCount)
{
if (frameCount <= 0 ||
startFrame < 0 ||
endFrameExclusive <= startFrame ||
endFrameExclusive > frameCount)
{
throw new ArgumentOutOfRangeException(
nameof(endFrameExclusive),
$"Frame range [{startFrame}, {endFrameExclusive}) is invalid " +
$"for a {frameCount}-frame camera.");
}
}
internal static void ValidateOutputSettings(
AICameraABRenderCodec codec,
int width,
int height)
{
if (width <= 0 || height <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(width),
"Movie width and height must both be positive.");
}
if (codec == AICameraABRenderCodec.MP4 &&
(width % 2 != 0 || height % 2 != 0))
{
throw new ArgumentException(
"MP4/H.264 output requires even width and height.");
}
}
internal static AICameraABCandidateProbe ReadCandidate(
string label,
string generatedDirectory)
{
if (string.IsNullOrWhiteSpace(generatedDirectory))
{
throw new ArgumentException(
$"Candidate {label} directory is empty.",
nameof(generatedDirectory));
}
var canonicalDirectory = Path.GetFullPath(generatedDirectory);
var metadataPath = ResolveInputFile(
canonicalDirectory,
"metadata.json",
true);
var metadata = JsonUtility.FromJson<AICameraABGeneratedCameraMetadata>(
File.ReadAllText(metadataPath));
if (metadata == null ||
metadata.sampleRate <= 0 ||
metadata.frameCount <= 0 ||
metadata.duration <= 0 ||
string.IsNullOrWhiteSpace(metadata.worldCameraFile) ||
string.IsNullOrWhiteSpace(metadata.timeFile))
{
throw new InvalidDataException(
$"Candidate {label} metadata is incomplete.");
}
var cameraPath = ResolveInputFile(
canonicalDirectory,
metadata.worldCameraFile,
true);
var timePath = ResolveInputFile(
canonicalDirectory,
metadata.timeFile,
true);
var shotsPath = string.IsNullOrWhiteSpace(metadata.shotsFile)
? string.Empty
: ResolveInputFile(
canonicalDirectory,
metadata.shotsFile,
true);
var result = new AICameraABCandidateResult
{
label = label,
generatedDirectory = canonicalDirectory,
horizontalFramingPolicy = metadata.horizontalFramingPolicy,
metadataSha256 = ComputeSha256(metadataPath),
cameraSha256 = ComputeSha256(cameraPath),
timeSha256 = ComputeSha256(timePath),
shotsSha256 = string.IsNullOrWhiteSpace(shotsPath)
? string.Empty
: ComputeSha256(shotsPath),
declaredCameraSha256 =
metadata.outputSha256?.worldCamera ?? string.Empty,
declaredTimeSha256 =
metadata.outputSha256?.time ?? string.Empty,
declaredShotsSha256 =
metadata.outputSha256?.shots ?? string.Empty,
songName = metadata.songName,
songId = metadata.songId,
datasetId = metadata.datasetId,
targetEvaluationGroupId = metadata.targetEvaluationGroupId,
targetAspectRatio = GetTargetAspectRatio(metadata),
sampleRate = metadata.sampleRate,
frameCount = metadata.frameCount,
duration = metadata.duration,
status = "queued"
};
ValidateDeclaredSha(
label,
"camera",
result.declaredCameraSha256,
result.cameraSha256);
ValidateDeclaredSha(
label,
"time",
result.declaredTimeSha256,
result.timeSha256);
ValidateDeclaredSha(
label,
"shots",
result.declaredShotsSha256,
result.shotsSha256);
return new AICameraABCandidateProbe
{
Metadata = metadata,
Result = result
};
}
internal static AICameraABFairnessInfo ValidateCandidatePair(
AICameraABCandidateProbe candidateA,
AICameraABCandidateProbe candidateB,
AICameraABRenderRequest request)
{
if (candidateA == null || candidateB == null)
{
throw new ArgumentNullException(
candidateA == null ? nameof(candidateA) : nameof(candidateB));
}
var a = candidateA.Result;
var b = candidateB.Result;
var sameSongId = string.Equals(
a.songId ?? string.Empty,
b.songId ?? string.Empty,
StringComparison.Ordinal);
var sameEvaluationGroup = string.Equals(
a.targetEvaluationGroupId ?? string.Empty,
b.targetEvaluationGroupId ?? string.Empty,
StringComparison.Ordinal);
var sameDatasetId = string.Equals(
a.datasetId ?? string.Empty,
b.datasetId ?? string.Empty,
StringComparison.Ordinal);
var sameAspect = Math.Abs(
a.targetAspectRatio - b.targetAspectRatio) < 0.0001;
var fairness = new AICameraABFairnessInfo
{
sameSampleRate = a.sampleRate == b.sampleRate,
sameFrameCount = a.frameCount == b.frameCount,
sameTimeSha256 = string.Equals(
a.timeSha256,
b.timeSha256,
StringComparison.OrdinalIgnoreCase),
sameSongId = sameSongId,
sameDatasetId = sameDatasetId,
sameTargetAspectRatio = sameAspect,
sameSceneAndTimeline = true,
sharedTimeSha256 = a.timeSha256,
sharedSongId = a.songId,
targetEvaluationGroupId = a.targetEvaluationGroupId
};
if (!fairness.sameSampleRate ||
!fairness.sameFrameCount ||
!fairness.sameTimeSha256 ||
!sameSongId ||
!sameDatasetId ||
!sameEvaluationGroup ||
!sameAspect)
{
throw new InvalidDataException(
"A/B candidates must share sample rate, frame count, " +
"time.f64, songId, datasetId, evaluation group, and " +
"target aspect ratio.");
}
ValidateFrameRange(
request.startFrame,
request.endFrameExclusive,
a.frameCount);
ValidateOutputSettings(request.codec, request.width, request.height);
if (a.targetAspectRatio > 0)
{
var outputAspect = request.width / (double)request.height;
if (Math.Abs(outputAspect - a.targetAspectRatio) > 0.01)
{
throw new ArgumentException(
$"Output aspect ratio {outputAspect:F4} does not match " +
$"candidate target aspect {a.targetAspectRatio:F4}.");
}
}
return fairness;
}
internal static string ComputeSha256(string path)
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
1024 * 1024,
FileOptions.SequentialScan);
using var sha = SHA256.Create();
return BitConverter.ToString(sha.ComputeHash(stream))
.Replace("-", string.Empty)
.ToLowerInvariant();
}
internal static string SanitizeFileName(string value)
{
var source = string.IsNullOrWhiteSpace(value) ? "candidate" : value;
var invalid = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(source.Length);
foreach (var character in source)
{
builder.Append(
Array.IndexOf(invalid, character) >= 0 ? '_' : character);
}
return builder.ToString().Trim().TrimEnd('.');
}
internal static T ReadJson<T>(string path)
{
if (!File.Exists(path))
{
return default;
}
return JsonUtility.FromJson<T>(File.ReadAllText(path));
}
internal static void WriteJsonAtomic<T>(string path, T value)
{
const int maximumReplaceAttempts = 8;
var canonicalPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(canonicalPath)
?? throw new InvalidOperationException(
$"Path '{canonicalPath}' has no parent directory.");
Directory.CreateDirectory(directory);
var temporaryPath =
$"{canonicalPath}.{Guid.NewGuid():N}.tmp";
try
{
File.WriteAllText(
temporaryPath,
JsonUtility.ToJson(value, true),
new UTF8Encoding(false));
for (var attempt = 0; ; attempt++)
{
try
{
if (File.Exists(canonicalPath))
{
File.Replace(temporaryPath, canonicalPath, null);
}
else
{
File.Move(temporaryPath, canonicalPath);
}
return;
}
catch (IOException)
when (attempt + 1 < maximumReplaceAttempts)
{
Thread.Sleep(10 * (attempt + 1));
}
catch (UnauthorizedAccessException)
when (attempt + 1 < maximumReplaceAttempts)
{
Thread.Sleep(10 * (attempt + 1));
}
}
}
finally
{
if (File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
}
}
internal static bool IsTerminalPhase(string phase)
{
return phase == AICameraABRenderRunner.PhaseComplete ||
phase == AICameraABRenderRunner.PhaseFailed ||
phase == AICameraABRenderRunner.PhaseCancelled;
}
private static double GetTargetAspectRatio(
AICameraABGeneratedCameraMetadata metadata)
{
return metadata.targetAspectRatio > 0
? metadata.targetAspectRatio
: metadata.aspectRatioOverride;
}
private static string ResolveInputFile(
string rootDirectory,
string relativePath,
bool requireFile)
{
if (string.IsNullOrWhiteSpace(relativePath) ||
Path.IsPathRooted(relativePath))
{
throw new InvalidDataException(
$"Generated input path '{relativePath}' must be relative.");
}
var root = Path.GetFullPath(rootDirectory);
var resolved = Path.GetFullPath(Path.Combine(root, relativePath));
var prefix = root.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar) +
Path.DirectorySeparatorChar;
if (!resolved.StartsWith(prefix, PathComparison))
{
throw new InvalidDataException(
$"Generated input '{relativePath}' escapes its directory.");
}
if (requireFile && !File.Exists(resolved))
{
throw new FileNotFoundException(
"Generated camera input was not found.",
resolved);
}
return resolved;
}
private static void ValidateDeclaredSha(
string label,
string inputName,
string declared,
string actual)
{
if (string.IsNullOrWhiteSpace(declared))
{
return;
}
if (string.IsNullOrWhiteSpace(actual) ||
!string.Equals(
declared,
actual,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"Candidate {label} {inputName} SHA256 does not match metadata.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9e61af0fef774b80bbf7fc1121b9ae29
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 249ad3cd8eec495a8419e994e185a41b
+210
View File
@@ -0,0 +1,210 @@
using System;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Editor
{
public sealed class AICameraABRenderWindow : EditorWindow
{
private string candidateADirectory = string.Empty;
private string candidateBDirectory = string.Empty;
private string outputRoot = string.Empty;
private int startFrame;
private int endFrameExclusive = 1;
private AICameraABRenderCodec codec =
AICameraABRenderCodec.MP4;
private int width = 1920;
private int height = 1080;
private Vector2 scrollPosition;
[MenuItem("Tools/Streamingle/Timeline/Render AI Camera A-B")]
public static void Open()
{
GetWindow<AICameraABRenderWindow>(
"AI Camera A-B Render");
}
private void OnEnable()
{
if (string.IsNullOrWhiteSpace(outputRoot))
{
outputRoot = Path.GetFullPath(Path.Combine(
Application.dataPath,
"..",
"Recordings",
"CWAI_AB"));
}
}
private void OnInspectorUpdate()
{
Repaint();
}
private void OnGUI()
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
EditorGUILayout.LabelField(
"AI Camera A-B Movie Render",
EditorStyles.boldLabel);
EditorGUILayout.LabelField(
"Renders two generated camera candidates against the same " +
"saved Scene, Timeline frame range, and Timeline audio.",
EditorStyles.wordWrappedLabel);
EditorGUILayout.Space();
DrawFolderField(
"Candidate A",
ref candidateADirectory,
"Select generated camera candidate A");
DrawFolderField(
"Candidate B",
ref candidateBDirectory,
"Select generated camera candidate B");
DrawFolderField(
"Output Root",
ref outputRoot,
"Select A-B movie output directory");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Frame Range", EditorStyles.boldLabel);
startFrame = EditorGUILayout.IntField(
"Start Frame",
startFrame);
endFrameExclusive = EditorGUILayout.IntField(
"End Frame (Exclusive)",
endFrameExclusive);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Movie", EditorStyles.boldLabel);
codec = (AICameraABRenderCodec)EditorGUILayout.EnumPopup(
"Codec",
codec);
width = EditorGUILayout.IntField("Width", width);
height = EditorGUILayout.IntField("Height", height);
EditorGUILayout.HelpBox(
"MP4 uses H.264 and requires even dimensions. WebM uses VP8. " +
"Both candidates use MainCamera and include Timeline audio.",
MessageType.Info);
EditorGUILayout.Space();
DrawActiveJob();
EditorGUILayout.Space();
var hasActive = AICameraABRenderRunner.TryGetActiveStatus(
out _,
out var phase,
out _);
var nonTerminalJob =
hasActive &&
!AICameraABRenderUtility.IsTerminalPhase(phase);
using (new EditorGUI.DisabledScope(nonTerminalJob))
{
if (GUILayout.Button("Start A-B Render", GUILayout.Height(30)))
{
StartRender();
}
}
EditorGUILayout.EndScrollView();
}
private void DrawActiveJob()
{
if (!AICameraABRenderRunner.TryGetActiveStatus(
out var jobId,
out var phase,
out var manifestPath))
{
EditorGUILayout.LabelField("No active A-B render job.");
return;
}
EditorGUILayout.LabelField("Current Job", EditorStyles.boldLabel);
EditorGUILayout.SelectableLabel(
jobId,
EditorStyles.textField,
GUILayout.Height(EditorGUIUtility.singleLineHeight));
EditorGUILayout.LabelField("Phase", phase);
EditorGUILayout.LabelField(
"Manifest",
manifestPath,
EditorStyles.wordWrappedLabel);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Reveal Manifest") &&
!string.IsNullOrWhiteSpace(manifestPath))
{
EditorUtility.RevealInFinder(manifestPath);
}
using (new EditorGUI.DisabledScope(
AICameraABRenderUtility.IsTerminalPhase(phase)))
{
if (GUILayout.Button("Cancel"))
{
AICameraABRenderRunner.CancelABRenderForCli(jobId);
}
}
}
}
private void StartRender()
{
try
{
var request = new AICameraABRenderRequest
{
candidateADirectory = candidateADirectory,
candidateBDirectory = candidateBDirectory,
outputRoot = outputRoot,
startFrame = startFrame,
endFrameExclusive = endFrameExclusive,
codec = codec,
width = width,
height = height
};
var started = AICameraABRenderRunner.Start(request);
Debug.Log(
$"CW-AI A/B render started. Manifest: " +
$"{started.manifestPath}");
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorUtility.DisplayDialog(
"Unable to start A-B render",
exception.Message,
"OK");
}
}
private static void DrawFolderField(
string label,
ref string value,
string dialogTitle)
{
using (new EditorGUILayout.HorizontalScope())
{
value = EditorGUILayout.TextField(label, value);
if (GUILayout.Button("Browse", GUILayout.Width(70)))
{
var initialDirectory = Directory.Exists(value)
? value
: Directory.GetParent(Application.dataPath)?.FullName ??
string.Empty;
var selected = EditorUtility.OpenFolderPanel(
dialogTitle,
initialDirectory,
string.Empty);
if (!string.IsNullOrWhiteSpace(selected))
{
value = selected;
}
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e32ae9d4a42347859c368bc701901af7
+604
View File
@@ -0,0 +1,604 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Editor
{
internal sealed class AICameraCliRunner : IDisposable
{
internal const string ExecutableName = "CWCameraWorker.exe";
internal const string EventProtocolVersion = "1";
private readonly ConcurrentQueue<ProcessMessage> messages =
new ConcurrentQueue<ProcessMessage>();
private Process process;
private string cancellationFile = string.Empty;
private bool cancellationRequested;
private bool exitObserved;
private int exitCode;
internal bool IsRunning
{
get
{
if (process == null || exitObserved)
{
return false;
}
try
{
return !process.HasExited;
}
catch (InvalidOperationException)
{
return false;
}
}
}
internal bool CancellationRequested => cancellationRequested;
internal void Start(ProcessRequest request)
{
if (process != null)
{
throw new InvalidOperationException(
"A camera worker process is already attached to this runner.");
}
ValidateRequest(request);
cancellationFile = Path.GetFullPath(request.CancellationFile);
var cancellationDirectory = Path.GetDirectoryName(cancellationFile);
if (string.IsNullOrWhiteSpace(cancellationDirectory))
{
throw new InvalidOperationException(
"The camera worker cancellation directory is invalid.");
}
Directory.CreateDirectory(cancellationDirectory);
if (File.Exists(cancellationFile))
{
File.Delete(cancellationFile);
}
var startInfo = new ProcessStartInfo
{
FileName = Path.GetFullPath(request.ExecutablePath),
Arguments = JoinArguments(request.Arguments),
WorkingDirectory = Path.GetFullPath(request.WorkingDirectory),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
startInfo.EnvironmentVariables["PYTHONUTF8"] = "1";
startInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = false
};
process.OutputDataReceived += OnOutputDataReceived;
process.ErrorDataReceived += OnErrorDataReceived;
try
{
if (!process.Start())
{
throw new InvalidOperationException(
"CWCameraWorker could not be started.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
catch
{
DisposeProcess();
throw;
}
}
internal bool TryDequeueMessage(out ProcessMessage message)
{
return messages.TryDequeue(out message);
}
internal bool TryGetExitCode(out int completedExitCode)
{
completedExitCode = 0;
if (process == null)
{
return false;
}
if (!exitObserved)
{
try
{
if (!process.HasExited)
{
return false;
}
// The parameterless overload also waits for redirected
// asynchronous output handlers to receive their final line.
process.WaitForExit();
exitCode = process.ExitCode;
exitObserved = true;
}
catch (InvalidOperationException)
{
return false;
}
}
completedExitCode = exitCode;
return true;
}
internal bool RequestCancellation()
{
if (process == null || exitObserved || cancellationRequested)
{
return false;
}
var directory = Path.GetDirectoryName(cancellationFile);
if (string.IsNullOrWhiteSpace(directory))
{
throw new InvalidOperationException(
"The camera worker cancellation path is unavailable.");
}
Directory.CreateDirectory(directory);
File.WriteAllText(
cancellationFile,
DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
new UTF8Encoding(false));
cancellationRequested = true;
return true;
}
internal void CompleteAndDispose()
{
if (process != null && !exitObserved)
{
throw new InvalidOperationException(
"A running camera worker cannot be disposed as completed.");
}
DisposeProcess();
TryDeleteCancellationFile();
}
public void Dispose()
{
DisposeProcess();
if (exitObserved)
{
TryDeleteCancellationFile();
}
}
internal static string ResolveExecutable(
string configuredPath,
string cwAiRoot,
string projectRoot,
string packageRoot = "")
{
var candidates = new List<string>();
AddCandidate(candidates, configuredPath);
AddCandidate(
candidates,
Environment.GetEnvironmentVariable(
"CWAI_WORKER_EXECUTABLE"));
AddRootCandidates(candidates, cwAiRoot);
if (!string.IsNullOrWhiteSpace(projectRoot))
{
AddCandidate(
candidates,
Path.Combine(
projectRoot,
"Tools",
"CWCameraWorker",
ExecutableName));
}
var physicalPackageRoot = packageRoot;
if (string.IsNullOrWhiteSpace(physicalPackageRoot))
{
try
{
physicalPackageRoot = FileUtil.GetPhysicalPath(
"Packages/com.mingle.cw-ai");
}
catch (Exception)
{
physicalPackageRoot = string.Empty;
}
}
if (!string.IsNullOrWhiteSpace(physicalPackageRoot))
{
AddCandidate(
candidates,
Path.Combine(
physicalPackageRoot,
"Tools~",
"CWCameraWorker",
ExecutableName));
AddCandidate(
candidates,
Path.Combine(
physicalPackageRoot,
"Tools~",
ExecutableName));
}
return candidates
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(Path.GetFullPath)
.FirstOrDefault(File.Exists) ?? string.Empty;
}
internal static string JoinArguments(IEnumerable<string> arguments)
{
if (arguments == null)
{
throw new ArgumentNullException(nameof(arguments));
}
return string.Join(" ", arguments.Select(QuoteArgument));
}
internal static string QuoteArgument(string argument)
{
argument ??= string.Empty;
if (argument.Length > 0 &&
argument.All(character =>
!char.IsWhiteSpace(character) && character != '"'))
{
return argument;
}
var result = new StringBuilder(argument.Length + 2);
result.Append('"');
var backslashCount = 0;
foreach (var character in argument)
{
if (character == '\\')
{
backslashCount++;
continue;
}
if (character == '"')
{
result.Append('\\', backslashCount * 2 + 1);
result.Append('"');
backslashCount = 0;
continue;
}
result.Append('\\', backslashCount);
backslashCount = 0;
result.Append(character);
}
result.Append('\\', backslashCount * 2);
result.Append('"');
return result.ToString();
}
internal static bool TryParseEvent(
string line,
out CliEvent parsedEvent)
{
parsedEvent = default;
var normalized = (line ?? string.Empty).Trim();
const string prefix = "CWAI_EVENT ";
if (normalized.StartsWith(prefix, StringComparison.Ordinal))
{
normalized = normalized.Substring(prefix.Length).TrimStart();
}
if (normalized.Length < 2 ||
normalized[0] != '{' ||
normalized[normalized.Length - 1] != '}')
{
return false;
}
CliEventPayload payload;
try
{
payload = JsonUtility.FromJson<CliEventPayload>(normalized);
}
catch (ArgumentException)
{
return false;
}
var eventType = FirstNotEmpty(
payload?.type,
payload?.eventType,
payload?.@event);
if (string.IsNullOrWhiteSpace(eventType))
{
return false;
}
var hasProgress = ContainsJsonProperty(normalized, "progress");
var hasEta = ContainsJsonProperty(normalized, "etaSeconds");
parsedEvent = new CliEvent(
eventType.Trim(),
payload.protocolVersion ?? string.Empty,
payload.jobId ?? string.Empty,
payload.phase ?? string.Empty,
payload.message ?? string.Empty,
hasProgress
? Mathf.Clamp01(payload.progress)
: -1f,
hasEta && IsFinite(payload.etaSeconds)
? Math.Max(0d, payload.etaSeconds)
: -1d,
payload.outputPath ?? string.Empty,
payload.error ?? string.Empty);
return true;
}
private static void ValidateRequest(ProcessRequest request)
{
if (string.IsNullOrWhiteSpace(request.ExecutablePath) ||
!File.Exists(request.ExecutablePath))
{
throw new FileNotFoundException(
"CWCameraWorker.exe was not found.",
request.ExecutablePath);
}
if (string.IsNullOrWhiteSpace(request.WorkingDirectory) ||
!Directory.Exists(request.WorkingDirectory))
{
throw new DirectoryNotFoundException(
"The camera worker working directory was not found: " +
request.WorkingDirectory);
}
if (request.Arguments == null || request.Arguments.Count == 0)
{
throw new ArgumentException(
"The camera worker command is required.",
nameof(request));
}
if (string.IsNullOrWhiteSpace(request.CancellationFile))
{
throw new ArgumentException(
"The camera worker cancellation file is required.",
nameof(request));
}
}
private static void AddRootCandidates(
ICollection<string> candidates,
string root)
{
if (string.IsNullOrWhiteSpace(root))
{
return;
}
AddCandidate(
candidates,
Path.Combine(root, "dist", "CWCameraWorker", ExecutableName));
AddCandidate(
candidates,
Path.Combine(root, "dist", ExecutableName));
AddCandidate(
candidates,
Path.Combine(root, "tools", "CWCameraWorker", ExecutableName));
AddCandidate(
candidates,
Path.Combine(root, "CWCameraWorker", ExecutableName));
AddCandidate(candidates, Path.Combine(root, ExecutableName));
}
private static void AddCandidate(
ICollection<string> candidates,
string candidate)
{
if (!string.IsNullOrWhiteSpace(candidate))
{
candidates.Add(candidate.Trim());
}
}
private static string FirstNotEmpty(params string[] values)
{
return values.FirstOrDefault(
value => !string.IsNullOrWhiteSpace(value)) ??
string.Empty;
}
private static bool ContainsJsonProperty(string json, string name)
{
return json.IndexOf(
"\"" + name + "\"",
StringComparison.Ordinal) >= 0;
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
private void OnOutputDataReceived(object sender, DataReceivedEventArgs args)
{
EnqueueLine(args.Data, false);
}
private void OnErrorDataReceived(object sender, DataReceivedEventArgs args)
{
EnqueueLine(args.Data, true);
}
private void EnqueueLine(string line, bool isError)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
if (TryParseEvent(line, out var parsedEvent))
{
messages.Enqueue(new ProcessMessage(parsedEvent));
return;
}
messages.Enqueue(new ProcessMessage(line, isError));
}
private void DisposeProcess()
{
if (process == null)
{
return;
}
process.OutputDataReceived -= OnOutputDataReceived;
process.ErrorDataReceived -= OnErrorDataReceived;
process.Dispose();
process = null;
}
private void TryDeleteCancellationFile()
{
if (string.IsNullOrWhiteSpace(cancellationFile) ||
!File.Exists(cancellationFile))
{
return;
}
try
{
File.Delete(cancellationFile);
}
catch (IOException)
{
// A stale sentinel is deleted before the next job starts.
}
catch (UnauthorizedAccessException)
{
// Preserve the completed result even if local cleanup is denied.
}
}
[Serializable]
private sealed class CliEventPayload
{
public string type;
public string eventType;
public string @event;
public string protocolVersion;
public string jobId;
public string phase;
public string message;
public float progress;
public double etaSeconds;
public string outputPath;
public string error;
}
internal readonly struct ProcessRequest
{
internal ProcessRequest(
string executablePath,
string workingDirectory,
IReadOnlyList<string> arguments,
string cancellationFile)
{
ExecutablePath = executablePath;
WorkingDirectory = workingDirectory;
Arguments = arguments;
CancellationFile = cancellationFile;
}
internal string ExecutablePath { get; }
internal string WorkingDirectory { get; }
internal IReadOnlyList<string> Arguments { get; }
internal string CancellationFile { get; }
}
internal readonly struct ProcessMessage
{
internal ProcessMessage(string text, bool isError)
{
Text = text ?? string.Empty;
IsError = isError;
IsEvent = false;
Event = default;
}
internal ProcessMessage(CliEvent parsedEvent)
{
Text = string.Empty;
IsError = false;
IsEvent = true;
Event = parsedEvent;
}
internal string Text { get; }
internal bool IsError { get; }
internal bool IsEvent { get; }
internal CliEvent Event { get; }
}
internal readonly struct CliEvent
{
internal CliEvent(
string eventType,
string protocolVersion,
string jobId,
string phase,
string message,
float progress,
double etaSeconds,
string outputPath,
string error)
{
EventType = eventType;
ProtocolVersion = protocolVersion;
JobId = jobId;
Phase = phase;
Message = message;
Progress = progress;
EtaSeconds = etaSeconds;
OutputPath = outputPath;
Error = error;
}
internal string EventType { get; }
internal string ProtocolVersion { get; }
internal string JobId { get; }
internal string Phase { get; }
internal string Message { get; }
internal float Progress { get; }
internal double EtaSeconds { get; }
internal string OutputPath { get; }
internal string Error { get; }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f8bdd831f8794ea18ca288f3745462b1
@@ -0,0 +1,960 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
/// <summary>
/// Explicitly snapshots the difference between generated cut boundaries and
/// the currently edited AI preview Timeline. It never mutates or saves Unity
/// assets, scenes, or Timeline clips.
/// </summary>
public static class AICameraCutCorrectionRecorder
{
internal const string CurrentSchemaVersion =
"ai-camera-cut-correction-snapshot-v1";
internal const int RequiredSampleRate = 60;
internal const int MoveMatchWindowFrames = 180;
private const double FrameAlignmentTolerance = 0.25d;
private const double CostEpsilon = 0.000000001d;
private const string EditorStyleHashPrefix =
"editor-style-sha256:";
/// <summary>
/// Captures one correction snapshot for the active AI preview belonging
/// to <paramref name="sourceDirector"/>. The returned value is the full
/// path of the newly written JSON file.
/// </summary>
public static string CaptureForCli(
PlayableDirector sourceDirector,
string generatedDirectory,
string editorStyleId,
string outputDirectory)
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
throw new InvalidOperationException(
"Cut correction capture is available only in Edit Mode.");
}
if (sourceDirector == null)
{
throw new ArgumentNullException(nameof(sourceDirector));
}
if (!AICameraTimelinePreviewImporter
.TryGetPreviewTimelineForEditor(
sourceDirector,
out var timeline))
{
throw new InvalidOperationException(
"The selected source Timeline has no AI camera preview.");
}
if (!AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForEditor(
sourceDirector,
out var provenance))
{
throw new InvalidOperationException(
"The AI preview has no generation provenance. Re-import " +
"the generated camera before recording corrections.");
}
var fullGeneratedDirectory = RequireDirectoryPath(
generatedDirectory,
nameof(generatedDirectory));
// Fail closed if any generated payload no longer matches metadata.
AICameraTimelinePreviewImporter.ValidateGeneratedDirectoryForCli(
fullGeneratedDirectory);
return CaptureTimeline(
timeline,
fullGeneratedDirectory,
editorStyleId,
outputDirectory,
DateTime.UtcNow,
provenance.MetadataSha256);
}
internal static string CaptureTimelineForTests(
TimelineAsset timeline,
string generatedDirectory,
string editorStyleId,
string outputDirectory,
DateTime capturedUtc,
string expectedMetadataSha256 = null)
{
return CaptureTimeline(
timeline,
RequireDirectoryPath(
generatedDirectory,
nameof(generatedDirectory)),
editorStyleId,
outputDirectory,
capturedUtc,
expectedMetadataSha256);
}
private static string CaptureTimeline(
TimelineAsset timeline,
string generatedDirectory,
string editorStyleId,
string outputDirectory,
DateTime capturedUtc,
string expectedMetadataSha256)
{
if (timeline == null)
{
throw new ArgumentNullException(nameof(timeline));
}
var input = ReadGenerationInput(generatedDirectory);
if (!string.IsNullOrWhiteSpace(expectedMetadataSha256))
{
var expected = NormalizeSha256(
expectedMetadataSha256,
"preview provenance metadata SHA-256");
if (!string.Equals(
expected,
input.MetadataSha256,
StringComparison.Ordinal))
{
throw new InvalidDataException(
"The selected AI preview belongs to a different " +
"generation. Its metadata SHA-256 does not match.");
}
}
var finalBoundaries = ExtractFinalBoundaryFrames(
timeline,
input.Metadata.frameCount);
var corrections = BuildCorrections(
input.OriginalBoundaryFrames,
finalBoundaries);
var anonymizedEditorStyleId = AnonymizeEditorStyleId(
editorStyleId);
var timelineAssetPath = AssetDatabase.GetAssetPath(timeline);
var snapshot = new CorrectionSnapshot
{
schemaVersion = CurrentSchemaVersion,
capturedUtc = capturedUtc
.ToUniversalTime()
.ToString(
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
CultureInfo.InvariantCulture),
sampleRate = RequiredSampleRate,
songName = input.Metadata.songName,
songId = input.Metadata.songId,
datasetId = input.Metadata.datasetId,
generationHashSha256 = input.MetadataSha256,
generationOutputSha256 = new OutputSha256
{
worldCamera = NormalizeSha256(
input.Metadata.outputSha256.worldCamera,
"metadata outputSha256.worldCamera"),
time = NormalizeSha256(
input.Metadata.outputSha256.time,
"metadata outputSha256.time"),
shots = NormalizeSha256(
input.Metadata.outputSha256.shots,
"metadata outputSha256.shots")
},
editorStyleId = anonymizedEditorStyleId,
previewTimelineGuid = string.IsNullOrWhiteSpace(
timelineAssetPath)
? string.Empty
: AssetDatabase.AssetPathToGUID(timelineAssetPath),
matchingPolicy = "ordered-dp-v1",
moveMatchWindowFrames = MoveMatchWindowFrames,
ordinalBase = 0,
originalBoundaryFrames = input.OriginalBoundaryFrames,
finalBoundaryFrames = finalBoundaries,
originalBoundarySha256 = ComputeBoundarySha256(
input.OriginalBoundaryFrames),
finalBoundarySha256 = ComputeBoundarySha256(
finalBoundaries),
originalBoundaryCount = input.OriginalBoundaryFrames.Length,
finalBoundaryCount = finalBoundaries.Length,
corrections = corrections
};
foreach (var correction in corrections)
{
switch (correction.action)
{
case "keep":
snapshot.keepCount++;
break;
case "move":
snapshot.moveCount++;
break;
case "add":
snapshot.addCount++;
break;
case "delete":
snapshot.deleteCount++;
break;
}
}
var json = JsonUtility.ToJson(snapshot, true) + Environment.NewLine;
return WriteSnapshotAtomically(
outputDirectory,
snapshot,
json);
}
private static GenerationInput ReadGenerationInput(
string generatedDirectory)
{
var metadataPath = Path.Combine(
generatedDirectory,
"metadata.json");
if (!File.Exists(metadataPath))
{
throw new FileNotFoundException(
"Generated camera metadata was not found.",
metadataPath);
}
var metadataBytes = File.ReadAllBytes(metadataPath);
var metadataJson = Encoding.UTF8.GetString(metadataBytes);
if (metadataJson.Length > 0 && metadataJson[0] == '\uFEFF')
{
metadataJson = metadataJson.Substring(1);
}
var metadata = JsonUtility.FromJson<GeneratedMetadata>(
metadataJson);
if (metadata == null)
{
throw new InvalidDataException(
"Generated camera metadata is invalid JSON.");
}
ValidateMetadata(metadata);
var shotsPath = ResolveSafePayloadPath(
generatedDirectory,
metadata.shotsFile,
"shotsFile");
if (!File.Exists(shotsPath))
{
throw new FileNotFoundException(
"Generated camera shot definitions were not found.",
shotsPath);
}
var shotFile = JsonUtility.FromJson<GeneratedShotFile>(
File.ReadAllText(shotsPath));
if (shotFile?.shots == null || shotFile.shots.Count == 0)
{
throw new InvalidDataException(
"Generated shot definitions contain no shots.");
}
var cursor = 0;
for (var index = 0; index < shotFile.shots.Count; index++)
{
var shot = shotFile.shots[index];
if (shot == null || shot.startFrame != cursor ||
shot.endFrameExclusive <= shot.startFrame ||
shot.endFrameExclusive > metadata.frameCount)
{
throw new InvalidDataException(
$"Generated shot {index} is not a contiguous, " +
"positive frame range.");
}
cursor = shot.endFrameExclusive;
}
if (cursor != metadata.frameCount)
{
throw new InvalidDataException(
"Generated shots do not cover the complete song.");
}
var boundaries = shotFile.shots
.Take(shotFile.shots.Count - 1)
.Select(shot => shot.endFrameExclusive)
.ToArray();
ValidateBoundarySequence(
boundaries,
metadata.frameCount,
"generated");
return new GenerationInput
{
Metadata = metadata,
MetadataSha256 = ComputeSha256(metadataBytes),
OriginalBoundaryFrames = boundaries
};
}
private static void ValidateMetadata(GeneratedMetadata metadata)
{
if (string.IsNullOrWhiteSpace(metadata.songName))
{
throw new InvalidDataException(
"metadata.songName is required.");
}
if (string.IsNullOrWhiteSpace(metadata.songId))
{
throw new InvalidDataException(
"metadata.songId is required.");
}
if (string.IsNullOrWhiteSpace(metadata.datasetId))
{
throw new InvalidDataException(
"metadata.datasetId is required.");
}
if (metadata.sampleRate != RequiredSampleRate)
{
throw new InvalidDataException(
$"Cut correction capture requires exactly " +
$"{RequiredSampleRate} FPS, but metadata declares " +
$"{metadata.sampleRate} FPS.");
}
if (metadata.frameCount <= 0)
{
throw new InvalidDataException(
"metadata.frameCount must be positive.");
}
if (metadata.outputSha256 == null)
{
throw new InvalidDataException(
"metadata.outputSha256 is required.");
}
NormalizeSha256(
metadata.outputSha256.worldCamera,
"metadata outputSha256.worldCamera");
NormalizeSha256(
metadata.outputSha256.time,
"metadata outputSha256.time");
NormalizeSha256(
metadata.outputSha256.shots,
"metadata outputSha256.shots");
}
private static int[] ExtractFinalBoundaryFrames(
TimelineAsset timeline,
int frameCount)
{
var matchingTracks = timeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Where(track =>
track.name == AICameraTimelinePreviewImporter
.GeneratedCinemachineTrackName)
.ToArray();
if (matchingTracks.Length != 1)
{
throw new InvalidDataException(
$"The preview Timeline must contain exactly one " +
$"'{AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName}' " +
"track.");
}
var clips = matchingTracks[0]
.GetClips()
.OrderBy(clip => clip.start)
.ThenBy(clip => clip.duration)
.ToArray();
if (clips.Length == 0)
{
throw new InvalidDataException(
"The generated Cinemachine track contains no clips.");
}
var starts = new int[clips.Length];
var ends = new int[clips.Length];
for (var index = 0; index < clips.Length; index++)
{
var clip = clips[index];
if (clip.easeInDuration > 0.0000001d ||
clip.easeOutDuration > 0.0000001d)
{
throw new InvalidDataException(
$"Cinemachine clip {index} has an ease/blend. " +
"Cut learning accepts hard cuts only.");
}
starts[index] = ToAlignedFrame(
clip.start,
$"Cinemachine clip {index} start");
ends[index] = ToAlignedFrame(
clip.end,
$"Cinemachine clip {index} end");
if (ends[index] <= starts[index])
{
throw new InvalidDataException(
$"Cinemachine clip {index} has no positive duration.");
}
}
if (starts[0] != 0)
{
throw new InvalidDataException(
"The generated Cinemachine track must begin at frame 0.");
}
for (var index = 1; index < clips.Length; index++)
{
if (starts[index] != ends[index - 1])
{
throw new InvalidDataException(
$"Cut boundary before clip {index} has a gap or " +
"overlap. Align both clip edges to the same frame " +
"before recording corrections.");
}
}
if (ends[ends.Length - 1] != frameCount)
{
throw new InvalidDataException(
$"The generated Cinemachine track must end at frame " +
$"{frameCount}.");
}
var boundaries = starts.Skip(1).ToArray();
ValidateBoundarySequence(boundaries, frameCount, "edited");
return boundaries;
}
private static int ToAlignedFrame(double seconds, string label)
{
if (double.IsNaN(seconds) || double.IsInfinity(seconds))
{
throw new InvalidDataException(
$"{label} is not a finite Timeline time.");
}
var frameValue = seconds * RequiredSampleRate;
var rounded = Math.Round(
frameValue,
MidpointRounding.AwayFromZero);
if (Math.Abs(frameValue - rounded) > FrameAlignmentTolerance)
{
throw new InvalidDataException(
$"{label} is not aligned to a {RequiredSampleRate} FPS " +
"frame boundary.");
}
if (rounded < int.MinValue || rounded > int.MaxValue)
{
throw new InvalidDataException(
$"{label} is outside the supported frame range.");
}
return (int)rounded;
}
private static CorrectionRecord[] BuildCorrections(
int[] originalBoundaries,
int[] finalBoundaries)
{
var originalCount = originalBoundaries.Length;
var finalCount = finalBoundaries.Length;
var costs = new double[originalCount + 1, finalCount + 1];
var steps = new DiffStep[originalCount + 1, finalCount + 1];
for (var originalIndex = 0;
originalIndex <= originalCount;
originalIndex++)
{
for (var finalIndex = 0;
finalIndex <= finalCount;
finalIndex++)
{
costs[originalIndex, finalIndex] = double.PositiveInfinity;
}
}
costs[0, 0] = 0d;
for (var originalIndex = 0;
originalIndex <= originalCount;
originalIndex++)
{
for (var finalIndex = 0;
finalIndex <= finalCount;
finalIndex++)
{
if (originalIndex == 0 && finalIndex == 0)
{
continue;
}
var bestCost = double.PositiveInfinity;
var bestPriority = int.MaxValue;
var bestStep = DiffStep.None;
if (originalIndex > 0 && finalIndex > 0)
{
var delta = Math.Abs(
originalBoundaries[originalIndex - 1] -
finalBoundaries[finalIndex - 1]);
if (delta <= MoveMatchWindowFrames)
{
var matchCost = delta == 0
? 0d
: 0.25d +
1.5d * delta / MoveMatchWindowFrames;
SelectCandidate(
costs[originalIndex - 1, finalIndex - 1] +
matchCost,
0,
DiffStep.Match,
ref bestCost,
ref bestPriority,
ref bestStep);
}
}
if (originalIndex > 0)
{
SelectCandidate(
costs[originalIndex - 1, finalIndex] + 1d,
1,
DiffStep.Delete,
ref bestCost,
ref bestPriority,
ref bestStep);
}
if (finalIndex > 0)
{
SelectCandidate(
costs[originalIndex, finalIndex - 1] + 1d,
2,
DiffStep.Add,
ref bestCost,
ref bestPriority,
ref bestStep);
}
costs[originalIndex, finalIndex] = bestCost;
steps[originalIndex, finalIndex] = bestStep;
}
}
var reverse = new List<CorrectionRecord>(
originalCount + finalCount);
var originalCursor = originalCount;
var finalCursor = finalCount;
while (originalCursor > 0 || finalCursor > 0)
{
switch (steps[originalCursor, finalCursor])
{
case DiffStep.Match:
{
var originalFrame =
originalBoundaries[originalCursor - 1];
var finalFrame = finalBoundaries[finalCursor - 1];
reverse.Add(new CorrectionRecord
{
action = originalFrame == finalFrame
? "keep"
: "move",
hasOriginal = true,
originalFrame = originalFrame,
originalOrdinal = originalCursor - 1,
hasFinal = true,
finalFrame = finalFrame,
finalOrdinal = finalCursor - 1,
deltaFrames = finalFrame - originalFrame
});
originalCursor--;
finalCursor--;
break;
}
case DiffStep.Delete:
reverse.Add(new CorrectionRecord
{
action = "delete",
hasOriginal = true,
originalFrame =
originalBoundaries[originalCursor - 1],
originalOrdinal = originalCursor - 1,
hasFinal = false,
finalFrame = -1,
finalOrdinal = -1,
deltaFrames = 0
});
originalCursor--;
break;
case DiffStep.Add:
reverse.Add(new CorrectionRecord
{
action = "add",
hasOriginal = false,
originalFrame = -1,
originalOrdinal = -1,
hasFinal = true,
finalFrame = finalBoundaries[finalCursor - 1],
finalOrdinal = finalCursor - 1,
deltaFrames = 0
});
finalCursor--;
break;
default:
throw new InvalidOperationException(
"Unable to construct a deterministic cut diff.");
}
}
reverse.Reverse();
return reverse.ToArray();
}
private static void SelectCandidate(
double candidateCost,
int candidatePriority,
DiffStep candidateStep,
ref double bestCost,
ref int bestPriority,
ref DiffStep bestStep)
{
if (double.IsPositiveInfinity(candidateCost))
{
return;
}
if (candidateCost < bestCost - CostEpsilon ||
(Math.Abs(candidateCost - bestCost) <= CostEpsilon &&
candidatePriority < bestPriority))
{
bestCost = candidateCost;
bestPriority = candidatePriority;
bestStep = candidateStep;
}
}
private static void ValidateBoundarySequence(
int[] boundaries,
int frameCount,
string label)
{
var previous = 0;
for (var index = 0; index < boundaries.Length; index++)
{
var frame = boundaries[index];
if (frame <= previous || frame >= frameCount)
{
throw new InvalidDataException(
$"The {label} cut boundary at index {index} is " +
"outside the song or not strictly increasing.");
}
previous = frame;
}
}
private static string AnonymizeEditorStyleId(string editorStyleId)
{
if (string.IsNullOrWhiteSpace(editorStyleId))
{
throw new ArgumentException(
"An editor style ID is required. Use a stable nickname " +
"that does not contain personal information.",
nameof(editorStyleId));
}
var normalized = editorStyleId.Trim();
if (normalized.StartsWith(
EditorStyleHashPrefix,
StringComparison.OrdinalIgnoreCase))
{
var existingHash = NormalizeSha256(
normalized.Substring(EditorStyleHashPrefix.Length),
"editor style ID");
return EditorStyleHashPrefix + existingHash;
}
return EditorStyleHashPrefix + ComputeSha256(
Encoding.UTF8.GetBytes(normalized));
}
private static string ComputeBoundarySha256(int[] boundaries)
{
var canonical = string.Join(
",",
boundaries.Select(frame =>
frame.ToString(CultureInfo.InvariantCulture)));
return ComputeSha256(Encoding.UTF8.GetBytes(canonical));
}
private static string ComputeSha256(byte[] bytes)
{
using var algorithm = SHA256.Create();
var hash = algorithm.ComputeHash(bytes);
var builder = new StringBuilder(hash.Length * 2);
foreach (var value in hash)
{
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private static string NormalizeSha256(string value, string label)
{
var normalized = value?.Trim().ToLowerInvariant();
if (normalized == null || normalized.Length != 64 ||
normalized.Any(character =>
!(character >= '0' && character <= '9') &&
!(character >= 'a' && character <= 'f')))
{
throw new InvalidDataException(
$"{label} must be a 64-character SHA-256 value.");
}
return normalized;
}
private static string ResolveSafePayloadPath(
string rootDirectory,
string relativePath,
string label)
{
if (string.IsNullOrWhiteSpace(relativePath) ||
Path.IsPathRooted(relativePath))
{
throw new InvalidDataException(
$"metadata.{label} must be a relative path.");
}
var segments = relativePath.Split(
new[] { '/', '\\' },
StringSplitOptions.None);
if (segments.Any(segment =>
string.IsNullOrWhiteSpace(segment) ||
segment == "." ||
segment == ".."))
{
throw new InvalidDataException(
$"metadata.{label} contains an unsafe path segment.");
}
var root = Path.GetFullPath(rootDirectory);
var candidate = Path.GetFullPath(
Path.Combine(root, relativePath));
var rootPrefix = root.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar) +
Path.DirectorySeparatorChar;
if (!candidate.StartsWith(
rootPrefix,
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"metadata.{label} escapes the generated directory.");
}
return candidate;
}
private static string RequireDirectoryPath(
string value,
string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException(
"A directory path is required.",
parameterName);
}
var path = Path.GetFullPath(value);
if (!Directory.Exists(path))
{
throw new DirectoryNotFoundException(
$"Directory was not found: {path}");
}
return path;
}
private static string WriteSnapshotAtomically(
string outputDirectory,
CorrectionSnapshot snapshot,
string json)
{
if (string.IsNullOrWhiteSpace(outputDirectory))
{
throw new ArgumentException(
"A correction log output directory is required.",
nameof(outputDirectory));
}
var outputRoot = Path.GetFullPath(outputDirectory);
Directory.CreateDirectory(outputRoot);
var songToken = SanitizeFileToken(snapshot.songId);
var timestampToken = DateTime.Parse(
snapshot.capturedUtc,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal |
DateTimeStyles.AdjustToUniversal)
.ToString(
"yyyyMMdd'T'HHmmssfff'Z'",
CultureInfo.InvariantCulture);
var baseName =
$"cut_correction_{songToken}_{timestampToken}_" +
$"{snapshot.generationHashSha256.Substring(0, 8)}_" +
$"{snapshot.finalBoundarySha256.Substring(0, 8)}";
var outputPath = Path.Combine(outputRoot, baseName + ".json");
for (var suffix = 2; File.Exists(outputPath); suffix++)
{
outputPath = Path.Combine(
outputRoot,
$"{baseName}_{suffix:D2}.json");
}
var temporaryPath =
outputPath + $".tmp-{Guid.NewGuid():N}";
try
{
File.WriteAllText(
temporaryPath,
json,
new UTF8Encoding(false));
File.Move(temporaryPath, outputPath);
}
finally
{
if (File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
}
return outputPath;
}
private static string SanitizeFileToken(string value)
{
var builder = new StringBuilder();
foreach (var character in value ?? string.Empty)
{
builder.Append(
char.IsLetterOrDigit(character) ||
character == '-' || character == '_'
? character
: '_');
if (builder.Length >= 48)
{
break;
}
}
return builder.Length == 0 ? "song" : builder.ToString();
}
private enum DiffStep
{
None,
Match,
Delete,
Add
}
private sealed class GenerationInput
{
internal GeneratedMetadata Metadata;
internal string MetadataSha256;
internal int[] OriginalBoundaryFrames;
}
[Serializable]
private sealed class GeneratedMetadata
{
public string songName;
public string songId;
public string datasetId;
public int sampleRate;
public int frameCount;
public string shotsFile;
public OutputSha256 outputSha256;
}
[Serializable]
private sealed class GeneratedShotFile
{
public List<GeneratedShot> shots;
}
[Serializable]
private sealed class GeneratedShot
{
public int startFrame;
public int endFrameExclusive;
}
[Serializable]
private sealed class CorrectionSnapshot
{
public string schemaVersion;
public string capturedUtc;
public int sampleRate;
public string songName;
public string songId;
public string datasetId;
public string generationHashSha256;
public OutputSha256 generationOutputSha256;
public string editorStyleId;
public string previewTimelineGuid;
public string matchingPolicy;
public int moveMatchWindowFrames;
public int ordinalBase;
public int[] originalBoundaryFrames;
public int[] finalBoundaryFrames;
public string originalBoundarySha256;
public string finalBoundarySha256;
public int originalBoundaryCount;
public int finalBoundaryCount;
public int keepCount;
public int moveCount;
public int addCount;
public int deleteCount;
public CorrectionRecord[] corrections;
}
[Serializable]
private sealed class OutputSha256
{
public string worldCamera;
public string time;
public string shots;
}
[Serializable]
private sealed class CorrectionRecord
{
public string action;
public bool hasOriginal;
public int originalFrame;
public int originalOrdinal;
public bool hasFinal;
public int finalFrame;
public int finalOrdinal;
public int deltaFrames;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ef4feff8a1c42a0be6affcf5d6429db
@@ -0,0 +1,951 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.UIElements;
namespace Streamingle.Editor
{
public sealed partial class AICameraGeneratorWindow
{
private static readonly string[] GeneratorUxmlPaths =
{
"Packages/com.mingle.cw-ai/Editor/UI/AICameraGeneratorWindow.uxml",
"Assets/Scripts/Editor/TimelineTools/UI/AICameraGeneratorWindow.uxml"
};
private static readonly string[] GeneratorUssPaths =
{
"Packages/com.mingle.cw-ai/Editor/UI/AICameraGeneratorWindow.uss",
"Assets/Scripts/Editor/TimelineTools/UI/AICameraGeneratorWindow.uss"
};
private VisualElement _generatorUiRoot;
private ScrollView _mainScroll;
private VisualElement _readinessCard;
private Label _readinessTitle;
private Label _readinessDetail;
private DropdownField _sourceDirectorDropdown;
private HelpBox _sourceSummaryBox;
private DropdownField _cutRhythmDropdown;
private Toggle _freshCandidateToggle;
private HelpBox _freshCandidateWarning;
private Button _generateFullButton;
private VisualElement _refinementCard;
private VisualElement _selectedShotPickerRow;
private HelpBox _selectedShotInfo;
private DropdownField _selectedShotDropdown;
private Foldout _shotOptionsFoldout;
private DropdownField _shotSizeDropdown;
private DropdownField _motionDropdown;
private DropdownField _compositionDropdown;
private Slider _motionIntensitySlider;
private FloatField _distanceField;
private Slider _bodyFollowSlider;
private Button _resetShotOptionsButton;
private Button _regenerateShotButton;
private Button _removeResultButton;
private VisualElement _finalCard;
private Foldout _feedbackFoldout;
private TextField _editorStyleIdField;
private Button _recordCorrectionButton;
private Button _saveFinalSceneButton;
private Foldout _advancedFoldout;
private VisualElement _advancedConfigurationHost;
private IMGUIContainer _advancedConfigurationGui;
private Button _extractDatasetButton;
private Button _reapplyFullButton;
private Button _reapplyShotButton;
private Button _buildCacheButton;
private Button _refreshDiagnosticsButton;
private Button _clearLogButton;
private TextField _logField;
private VisualElement _statusFooter;
private Label _statusMessage;
private Label _generationStageLabel;
private ProgressBar _generationProgress;
private Label _generationTimingLabel;
private HelpBox _appliedSeedBox;
private Button _statusLogButton;
private Button _cancelGenerationButton;
private IVisualElementScheduledItem _generatorUiRefreshItem;
private PlayableDirector[] _uiSourceDirectors =
Array.Empty<PlayableDirector>();
private PlayableDirector _uiRenderedSourceDirector;
private string _uiShotSignature = string.Empty;
private string _uiRenderedLog = string.Empty;
private bool _uiSourceReady;
private bool _uiLibraryReady;
private string _uiLibraryIssue = string.Empty;
private double _nextSourceValidationTime;
private double _nextLibraryValidationTime;
public void CreateGUI()
{
_generatorUiRefreshItem?.Pause();
var root = rootVisualElement;
root.Clear();
try
{
var uxmlPath = ResolveUiAssetPathForTests(false);
var visualTree = string.IsNullOrWhiteSpace(uxmlPath)
? null
: AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath);
if (visualTree == null)
{
throw new FileNotFoundException(
"AI 카메라 생성 UXML을 찾을 수 없습니다.",
uxmlPath);
}
visualTree.CloneTree(root);
var ussPath = ResolveUiAssetPathForTests(true);
var styleSheet = string.IsNullOrWhiteSpace(ussPath)
? null
: AssetDatabase.LoadAssetAtPath<StyleSheet>(ussPath);
if (styleSheet != null && !root.styleSheets.Contains(styleSheet))
{
root.styleSheets.Add(styleSheet);
}
CacheGeneratorUiElements();
BuildAdvancedConfigurationUi();
RegisterGeneratorUiCallbacks();
RefreshGeneratorUi(true);
if (styleSheet == null)
{
_generatorUiRoot.Insert(
0,
new HelpBox(
"스타일 파일을 찾지 못해 기본 Unity 스타일로 표시합니다.",
HelpBoxMessageType.Warning));
}
_generatorUiRefreshItem = root.schedule
.Execute(() => RefreshGeneratorUi(false))
.Every(200);
}
catch (Exception exception)
{
BuildGeneratorUiFallback(exception);
}
}
internal static string ResolveUiAssetPathForTests(bool styleSheet)
{
var candidates = styleSheet
? GeneratorUssPaths
: GeneratorUxmlPaths;
foreach (var candidate in candidates)
{
var asset = styleSheet
? (UnityEngine.Object)AssetDatabase.LoadAssetAtPath<StyleSheet>(
candidate)
: AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(candidate);
if (asset != null)
{
return candidate;
}
}
return string.Empty;
}
private void CacheGeneratorUiElements()
{
_generatorUiRoot = RequireUi<VisualElement>("camera-generator-root");
_mainScroll = RequireUi<ScrollView>("main-scroll");
_readinessCard = RequireUi<VisualElement>("readiness-card");
_readinessTitle = RequireUi<Label>("readiness-title");
_readinessDetail = RequireUi<Label>("readiness-detail");
_sourceDirectorDropdown =
RequireUi<DropdownField>("source-director-dropdown");
_sourceSummaryBox = RequireUi<HelpBox>("source-summary-box");
_cutRhythmDropdown =
RequireUi<DropdownField>("cut-rhythm-dropdown");
_freshCandidateToggle =
RequireUi<Toggle>("fresh-candidate-toggle");
_freshCandidateWarning =
RequireUi<HelpBox>("fresh-candidate-warning");
_generateFullButton = RequireUi<Button>("generate-full-button");
_refinementCard = RequireUi<VisualElement>("refinement-card");
_selectedShotPickerRow =
RequireUi<VisualElement>("selected-shot-picker-row");
_selectedShotInfo = RequireUi<HelpBox>("selected-shot-info");
_selectedShotDropdown =
RequireUi<DropdownField>("selected-shot-dropdown");
_shotOptionsFoldout = RequireUi<Foldout>("shot-options-foldout");
_shotSizeDropdown = RequireUi<DropdownField>("shot-size-dropdown");
_motionDropdown = RequireUi<DropdownField>("motion-dropdown");
_compositionDropdown =
RequireUi<DropdownField>("composition-dropdown");
_motionIntensitySlider =
RequireUi<Slider>("motion-intensity-slider");
_distanceField = RequireUi<FloatField>("distance-field");
_bodyFollowSlider = RequireUi<Slider>("body-follow-slider");
_resetShotOptionsButton =
RequireUi<Button>("reset-shot-options-button");
_regenerateShotButton =
RequireUi<Button>("regenerate-shot-button");
_removeResultButton = RequireUi<Button>("remove-result-button");
_finalCard = RequireUi<VisualElement>("final-card");
_feedbackFoldout = RequireUi<Foldout>("feedback-foldout");
_editorStyleIdField = RequireUi<TextField>("editor-style-id-field");
_recordCorrectionButton =
RequireUi<Button>("record-correction-button");
_saveFinalSceneButton = RequireUi<Button>("save-final-scene-button");
_advancedFoldout = RequireUi<Foldout>("advanced-foldout");
_advancedConfigurationHost =
RequireUi<VisualElement>("advanced-configuration-host");
_extractDatasetButton = RequireUi<Button>("extract-dataset-button");
_reapplyFullButton = RequireUi<Button>("reapply-full-button");
_reapplyShotButton = RequireUi<Button>("reapply-shot-button");
_buildCacheButton = RequireUi<Button>("build-cache-button");
_refreshDiagnosticsButton =
RequireUi<Button>("refresh-diagnostics-button");
_clearLogButton = RequireUi<Button>("clear-log-button");
_logField = RequireUi<TextField>("log-field");
_statusFooter = RequireUi<VisualElement>("status-footer");
_statusMessage = RequireUi<Label>("status-message");
_generationStageLabel =
RequireUi<Label>("generation-stage-label");
_generationProgress = RequireUi<ProgressBar>("generation-progress");
_generationTimingLabel =
RequireUi<Label>("generation-timing-label");
_appliedSeedBox = RequireUi<HelpBox>("applied-seed-box");
_statusLogButton = RequireUi<Button>("status-log-button");
_cancelGenerationButton =
RequireUi<Button>("cancel-generation-button");
}
private T RequireUi<T>(string name) where T : VisualElement
{
var element = rootVisualElement.Q<T>(name);
if (element == null)
{
throw new InvalidDataException(
$"AICameraGeneratorWindow.uxml에 '{name}' 요소가 없습니다.");
}
return element;
}
private void BuildAdvancedConfigurationUi()
{
_advancedConfigurationHost.Clear();
_advancedConfigurationGui = new IMGUIContainer(
DrawAdvancedConfigurationForUi);
_advancedConfigurationGui.AddToClassList("advanced-imgui");
_advancedConfigurationHost.Add(_advancedConfigurationGui);
_logField.multiline = true;
_logField.isReadOnly = true;
}
private void RegisterGeneratorUiCallbacks()
{
_sourceDirectorDropdown.RegisterValueChangedCallback(evt =>
{
var selectedIndex =
_sourceDirectorDropdown.choices.IndexOf(evt.newValue) - 1;
_sourceDirector = selectedIndex >= 0 &&
selectedIndex < _uiSourceDirectors.Length
? _uiSourceDirectors[selectedIndex]
: null;
RefreshShotNames();
RefreshGeneratorUi(true);
});
_cutRhythmDropdown.RegisterValueChangedCallback(evt =>
{
var selectedIndex =
_cutRhythmDropdown.choices.IndexOf(evt.newValue);
if (selectedIndex >= 0)
{
_cutRhythm = (CutRhythmPreference)selectedIndex;
}
});
_freshCandidateToggle.RegisterValueChangedCallback(evt =>
{
_forceFreshCandidatePool = evt.newValue;
RefreshGeneratorUi(false);
});
_generateFullButton.clicked += () =>
{
ExportGenerateAndImport();
RefreshGeneratorUi(true);
};
_selectedShotDropdown.RegisterValueChangedCallback(evt =>
{
var selectedIndex =
_selectedShotDropdown.choices.IndexOf(evt.newValue);
if (selectedIndex >= 0 && selectedIndex < _shotNames.Length)
{
_selectedShotIndex = selectedIndex;
}
});
_shotOptionsFoldout.RegisterValueChangedCallback(evt =>
_showDirectionSettings = evt.newValue);
_shotSizeDropdown.RegisterValueChangedCallback(evt =>
{
var selectedIndex = _shotSizeDropdown.choices.IndexOf(evt.newValue);
if (selectedIndex >= 0)
{
_shotSize = (ShotSizePreference)selectedIndex;
}
});
_motionDropdown.RegisterValueChangedCallback(evt =>
{
var selectedIndex = _motionDropdown.choices.IndexOf(evt.newValue);
if (selectedIndex >= 0)
{
_motion = (MotionPreference)selectedIndex;
}
});
_compositionDropdown.RegisterValueChangedCallback(evt =>
{
var selectedIndex =
_compositionDropdown.choices.IndexOf(evt.newValue);
if (selectedIndex >= 0)
{
_composition = (CompositionPreference)selectedIndex;
}
});
_motionIntensitySlider.RegisterValueChangedCallback(evt =>
_motionIntensity = evt.newValue);
_distanceField.RegisterValueChangedCallback(evt =>
_distanceMeters = Mathf.Max(0f, evt.newValue));
_bodyFollowSlider.RegisterValueChangedCallback(evt =>
_bodyFollowStrength = evt.newValue);
_resetShotOptionsButton.clicked += ResetSelectedShotOptions;
_regenerateShotButton.clicked += () =>
{
StartGeneration(ProcessCompletionAction.ReplaceSelectedShot);
RefreshGeneratorUi(false);
};
_removeResultButton.clicked += () =>
{
RunEditorAction(
"AI 카메라 결과를 제거하는 중...",
delegate
{
var result = RemoveScopedPreview();
RefreshShotNames();
return result;
});
RefreshGeneratorUi(true);
};
_editorStyleIdField.RegisterValueChangedCallback(evt =>
_editorStyleId = evt.newValue);
_recordCorrectionButton.clicked += () =>
{
RunEditorAction(
"컷 수정 기록을 저장하는 중...",
CaptureCutCorrectionSnapshot);
RefreshGeneratorUi(false);
};
_saveFinalSceneButton.clicked += SaveActiveSceneCopy;
_feedbackFoldout.SetValueWithoutNotify(false);
_advancedFoldout.SetValueWithoutNotify(_showAdvancedSettings);
_advancedFoldout.RegisterValueChangedCallback(evt =>
{
_showAdvancedSettings = evt.newValue;
if (evt.newValue)
{
_advancedConfigurationGui?.MarkDirtyRepaint();
RefreshGeneratorUi(true);
}
});
_extractDatasetButton.clicked += ExtractDatasetFromUi;
_reapplyFullButton.clicked += () =>
{
ImportFullResult(_generatedFolder);
RefreshGeneratorUi(true);
};
_reapplyShotButton.clicked += () =>
{
ImportSelectedShot(_generatedFolder, _selectedShotIndex);
RefreshGeneratorUi(true);
};
_buildCacheButton.clicked += () =>
{
StartPreparationCacheBuild();
RefreshGeneratorUi(false);
};
_refreshDiagnosticsButton.clicked += () =>
{
RefreshDatasetSongs(true);
ValidateOrAutoSelectSourceDirector();
RefreshShotNames();
RefreshGeneratorUi(true);
};
_clearLogButton.clicked += () =>
{
_log = string.Empty;
_uiRenderedLog = string.Empty;
_logField.SetValueWithoutNotify("아직 로그가 없습니다.");
};
_statusLogButton.clicked += () =>
{
_showAdvancedSettings = true;
_advancedFoldout.SetValueWithoutNotify(true);
RefreshGeneratorUi(true);
_mainScroll.schedule.Execute(() =>
_mainScroll.ScrollTo(_advancedFoldout));
};
_cancelGenerationButton.clicked += CancelGeneration;
_generatorUiRoot.RegisterCallback<GeometryChangedEvent>(evt =>
SetClassState(
_generatorUiRoot,
"narrow-layout",
evt.newRect.width > 0f && evt.newRect.width < 680f));
}
private void ResetSelectedShotOptions()
{
_shotSize = ShotSizePreference.Auto;
_motion = MotionPreference.Auto;
_composition = CompositionPreference.Auto;
_motionIntensity = 1f;
_distanceMeters = 0f;
_bodyFollowStrength = 0.18f;
RefreshGeneratorUi(false);
}
private void ExtractDatasetFromUi()
{
RunEditorAction(
"Timeline 데이터를 추출하는 중...",
delegate
{
var output = TimelineCameraDatasetExporter
.ExportAll60FpsForCli();
ConfigureExportedDataset(output, _songId.Trim());
return $"추출 완료: {output}\n" +
$"곡 {_datasetSongNames.Length:N0}개를 찾았습니다.";
});
RefreshGeneratorUi(true);
}
private void DrawAdvancedConfigurationForUi()
{
EditorGUILayout.LabelField(
"경로와 생성 세부 설정",
EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"일반 생성에는 변경할 필요가 없습니다. Worker 경로, 입력 ID, " +
"Seed와 키 단순화를 직접 조정할 때만 사용하세요.",
MessageType.Info);
using (new EditorGUI.DisabledScope(IsProcessRunning))
{
DrawPathSettings();
EditorGUILayout.Space(10f);
DrawTargetSettings();
}
}
private void RefreshGeneratorUi(bool refreshLists)
{
if (_generatorUiRoot == null || _generatorUiRoot.panel == null)
{
return;
}
if (refreshLists || _uiRenderedSourceDirector != _sourceDirector)
{
RefreshSourceDirectorDropdown();
_nextSourceValidationTime = 0d;
}
var shotSignature = string.Join("\u001f", _shotNames);
if (refreshLists || !string.Equals(
shotSignature,
_uiShotSignature,
StringComparison.Ordinal))
{
RefreshShotDropdown();
_uiShotSignature = shotSignature;
}
RefreshSourceSummary(refreshLists);
RefreshGenerationLibraryStatus(refreshLists);
RefreshReadiness();
RefreshGenerationControls();
RefreshRefinementControls();
RefreshStatusFooter();
RefreshAdvancedControls();
}
private void RefreshSourceDirectorDropdown()
{
_uiSourceDirectors = GetAvailableSourceDirectors();
if (_sourceDirector != null &&
!_uiSourceDirectors.Contains(_sourceDirector))
{
_sourceDirector = null;
}
if (_sourceDirector == null && _uiSourceDirectors.Length == 1)
{
_sourceDirector = _uiSourceDirectors[0];
RefreshShotNames();
}
var choices = new List<string>();
if (_uiSourceDirectors.Length == 0)
{
choices.Add("현재 씬에 사용할 Timeline이 없습니다");
}
else
{
choices.Add("Timeline을 선택하세요");
choices.AddRange(_uiSourceDirectors.Select(GetSourceDirectorLabel));
}
_sourceDirectorDropdown.choices = choices;
var currentIndex = Array.IndexOf(_uiSourceDirectors, _sourceDirector);
var choiceIndex = _uiSourceDirectors.Length == 0
? 0
: Mathf.Clamp(currentIndex + 1, 0, choices.Count - 1);
_sourceDirectorDropdown.SetValueWithoutNotify(choices[choiceIndex]);
_uiRenderedSourceDirector = _sourceDirector;
}
private void RefreshSourceSummary(bool force)
{
var now = EditorApplication.timeSinceStartup;
if (!force && now < _nextSourceValidationTime)
{
return;
}
_nextSourceValidationTime = now + 1d;
if (_sourceDirector == null)
{
_uiSourceReady = false;
_sourceSummaryBox.text =
"모션과 음원이 들어 있는 원본 Timeline을 선택하세요.";
_sourceSummaryBox.messageType = HelpBoxMessageType.Warning;
return;
}
try
{
var summary = TimelineCameraDatasetExporter
.GetGenerationInputSummaryForEditor(_sourceDirector);
_uiSourceReady = summary.isValid;
if (!summary.isValid)
{
_sourceSummaryBox.text = string.IsNullOrWhiteSpace(summary.error)
? "캐릭터 모션과 음원을 확인할 수 없습니다."
: summary.error;
_sourceSummaryBox.messageType = HelpBoxMessageType.Error;
return;
}
_sourceSummaryBox.text =
$"캐릭터 {summary.characterName}\n" +
$"음원 {summary.audioClipName} · " +
$"{summary.duration:F1}초";
_sourceSummaryBox.messageType = HelpBoxMessageType.Info;
}
catch (Exception exception)
{
_uiSourceReady = false;
_sourceSummaryBox.text = exception.Message;
_sourceSummaryBox.messageType = HelpBoxMessageType.Error;
}
}
private void RefreshReadiness()
{
var running = IsProcessRunning;
var ready = _uiSourceReady && _uiLibraryReady;
SetClassState(_readinessCard, "readiness--ready", !running && ready);
SetClassState(_readinessCard, "readiness--running", running);
SetClassState(
_readinessCard,
"readiness--warning",
!running && !ready);
if (running)
{
_readinessTitle.text = "카메라를 생성하고 있습니다";
_readinessDetail.text =
"현재 단계와 예상 남은 시간은 아래 상태 영역에서 확인할 수 있습니다.";
}
else if (!_uiLibraryReady)
{
_readinessTitle.text = "생성 도구 설정이 필요합니다";
_readinessDetail.text = _uiLibraryIssue;
}
else if (_uiSourceReady)
{
_readinessTitle.text = "Timeline 입력 확인 완료";
_readinessDetail.text =
"모션과 음원이 확인되었습니다. 전체 생성 버튼을 눌러 시작하세요.";
}
else if (_sourceDirector == null)
{
_readinessTitle.text = "Timeline 선택이 필요합니다";
_readinessDetail.text =
"현재 씬에서 모션과 음원이 있는 Timeline을 선택하세요.";
}
else
{
_readinessTitle.text = "입력 구성을 확인해주세요";
_readinessDetail.text =
"선택한 Timeline에서 캐릭터 모션 또는 음원을 찾지 못했습니다.";
}
}
private void RefreshGenerationLibraryStatus(bool force)
{
var now = EditorApplication.timeSinceStartup;
if (!force && now < _nextLibraryValidationTime)
{
return;
}
_nextLibraryValidationTime = now + 1d;
var repaired = TryAutoRepairGenerationLibraryRoot();
var root = string.IsNullOrWhiteSpace(_cwAiRoot)
? string.Empty
: Path.GetFullPath(_cwAiRoot);
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
_uiLibraryReady = false;
_uiLibraryIssue =
"참조 데이터 폴더를 찾지 못했습니다. 고급·진단에서 경로를 확인하세요.";
return;
}
if (!Directory.Exists(Path.Combine(root, "DatasetExports")) ||
!File.Exists(Path.Combine(
root,
"reports",
"training_index.json")) ||
!File.Exists(Path.Combine(
root,
"models",
"cut_ranker_v2.json")))
{
_uiLibraryReady = false;
_uiLibraryIssue =
"참조 데이터가 완전하지 않습니다. 고급·진단에서 CW-AI 루트를 확인하세요.";
return;
}
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
!File.Exists(_cliExecutable))
{
_cliExecutable = ResolveCliExecutable(true);
}
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
!File.Exists(_cliExecutable))
{
_uiLibraryReady = false;
_uiLibraryIssue =
"CWCameraWorker를 찾지 못했습니다. 고급·진단에서 실행 파일을 확인하세요.";
return;
}
_uiLibraryReady = true;
_uiLibraryIssue = string.Empty;
if (repaired &&
!string.IsNullOrWhiteSpace(_status) &&
_status.IndexOf(
"training_index",
StringComparison.OrdinalIgnoreCase) >= 0)
{
_status =
"참조 데이터 경로를 자동 복구했습니다. 전체 생성을 다시 실행하세요.";
}
}
private void RefreshGenerationControls()
{
var running = IsProcessRunning;
_sourceDirectorDropdown.SetEnabled(
!running && _uiSourceDirectors.Length > 0);
_cutRhythmDropdown.choices =
new List<string>(SimpleCutRhythmNames);
_cutRhythmDropdown.SetValueWithoutNotify(
SimpleCutRhythmNames[Mathf.Clamp(
(int)_cutRhythm,
0,
SimpleCutRhythmNames.Length - 1)]);
_cutRhythmDropdown.SetEnabled(!running);
_freshCandidateToggle.SetValueWithoutNotify(_forceFreshCandidatePool);
_freshCandidateToggle.SetEnabled(!running);
SetDisplayed(_freshCandidateWarning, _forceFreshCandidatePool);
_generateFullButton.text = running
? "카메라 생성 중..."
: FullAutonomousGenerationButtonLabel;
_generateFullButton.SetEnabled(
!running && _uiSourceReady && _uiLibraryReady);
}
private void RefreshShotDropdown()
{
var choices = _shotNames.Length > 0
? new List<string>(_shotNames)
: new List<string> { "가져온 AI 카메라가 없습니다" };
_selectedShotDropdown.choices = choices;
_selectedShotIndex = _shotNames.Length == 0
? 0
: Mathf.Clamp(_selectedShotIndex, 0, _shotNames.Length - 1);
_selectedShotDropdown.SetValueWithoutNotify(
choices[Mathf.Clamp(_selectedShotIndex, 0, choices.Count - 1)]);
}
private void RefreshRefinementControls()
{
var hasShots = _shotNames.Length > 0;
var running = IsProcessRunning;
_editorStyleIdField.SetValueWithoutNotify(_editorStyleId);
_recordCorrectionButton.SetEnabled(
hasShots &&
!running &&
!string.IsNullOrWhiteSpace(_editorStyleId) &&
_sourceDirector != null);
_saveFinalSceneButton.SetEnabled(hasShots && !running);
SetDisplayed(_refinementCard, hasShots);
SetDisplayed(_finalCard, hasShots);
SetDisplayed(_feedbackFoldout, hasShots);
if (!hasShots)
{
return;
}
var selectedFromTimeline =
TrySyncSelectedShotFromEditor(out var selectedCameraName);
_selectedShotIndex = Mathf.Clamp(
_selectedShotIndex,
0,
_shotNames.Length - 1);
_selectedShotDropdown.SetValueWithoutNotify(
_shotNames[_selectedShotIndex]);
SetDisplayed(_selectedShotPickerRow, !selectedFromTimeline);
_selectedShotInfo.text = selectedFromTimeline
? $"Timeline/Hierarchy 선택 {selectedCameraName} · " +
$"카메라 {_selectedShotIndex:D3}"
: "Timeline의 AI 카메라를 선택하면 자동으로 연결됩니다. " +
"아래 목록에서 직접 선택할 수도 있습니다.";
_selectedShotInfo.messageType = selectedFromTimeline
? HelpBoxMessageType.Info
: HelpBoxMessageType.None;
_shotOptionsFoldout.SetValueWithoutNotify(_showDirectionSettings);
_shotOptionsFoldout.SetEnabled(!running);
_shotSizeDropdown.choices = new List<string>(SimpleShotSizeNames);
_shotSizeDropdown.SetValueWithoutNotify(
SimpleShotSizeNames[Mathf.Clamp(
(int)_shotSize,
0,
SimpleShotSizeNames.Length - 1)]);
_motionDropdown.choices = new List<string>(SimpleMotionNames);
_motionDropdown.SetValueWithoutNotify(
SimpleMotionNames[Mathf.Clamp(
(int)_motion,
0,
SimpleMotionNames.Length - 1)]);
_compositionDropdown.choices =
new List<string>(SimpleCompositionNames);
_compositionDropdown.SetValueWithoutNotify(
SimpleCompositionNames[Mathf.Clamp(
(int)_composition,
0,
SimpleCompositionNames.Length - 1)]);
_motionIntensitySlider.SetValueWithoutNotify(_motionIntensity);
_distanceField.SetValueWithoutNotify(_distanceMeters);
_bodyFollowSlider.SetValueWithoutNotify(_bodyFollowStrength);
_regenerateShotButton.text = selectedFromTimeline
? "선택한 카메라 재생성"
: "목록의 카메라 재생성";
_regenerateShotButton.tooltip = selectedFromTimeline
? SelectedCameraRegenerationButtonLabel
: ListedCameraRegenerationButtonLabel;
_regenerateShotButton.SetEnabled(!running);
_removeResultButton.SetEnabled(!running);
}
private void RefreshStatusFooter()
{
var running = IsProcessRunning;
var status = string.IsNullOrWhiteSpace(_status)
? "준비됨"
: _status.Trim();
_statusMessage.text = status;
var isError = status.IndexOf(
"실패",
StringComparison.OrdinalIgnoreCase) >= 0 ||
status.IndexOf(
"오류",
StringComparison.OrdinalIgnoreCase) >= 0 ||
status.IndexOf(
"찾을 수 없",
StringComparison.OrdinalIgnoreCase) >= 0;
SetClassState(_statusFooter, "status--running", running);
SetClassState(_statusFooter, "status--error", !running && isError);
SetClassState(_statusFooter, "status--ready", !running && !isError);
SetDisplayed(_statusLogButton, !running && isError);
if (_generationOperationActive)
{
var stageText = _generationStageIndex > 0
? $"{_generationStageIndex}/{_generationStageCount}단계 · " +
_generationStage
: _generationStage;
_generationStageLabel.text = stageText;
SetDisplayed(_generationStageLabel, true);
SetDisplayed(
_generationProgress,
_generationStageProgress >= 0f);
if (_generationStageProgress >= 0f)
{
_generationProgress.value = Mathf.Clamp01(
_generationStageProgress);
_generationProgress.title = stageText;
}
_generationTimingLabel.text = BuildRunningTimingText();
SetDisplayed(_generationTimingLabel, true);
}
else
{
SetDisplayed(_generationStageLabel, false);
SetDisplayed(_generationProgress, false);
var recentDuration = _lastCompletedDurationSeconds > 0d
? _lastCompletedDurationSeconds
: EditorPrefs.GetFloat(
RuntimeHistoryKey("LastSeconds"),
-1f);
_generationTimingLabel.text = recentDuration > 0d
? "최근 생성 " + FormatObservedDuration(recentDuration)
: string.Empty;
SetDisplayed(
_generationTimingLabel,
!isError && recentDuration > 0d);
}
SetDisplayed(_cancelGenerationButton, running);
_cancelGenerationButton.SetEnabled(
running && _cliRunner?.CancellationRequested != true);
SetDisplayed(_appliedSeedBox, _hasLastAppliedSeed);
if (_hasLastAppliedSeed)
{
_appliedSeedBox.text = BuildAppliedSeedSummary(
_lastAppliedSeed,
_seed,
_lastAppliedSeedWasSelectedShot,
_lastAppliedSeedShotIndex);
}
}
private void RefreshAdvancedControls()
{
_advancedFoldout.SetValueWithoutNotify(_showAdvancedSettings);
_extractDatasetButton.SetEnabled(!IsProcessRunning);
_reapplyFullButton.SetEnabled(!IsProcessRunning);
_reapplyShotButton.SetEnabled(
!IsProcessRunning && _shotNames.Length > 0);
_buildCacheButton.SetEnabled(
!IsProcessRunning && _uiLibraryReady);
_refreshDiagnosticsButton.SetEnabled(!IsProcessRunning);
if (_showAdvancedSettings)
{
_advancedConfigurationGui?.MarkDirtyRepaint();
var logText = string.IsNullOrWhiteSpace(_log)
? "아직 로그가 없습니다."
: _log;
if (!string.Equals(
logText,
_uiRenderedLog,
StringComparison.Ordinal))
{
_uiRenderedLog = logText;
_logField.SetValueWithoutNotify(logText);
}
}
}
private void BuildGeneratorUiFallback(Exception exception)
{
var root = rootVisualElement;
root.Clear();
var helpBox = new HelpBox(
"새 UI를 불러오지 못해 호환 화면으로 전환했습니다.\n" +
exception.Message,
HelpBoxMessageType.Error);
helpBox.style.marginLeft = 8f;
helpBox.style.marginRight = 8f;
helpBox.style.marginTop = 8f;
root.Add(helpBox);
var legacyContainer = new IMGUIContainer(DrawLegacyGui);
legacyContainer.style.flexGrow = 1f;
root.Add(legacyContainer);
UnityEngine.Debug.LogException(exception);
}
private void OnSelectionChange()
{
RefreshGeneratorUi(false);
}
private void OnHierarchyChange()
{
if (_generatorUiRoot == null)
{
return;
}
ValidateOrAutoSelectSourceDirector();
RefreshShotNames();
RefreshGeneratorUi(true);
}
private static void SetDisplayed(VisualElement element, bool displayed)
{
if (element != null)
{
element.style.display = displayed
? DisplayStyle.Flex
: DisplayStyle.None;
}
}
private static void SetClassState(
VisualElement element,
string className,
bool enabled)
{
if (enabled)
{
element.AddToClassList(className);
}
else
{
element.RemoveFromClassList(className);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91c38b82e8714b4da4f92c32e990cc51
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ff40a5b8a5b43e492463c46ba8f4995
@@ -0,0 +1,41 @@
using UnityEngine;
namespace Streamingle.Editor
{
/// <summary>
/// Preview-only lineage stored as a hidden Timeline subasset. This lives in
/// the Editor assembly and is removed when a durable Final Timeline is made.
/// </summary>
public sealed class AICameraPreviewGenerationProvenance : ScriptableObject
{
internal const string CurrentSchemaVersion =
"ai-camera-preview-generation-provenance-v1";
[SerializeField, HideInInspector]
private string _schemaVersion = CurrentSchemaVersion;
[SerializeField, HideInInspector]
private string _cwAiRelativeGeneratedFolder = string.Empty;
[SerializeField, HideInInspector]
private string _absoluteGeneratedFolder = string.Empty;
[SerializeField, HideInInspector]
private string _metadataSha256 = string.Empty;
internal string SchemaVersion => _schemaVersion;
internal string CwAiRelativeGeneratedFolder =>
_cwAiRelativeGeneratedFolder;
internal string AbsoluteGeneratedFolder => _absoluteGeneratedFolder;
internal string MetadataSha256 => _metadataSha256;
internal void SetValues(
string cwAiRelativeGeneratedFolder,
string absoluteGeneratedFolder,
string metadataSha256)
{
_schemaVersion = CurrentSchemaVersion;
_cwAiRelativeGeneratedFolder =
cwAiRelativeGeneratedFolder ?? string.Empty;
_absoluteGeneratedFolder = absoluteGeneratedFolder ?? string.Empty;
_metadataSha256 = metadataSha256 ?? string.Empty;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d8f3a1c2e4b54f6789012345abcde678
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 31604c3bc92fd194484b9159429dd3c2
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Mingle.CWAI.Editor.Tests")]
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a1a242e13969456688f2467d4acf4930
@@ -0,0 +1,21 @@
{
"name": "Mingle.CWAI.Editor",
"rootNamespace": "Streamingle.Editor",
"references": [
"Unity.Cinemachine",
"Unity.Cinemachine.Editor",
"Unity.Recorder.Editor",
"Unity.Timeline"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c99c6ba712c380b43bd392841b1eb410
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c3ab4b71dae56314c981d64b2d3fd3d7
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47fb91e0c133d204d84638cf2b4a98ea
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8c8c76ce67b4489299ad2b2afb1cdf3d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,522 @@
.camera-generator-root {
flex-grow: 1;
background-color: #17191d;
color: #e7eaf0;
}
.main-scroll {
flex-grow: 1;
}
.page {
align-self: center;
max-width: 920px;
padding: 12px 16px 18px 16px;
width: 100%;
}
.hero {
background-color: #20242a;
border-color: #343941;
border-radius: 10px;
border-width: 1px;
margin-bottom: 8px;
padding: 14px 16px;
}
.hero-row,
.card-heading,
.button-row,
.field-pair,
.log-heading,
.status-footer,
.readiness-card,
.flow-strip {
flex-direction: row;
}
.hero-row,
.card-heading,
.log-heading,
.status-footer,
.readiness-card {
align-items: center;
}
.hero-copy,
.card-heading-copy,
.readiness-copy,
.status-copy {
flex-grow: 1;
flex-shrink: 1;
}
.eyebrow {
color: #67d5bd;
font-size: 10px;
letter-spacing: 1.6px;
-unity-font-style: bold;
}
.hero-title {
color: #ffffff;
font-size: 22px;
margin-top: 2px;
-unity-font-style: bold;
}
.hero-description {
color: #b8bec8;
font-size: 11px;
margin-top: 4px;
white-space: normal;
}
.backend-badge,
.recommended-badge {
background-color: #173d36;
border-color: #2b6c5f;
border-radius: 10px;
border-width: 1px;
color: #8ce9d3;
font-size: 10px;
margin-left: 12px;
padding: 5px 9px;
-unity-font-style: bold;
}
.flow-strip {
flex-wrap: nowrap;
margin-top: 11px;
}
.flow-item {
background-color: #181b20;
border-radius: 4px;
color: #aeb5c0;
flex-basis: 0;
flex-grow: 1;
font-size: 9px;
margin-right: 3px;
min-width: 0;
padding: 5px 3px;
-unity-text-align: middle-center;
}
.readiness-card {
background-color: #22262b;
border-color: #454b54;
border-radius: 7px;
border-width: 1px;
border-left-width: 3px;
margin-bottom: 8px;
padding: 8px 11px;
}
.readiness-dot {
background-color: #d5a84a;
border-radius: 5px;
height: 10px;
margin-right: 10px;
width: 10px;
}
.readiness-title {
color: #f2f4f7;
font-size: 12px;
-unity-font-style: bold;
}
.readiness-detail {
color: #a8afb9;
font-size: 10px;
margin-top: 2px;
white-space: normal;
}
.readiness--ready {
border-left-color: #4fba93;
}
.readiness--ready .readiness-dot {
background-color: #56d3a6;
}
.readiness--ready .readiness-detail {
display: none;
}
.readiness--running {
border-left-color: #5d9fe8;
}
.readiness--running .readiness-dot {
background-color: #69adf4;
}
.readiness--warning {
border-left-color: #d5a84a;
}
.workflow-card,
.utility-foldout {
background-color: #22252a;
border-color: #343941;
border-radius: 8px;
border-width: 1px;
margin-bottom: 8px;
padding: 11px 12px;
}
.generation-card {
border-color: #343941;
}
.refinement-card {
display: none;
}
.card-heading {
margin-bottom: 8px;
}
.step-badge {
background-color: #2a7968;
border-radius: 13px;
color: #ffffff;
font-size: 11px;
height: 26px;
margin-right: 10px;
width: 26px;
-unity-font-style: bold;
-unity-text-align: middle-center;
}
.card-title {
color: #f3f5f7;
font-size: 14px;
-unity-font-style: bold;
}
.card-description,
.foldout-description,
.inline-note {
color: #b0b7c1;
font-size: 11px;
white-space: normal;
}
.card-description {
margin-top: 2px;
}
.form-field,
.compact-field,
.summary-box,
.fresh-toggle,
.inline-note {
margin-bottom: 8px;
}
.camera-generator-root .unity-base-field__label {
color: #c6ccd5;
min-width: 112px;
}
.camera-generator-root .unity-base-field__input,
.camera-generator-root .unity-popup-field__input {
background-color: #17191d;
border-color: #3a4049;
color: #e7eaf0;
}
.field-pair {
flex-wrap: wrap;
}
.compact-field {
flex-grow: 1;
margin-right: 8px;
min-width: 210px;
}
.inner-foldout {
background-color: #1c1f23;
border-radius: 6px;
margin-bottom: 10px;
padding: 9px;
}
.inner-foldout > .unity-foldout__toggle,
.utility-foldout > .unity-foldout__toggle {
color: #e3e6eb;
-unity-font-style: bold;
}
.fresh-toggle {
margin-top: 4px;
}
.hidden-box,
.applied-seed-box,
.generation-stage,
.generation-progress,
.footer-action,
.cancel-action {
display: none;
}
.primary-action,
.secondary-action,
.quiet-action,
.danger-action,
.cancel-action,
.mini-action {
border-radius: 5px;
-unity-font-style: bold;
}
.primary-action {
background-color: #2b8a74;
border-color: #43a890;
color: #ffffff;
font-size: 13px;
height: 42px;
margin-top: 6px;
}
.primary-action:hover {
background-color: #329a82;
}
.secondary-action {
background-color: #315f87;
border-color: #477aa4;
color: #ffffff;
height: 34px;
}
.secondary-action:hover {
background-color: #3a6f9c;
}
.quiet-action {
background-color: #2a2e34;
border-color: #424851;
color: #d7dbe1;
min-height: 28px;
}
.quiet-action:hover {
background-color: #343941;
}
.danger-action,
.cancel-action {
background-color: #512d32;
border-color: #764049;
color: #f1c2c8;
height: 34px;
min-width: 130px;
}
.danger-action:hover,
.cancel-action:hover {
background-color: #66363d;
}
.button-row {
flex-wrap: wrap;
margin-top: 6px;
}
.button-row > Button {
margin-right: 6px;
margin-top: 4px;
}
.grow-action {
flex-grow: 1;
min-width: 185px;
}
.final-card .secondary-action {
height: 38px;
}
.utility-foldout {
margin-top: 4px;
}
.foldout-description {
margin-bottom: 10px;
margin-left: 18px;
margin-top: 7px;
}
.advanced-configuration-host {
background-color: #1a1c20;
border-radius: 5px;
margin: 8px 0 10px 18px;
padding: 8px;
}
.advanced-imgui {
min-height: 330px;
}
.subsection-title {
color: #dce0e6;
font-size: 11px;
margin-left: 18px;
margin-top: 8px;
-unity-font-style: bold;
}
.advanced-actions,
.log-field {
margin-left: 18px;
}
.log-heading {
justify-content: space-between;
margin-top: 8px;
}
.mini-action {
background-color: #292d32;
border-color: #40464f;
color: #cdd2d9;
font-size: 9px;
height: 22px;
min-width: 56px;
}
.log-field {
height: 190px;
margin-top: 5px;
}
.status-footer {
background-color: #1d2025;
border-color: #343941;
border-top-width: 1px;
flex-shrink: 0;
min-height: 48px;
padding: 7px 14px;
}
.status-copy {
margin-right: 12px;
}
.status-kicker {
color: #7f8995;
font-size: 9px;
letter-spacing: 0.6px;
-unity-font-style: bold;
}
.status-message {
color: #e6e9ee;
font-size: 11px;
margin-top: 2px;
max-height: 38px;
white-space: normal;
}
.generation-stage {
color: #7fb7f3;
font-size: 10px;
margin-top: 4px;
-unity-font-style: bold;
}
.generation-progress {
height: 17px;
margin-top: 4px;
}
.generation-timing {
color: #929ba7;
font-size: 10px;
margin-top: 3px;
}
.applied-seed-box {
margin-top: 5px;
}
.status--ready {
border-top-color: #346b5d;
}
.status--running {
border-top-color: #4d84bd;
}
.status--error {
background-color: #251d20;
border-top-color: #8c4650;
}
.status--error .status-message {
color: #f2b9c1;
}
.cancel-action {
flex-shrink: 0;
}
.footer-action {
background-color: #2b3037;
border-color: #474e58;
border-radius: 5px;
color: #d8dde5;
flex-shrink: 0;
height: 30px;
margin-right: 6px;
min-width: 74px;
-unity-font-style: bold;
}
.narrow-layout .backend-badge {
display: none;
}
.narrow-layout .page {
padding-left: 12px;
padding-right: 12px;
}
.narrow-layout .field-pair,
.narrow-layout .button-row {
flex-direction: column;
}
.narrow-layout .compact-field,
.narrow-layout .button-row > Button {
margin-right: 0;
min-width: 0;
width: 100%;
}
.narrow-layout .camera-generator-root .unity-base-field__label,
.camera-generator-root.narrow-layout .unity-base-field__label {
min-width: 104px;
}
.summary-box .unity-help-box__icon {
height: 16px;
margin-right: 6px;
width: 16px;
}
.summary-box .unity-help-box__label {
font-size: 10px;
}
.camera-generator-root Button:disabled,
.camera-generator-root .unity-base-field:disabled {
opacity: 0.46;
}
@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: f39e900b5f7d47ab9f05d60ac33a6c68
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
disableValidation: 0
unsupportedSelectorAction: 0
@@ -0,0 +1,136 @@
<ui:UXML xmlns:ui="UnityEngine.UIElements">
<ui:VisualElement name="camera-generator-root" class="camera-generator-root">
<ui:ScrollView name="main-scroll" class="main-scroll">
<ui:VisualElement class="page">
<ui:VisualElement class="hero">
<ui:VisualElement class="hero-row">
<ui:VisualElement class="hero-copy">
<ui:Label text="CAMERA WORK AI" class="eyebrow" />
<ui:Label text="AI 카메라 생성" class="hero-title" />
<ui:Label text="Timeline의 모션과 음원으로 편집 가능한 Cinemachine 카메라를 생성합니다." class="hero-description" />
</ui:VisualElement>
<ui:Label text="고품질 Worker" class="backend-badge" />
</ui:VisualElement>
<ui:VisualElement class="flow-strip">
<ui:Label text="1 입력" class="flow-item" />
<ui:Label text="2 전체 생성" class="flow-item" />
<ui:Label text="3 샷 다듬기" class="flow-item" />
<ui:Label text="4 저장" class="flow-item" />
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement name="readiness-card" class="readiness-card readiness--warning">
<ui:VisualElement class="readiness-dot" />
<ui:VisualElement class="readiness-copy">
<ui:Label name="readiness-title" text="Timeline 선택이 필요합니다" class="readiness-title" />
<ui:Label name="readiness-detail" text="현재 씬에서 모션과 음원이 있는 Timeline을 선택하세요." class="readiness-detail" />
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement class="workflow-card">
<ui:VisualElement class="card-heading">
<ui:Label text="1" class="step-badge" />
<ui:VisualElement class="card-heading-copy">
<ui:Label text="입력 Timeline" class="card-title" />
<ui:Label text="모션과 음원이 있는 Timeline을 선택합니다. 기존 카메라는 사용하지 않습니다." class="card-description" />
</ui:VisualElement>
</ui:VisualElement>
<ui:DropdownField name="source-director-dropdown" label="원본 Timeline" class="form-field" />
<ui:HelpBox name="source-summary-box" message-type="Warning" text="모션과 음원이 들어 있는 원본 Timeline을 선택하세요." class="summary-box" />
</ui:VisualElement>
<ui:VisualElement class="workflow-card generation-card">
<ui:VisualElement class="card-heading">
<ui:Label text="2" class="step-badge" />
<ui:VisualElement class="card-heading-copy">
<ui:Label text="전체 카메라 자율 생성" class="card-title" />
<ui:Label text="학습된 컷 리듬과 카메라 문법으로 전체 구간을 자유롭게 구성합니다." class="card-description" />
</ui:VisualElement>
</ui:VisualElement>
<ui:DropdownField name="cut-rhythm-dropdown" label="컷 리듬" tooltip="자동은 음악, 동작과 작업자 컷 데이터를 함께 분석합니다." class="form-field" />
<ui:Label text="거리·무빙·구도는 전체 생성 후 원하는 샷만 따로 조절할 수 있습니다." class="inline-note" />
<ui:Toggle name="fresh-candidate-toggle" text="후보 풀도 새로 계산 (느림)" tooltip="캐시를 사용하지 않고 이번 후보를 처음부터 다시 계산합니다." class="fresh-toggle" />
<ui:HelpBox name="fresh-candidate-warning" message-type="Warning" text="후보 풀을 다시 계산하므로 이번 생성은 더 오래 걸립니다." class="hidden-box" />
<ui:Button name="generate-full-button" text="전체 카메라 자율 생성" class="primary-action" />
</ui:VisualElement>
<ui:VisualElement name="refinement-card" class="workflow-card refinement-card">
<ui:VisualElement class="card-heading">
<ui:Label text="3" class="step-badge" />
<ui:VisualElement class="card-heading-copy">
<ui:Label text="선택한 카메라 다듬기" class="card-title" />
<ui:Label text="Timeline 또는 Hierarchy에서 카메라를 선택한 뒤 필요한 샷만 다시 생성합니다." class="card-description" />
</ui:VisualElement>
</ui:VisualElement>
<ui:HelpBox name="selected-shot-info" message-type="None" text="Timeline의 AI 카메라를 선택하면 자동으로 연결됩니다." class="summary-box" />
<ui:VisualElement name="selected-shot-picker-row">
<ui:DropdownField name="selected-shot-dropdown" label="카메라 직접 선택" class="form-field" />
</ui:VisualElement>
<ui:Foldout name="shot-options-foldout" text="재생성 옵션" value="false" class="inner-foldout">
<ui:Label text="아래 설정은 선택한 카메라 한 개에만 적용됩니다." class="inline-note" />
<ui:VisualElement class="field-pair">
<ui:DropdownField name="shot-size-dropdown" label="샷 거리" class="compact-field" />
<ui:DropdownField name="composition-dropdown" label="인물 구도" class="compact-field" />
</ui:VisualElement>
<ui:DropdownField name="motion-dropdown" label="카메라 움직임" class="form-field" />
<ui:Slider name="motion-intensity-slider" label="움직임 강도" low-value="0" high-value="2" show-input-field="true" class="form-field" />
<ui:FloatField name="distance-field" label="직접 거리 (m)" tooltip="0이면 선택한 샷 거리와 캐릭터 크기에 맞춰 자동 결정합니다." class="form-field" />
<ui:Slider name="body-follow-slider" label="몸 추종 강도" low-value="0" high-value="1" show-input-field="true" tooltip="0이면 무대 기준으로 안정적이고, 1이면 캐릭터 이동을 강하게 따라갑니다." class="form-field" />
<ui:Button name="reset-shot-options-button" text="옵션을 자동으로 되돌리기" class="quiet-action" />
</ui:Foldout>
<ui:VisualElement class="button-row">
<ui:Button name="regenerate-shot-button" text="선택한 카메라 재생성" class="secondary-action grow-action" />
<ui:Button name="remove-result-button" text="생성 결과 지우기" class="danger-action" />
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement name="final-card" class="workflow-card final-card">
<ui:VisualElement class="card-heading">
<ui:Label text="4" class="step-badge" />
<ui:VisualElement class="card-heading-copy">
<ui:Label text="완성본 보존" class="card-title" />
<ui:Label text="현재 씬을 원본과 별개의 AI_Camera_Final 씬으로 저장합니다." class="card-description" />
</ui:VisualElement>
</ui:VisualElement>
<ui:Button name="save-final-scene-button" text="완성본 씬으로 저장" class="secondary-action" />
</ui:VisualElement>
<ui:Foldout name="feedback-foldout" text="선택 사항 · 컷 수정 기록" value="false" class="utility-foldout">
<ui:Label text="AI 카메라 컷을 직접 수정한 뒤 기록하면 유지한 컷과 변경한 컷을 익명 데이터로 저장합니다. 씬과 Timeline 자체는 저장하지 않습니다." class="foldout-description" />
<ui:TextField name="editor-style-id-field" label="익명 편집 스타일 ID" class="form-field" />
<ui:Button name="record-correction-button" text="컷 수정 기록 저장" class="quiet-action" />
</ui:Foldout>
<ui:Foldout name="advanced-foldout" text="고급 · 진단" value="false" class="utility-foldout advanced-foldout">
<ui:Label text="경로, Worker, Seed, 캐시와 수동 복구는 문제가 있거나 세부 조정이 필요할 때만 사용하세요." class="foldout-description" />
<ui:VisualElement name="advanced-configuration-host" class="advanced-configuration-host" />
<ui:Label text="수동 작업" class="subsection-title" />
<ui:VisualElement class="button-row advanced-actions">
<ui:Button name="extract-dataset-button" text="Timeline 데이터만 추출" class="quiet-action grow-action" />
<ui:Button name="reapply-full-button" text="현재 결과 전체 다시 적용" class="quiet-action grow-action" />
<ui:Button name="reapply-shot-button" text="현재 결과로 선택 카메라 교체" class="quiet-action grow-action" />
<ui:Button name="build-cache-button" text="준비 캐시 만들기·갱신" class="quiet-action grow-action" />
<ui:Button name="refresh-diagnostics-button" text="목록·상태 새로고침" class="quiet-action grow-action" />
</ui:VisualElement>
<ui:VisualElement class="log-heading">
<ui:Label text="실행 로그" class="subsection-title" />
<ui:Button name="clear-log-button" text="지우기" class="mini-action" />
</ui:VisualElement>
<ui:TextField name="log-field" multiline="true" is-read-only="true" class="log-field" />
</ui:Foldout>
</ui:VisualElement>
</ui:ScrollView>
<ui:VisualElement name="status-footer" class="status-footer status--ready">
<ui:VisualElement class="status-copy">
<ui:Label name="status-message" text="모션과 음원이 들어 있는 Timeline을 선택하세요." class="status-message" />
<ui:Label name="generation-stage-label" text="" class="generation-stage" />
<ui:ProgressBar name="generation-progress" low-value="0" high-value="1" value="0" class="generation-progress" />
<ui:Label name="generation-timing-label" text="생성을 시작하면 단계와 예상 시간을 여기에 표시합니다." class="generation-timing" />
<ui:HelpBox name="applied-seed-box" message-type="Info" text="" class="applied-seed-box" />
</ui:VisualElement>
<ui:Button name="status-log-button" text="로그 보기" class="footer-action" />
<ui:Button name="cancel-generation-button" text="생성 취소" class="cancel-action" />
</ui:VisualElement>
</ui:VisualElement>
</ui:UXML>
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 8c71572bfd3749108603d0fe48f1f20b
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}