feat: add Camera Work AI package
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Mingle.CWAI.Editor.Tests")]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user