feat: add Camera Work AI package

This commit is contained in:
KINDNICK 2026-08-03 04:42:44 +09:00
parent 26c578416f
commit 0a30156023
2187 changed files with 119610 additions and 1 deletions

4
.gitattributes vendored
View File

@ -1 +1,5 @@
*.meta -whitespace *.meta -whitespace
CameraAI~/Tools~/CWCameraWorker/** -diff -whitespace
*.exe filter=lfs diff=lfs merge=lfs -text
*.dll filter=lfs diff=lfs merge=lfs -text
*.pyd filter=lfs diff=lfs merge=lfs -text

View File

@ -1,5 +1,15 @@
# Changelog # Changelog
## 0.1.5
- Added the separately installable `Mingle Camera Work AI` package under
`CameraAI~`, including the frozen Windows x64 `CWCameraWorker` distribution.
- Kept authored camera, character-motion, and source-audio reference data in
the access-controlled CW-AI data library rather than mirroring it here.
- Documented Git LFS, Unity Package Manager, data-root, and first-run setup.
- Moved Camera AI caches to the consuming Unity project's writable
`Library/CWAI` directory so the reference-data library can be read-only.
## 0.1.4 ## 0.1.4
- Fixed player build compile error: `ScheduleEditorFinalEvaluation` call in `BlendshapeGazeMixerBehaviour.ProcessFrame` was not wrapped in `#if UNITY_EDITOR`. - Fixed player build compile error: `ScheduleEditorFinalEvaluation` call in `BlendshapeGazeMixerBehaviour.ProcessFrame` was not wrapped in `#if UNITY_EDITOR`.

185
CameraAI~/CHANGELOG.md Normal file
View File

@ -0,0 +1,185 @@
# Changelog
## 0.4.1 - 2026-08-03
- Added the UXML/USS camera-generation workflow with a compact narrow-dock
layout, full preflight readiness, result-only refinement/save stages, and a
compact status footer.
- Fixed stale Unity-project folders being mistaken for a complete CW-AI data
library; the window now validates the training index and cut model and
automatically repairs the path to a complete sibling library.
- Prepared the package for the Streamingle Utilities Git distribution with the
frozen Windows x64 `CWCameraWorker` folder under `Tools~`; artist machines no
longer need a separate Python installation.
- Moved generated preparation and candidate caches out of the reference-data
library and into the Unity project's writable `Library/CWAI` directory. An
optional `CWAI_CACHE_ROOT` environment variable can override that location.
## 0.4.0 - 2026-08-02
- Replaced the Unity-native generation backends with the packaged
`CWCameraWorker.exe` high-quality pipeline.
- Added `generate`, `prepare-cache`, `version --json`, and `doctor` CLI
contracts with structured progress events and cooperative cancellation.
- Split generation-time data preparation from training-only dependencies; the
verified Windows onedir worker is 316.54 MiB and requires no separate
Python installation on artist workstations.
- Kept full-Timeline and selected-shot generation, per-camera AnimationClips,
curve simplification, provenance validation, and cut-correction capture in
the Unity Editor workflow.
- Removed the portable native runtime, exporter, generator implementation, and
their dedicated tests. Existing generated results and parity reports remain
importable historical artifacts.
## 0.3.5 - 2026-08-02
- Added the Unity-native Portable HQ v1 path: authored trajectory retargeting,
global cut dynamic programming, random-forest sequence-beam planning, and
multi-candidate safety/quality selection now run as one deterministic
generation pipeline.
- Materialized `unity_native_portable_hq_v1_arisa_final_pass1` as a 60-shot,
12,269-frame, 60 FPS Timeline. It contains 60 independent cameras,
Animators, Animation Tracks, and AnimationClips, with zero runtime
LookAt/Tracking bindings and zero template fallbacks.
- Preserved hard cuts with zero ease and an intentional 1 microsecond boundary
guard. The final run completed in 17.701 seconds; the isolated EditMode suite
passed 161/161.
- Verified byte-identical output for the same Seed. A different Seed changed
the selected authored template in 35% of shots and materially changed 32.8%
of frames. Real beat/mel features and the deduplicated weighted duration
prior remain explicit parity gaps.
- Added the experimental `작업자 카메라 기반 (Unity 내장 · 실험)` backend.
It loads a portable JSON/float32 runtime bundle containing 1,381 authored
camera templates and retargets them entirely inside Unity without requiring
Python at generation time.
- Added a content-addressed, hash-validated runtime-bundle exporter and Unity
loader. The original preparation cache is trusted only during explicit
export; the published runtime bundle contains no pickle or executable
serialization.
- Verified the initial Unity smoke run on the 12,269-frame input: 51 shots,
51 authored-template shots, zero template fallbacks, and 51 independent
camera Animators. At that checkpoint the path did not yet include the
high-quality cut planner, candidate retarget/re-score, or complete
composition pass; Portable HQ v1 above supersedes that limitation.
- Added an explicit Unity cut-correction snapshot workflow. It compares the
provenance-bound generated `shots.json` with edited hard-cut clip starts and
records ordered `keep/move/add/delete` operations without modifying or saving
the Scene or Timeline asset.
- Added causal three-shot cut context (duration, music/motion energy, beat
proximity), schema-v2 closed-loop ranker training, deterministic bounded
history-beam planning, v1 artifact rejection, and complete context trace
provenance. The v2 model passes its no-model non-regression gate but does not
yet replace the legacy cut path.
- Added the 60 FPS data-driven cut planner: broad music/motion candidates,
duplicate-aware authored duration prior, exact-audio soft prior, portable
listwise ranker, and deterministic global DAG optimization with trace output.
- Added high-quality full-generation cut-rhythm controls
(Auto/Slow/Normal/Fast), while selected-shot regeneration continues to lock
every existing cut.
- Added source-audio SHA-256 export so the generator can reuse approved timing
evidence for the same recording without copying camera trajectories.
- Added a shape-preserving relative-trajectory velocity limiter. The final
67-shot Arisa composite reduced within-shot position spikes over
`0.15m/frame` from `12` to `0` (`0.17551m` max to `0.11991m`) while preserving
visibility, cut boundaries, every non-selected shot, and baked-aim quality.
- Verified the final `12,269`-frame/67-shot/60 FPS preview in Unity with
compile `0/0`, related EditMode `65/65`, and no new Console errors after
Timeline import.
- Added true high-quality selected-shot generation with
`--only-shot-index`/`--base-generation`, skipping full-sequence planning and
preserving every non-selected camera frame, shot row, cut, and Unity clip.
- Added full-compatible materialized composites with base hashes and selected
span provenance, plus a separate Seed-independent per-shot candidate cache.
- Advanced the next base and Seed only after successful Timeline import, and
rejected incompatible Native/stale bases before launching Python.
- Added selected-shot ETA/cache reporting and regression coverage for paired
CLI arguments, fail-closed base validation, exact preservation, and A -> B
sequential regeneration; independent finite/coverage/person/aim/spike gates
pass on the final Arisa composites.
- Hardened base loading with exact JSON integer/numeric contracts, pre-read
payload byte-size checks, and direct non-reparse payload paths. Only
`selected_shot_directives.json` is accepted as an output sidecar.
- Added best-effort transactional directory publication with validated staging,
single-orphan-backup recovery, and fail-closed handling for ambiguous or
malformed recovery state.
- Preserved the legacy selection string while adding authoritative
`materializedShotSeedLineage` and an identical-base-hash reproducibility scope
for mixed-Seed composites.
- Kept the archived Arisa `2.058s/1.293s` selected-shot benchmark and recorded a
separate latest-user-base `2.489s/1.498s` result with `20/20` invariants.
Final-source hit revalidation was `1.430752s` total (`0.0006751s` candidate
retarget). Python is `215/215` with py_compile/Ruff passing; Unity EditMode is
`59/59`, importer validation accepted `12,269` frames/52 shots/60 FPS, and
compilation reported `0/0`.
## 0.3.4 - 2026-08-01
- Made Hybrid Seed deterministically select from camera candidates that pass
person-safety checks and are visually distinct after final composition.
- Kept the same Seed reproducible and made adjacent Seeds select different
candidates whenever a shot has at least two safe, distinct candidates.
- Limited variation candidates to a bounded score delta from the best safe
camera and preserved the full legacy safety-rescue search when needed.
- Decoupled the Seed-selected output from the deterministic sequence context,
preventing later shots from converging back to the same camera.
- Preserved explicit `static` direction and safety constraints; a shot may
remain unchanged when only one safe, distinct candidate exists.
- Clarified in the generator UI that reapplying an existing result does not
generate a new candidate and therefore does not use Seed.
- Distinguished full-result Seed state from a single regenerated camera, and
no longer reports an applied Seed when Timeline import fails.
## 0.3.3 - 2026-08-01
- Added live generation stages, elapsed time, and a backend-specific ETA range
that learns from completed timelines while keeping cache preparation separate.
- Bound every generated Cinemachine camera to its own `Animator`, Animation
Track, and `.anim` asset so offsets and curves can be edited per camera.
- Initialized every per-camera Track Offset from its first generated pose so
finite clips preserve world height instead of collapsing toward the floor.
- Added Timeline clip, animation asset, and Hierarchy camera selection for
regenerating exactly one generated shot while preserving all other shots.
- Prevented automatically directed opening shots from becoming static unless
the user explicitly requests a static shot or zero motion intensity.
- Made preview scope identifiers stable across script recompiles and Editor
reloads so selected-camera regeneration keeps resolving the same preview.
- Removed each scoped preview asset folder atomically to prevent orphaned clips
and duplicate-suffixed Timeline assets after repeated preview rebuilds.
## 0.3.2 - 2026-07-31
- Replaced the development-first generator layout with a Timeline-select and
one-click generation workflow.
- Hid repository paths, Python, dataset IDs, seeds, caches, and manual import
controls under an advanced foldout.
- Added localized optional direction controls and result-only per-camera
regeneration/final-save actions.
- Added a short Korean menu path while retaining the legacy menu entry.
- Improved multi-song one-click selection by matching the chosen source
Timeline to the freshly exported song manifest.
- Added a camera-free generation-input export for Timelines containing only
character motion and audio, including frame-aligned RMS/onset features.
- Existing Cinemachine tracks are explicitly ignored during generation-input
export; the simple workflow no longer exposes a “dataset song” selector.
- Allowed Hybrid generation targets outside the training index while keeping
the indexed worker-camera reference library split- and leakage-guarded.
## 0.3.1 - 2026-07-31
- Fixed multi-song Hybrid selection by distinguishing 16-hex song IDs from names.
- Added explicit source `PlayableDirector` scoping for preview import, removal, and regeneration.
- Made Final scene copies own independent Timeline and animation assets.
- Made Native safety validation fail closed for missing/non-finite joints.
- Rebased non-zero source Timeline time samples while preserving provenance.
- Added complete Native seed, global direction, and directives provenance metadata.
## 0.3.0 - 2026-07-31
- Added Python-free `Native Fast` camera generation inside the Unity Editor.
- Added learned `Python Hybrid Quality` generation controls and per-shot JSON directions.
- Stabilized camera bodies with shot-fixed stage anchors and low-frequency follow.
- Added fail-closed character-envelope framing checks and safe distance fallback.
- Added per-camera animation clips and selected-shot regeneration with GUID preservation.
- Added Exact, Balanced, and Editable error-bounded curve simplification presets.
- Added a content-addressed Hybrid preparation-cache workflow shared across
candidate seeds.

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ccccb305b4444bc4bcfd3d4fba5b0044

View File

@ -0,0 +1,65 @@
# External workstation installation
## Supported environment
- Windows 10/11 x64
- Unity 6000.0 or newer
- Git 2.14 or newer available on `PATH`
- Git LFS installed before Unity resolves the package
The bundled `CWCameraWorker` contains Python 3.10 and its inference
dependencies. Do not install Python on an artist workstation for this package.
The worker is an onedir build: `CWCameraWorker.exe` and its `_internal` folder
must remain together.
## Install from Git
In Unity Package Manager, choose **Add package from git URL** and enter:
```text
https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.5
```
The initial package download is large because the frozen Windows worker is
stored with Git LFS. If Git LFS was missing during the first resolve, install
it, remove the package lock entry, and add the package again.
## Connect the reference data library
The public utility package deliberately excludes authored camera, character
motion, and source audio data. Obtain the access-controlled `CW-AI` data
library separately with Git LFS and use one of these layouts:
1. Put `CW-AI` next to the Unity project directory.
2. Set the `CWAI_ROOT` environment variable to its absolute path before
launching Unity.
3. Open `Tools > Streamingle > AI 카메라 생성`, expand `고급 · 진단`, and
choose the `참조 데이터 루트` manually.
A valid data root contains all of the following:
```text
CW-AI/
DatasetExports/
reports/training_index.json
models/cut_ranker_v2.json
```
The generation window automatically finds the worker inside the installed
package and validates the reference data before enabling generation.
The reference library may be read-only. Generation caches are stored under
the current Unity project's `Library/CWAI` directory. Set `CWAI_CACHE_ROOT`
before launching Unity only when a different writable cache location is
required.
## First run
1. Open `Tools > Streamingle > AI 카메라 생성`.
2. Confirm the panel shows `Timeline 입력 확인 완료` after selecting a
Timeline containing character animation and audio.
3. Leave `후보 풀도 새로 계산 (느림)` disabled for the normal fast path.
4. Run `전체 카메라 자율 생성`.
Generated files and caches are written to the Unity project, never into the
reference library or the immutable Package Cache.

8
CameraAI~/Editor.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ae8819ff3bf95914eadcfefcdabb48ad
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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.");
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9e61af0fef774b80bbf7fc1121b9ae29

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 249ad3cd8eec495a8419e994e185a41b

View File

@ -0,0 +1,210 @@
using System;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Editor
{
public sealed class AICameraABRenderWindow : EditorWindow
{
private string candidateADirectory = string.Empty;
private string candidateBDirectory = string.Empty;
private string outputRoot = string.Empty;
private int startFrame;
private int endFrameExclusive = 1;
private AICameraABRenderCodec codec =
AICameraABRenderCodec.MP4;
private int width = 1920;
private int height = 1080;
private Vector2 scrollPosition;
[MenuItem("Tools/Streamingle/Timeline/Render AI Camera A-B")]
public static void Open()
{
GetWindow<AICameraABRenderWindow>(
"AI Camera A-B Render");
}
private void OnEnable()
{
if (string.IsNullOrWhiteSpace(outputRoot))
{
outputRoot = Path.GetFullPath(Path.Combine(
Application.dataPath,
"..",
"Recordings",
"CWAI_AB"));
}
}
private void OnInspectorUpdate()
{
Repaint();
}
private void OnGUI()
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
EditorGUILayout.LabelField(
"AI Camera A-B Movie Render",
EditorStyles.boldLabel);
EditorGUILayout.LabelField(
"Renders two generated camera candidates against the same " +
"saved Scene, Timeline frame range, and Timeline audio.",
EditorStyles.wordWrappedLabel);
EditorGUILayout.Space();
DrawFolderField(
"Candidate A",
ref candidateADirectory,
"Select generated camera candidate A");
DrawFolderField(
"Candidate B",
ref candidateBDirectory,
"Select generated camera candidate B");
DrawFolderField(
"Output Root",
ref outputRoot,
"Select A-B movie output directory");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Frame Range", EditorStyles.boldLabel);
startFrame = EditorGUILayout.IntField(
"Start Frame",
startFrame);
endFrameExclusive = EditorGUILayout.IntField(
"End Frame (Exclusive)",
endFrameExclusive);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Movie", EditorStyles.boldLabel);
codec = (AICameraABRenderCodec)EditorGUILayout.EnumPopup(
"Codec",
codec);
width = EditorGUILayout.IntField("Width", width);
height = EditorGUILayout.IntField("Height", height);
EditorGUILayout.HelpBox(
"MP4 uses H.264 and requires even dimensions. WebM uses VP8. " +
"Both candidates use MainCamera and include Timeline audio.",
MessageType.Info);
EditorGUILayout.Space();
DrawActiveJob();
EditorGUILayout.Space();
var hasActive = AICameraABRenderRunner.TryGetActiveStatus(
out _,
out var phase,
out _);
var nonTerminalJob =
hasActive &&
!AICameraABRenderUtility.IsTerminalPhase(phase);
using (new EditorGUI.DisabledScope(nonTerminalJob))
{
if (GUILayout.Button("Start A-B Render", GUILayout.Height(30)))
{
StartRender();
}
}
EditorGUILayout.EndScrollView();
}
private void DrawActiveJob()
{
if (!AICameraABRenderRunner.TryGetActiveStatus(
out var jobId,
out var phase,
out var manifestPath))
{
EditorGUILayout.LabelField("No active A-B render job.");
return;
}
EditorGUILayout.LabelField("Current Job", EditorStyles.boldLabel);
EditorGUILayout.SelectableLabel(
jobId,
EditorStyles.textField,
GUILayout.Height(EditorGUIUtility.singleLineHeight));
EditorGUILayout.LabelField("Phase", phase);
EditorGUILayout.LabelField(
"Manifest",
manifestPath,
EditorStyles.wordWrappedLabel);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Reveal Manifest") &&
!string.IsNullOrWhiteSpace(manifestPath))
{
EditorUtility.RevealInFinder(manifestPath);
}
using (new EditorGUI.DisabledScope(
AICameraABRenderUtility.IsTerminalPhase(phase)))
{
if (GUILayout.Button("Cancel"))
{
AICameraABRenderRunner.CancelABRenderForCli(jobId);
}
}
}
}
private void StartRender()
{
try
{
var request = new AICameraABRenderRequest
{
candidateADirectory = candidateADirectory,
candidateBDirectory = candidateBDirectory,
outputRoot = outputRoot,
startFrame = startFrame,
endFrameExclusive = endFrameExclusive,
codec = codec,
width = width,
height = height
};
var started = AICameraABRenderRunner.Start(request);
Debug.Log(
$"CW-AI A/B render started. Manifest: " +
$"{started.manifestPath}");
}
catch (Exception exception)
{
Debug.LogException(exception);
EditorUtility.DisplayDialog(
"Unable to start A-B render",
exception.Message,
"OK");
}
}
private static void DrawFolderField(
string label,
ref string value,
string dialogTitle)
{
using (new EditorGUILayout.HorizontalScope())
{
value = EditorGUILayout.TextField(label, value);
if (GUILayout.Button("Browse", GUILayout.Width(70)))
{
var initialDirectory = Directory.Exists(value)
? value
: Directory.GetParent(Application.dataPath)?.FullName ??
string.Empty;
var selected = EditorUtility.OpenFolderPanel(
dialogTitle,
initialDirectory,
string.Empty);
if (!string.IsNullOrWhiteSpace(selected))
{
value = selected;
}
}
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e32ae9d4a42347859c368bc701901af7

View File

@ -0,0 +1,604 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;
namespace Streamingle.Editor
{
internal sealed class AICameraCliRunner : IDisposable
{
internal const string ExecutableName = "CWCameraWorker.exe";
internal const string EventProtocolVersion = "1";
private readonly ConcurrentQueue<ProcessMessage> messages =
new ConcurrentQueue<ProcessMessage>();
private Process process;
private string cancellationFile = string.Empty;
private bool cancellationRequested;
private bool exitObserved;
private int exitCode;
internal bool IsRunning
{
get
{
if (process == null || exitObserved)
{
return false;
}
try
{
return !process.HasExited;
}
catch (InvalidOperationException)
{
return false;
}
}
}
internal bool CancellationRequested => cancellationRequested;
internal void Start(ProcessRequest request)
{
if (process != null)
{
throw new InvalidOperationException(
"A camera worker process is already attached to this runner.");
}
ValidateRequest(request);
cancellationFile = Path.GetFullPath(request.CancellationFile);
var cancellationDirectory = Path.GetDirectoryName(cancellationFile);
if (string.IsNullOrWhiteSpace(cancellationDirectory))
{
throw new InvalidOperationException(
"The camera worker cancellation directory is invalid.");
}
Directory.CreateDirectory(cancellationDirectory);
if (File.Exists(cancellationFile))
{
File.Delete(cancellationFile);
}
var startInfo = new ProcessStartInfo
{
FileName = Path.GetFullPath(request.ExecutablePath),
Arguments = JoinArguments(request.Arguments),
WorkingDirectory = Path.GetFullPath(request.WorkingDirectory),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
startInfo.EnvironmentVariables["PYTHONUTF8"] = "1";
startInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = false
};
process.OutputDataReceived += OnOutputDataReceived;
process.ErrorDataReceived += OnErrorDataReceived;
try
{
if (!process.Start())
{
throw new InvalidOperationException(
"CWCameraWorker could not be started.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
catch
{
DisposeProcess();
throw;
}
}
internal bool TryDequeueMessage(out ProcessMessage message)
{
return messages.TryDequeue(out message);
}
internal bool TryGetExitCode(out int completedExitCode)
{
completedExitCode = 0;
if (process == null)
{
return false;
}
if (!exitObserved)
{
try
{
if (!process.HasExited)
{
return false;
}
// The parameterless overload also waits for redirected
// asynchronous output handlers to receive their final line.
process.WaitForExit();
exitCode = process.ExitCode;
exitObserved = true;
}
catch (InvalidOperationException)
{
return false;
}
}
completedExitCode = exitCode;
return true;
}
internal bool RequestCancellation()
{
if (process == null || exitObserved || cancellationRequested)
{
return false;
}
var directory = Path.GetDirectoryName(cancellationFile);
if (string.IsNullOrWhiteSpace(directory))
{
throw new InvalidOperationException(
"The camera worker cancellation path is unavailable.");
}
Directory.CreateDirectory(directory);
File.WriteAllText(
cancellationFile,
DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
new UTF8Encoding(false));
cancellationRequested = true;
return true;
}
internal void CompleteAndDispose()
{
if (process != null && !exitObserved)
{
throw new InvalidOperationException(
"A running camera worker cannot be disposed as completed.");
}
DisposeProcess();
TryDeleteCancellationFile();
}
public void Dispose()
{
DisposeProcess();
if (exitObserved)
{
TryDeleteCancellationFile();
}
}
internal static string ResolveExecutable(
string configuredPath,
string cwAiRoot,
string projectRoot,
string packageRoot = "")
{
var candidates = new List<string>();
AddCandidate(candidates, configuredPath);
AddCandidate(
candidates,
Environment.GetEnvironmentVariable(
"CWAI_WORKER_EXECUTABLE"));
AddRootCandidates(candidates, cwAiRoot);
if (!string.IsNullOrWhiteSpace(projectRoot))
{
AddCandidate(
candidates,
Path.Combine(
projectRoot,
"Tools",
"CWCameraWorker",
ExecutableName));
}
var physicalPackageRoot = packageRoot;
if (string.IsNullOrWhiteSpace(physicalPackageRoot))
{
try
{
physicalPackageRoot = FileUtil.GetPhysicalPath(
"Packages/com.mingle.cw-ai");
}
catch (Exception)
{
physicalPackageRoot = string.Empty;
}
}
if (!string.IsNullOrWhiteSpace(physicalPackageRoot))
{
AddCandidate(
candidates,
Path.Combine(
physicalPackageRoot,
"Tools~",
"CWCameraWorker",
ExecutableName));
AddCandidate(
candidates,
Path.Combine(
physicalPackageRoot,
"Tools~",
ExecutableName));
}
return candidates
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(Path.GetFullPath)
.FirstOrDefault(File.Exists) ?? string.Empty;
}
internal static string JoinArguments(IEnumerable<string> arguments)
{
if (arguments == null)
{
throw new ArgumentNullException(nameof(arguments));
}
return string.Join(" ", arguments.Select(QuoteArgument));
}
internal static string QuoteArgument(string argument)
{
argument ??= string.Empty;
if (argument.Length > 0 &&
argument.All(character =>
!char.IsWhiteSpace(character) && character != '"'))
{
return argument;
}
var result = new StringBuilder(argument.Length + 2);
result.Append('"');
var backslashCount = 0;
foreach (var character in argument)
{
if (character == '\\')
{
backslashCount++;
continue;
}
if (character == '"')
{
result.Append('\\', backslashCount * 2 + 1);
result.Append('"');
backslashCount = 0;
continue;
}
result.Append('\\', backslashCount);
backslashCount = 0;
result.Append(character);
}
result.Append('\\', backslashCount * 2);
result.Append('"');
return result.ToString();
}
internal static bool TryParseEvent(
string line,
out CliEvent parsedEvent)
{
parsedEvent = default;
var normalized = (line ?? string.Empty).Trim();
const string prefix = "CWAI_EVENT ";
if (normalized.StartsWith(prefix, StringComparison.Ordinal))
{
normalized = normalized.Substring(prefix.Length).TrimStart();
}
if (normalized.Length < 2 ||
normalized[0] != '{' ||
normalized[normalized.Length - 1] != '}')
{
return false;
}
CliEventPayload payload;
try
{
payload = JsonUtility.FromJson<CliEventPayload>(normalized);
}
catch (ArgumentException)
{
return false;
}
var eventType = FirstNotEmpty(
payload?.type,
payload?.eventType,
payload?.@event);
if (string.IsNullOrWhiteSpace(eventType))
{
return false;
}
var hasProgress = ContainsJsonProperty(normalized, "progress");
var hasEta = ContainsJsonProperty(normalized, "etaSeconds");
parsedEvent = new CliEvent(
eventType.Trim(),
payload.protocolVersion ?? string.Empty,
payload.jobId ?? string.Empty,
payload.phase ?? string.Empty,
payload.message ?? string.Empty,
hasProgress
? Mathf.Clamp01(payload.progress)
: -1f,
hasEta && IsFinite(payload.etaSeconds)
? Math.Max(0d, payload.etaSeconds)
: -1d,
payload.outputPath ?? string.Empty,
payload.error ?? string.Empty);
return true;
}
private static void ValidateRequest(ProcessRequest request)
{
if (string.IsNullOrWhiteSpace(request.ExecutablePath) ||
!File.Exists(request.ExecutablePath))
{
throw new FileNotFoundException(
"CWCameraWorker.exe was not found.",
request.ExecutablePath);
}
if (string.IsNullOrWhiteSpace(request.WorkingDirectory) ||
!Directory.Exists(request.WorkingDirectory))
{
throw new DirectoryNotFoundException(
"The camera worker working directory was not found: " +
request.WorkingDirectory);
}
if (request.Arguments == null || request.Arguments.Count == 0)
{
throw new ArgumentException(
"The camera worker command is required.",
nameof(request));
}
if (string.IsNullOrWhiteSpace(request.CancellationFile))
{
throw new ArgumentException(
"The camera worker cancellation file is required.",
nameof(request));
}
}
private static void AddRootCandidates(
ICollection<string> candidates,
string root)
{
if (string.IsNullOrWhiteSpace(root))
{
return;
}
AddCandidate(
candidates,
Path.Combine(root, "dist", "CWCameraWorker", ExecutableName));
AddCandidate(
candidates,
Path.Combine(root, "dist", ExecutableName));
AddCandidate(
candidates,
Path.Combine(root, "tools", "CWCameraWorker", ExecutableName));
AddCandidate(
candidates,
Path.Combine(root, "CWCameraWorker", ExecutableName));
AddCandidate(candidates, Path.Combine(root, ExecutableName));
}
private static void AddCandidate(
ICollection<string> candidates,
string candidate)
{
if (!string.IsNullOrWhiteSpace(candidate))
{
candidates.Add(candidate.Trim());
}
}
private static string FirstNotEmpty(params string[] values)
{
return values.FirstOrDefault(
value => !string.IsNullOrWhiteSpace(value)) ??
string.Empty;
}
private static bool ContainsJsonProperty(string json, string name)
{
return json.IndexOf(
"\"" + name + "\"",
StringComparison.Ordinal) >= 0;
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
private void OnOutputDataReceived(object sender, DataReceivedEventArgs args)
{
EnqueueLine(args.Data, false);
}
private void OnErrorDataReceived(object sender, DataReceivedEventArgs args)
{
EnqueueLine(args.Data, true);
}
private void EnqueueLine(string line, bool isError)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
if (TryParseEvent(line, out var parsedEvent))
{
messages.Enqueue(new ProcessMessage(parsedEvent));
return;
}
messages.Enqueue(new ProcessMessage(line, isError));
}
private void DisposeProcess()
{
if (process == null)
{
return;
}
process.OutputDataReceived -= OnOutputDataReceived;
process.ErrorDataReceived -= OnErrorDataReceived;
process.Dispose();
process = null;
}
private void TryDeleteCancellationFile()
{
if (string.IsNullOrWhiteSpace(cancellationFile) ||
!File.Exists(cancellationFile))
{
return;
}
try
{
File.Delete(cancellationFile);
}
catch (IOException)
{
// A stale sentinel is deleted before the next job starts.
}
catch (UnauthorizedAccessException)
{
// Preserve the completed result even if local cleanup is denied.
}
}
[Serializable]
private sealed class CliEventPayload
{
public string type;
public string eventType;
public string @event;
public string protocolVersion;
public string jobId;
public string phase;
public string message;
public float progress;
public double etaSeconds;
public string outputPath;
public string error;
}
internal readonly struct ProcessRequest
{
internal ProcessRequest(
string executablePath,
string workingDirectory,
IReadOnlyList<string> arguments,
string cancellationFile)
{
ExecutablePath = executablePath;
WorkingDirectory = workingDirectory;
Arguments = arguments;
CancellationFile = cancellationFile;
}
internal string ExecutablePath { get; }
internal string WorkingDirectory { get; }
internal IReadOnlyList<string> Arguments { get; }
internal string CancellationFile { get; }
}
internal readonly struct ProcessMessage
{
internal ProcessMessage(string text, bool isError)
{
Text = text ?? string.Empty;
IsError = isError;
IsEvent = false;
Event = default;
}
internal ProcessMessage(CliEvent parsedEvent)
{
Text = string.Empty;
IsError = false;
IsEvent = true;
Event = parsedEvent;
}
internal string Text { get; }
internal bool IsError { get; }
internal bool IsEvent { get; }
internal CliEvent Event { get; }
}
internal readonly struct CliEvent
{
internal CliEvent(
string eventType,
string protocolVersion,
string jobId,
string phase,
string message,
float progress,
double etaSeconds,
string outputPath,
string error)
{
EventType = eventType;
ProtocolVersion = protocolVersion;
JobId = jobId;
Phase = phase;
Message = message;
Progress = progress;
EtaSeconds = etaSeconds;
OutputPath = outputPath;
Error = error;
}
internal string EventType { get; }
internal string ProtocolVersion { get; }
internal string JobId { get; }
internal string Phase { get; }
internal string Message { get; }
internal float Progress { get; }
internal double EtaSeconds { get; }
internal string OutputPath { get; }
internal string Error { get; }
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f8bdd831f8794ea18ca288f3745462b1

View File

@ -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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ef4feff8a1c42a0be6affcf5d6429db

View File

@ -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);
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 91c38b82e8714b4da4f92c32e990cc51

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ff40a5b8a5b43e492463c46ba8f4995

View File

@ -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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d8f3a1c2e4b54f6789012345abcde678

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 31604c3bc92fd194484b9159429dd3c2

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Mingle.CWAI.Editor.Tests")]

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a1a242e13969456688f2467d4acf4930

View File

@ -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
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c99c6ba712c380b43bd392841b1eb410
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c3ab4b71dae56314c981d64b2d3fd3d7

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47fb91e0c133d204d84638cf2b4a98ea

8
CameraAI~/Editor/UI.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8c8c76ce67b4489299ad2b2afb1cdf3d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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;
}

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 8c71572bfd3749108603d0fe48f1f20b
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

155
CameraAI~/README.md Normal file
View File

@ -0,0 +1,155 @@
# Mingle Camera Work AI Unity Package
생성할 새 Unity Timeline에서 캐릭터 모션과 원본 음원을 60 FPS로 추출하고,
완전히 새로 만든 카메라를 비파괴 프리뷰 Timeline으로 불러옵니다. 생성 입력에
Cinemachine Track이나 기존 카메라 애니메이션은 필요하지 않으며, 존재해도
생성 입력으로 사용하지 않습니다.
축적된 작업자 데이터를 재구성하는 고품질 생성 엔진은
`CWCameraWorker.exe`로 실행됩니다. 워커에 Python 런타임과 라이브러리가
포함되므로 작업 PC에 Python을 별도로 설치하지 않아도 됩니다.
동일한 구간과 음원으로 후보 A/B를 녹화하고 Camera AI Review Portal에
반입할 provenance manifest도 생성합니다.
설치와 씬 요구사항은 저장소의 `docs/UNITY_COLLECTION.md`를 참고하세요.
워커 빌드와 배포 검증은 `docs/CLI_WORKER_PACKAGING.md`에 정리되어 있습니다.
Unity Package Manager의 `Add package from git URL`에는 다음 주소를 사용할 수
있습니다.
`https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.5`
배포 패키지에는 Windows x64용 `CWCameraWorker` 폴더 전체가 포함됩니다.
Python은 따로 설치하지 않아도 되지만, Git 패키지의 대용량 바이너리를 받으려면
Unity를 열기 전에 Git과 Git LFS를 설치해야 합니다. Worker는 단일 파일 EXE가
아니므로 `CWCameraWorker.exe`와 같은 폴더의 `_internal` 내용을 함께 유지해야
합니다.
카메라 생성에 필요한 사내 참조 카메라·모션 데이터는 권한이 있는 `CW-AI`
데이터 루트에서 읽습니다. 해당 데이터는 이 유틸리티 패키지에 포함되지 않습니다.
다른 PC에서는 `CWAI_ROOT` 환경 변수, Unity 프로젝트 옆의 `CW-AI` 폴더 또는
생성 창의 `고급 · 진단 > 참조 데이터 루트` 중 하나로 위치를 지정합니다.
상세 설치 절차는 `Documentation~/EXTERNAL_INSTALLATION.md`를 참고하세요.
참조 데이터는 읽기 전용이어도 됩니다. 반복 생성 캐시는 기본적으로 현재 Unity
프로젝트의 `Library/CWAI`에 저장되며, 필요하면 `CWAI_CACHE_ROOT` 환경 변수로
별도의 쓰기 가능한 위치를 지정할 수 있습니다.
메뉴:
- `Tools/Streamingle/AI 카메라 생성`
- `Tools/Streamingle/Timeline/Export Camera Dataset (60 FPS)`
- `Tools/Streamingle/Timeline/Create AI Camera Preview`
- `Tools/Streamingle/Timeline/Remove AI Camera Preview`
- `Tools/Streamingle/Timeline/Render AI Camera A-B`
## 카메라 생성
일반 작업자는 다음 세 단계만 사용하면 됩니다.
1. `Tools > Streamingle > AI 카메라 생성`을 엽니다.
2. 모션 트랙과 Audio Track만 들어 있는 `원본 Timeline`을 선택하고
`새 카메라 생성하기`를 누릅니다. 입력 추출, 새 카메라 생성과 Timeline
반영이 자동으로 이어집니다. 실행 중에는 현재 단계, 경과 시간과 예상
남은 시간 범위가 표시됩니다.
3. 결과를 확인한 뒤 Timeline의 생성 카메라 clip 또는 Hierarchy의 카메라를
선택하고 필요하면 `선택한 카메라만 다시 생성` 또는
`완성본 씬으로 저장`을 누릅니다.
Timeline이 하나뿐이면 자동으로 선택됩니다. 특별한 연출 의도가 없다면
`연출 설정 (선택)`도 열 필요가 없습니다. 샷 거리, 움직임, 인물 구도와
강도를 직접 지정하려는 경우에만 펼칩니다.
경로, 워커 실행 파일, 내부 입력 ID, Seed, 캐시, 수동 가져오기와 로그는
`고급 설정 및 관리` 안에 있습니다. 기본 생성에서는 입력할 필요가 없습니다.
기본 화면에는 `데이터셋 곡`을 고르는 절차가 없습니다. 여기서 “입력”은 지금
선택한 새 Timeline 한 개이고, 고품질 워커가 내부적으로 참고하는 누적
작업자 카메라 데이터는 별도의 참조 라이브러리입니다. 새 Timeline을 그
라이브러리 안의 기존 곡과 연결하거나 기존 카메라를 정답으로 제공할 필요가
없습니다.
배포된 워커는 다음 명령을 제공합니다. Unity 생성 창은 `generate`
`prepare-cache`를 호출하고, 설치·배포 확인에는 `version``doctor`
사용합니다.
```powershell
CWCameraWorker.exe version --json
CWCameraWorker.exe doctor
CWCameraWorker.exe prepare-cache --help
CWCameraWorker.exe generate --help
```
`version --json`은 워커의 CLI 계약 버전을 확인하고, `doctor`는 배포 파일과
필수 데이터 경로를 진단합니다. `prepare-cache`는 반복 생성용
준비 캐시를 구성하며, `generate`는 전체 생성과 선택 샷 재생성을 처리합니다.
전체 결과는 카메라별 CinemachineCamera, Animator, Animation Track과 `.anim`으로
1:1 분리됩니다. 각 카메라의 Transform/Track Offset과 커브를 다른 샷에 영향을
주지 않고 조정할 수 있습니다. 선택 샷 재생성은 기존 clip GUID와 컷 경계를
유지하고 나머지 샷을 보존합니다. `Balanced` 또는 `Editable` 키 단순화를
선택하면 위치·Quaternion·렌즈 오차 한도 안에서 키가 축약됩니다.
예상 시간은 Timeline 길이와 준비·후보 캐시 상태를 기준으로 범위로 표시합니다.
같은 PC에서 완료된 전체 생성과 선택 샷 생성 시간을 별도로 누적해 이후 예측을
보정합니다. 준비 캐시 생성 시간은 실제 카메라 생성 기록에 섞지 않습니다.
처음 실행하는 PC에서는 `고품질 생성 준비 캐시 만들기 / 갱신`
한 번 실행합니다. 준비 모델 seed와 후보 seed는 분리되어 있으므로 이후
선택 샷 재생성에서 seed가 바뀌어도 같은 무결성 검증 캐시를 재사용합니다.
`Seed`는 안전 검사를 통과하고 최종 화면에서 서로 구별되는 카메라
후보 중 하나를 결정론적으로 선택합니다. 같은 Seed와 입력은 같은 결과를
재현하며, 안전한 구별 후보가 둘 이상이면 인접 Seed는 다른 후보를 선택합니다.
후보는 최고 안전 후보와의 선택 점수 차이가 허용 범위 안인 경우에만 사용되어,
다양성을 위해 구도 품질을 과도하게 낮추지 않습니다.
다음 샷의 계획 문맥은 최고 안전 후보로 고정되므로, 앞 샷에서 Seed로 고른
후보가 뒤 샷의 후보 풀을 바꾸어 다시 같은 카메라로 수렴하지 않습니다.
명시한 `static` 연출은 유지되고, 안전한 구별 후보가 하나뿐인 샷은 Seed를
바꿔도 같을 수 있습니다. 기존 결과를 다시 적용하는 기능은 새 후보를 생성하지
않으므로 Seed를 사용하지 않습니다.
`metadata.json`의 샷별 `variationSeed`, `variationPoolSize`,
`variationIndex`, `variationApplied`, `variationQualityEligibleCandidateCount`,
`variationSelectedScoreDeltaFromBest`, `sequenceContinuationPolicy`와 전체 요약
`seedVariationPolicy`,
`seedVariationAppliedShotCount`, `seedVariationPoolSizeMinMedianMax`로 Seed가
실제로 적용된 범위와 후보 풀이 하나뿐이었던 예외를 확인할 수 있습니다.
생성된 회전은 AnimationClip에 굽고 런타임 LookAt이나 Tracking Target을
사용하지 않습니다. 캐릭터 관절 envelope와 의미 기반 구도를 검증하며,
안전한 후보가 없으면 결과 생성을 중단합니다. 사용자가 `static` 또는
움직임 강도 0을 명시했을 때는 정지 의도를 유지합니다.
가져오기, 제거, 전체/선택 샷 재생성은 모두 같은 원본 Director scope만
사용합니다. 씬에 사용할 수 있는 `Timeline` Director가 하나뿐이면 자동으로
선택됩니다.
`현재 씬을 AI Camera Final 복사본으로 저장`은 preview Timeline과 생성된 `.anim`
파일을 Final 전용 asset 폴더로 복제하고, 복사된 씬이 그 복제본을 참조하게 합니다.
따라서 이후 작업 씬에서 preview를 제거하거나 재생성해도 Final 씬의 참조가 끊기지
않습니다.
샷별 JSON 예시는 `Samples~/CameraDirections/camera_directives.example.json`
에 있습니다.
## A/B 렌더
1. 씬과 Timeline을 저장합니다.
2. 두 생성 결과 디렉터리의 `metadata.json`, `world_camera.f32`,
`time.f64`, 선택적인 `shots.json`이 완전한지 확인합니다.
3. A/B 렌더 창에서 두 디렉터리와 `[startFrame, endFrameExclusive)`
구간을 선택합니다.
4. 렌더러는 두 후보의 sample rate, frame count, `time.f64`, song ID,
evaluation group, aspect ratio가 같은지 먼저 검증합니다.
5. 후보마다 원본 씬을 다시 열고 Preview Timeline을 만든 뒤, 기존
RecorderTrack은 mute하고 MainCamera와 Timeline audio를 녹화합니다.
6. 완료 폴더의 `manifest.json`과 두 영상을 웹 포털에 함께 반입합니다.
작업 상태는 `Library/CWAI/ABRender/active_job.json`에 저장되어 Play Mode
domain reload 뒤에도 이어집니다. 실패·취소 시 원본 scene setup을
복구하며, 원본 Timeline과 생성 카메라 파일은 수정하지 않습니다.
UniCLI 공개 진입점:
- `AICameraABRenderRunner.StartABRenderForCli(...)`
- `AICameraABRenderRunner.GetABRenderStatusForCli(jobId)`
- `AICameraABRenderRunner.CancelABRenderForCli(jobId)`

7
CameraAI~/README.md.meta Normal file
View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 84da0cdf89e3da2439ed0ad761e2fe0d
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,24 @@
{
"schemaVersion": "camera-directives-v1",
"defaults": {
"composition": "center",
"bodyFollowStrength": 0.18,
"motionIntensity": 1.0
},
"shots": [
{
"index": 0,
"shotSize": "wide",
"motion": "dolly_in",
"composition": "center",
"distanceMeters": 6.0,
"motionIntensity": 0.8
},
{
"index": 1,
"shotSize": "close",
"motion": "static",
"bodyFollowStrength": 0.0
}
]
}

View File

@ -0,0 +1,16 @@
# Third-party notices
The bundled Windows x64 `CWCameraWorker` is a frozen Python application. Its
runtime includes Python 3.10 and open-source packages including NumPy, SciPy,
scikit-learn, librosa, python-soundfile, joblib, PyInstaller, and their
transitive dependencies.
License and metadata files retained by the frozen distribution are stored
beside the corresponding components under
`Tools~/CWCameraWorker/_internal`. This package does not remove or replace
those notices.
This file is an inventory note, not a substitute for a consolidated legal
review. Before distributing the worker beyond authorized Streamingle or
mingle Studio workstations, review the exact worker build and its retained
license texts.

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 55f123ec8b7a4344b8468ea083861738
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
CameraAI~/Tests.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1c0ba2302b9c49cda5423455f6e7102b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1c974ef7459d45caa99203db8ef311b1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,165 @@
using System;
using NUnit.Framework;
using UnityEditor.Recorder.Timeline;
using UnityEngine;
using UnityEngine.Timeline;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraABRenderTests
{
[Test]
public void ToInclusiveEndFrame_UsesEndExclusiveContract()
{
Assert.That(
AICameraABRenderUtility.ToInclusiveEndFrame(10, 20, 100),
Is.EqualTo(19));
}
[Test]
public void ValidateFrameRange_RejectsEmptyRange()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => AICameraABRenderUtility.ValidateFrameRange(
10,
10,
100));
}
[Test]
public void ValidateOutputSettings_RejectsOddMp4Dimensions()
{
Assert.Throws<ArgumentException>(
() => AICameraABRenderUtility.ValidateOutputSettings(
AICameraABRenderCodec.MP4,
1919,
1080));
Assert.DoesNotThrow(
() => AICameraABRenderUtility.ValidateOutputSettings(
AICameraABRenderCodec.WEBM,
1919,
1080));
}
[Test]
public void ValidateCandidatePair_RejectsDifferentTimeData()
{
var candidateA = CreateProbe("same-time");
var candidateB = CreateProbe("different-time");
var request = CreateRequest();
Assert.Throws<System.IO.InvalidDataException>(
() => AICameraABRenderUtility.ValidateCandidatePair(
candidateA,
candidateB,
request));
}
[Test]
public void ValidateCandidatePair_RejectsDifferentDatasets()
{
var candidateA = CreateProbe("same-time", "dataset-a");
var candidateB = CreateProbe("same-time", "dataset-b");
Assert.Throws<System.IO.InvalidDataException>(
() => AICameraABRenderUtility.ValidateCandidatePair(
candidateA,
candidateB,
CreateRequest()));
}
[Test]
public void MuteCopiedRecorderTracks_MutesExistingRecorderTrack()
{
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var recorderTrack = timeline.CreateTrack<RecorderTrack>(
null,
"Existing Recorder");
recorderTrack.muted = false;
var mutedCount =
AICameraTimelinePreviewImporter.MuteCopiedRecorderTracks(
timeline);
Assert.That(mutedCount, Is.EqualTo(1));
Assert.That(recorderTrack.muted, Is.True);
Assert.That(
AICameraTimelinePreviewImporter.MuteCopiedRecorderTracks(
timeline),
Is.Zero);
}
finally
{
UnityEngine.Object.DestroyImmediate(timeline);
}
}
[Test]
public void WriteJsonAtomic_ReplacesExistingStateWithoutTempFiles()
{
var directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"cw-ai-ab-json-{Guid.NewGuid():N}");
var path = System.IO.Path.Combine(directory, "active_job.json");
try
{
AICameraABRenderUtility.WriteJsonAtomic(
path,
new AICameraABRenderJobState { jobId = "first" });
AICameraABRenderUtility.WriteJsonAtomic(
path,
new AICameraABRenderJobState { jobId = "second" });
var restored =
AICameraABRenderUtility.ReadJson<AICameraABRenderJobState>(
path);
Assert.That(restored.jobId, Is.EqualTo("second"));
Assert.That(
System.IO.Directory.GetFiles(directory, "*.tmp"),
Is.Empty);
}
finally
{
if (System.IO.Directory.Exists(directory))
{
System.IO.Directory.Delete(directory, true);
}
}
}
private static AICameraABCandidateProbe CreateProbe(
string timeSha,
string datasetId = "dataset")
{
return new AICameraABCandidateProbe
{
Metadata = new AICameraABGeneratedCameraMetadata(),
Result = new AICameraABCandidateResult
{
sampleRate = 60,
frameCount = 120,
duration = 2,
timeSha256 = timeSha,
songId = "song",
datasetId = datasetId,
targetEvaluationGroupId = "group",
targetAspectRatio = 16.0 / 9.0
}
};
}
private static AICameraABRenderRequest CreateRequest()
{
return new AICameraABRenderRequest
{
startFrame = 0,
endFrameExclusive = 120,
codec = AICameraABRenderCodec.MP4,
width = 1920,
height = 1080
};
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 99556bdd71254adaa3f4d8654875a01d

View File

@ -0,0 +1,126 @@
using System;
using System.IO;
using NUnit.Framework;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraCliRunnerTests
{
[Test]
public void ResolveExecutablePrefersConfiguredWorker()
{
var root = CreateTemporaryDirectory();
try
{
var configured = Path.Combine(root, "custom-worker.exe");
File.WriteAllText(configured, string.Empty);
Assert.That(
AICameraCliRunner.ResolveExecutable(
configured,
string.Empty,
string.Empty,
string.Empty),
Is.EqualTo(Path.GetFullPath(configured)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ResolveExecutableFindsConventionalDistributionFolder()
{
var root = CreateTemporaryDirectory();
try
{
var worker = Path.Combine(
root,
"dist",
"CWCameraWorker",
AICameraCliRunner.ExecutableName);
Directory.CreateDirectory(Path.GetDirectoryName(worker));
File.WriteAllText(worker, string.Empty);
Assert.That(
AICameraCliRunner.ResolveExecutable(
string.Empty,
root,
string.Empty,
string.Empty),
Is.EqualTo(Path.GetFullPath(worker)));
}
finally
{
Directory.Delete(root, true);
}
}
[TestCase("plain", "plain")]
[TestCase("two words", "\"two words\"")]
[TestCase("", "\"\"")]
public void QuoteArgumentUsesWindowsProcessEscaping(
string argument,
string expected)
{
Assert.That(AICameraCliRunner.QuoteArgument(argument), Is.EqualTo(expected));
}
[Test]
public void JsonLineEventParsesProgressAndIdentity()
{
var parsed = AICameraCliRunner.TryParseEvent(
"{\"type\":\"progress\",\"protocolVersion\":\"1\"," +
"\"jobId\":\"unity-1\",\"phase\":\"generate\"," +
"\"progress\":0.25,\"etaSeconds\":12.5}",
out var cliEvent);
Assert.That(parsed, Is.True);
Assert.That(cliEvent.EventType, Is.EqualTo("progress"));
Assert.That(cliEvent.JobId, Is.EqualTo("unity-1"));
Assert.That(cliEvent.Progress, Is.EqualTo(0.25f));
Assert.That(cliEvent.EtaSeconds, Is.EqualTo(12.5d));
}
[Test]
public void PrefixedEventParsesButOrdinaryLogDoesNot()
{
Assert.That(
AICameraCliRunner.TryParseEvent(
"CWAI_EVENT {\"eventType\":\"stage\",\"phase\":\"prepare\"}",
out var cliEvent),
Is.True);
Assert.That(cliEvent.EventType, Is.EqualTo("stage"));
Assert.That(
AICameraCliRunner.TryParseEvent("preparing camera data", out _),
Is.False);
}
[Test]
public void ResultEventParsesGeneratedOutputPath()
{
var expected = Path.Combine(
Path.GetTempPath(),
"cw-ai generated result");
var jsonPath = expected.Replace("\\", "\\\\");
Assert.That(
AICameraCliRunner.TryParseEvent(
"{\"type\":\"result\",\"protocolVersion\":\"1\"," +
"\"outputPath\":\"" + jsonPath + "\"}",
out var cliEvent),
Is.True);
Assert.That(cliEvent.OutputPath, Is.EqualTo(expected));
}
private static string CreateTemporaryDirectory()
{
var path = Path.Combine(
Path.GetTempPath(),
"cw-ai-cli-runner-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 30c854c9afbd493f8b2e660c23584f83

View File

@ -0,0 +1,484 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
public sealed class AICameraCurveSimplifierTests
{
[Test]
public void ExactPresetKeepsEverySampleAndNormalizesQuaternionSigns()
{
var samples = CreateSamples(121);
var result = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
var expected = Enumerable.Range(0, samples.Times.Length).ToArray();
CollectionAssert.AreEqual(expected, result.PositionIndices);
CollectionAssert.AreEqual(expected, result.RotationIndices);
CollectionAssert.AreEqual(expected, result.FieldOfViewIndices);
CollectionAssert.AreEqual(expected, result.DutchIndices);
for (var index = 1; index < result.Rotations.Length; index++)
{
Assert.That(
Quaternion.Dot(
result.Rotations[index - 1],
result.Rotations[index]),
Is.GreaterThanOrEqualTo(0f),
$"Quaternion sign changed at sample {index}.");
}
}
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)]
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)]
public void ReducedPresetsRespectConfiguredVectorAngleAndLensErrors(
AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
{
var samples = CreateSamples(241);
var result = Simplify(samples, preset);
var settings =
AICameraTimelinePreviewImporter.GetCurveSimplificationSettings(preset);
AssertVectorError(
result,
settings.PositionErrorMeters + 0.00001f);
AssertQuaternionError(
result,
settings.QuaternionAngleErrorDegrees + 0.0001f);
AssertScalarError(
result.Times,
result.FieldOfView,
result.FieldOfViewIndices,
settings.FieldOfViewErrorDegrees + 0.00001f,
"FOV");
AssertScalarError(
result.Times,
result.Dutch,
result.DutchIndices,
settings.DutchErrorDegrees + 0.00001f,
"Dutch");
}
[Test]
public void EveryChannelPreservesEndpointsAndGlobalExtrema()
{
var samples = CreateSamples(181);
var result = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);
AssertEndpoints(result.PositionIndices, samples.Times.Length);
AssertEndpoints(result.RotationIndices, samples.Times.Length);
AssertEndpoints(result.FieldOfViewIndices, samples.Times.Length);
AssertEndpoints(result.DutchIndices, samples.Times.Length);
AssertExtrema(
result.PositionIndices,
result.Positions.Select(value => value.x).ToArray());
AssertExtrema(
result.PositionIndices,
result.Positions.Select(value => value.y).ToArray());
AssertExtrema(
result.PositionIndices,
result.Positions.Select(value => value.z).ToArray());
AssertExtrema(result.FieldOfViewIndices, result.FieldOfView);
AssertExtrema(result.DutchIndices, result.Dutch);
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.x).ToArray());
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.y).ToArray());
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.z).ToArray());
AssertExtrema(
result.RotationIndices,
result.Rotations.Select(value => value.w).ToArray());
}
[Test]
public void QuaternionComponentsUseOneStrictlyIncreasingTimeIndexSet()
{
var samples = CreateSamples(151);
var result = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced);
Assert.That(result.RotationIndices, Is.Ordered.Ascending);
Assert.That(
result.RotationIndices.Distinct().Count(),
Is.EqualTo(result.RotationIndices.Length));
foreach (var index in result.RotationIndices)
{
Assert.That(index, Is.InRange(0, result.Times.Length - 1));
}
for (var index = 1; index < result.Rotations.Length; index++)
{
Assert.That(
Quaternion.Dot(
result.Rotations[index - 1],
result.Rotations[index]),
Is.GreaterThanOrEqualTo(0f));
}
}
[Test]
public void EditablePresetReducesSmoothCameraDataMoreThanExact()
{
var samples = CreateSamples(301);
var exact = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
var editable = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable);
Assert.That(
editable.PositionIndices.Length,
Is.LessThan(exact.PositionIndices.Length));
Assert.That(
editable.RotationIndices.Length,
Is.LessThan(exact.RotationIndices.Length));
Assert.That(
editable.FieldOfViewIndices.Length,
Is.LessThan(exact.FieldOfViewIndices.Length));
Assert.That(
editable.DutchIndices.Length,
Is.LessThan(exact.DutchIndices.Length));
}
[Test]
public void ExplicitDirectorScopesSeparateSameNamedSongDirectors()
{
var firstObject = new GameObject("SongDirector");
var secondObject = new GameObject("SongDirector");
var firstTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
var secondTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var firstDirector = firstObject.AddComponent<PlayableDirector>();
var secondDirector = secondObject.AddComponent<PlayableDirector>();
firstDirector.playableAsset = firstTimeline;
secondDirector.playableAsset = secondTimeline;
var firstName = AICameraTimelinePreviewImporter
.GetScopedPreviewDirectorName(firstDirector);
var secondName = AICameraTimelinePreviewImporter
.GetScopedPreviewDirectorName(secondDirector);
StringAssert.StartsWith(
AICameraTimelinePreviewImporter.PreviewDirectorName + "__",
firstName);
Assert.That(secondName, Is.Not.EqualTo(firstName));
}
finally
{
UnityEngine.Object.DestroyImmediate(firstObject);
UnityEngine.Object.DestroyImmediate(secondObject);
UnityEngine.Object.DestroyImmediate(firstTimeline);
UnityEngine.Object.DestroyImmediate(secondTimeline);
}
}
[Test]
public void PreviewDirectorDetectionAcceptsScopedGeneratedPreviewOnly()
{
var previewObject = new GameObject(
AICameraTimelinePreviewImporter.PreviewDirectorName + "__Song_1234");
var ordinaryObject = new GameObject("Song");
var previewTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
var ordinaryTimeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
previewTimeline.CreateTrack<AnimationTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var preview = previewObject.AddComponent<PlayableDirector>();
var ordinary = ordinaryObject.AddComponent<PlayableDirector>();
preview.playableAsset = previewTimeline;
ordinary.playableAsset = ordinaryTimeline;
Assert.That(
AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(preview),
Is.True);
Assert.That(
AICameraTimelinePreviewImporter
.IsPreviewDirectorForEditor(ordinary),
Is.False);
}
finally
{
UnityEngine.Object.DestroyImmediate(previewObject);
UnityEngine.Object.DestroyImmediate(ordinaryObject);
UnityEngine.Object.DestroyImmediate(previewTimeline);
UnityEngine.Object.DestroyImmediate(ordinaryTimeline);
}
}
[Test]
public void FinalAssetFolderMustStayBelowAssets()
{
Assert.That(
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets/Shows/AI_CameraFinal/SongA/"),
Is.EqualTo("Assets/Shows/AI_CameraFinal/SongA"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets/../Outside"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"C:/Outside"));
Assert.Throws<ArgumentException>(() =>
AICameraTimelinePreviewImporter.NormalizeFinalAssetFolder(
"Assets"));
}
[Test]
public void FinalizationClearsDisposablePreviewTrackBindings()
{
var directorObject = new GameObject("FinalDestinationDirector");
var animatorObject = new GameObject("DisposablePreviewAnimator");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
var animator = animatorObject.AddComponent<Animator>();
var track = timeline.CreateTrack<AnimationTrack>(
null,
"Disposable Preview Track");
director.playableAsset = timeline;
director.SetGenericBinding(track, animator);
Assert.That(director.GetGenericBinding(track), Is.SameAs(animator));
AICameraTimelinePreviewImporter
.ClearTimelineBindingsForFinalization(director, timeline);
Assert.That(director.GetGenericBinding(track), Is.Null);
}
finally
{
UnityEngine.Object.DestroyImmediate(directorObject);
UnityEngine.Object.DestroyImmediate(animatorObject);
UnityEngine.Object.DestroyImmediate(timeline);
}
}
private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
Simplify(
CameraSamples samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
{
return AICameraTimelinePreviewImporter.SimplifyCameraCurves(
samples.Times,
samples.Positions,
samples.Rotations,
samples.FieldOfView,
samples.Dutch,
preset);
}
private static CameraSamples CreateSamples(int count)
{
var times = new double[count];
var positions = new Vector3[count];
var rotations = new Quaternion[count];
var fieldOfView = new float[count];
var dutch = new float[count];
for (var index = 0; index < count; index++)
{
var time = index / 60.0;
times[index] = time;
positions[index] = new Vector3(
1.2f * Mathf.Sin((float)time * 0.8f),
1.6f + 0.25f * Mathf.Cos((float)time * 1.1f),
-3.2f + 0.7f * Mathf.Sin((float)time * 0.45f));
var rotation = Quaternion.Euler(
8f * Mathf.Sin((float)time * 0.7f),
35f * Mathf.Sin((float)time * 0.35f),
3f * Mathf.Cos((float)time * 0.9f));
rotations[index] = index % 11 == 0
? new Quaternion(
-rotation.x,
-rotation.y,
-rotation.z,
-rotation.w)
: rotation;
fieldOfView[index] =
42f + 7f * Mathf.Sin((float)time * 0.55f);
dutch[index] =
2.5f * Mathf.Cos((float)time * 0.75f);
}
return new CameraSamples(
times,
positions,
rotations,
fieldOfView,
dutch);
}
private static void AssertVectorError(
AICameraTimelinePreviewImporter.SimplifiedCameraCurves result,
float maximumError)
{
ForEverySegmentSample(
result.Times,
result.PositionIndices,
(sample, left, right, blend) =>
{
var reconstructed = Vector3.LerpUnclamped(
result.Positions[left],
result.Positions[right],
blend);
Assert.That(
Vector3.Distance(result.Positions[sample], reconstructed),
Is.LessThanOrEqualTo(maximumError),
$"Position error exceeded at sample {sample}.");
});
}
private static void AssertQuaternionError(
AICameraTimelinePreviewImporter.SimplifiedCameraCurves result,
float maximumError)
{
ForEverySegmentSample(
result.Times,
result.RotationIndices,
(sample, left, right, blend) =>
{
var reconstructed = Normalize(new Quaternion(
Mathf.LerpUnclamped(
result.Rotations[left].x,
result.Rotations[right].x,
blend),
Mathf.LerpUnclamped(
result.Rotations[left].y,
result.Rotations[right].y,
blend),
Mathf.LerpUnclamped(
result.Rotations[left].z,
result.Rotations[right].z,
blend),
Mathf.LerpUnclamped(
result.Rotations[left].w,
result.Rotations[right].w,
blend)));
Assert.That(
Quaternion.Angle(result.Rotations[sample], reconstructed),
Is.LessThanOrEqualTo(maximumError),
$"Quaternion angle error exceeded at sample {sample}.");
});
}
private static void AssertScalarError(
IReadOnlyList<double> times,
IReadOnlyList<float> values,
IReadOnlyList<int> indices,
float maximumError,
string label)
{
ForEverySegmentSample(
times,
indices,
(sample, left, right, blend) =>
{
var reconstructed = Mathf.LerpUnclamped(
values[left],
values[right],
blend);
Assert.That(
Mathf.Abs(values[sample] - reconstructed),
Is.LessThanOrEqualTo(maximumError),
$"{label} error exceeded at sample {sample}.");
});
}
private static void ForEverySegmentSample(
IReadOnlyList<double> times,
IReadOnlyList<int> indices,
Action<int, int, int, float> assertion)
{
for (var segmentIndex = 1;
segmentIndex < indices.Count;
segmentIndex++)
{
var left = indices[segmentIndex - 1];
var right = indices[segmentIndex];
var duration = times[right] - times[left];
for (var sample = left; sample <= right; sample++)
{
var blend = duration > double.Epsilon
? (float)((times[sample] - times[left]) / duration)
: 0f;
assertion(sample, left, right, blend);
}
}
}
private static void AssertEndpoints(IReadOnlyCollection<int> indices, int count)
{
Assert.That(indices, Does.Contain(0));
Assert.That(indices, Does.Contain(count - 1));
}
private static void AssertExtrema(
IReadOnlyCollection<int> indices,
IReadOnlyList<float> values)
{
var minimum = Enumerable.Range(0, values.Count)
.OrderBy(index => values[index])
.First();
var maximum = Enumerable.Range(0, values.Count)
.OrderByDescending(index => values[index])
.First();
Assert.That(indices, Does.Contain(minimum));
Assert.That(indices, Does.Contain(maximum));
}
private static Quaternion Normalize(Quaternion value)
{
var magnitude = Mathf.Sqrt(
value.x * value.x +
value.y * value.y +
value.z * value.z +
value.w * value.w);
return new Quaternion(
value.x / magnitude,
value.y / magnitude,
value.z / magnitude,
value.w / magnitude);
}
private readonly struct CameraSamples
{
public CameraSamples(
double[] times,
Vector3[] positions,
Quaternion[] rotations,
float[] fieldOfView,
float[] dutch)
{
Times = times;
Positions = positions;
Rotations = rotations;
FieldOfView = fieldOfView;
Dutch = dutch;
}
public double[] Times { get; }
public Vector3[] Positions { get; }
public Quaternion[] Rotations { get; }
public float[] FieldOfView { get; }
public float[] Dutch { get; }
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5856da1abe05460b9e98bed6d6d2536d

View File

@ -0,0 +1,426 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
using UnityEngine.Timeline;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraCutCorrectionRecorderTests
{
private string _temporaryRoot;
private readonly List<TimelineAsset> _timelines =
new List<TimelineAsset>();
[SetUp]
public void SetUp()
{
_temporaryRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_cut_corrections_{Guid.NewGuid():N}");
Directory.CreateDirectory(_temporaryRoot);
}
[TearDown]
public void TearDown()
{
foreach (var timeline in _timelines.Where(value => value != null))
{
UnityEngine.Object.DestroyImmediate(timeline);
}
_timelines.Clear();
if (!string.IsNullOrWhiteSpace(_temporaryRoot) &&
Directory.Exists(_temporaryRoot))
{
Directory.Delete(_temporaryRoot, true);
}
}
[Test]
public void CaptureRecordsKeepMoveAddAndDeleteWithoutRawEditorId()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 60, 180, 600, 720 });
var timeline = CreateTimeline(
new[] { 0, 60, 210, 300, 720 });
const string rawEditorId = "operator-secret@example.com";
var path = AICameraCutCorrectionRecorder
.CaptureTimelineForTests(
timeline,
generated,
rawEditorId,
Path.Combine(_temporaryRoot, "logs"),
new DateTime(
2026,
8,
2,
5,
4,
3,
210,
DateTimeKind.Utc));
var json = File.ReadAllText(path);
Assert.That(
json,
Does.Contain(
$"\"schemaVersion\": " +
$"\"{AICameraCutCorrectionRecorder.CurrentSchemaVersion}\""));
Assert.That(json, Does.Contain("\"sampleRate\": 60"));
Assert.That(json, Does.Contain("\"songId\": \"song-1\""));
Assert.That(json, Does.Contain("\"datasetId\": \"dataset-1\""));
Assert.That(json, Does.Contain("\"keepCount\": 1"));
Assert.That(json, Does.Contain("\"moveCount\": 1"));
Assert.That(json, Does.Contain("\"addCount\": 1"));
Assert.That(json, Does.Contain("\"deleteCount\": 1"));
Assert.That(json, Does.Contain("\"deltaFrames\": 30"));
Assert.That(
json,
Does.Contain("editor-style-sha256:"));
Assert.That(json, Does.Not.Contain(rawEditorId));
var bytes = File.ReadAllBytes(path);
Assert.That(
bytes.Take(3).ToArray(),
Is.Not.EqualTo(new byte[] { 0xEF, 0xBB, 0xBF }));
Assert.That(
Directory.GetFiles(
Path.GetDirectoryName(path),
"*.tmp-*"),
Is.Empty);
}
[Test]
public void UnchangedBoundariesAreRecordedAsAcceptedKeeps()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 60, 180, 600, 720 });
var timeline = CreateTimeline(
new[] { 0, 60, 180, 600, 720 });
var path = AICameraCutCorrectionRecorder
.CaptureTimelineForTests(
timeline,
generated,
"stable-editor",
Path.Combine(_temporaryRoot, "logs"),
DateTime.UnixEpoch);
var json = File.ReadAllText(path);
Assert.That(json, Does.Contain("\"keepCount\": 3"));
Assert.That(json, Does.Contain("\"moveCount\": 0"));
Assert.That(json, Does.Contain("\"addCount\": 0"));
Assert.That(json, Does.Contain("\"deleteCount\": 0"));
}
[Test]
public void SameInputsProduceDeterministicSnapshotContent()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 60, 180, 600, 720 });
var timeline = CreateTimeline(
new[] { 0, 60, 210, 300, 720 });
var capturedUtc = new DateTime(
2026,
8,
2,
1,
2,
3,
DateTimeKind.Utc);
var first = AICameraCutCorrectionRecorder
.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs-a"),
capturedUtc);
var second = AICameraCutCorrectionRecorder
.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs-b"),
capturedUtc);
Assert.That(
File.ReadAllText(second),
Is.EqualTo(File.ReadAllText(first)));
}
[Test]
public void CaptureDoesNotModifyTimelineOrDirtyState()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 60, 180, 600, 720 });
var timeline = CreateTimeline(
new[] { 0, 60, 210, 300, 720 });
var before = timeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Single()
.GetClips()
.OrderBy(clip => clip.start)
.Select(clip => new[]
{
clip.start,
clip.duration,
clip.easeInDuration,
clip.easeOutDuration
})
.ToArray();
var wasDirty = EditorUtility.IsDirty(timeline);
AICameraCutCorrectionRecorder.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs"),
DateTime.UtcNow);
var after = timeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Single()
.GetClips()
.OrderBy(clip => clip.start)
.Select(clip => new[]
{
clip.start,
clip.duration,
clip.easeInDuration,
clip.easeOutDuration
})
.ToArray();
Assert.That(after, Is.EqualTo(before));
Assert.That(EditorUtility.IsDirty(timeline), Is.EqualTo(wasDirty));
}
[Test]
public void RejectsMetadataThatIsNotSixtyFps()
{
var generated = CreateGeneration(
30,
720,
new[] { 0, 720 });
var timeline = CreateTimeline(new[] { 0, 720 });
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraCutCorrectionRecorder.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs"),
DateTime.UtcNow));
Assert.That(exception.Message, Does.Contain("60 FPS"));
}
[Test]
public void RejectsStalePreviewMetadataHash()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 720 });
var timeline = CreateTimeline(new[] { 0, 720 });
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraCutCorrectionRecorder.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs"),
DateTime.UtcNow,
new string('0', 64)));
Assert.That(exception.Message, Does.Contain("different generation"));
}
[Test]
public void RejectsGapBetweenEditedCinemachineClips()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 60, 720 });
var timeline = CreateTimelineWithGap();
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraCutCorrectionRecorder.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs"),
DateTime.UtcNow));
Assert.That(exception.Message, Does.Contain("gap or overlap"));
}
[Test]
public void RejectsSubframeEditedBoundary()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 60, 720 });
var timeline = CreateTimeline(
new[] { 0d, 60.4d, 720d });
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraCutCorrectionRecorder.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs"),
DateTime.UtcNow));
Assert.That(exception.Message, Does.Contain("not aligned"));
}
[Test]
public void AcceptsImportersMicroscopicHardCutGuard()
{
var generated = CreateGeneration(
60,
720,
new[] { 0, 60, 180, 720 });
var timeline = CreateTimeline(
new[] { 0, 60, 180, 720 });
var clips = timeline.GetOutputTracks()
.OfType<CinemachineTrack>()
.Single()
.GetClips()
.OrderBy(clip => clip.start)
.ToArray();
clips[0].duration -= 0.000001d;
clips[1].duration -= 0.000001d;
var path = AICameraCutCorrectionRecorder
.CaptureTimelineForTests(
timeline,
generated,
"editor-a",
Path.Combine(_temporaryRoot, "logs"),
DateTime.UtcNow);
Assert.That(File.Exists(path), Is.True);
Assert.That(
File.ReadAllText(path),
Does.Contain("\"keepCount\": 2"));
}
private string CreateGeneration(
int sampleRate,
int frameCount,
IReadOnlyList<int> edges)
{
var generated = Path.Combine(
_temporaryRoot,
$"generated-{Guid.NewGuid():N}");
Directory.CreateDirectory(generated);
var hashA = new string('a', 64);
var hashB = new string('b', 64);
var hashC = new string('c', 64);
var metadata =
"{\n" +
" \"songName\": \"Test Song\",\n" +
" \"songId\": \"song-1\",\n" +
" \"datasetId\": \"dataset-1\",\n" +
$" \"sampleRate\": {sampleRate},\n" +
$" \"frameCount\": {frameCount},\n" +
" \"shotsFile\": \"shots.json\",\n" +
" \"outputSha256\": {\n" +
$" \"worldCamera\": \"{hashA}\",\n" +
$" \"time\": \"{hashB}\",\n" +
$" \"shots\": \"{hashC}\"\n" +
" }\n" +
"}\n";
File.WriteAllText(
Path.Combine(generated, "metadata.json"),
metadata,
new UTF8Encoding(false));
var shots = new StringBuilder();
shots.Append("{\n \"shots\": [\n");
for (var index = 0; index < edges.Count - 1; index++)
{
shots.Append(
$" {{ \"startFrame\": {edges[index]}, " +
$"\"endFrameExclusive\": {edges[index + 1]} }}");
shots.Append(index + 1 < edges.Count - 1 ? ",\n" : "\n");
}
shots.Append(" ]\n}\n");
File.WriteAllText(
Path.Combine(generated, "shots.json"),
shots.ToString(),
new UTF8Encoding(false));
return generated;
}
private TimelineAsset CreateTimeline(IReadOnlyList<int> edges)
{
return CreateTimeline(edges.Select(value => (double)value).ToArray());
}
private TimelineAsset CreateTimeline(IReadOnlyList<double> edgeFrames)
{
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
_timelines.Add(timeline);
var track = timeline.CreateTrack<CinemachineTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName);
for (var index = 0; index < edgeFrames.Count - 1; index++)
{
var clip = track.CreateDefaultClip();
clip.start = edgeFrames[index] /
AICameraCutCorrectionRecorder.RequiredSampleRate;
clip.duration =
(edgeFrames[index + 1] - edgeFrames[index]) /
AICameraCutCorrectionRecorder.RequiredSampleRate;
clip.easeInDuration = 0d;
clip.easeOutDuration = 0d;
}
return timeline;
}
private TimelineAsset CreateTimelineWithGap()
{
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
_timelines.Add(timeline);
var track = timeline.CreateTrack<CinemachineTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName);
var first = track.CreateDefaultClip();
first.start = 0d;
first.duration = 59d /
AICameraCutCorrectionRecorder.RequiredSampleRate;
first.easeInDuration = 0d;
first.easeOutDuration = 0d;
var second = track.CreateDefaultClip();
second.start = 60d /
AICameraCutCorrectionRecorder.RequiredSampleRate;
second.duration = 660d /
AICameraCutCorrectionRecorder.RequiredSampleRate;
second.easeInDuration = 0d;
second.easeOutDuration = 0d;
return timeline;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1b88c37fe4bd4e48818660d18810ae43

View File

@ -0,0 +1,844 @@
using System;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using NUnit.Framework;
using UnityEngine;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraGeneratorWindowTests
{
[Serializable]
private sealed class DirectiveDocument
{
public string schemaVersion;
public DirectiveShot[] shots;
}
[Serializable]
private sealed class DirectiveShot
{
public int index;
public string shotSize;
public string motion;
public string composition;
public float distanceMeters;
public float motionIntensity;
public float bodyFollowStrength;
}
private static string HybridMetadataJson(
string generationMode = "hierarchical_hybrid_retarget_v12",
string plannerMode = "hierarchical",
double targetAspectRatio = 16d / 9d)
{
return "{" +
"\"schemaVersion\":\"3.6\"," +
$"\"generationMode\":\"{generationMode}\"," +
"\"songId\":\"song-id\"," +
"\"datasetId\":\"dataset-id\"," +
"\"frameCount\":6," +
"\"sampleRate\":60," +
"\"shotCount\":2," +
$"\"plannerMode\":\"{plannerMode}\"," +
"\"targetAspectRatio\":" +
targetAspectRatio.ToString(
"R",
CultureInfo.InvariantCulture) + "," +
"\"worldCameraFile\":\"generated_camera_world.f32\"," +
"\"timeFile\":\"time.f64\"," +
"\"shotsFile\":\"shots.json\"," +
"\"outputSha256\":{" +
"\"worldCamera\":\"camera-hash\"," +
"\"time\":\"time-hash\"," +
"\"shots\":\"shots-hash\"}}";
}
private static string HybridShotsJson(string schemaVersion = "1.2")
{
return "{" +
$"\"schemaVersion\":\"{schemaVersion}\"," +
"\"shots\":[" +
"{\"index\":0,\"startFrame\":0,\"endFrameExclusive\":3}," +
"{\"index\":1,\"startFrame\":3,\"endFrameExclusive\":6}]}";
}
[Test]
public void DefaultCwAiRootPrefersConfiguredThenSiblingRepository()
{
var root = Path.Combine(
Path.GetTempPath(),
"cw-ai-root-tests-" + Guid.NewGuid().ToString("N"));
var project = Path.Combine(root, "UnityProject");
var configured = Path.Combine(root, "ConfiguredCWAI");
var sibling = Path.Combine(root, "CW-AI");
Directory.CreateDirectory(project);
CreateCwAiRootMarker(configured);
CreateCwAiRootMarker(sibling);
try
{
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
configured),
Is.EqualTo(Path.GetFullPath(configured)));
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
string.Empty),
Is.EqualTo(Path.GetFullPath(sibling)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void DefaultCwAiRootSkipsProjectCopyWithoutTrainingIndex()
{
var root = Path.Combine(
Path.GetTempPath(),
"cw-ai-incomplete-root-tests-" + Guid.NewGuid().ToString("N"));
var project = Path.Combine(root, "UnityProject");
var sibling = Path.Combine(root, "CW-AI");
Directory.CreateDirectory(Path.Combine(project, "DatasetExports"));
Directory.CreateDirectory(Path.Combine(
project,
"MachineLearning",
"CameraDirector"));
CreateCwAiRootMarker(sibling);
try
{
Assert.That(
AICameraGeneratorWindow.IsGenerationLibraryRoot(project),
Is.False);
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
project),
Is.EqualTo(Path.GetFullPath(sibling)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void DefaultCwAiRootSkipsMalformedConfiguredPath()
{
var root = Path.Combine(
Path.GetTempPath(),
"cw-ai-malformed-root-tests-" + Guid.NewGuid().ToString("N"));
var project = Path.Combine(root, "UnityProject");
var sibling = Path.Combine(root, "CW-AI");
Directory.CreateDirectory(project);
CreateCwAiRootMarker(sibling);
try
{
Assert.That(
AICameraGeneratorWindow.ResolveDefaultCwAiRoot(
project,
"\0invalid"),
Is.EqualTo(Path.GetFullPath(sibling)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ProjectCacheRootDefaultsToWritableProjectLibrary()
{
var project = Path.Combine(
Path.GetTempPath(),
"cw-ai-cache-root-tests-" + Guid.NewGuid().ToString("N"));
var configured = Path.Combine(project, "ConfiguredCache");
Assert.That(
AICameraGeneratorWindow.ResolveProjectCacheRoot(
project,
string.Empty),
Is.EqualTo(Path.Combine(
Path.GetFullPath(project),
"Library",
"CWAI")));
Assert.That(
AICameraGeneratorWindow.ResolveProjectCacheRoot(
project,
configured),
Is.EqualTo(Path.GetFullPath(configured)));
}
private static void CreateCwAiRootMarker(string root)
{
Directory.CreateDirectory(Path.Combine(root, "DatasetExports"));
Directory.CreateDirectory(Path.Combine(
root,
"MachineLearning",
"CameraDirector"));
var reports = Directory.CreateDirectory(Path.Combine(root, "reports"));
File.WriteAllText(
Path.Combine(reports.FullName, "training_index.json"),
"{}");
var models = Directory.CreateDirectory(Path.Combine(root, "models"));
File.WriteAllText(
Path.Combine(models.FullName, "cut_ranker_v2.json"),
"{}");
}
[Test]
public void CliWorkerIsTheOnlyHighQualityBackend()
{
Assert.That(
AICameraGeneratorWindow.HybridQualityBackendLabel,
Does.Contain("CWCameraWorker"));
Assert.That(
AICameraGeneratorWindow.HybridQualityBackendDescription,
Does.Contain("별도 Python 설치가 필요하지 않습니다"));
}
[TestCase(0, "auto")]
[TestCase(1, "slow")]
[TestCase(2, "normal")]
[TestCase(3, "fast")]
public void CutRhythmUsesStableDataPlannerCliValues(
int value,
string expected)
{
Assert.That(
AICameraGeneratorWindow.CutDensityCliValueForTests(value),
Is.EqualTo(expected));
Assert.That(
AICameraGeneratorWindow.CutRhythmExplanation,
Does.Contain("음악"));
}
[TestCase("auto", "balanced")]
[TestCase("center", "centered")]
[TestCase("centered", "centered")]
[TestCase("left_third", "left_third")]
[TestCase("right-third", "right_third")]
public void HybridHorizontalFramingPreservesExplicitDirection(
string composition,
string expected)
{
Assert.That(
AICameraGeneratorWindow.HybridHorizontalFraming(composition),
Is.EqualTo(expected));
}
[Test]
public void FullGenerationIgnoresPersistedManualOverrides()
{
var unusedOutput = Path.Combine(
Path.GetTempPath(),
"cw-ai-full-scope-" + Guid.NewGuid().ToString("N"));
var scope = AICameraGeneratorWindow.CreateGenerationControlScope(
false,
12,
unusedOutput,
"close",
"orbit_right",
"left_third",
9.5f,
2f,
1f);
Assert.That(scope.ShotSize, Is.EqualTo("auto"));
Assert.That(scope.Motion, Is.EqualTo("auto"));
Assert.That(scope.Composition, Is.EqualTo("auto"));
Assert.That(scope.DistanceMeters, Is.Zero);
Assert.That(scope.MotionIntensity, Is.EqualTo(1f));
Assert.That(scope.BodyFollowStrength, Is.EqualTo(0.18f));
Assert.That(scope.DirectivesPath, Is.Empty);
Assert.That(scope.UsesSelectedShotOverrides, Is.False);
Assert.That(Directory.Exists(unusedOutput), Is.False);
}
[Test]
public void SelectedShotRegenerationSerializesOnlyThatShotsOverrides()
{
var output = Path.Combine(
Path.GetTempPath(),
"cw-ai-selected-scope-" + Guid.NewGuid().ToString("N"));
try
{
var scope =
AICameraGeneratorWindow.CreateGenerationControlScope(
true,
7,
output,
"medium",
"truck_left",
"right_third",
6.25f,
1.4f,
0.08f);
Assert.That(scope.ShotSize, Is.EqualTo("auto"));
Assert.That(scope.Motion, Is.EqualTo("auto"));
Assert.That(scope.Composition, Is.EqualTo("auto"));
Assert.That(scope.DistanceMeters, Is.Zero);
Assert.That(scope.MotionIntensity, Is.EqualTo(1f));
Assert.That(scope.BodyFollowStrength, Is.EqualTo(0.18f));
Assert.That(scope.UsesSelectedShotOverrides, Is.True);
Assert.That(File.Exists(scope.DirectivesPath), Is.True);
var document = JsonUtility.FromJson<DirectiveDocument>(
File.ReadAllText(scope.DirectivesPath));
Assert.That(
document.schemaVersion,
Is.EqualTo("camera-directives-v1"));
Assert.That(document.shots, Has.Length.EqualTo(1));
Assert.That(document.shots[0].index, Is.EqualTo(7));
Assert.That(document.shots[0].shotSize, Is.EqualTo("medium"));
Assert.That(document.shots[0].motion, Is.EqualTo("truck_left"));
Assert.That(
document.shots[0].composition,
Is.EqualTo("right_third"));
Assert.That(document.shots[0].distanceMeters, Is.EqualTo(6.25f));
Assert.That(document.shots[0].motionIntensity, Is.EqualTo(1.4f));
Assert.That(document.shots[0].bodyFollowStrength, Is.EqualTo(0.08f));
}
finally
{
if (Directory.Exists(output))
{
Directory.Delete(output, true);
}
}
}
[Test]
public void FullGenerationDoesNotEmitSelectedShotOneShotArguments()
{
Assert.That(
AICameraGeneratorWindow.BuildSelectedShotOneShotArguments(
-1,
string.Empty),
Is.Empty);
}
[Test]
public void SelectedShotOneShotArgumentsIncludeIndexAndBaseGeneration()
{
var baseGeneration = Path.Combine(
Path.GetTempPath(),
"cw ai base generation");
var arguments =
AICameraGeneratorWindow.BuildSelectedShotOneShotArguments(
7,
baseGeneration);
Assert.That(arguments, Does.Contain("--only-shot-index"));
Assert.That(arguments, Does.Contain("7"));
Assert.That(arguments, Does.Contain("--base-generation"));
Assert.That(
arguments,
Does.Contain(Path.GetFullPath(baseGeneration)));
}
[Test]
public void SelectedShotTimingUsesSeparateOneShotHistory()
{
var text =
AICameraGeneratorWindow.BuildSelectedShotGenerationTimingText(
4d,
10d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss);
Assert.That(text, Does.Contain("선택 카메라 1개만 계산 중"));
Assert.That(text, Does.Contain("최근 단일 샷 기준 총"));
Assert.That(text, Does.Contain("새 후보 풀 계산 중"));
}
[Test]
public void FailedImportPreservesPreviousGeneratedFolder()
{
var previous = Path.Combine(Path.GetTempPath(), "previous-base");
var failedCandidate = Path.Combine(
Path.GetTempPath(),
"failed-candidate");
Assert.That(
AICameraGeneratorWindow.GeneratedFolderAfterImport(
previous,
failedCandidate,
false),
Is.EqualTo(previous));
}
[Test]
public void SuccessfulImportAdvancesGeneratedFolder()
{
var candidate = Path.Combine(Path.GetTempPath(), "new-base");
Assert.That(
AICameraGeneratorWindow.GeneratedFolderAfterImport(
"previous-base",
candidate,
true),
Is.EqualTo(Path.GetFullPath(candidate)));
}
[Test]
public void HybridBaseContractAcceptsFullCompositeSchema()
{
Assert.DoesNotThrow(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(),
HybridShotsJson()));
}
[Test]
public void HybridBaseContractRejectsNonHybridOutputBeforeWorkerStarts()
{
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(
generationMode: "unity_native_fast",
plannerMode: string.Empty,
targetAspectRatio: 0d),
HybridShotsJson("1.3")));
Assert.That(exception.Message, Does.Contain("Hybrid"));
}
[Test]
public void HybridBaseContractRequiresTargetAspectRatio()
{
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(targetAspectRatio: 0d),
HybridShotsJson()));
Assert.That(exception.Message, Does.Contain("targetAspectRatio"));
}
[Test]
public void HybridBaseContractRequiresShotsSchema12()
{
var exception = Assert.Throws<InvalidDataException>(() =>
AICameraGeneratorWindow
.ValidateHybridBaseGenerationContractForTests(
HybridMetadataJson(),
HybridShotsJson("1.3")));
Assert.That(exception.Message, Does.Contain("schema 1.2"));
}
[Test]
public void WindowProvenanceRecoveryPrefersCwAiRelativePathAfterMove()
{
var newRoot = Path.Combine(
Path.GetTempPath(),
"cw-ai-moved-" + Guid.NewGuid().ToString("N"));
var relative = Path.Combine(
"examples",
"UnityGenerated",
"camera_result");
var movedFolder = Path.Combine(newRoot, relative);
Directory.CreateDirectory(movedFolder);
try
{
Assert.That(
AICameraGeneratorWindow
.ResolveGeneratedFolderFromProvenance(
newRoot,
relative.Replace('\\', '/'),
@"D:\OldPc\CW-AI\examples\UnityGenerated\camera_result"),
Is.EqualTo(Path.GetFullPath(movedFolder)));
}
finally
{
Directory.Delete(newRoot, true);
}
}
[Test]
public void WindowProvenanceRecoveryUsesAbsoluteFallback()
{
var fallback = Path.Combine(
Path.GetTempPath(),
"cw-ai-fallback-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(fallback);
try
{
Assert.That(
AICameraGeneratorWindow
.ResolveGeneratedFolderFromProvenance(
Path.Combine(fallback, "missing-root"),
"examples/UnityGenerated/missing",
fallback),
Is.EqualTo(Path.GetFullPath(fallback)));
}
finally
{
Directory.Delete(fallback, true);
}
}
[Test]
public void WindowProvenanceRejectsMetadataChangedBehindStoredHash()
{
var generatedFolder = Path.Combine(
Path.GetTempPath(),
"cw-ai-provenance-hash-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(generatedFolder);
var metadataPath = Path.Combine(generatedFolder, "metadata.json");
try
{
File.WriteAllText(metadataPath, "{\"generation\":1}");
string expected;
using (var algorithm = SHA256.Create())
{
expected = BitConverter.ToString(
algorithm.ComputeHash(File.ReadAllBytes(metadataPath)))
.Replace("-", string.Empty)
.ToLowerInvariant();
}
Assert.DoesNotThrow(() =>
AICameraGeneratorWindow.ValidateProvenanceMetadataSha256(
generatedFolder,
expected));
File.WriteAllText(metadataPath, "{\"generation\":2}");
Assert.That(
() => AICameraGeneratorWindow
.ValidateProvenanceMetadataSha256(
generatedFolder,
expected),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("does not match"));
}
finally
{
Directory.Delete(generatedFolder, true);
}
}
[Test]
public void ColdStartHybridEstimateIsBroadAndMarkedInitial()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
204.48d,
0d,
0,
-1d,
0d);
Assert.That(estimate.IsAvailable, Is.True);
Assert.That(estimate.IsInitial, Is.True);
Assert.That(
estimate.MinimumTotalSeconds,
Is.EqualTo(79.4112d).Within(1e-4));
Assert.That(
estimate.MaximumTotalSeconds,
Is.EqualTo(148.23424d).Within(1e-4));
Assert.That(
estimate.MinimumRemainingSeconds,
Is.EqualTo(79.4112d).Within(1e-4));
Assert.That(
estimate.MaximumRemainingSeconds,
Is.EqualTo(148.23424d).Within(1e-4));
}
[Test]
public void HistoricalEstimateUsesObservedRateAndElapsedTime()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d);
Assert.That(estimate.IsAvailable, Is.True);
Assert.That(estimate.IsInitial, Is.False);
Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(85d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(115d));
Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(65d));
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(95d));
}
[Test]
public void CandidateCacheHitReplacesGeneralHistoryWithShortTailBudget()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
10d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false);
Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(13d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(30d));
Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(3d));
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(20d));
}
[Test]
public void CandidateCacheHitTailCountsDownFromObservationTime()
{
var initial = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
10d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false,
10d);
var later = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
15d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false,
10d);
Assert.That(initial.MaximumRemainingSeconds, Is.EqualTo(20d));
Assert.That(later.MaximumRemainingSeconds, Is.EqualTo(15d));
Assert.That(
later.MaximumRemainingSeconds,
Is.LessThan(initial.MaximumRemainingSeconds));
Assert.That(later.MaximumTotalSeconds, Is.EqualTo(30d));
}
[Test]
public void PreparationCacheMissAddsColdPreparationPenalty()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Miss,
AICameraGeneratorWindow.CacheObservation.Miss,
2,
false);
Assert.That(estimate.MinimumTotalSeconds, Is.EqualTo(103d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(164d));
Assert.That(estimate.MinimumRemainingSeconds, Is.EqualTo(83d));
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(144d));
}
[Test]
public void ImportStageBoundsStaleRemainingEstimate()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss,
4,
true);
Assert.That(estimate.MinimumRemainingSeconds, Is.Zero);
Assert.That(estimate.MaximumRemainingSeconds, Is.EqualTo(24d));
Assert.That(estimate.MaximumTotalSeconds, Is.EqualTo(44d));
}
[Test]
public void ImportStageTailCountsDownFromObservationTime()
{
var initial = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
20d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss,
4,
true,
double.NaN,
20d);
var later = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
21d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Miss,
4,
true,
double.NaN,
20d);
Assert.That(initial.MaximumRemainingSeconds, Is.EqualTo(24d));
Assert.That(later.MaximumRemainingSeconds, Is.EqualTo(23d));
Assert.That(
later.MaximumRemainingSeconds,
Is.LessThan(initial.MaximumRemainingSeconds));
Assert.That(initial.MaximumTotalSeconds, Is.EqualTo(44d));
Assert.That(later.MaximumTotalSeconds, Is.EqualTo(44d));
}
[Test]
public void InvalidDurationDoesNotClaimAnEstimate()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
0d,
10d,
2,
0.5d,
0.1d);
Assert.That(estimate.IsAvailable, Is.False);
Assert.That(estimate.MinimumTotalSeconds, Is.Zero);
Assert.That(estimate.MaximumTotalSeconds, Is.Zero);
Assert.That(estimate.MinimumRemainingSeconds, Is.Zero);
Assert.That(estimate.MaximumRemainingSeconds, Is.Zero);
}
[Test]
public void TimingTextShowsElapsedTotalRemainingAndCacheState()
{
var estimate = AICameraGeneratorWindow.CalculateRuntimeEstimate(
200d,
10d,
4,
0.5d,
0.05d,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit,
3,
false);
var text = AICameraGeneratorWindow.BuildGenerationTimingText(
10d,
estimate,
AICameraGeneratorWindow.CacheObservation.Hit,
AICameraGeneratorWindow.CacheObservation.Hit);
Assert.That(text, Does.Contain("경과 10초"));
Assert.That(text, Does.Contain("최근 실측 예상 총"));
Assert.That(text, Does.Contain("남은 시간"));
Assert.That(text, Does.Contain("안전 후보 캐시 사용 중"));
}
[TestCase("Candidate cache hit: abc", "candidate", 1)]
[TestCase("[candidate-cache] miss: none", "candidate", 2)]
[TestCase(
"Candidate cache runtime validation failed; falling back",
"candidate",
2)]
[TestCase("Preparation cache invalid: sha mismatch", "preparation", 2)]
[TestCase("unrelated message", "candidate", 0)]
public void CacheLogObservationParsesStableSignals(
string message,
string cacheName,
int expected)
{
Assert.That(
AICameraGeneratorWindow.ParseCacheObservation(
message,
cacheName),
Is.EqualTo(
(AICameraGeneratorWindow.CacheObservation)expected));
}
[Test]
public void CandidateCacheModeDefaultsToAutoAndFreshPoolUsesOff()
{
Assert.That(
AICameraGeneratorWindow.CandidateCacheMode(false),
Is.EqualTo("auto"));
Assert.That(
AICameraGeneratorWindow.CandidateCacheMode(true),
Is.EqualTo("off"));
}
[Test]
public void AppliedSeedSummarySeparatesCurrentResultFromNextGeneration()
{
var summary = AICameraGeneratorWindow.BuildAppliedSeedSummary(
20489696,
20489697,
false,
-1);
Assert.That(
summary,
Is.EqualTo(
"현재 전체 생성 결과 Seed: 20489696\n" +
"다음 생성에 사용할 Seed: 20489697"));
}
[Test]
public void AppliedSeedSummaryMarksSelectedShotAsMixedResult()
{
var summary = AICameraGeneratorWindow.BuildAppliedSeedSummary(
20489696,
20489697,
true,
8);
Assert.That(summary, Does.Contain("카메라: 008"));
Assert.That(summary, Does.Contain("해당 카메라 Seed: 20489696"));
Assert.That(summary, Does.Contain("나머지 카메라는 기존 결과"));
}
[Test]
public void SeedAndReapplyLabelsDescribeDifferentActions()
{
Assert.That(
AICameraGeneratorWindow.NextGenerationSeedLabel,
Does.Contain("다음 생성"));
Assert.That(
AICameraGeneratorWindow.SelectedCameraRegenerationButtonLabel,
Does.Contain("안전 후보"));
Assert.That(
AICameraGeneratorWindow.ListedCameraRegenerationButtonLabel,
Does.Contain("안전 후보"));
Assert.That(
AICameraGeneratorWindow.ReapplyCurrentResultButtonLabel,
Does.Contain("Seed 사용 안 함"));
Assert.That(
AICameraGeneratorWindow.ReapplyCurrentResultWarning,
Does.Contain("새 후보를 생성하지 않으며"));
Assert.That(
AICameraGeneratorWindow.SeedCandidateExplanation,
Does.Contain("안전 후보 풀을 재사용"));
Assert.That(
AICameraGeneratorWindow.FreshCandidatePoolOptionLabel,
Does.Contain("후보 풀 캐시 무시하고 재계산"));
Assert.That(
AICameraGeneratorWindow.FullAutonomousGenerationButtonLabel,
Does.Contain("자율 생성"));
Assert.That(
AICameraGeneratorWindow.FullGenerationAutonomyExplanation,
Does.Contain("전체 생성에 적용되지 않습니다"));
Assert.That(
AICameraGeneratorWindow.SelectedShotOverrideExplanation,
Does.Contain("선택한 카메라를 다시 생성할 때만"));
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a3ea2034f8a4ce1b45f1673472e5e6f

View File

@ -0,0 +1,67 @@
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraGeneratorWindowUiTests
{
[Test]
public void UiAssetsLoadAndExposeTheCoreWorkflowContract()
{
var uxmlPath =
AICameraGeneratorWindow.ResolveUiAssetPathForTests(false);
var ussPath =
AICameraGeneratorWindow.ResolveUiAssetPathForTests(true);
Assert.That(uxmlPath, Is.Not.Empty);
Assert.That(ussPath, Is.Not.Empty);
var visualTree =
AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath);
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(ussPath);
Assert.That(visualTree, Is.Not.Null);
Assert.That(styleSheet, Is.Not.Null);
var root = visualTree.CloneTree();
Assert.That(
root.Q<DropdownField>("source-director-dropdown"),
Is.Not.Null);
Assert.That(root.Q<Button>("generate-full-button"), Is.Not.Null);
Assert.That(root.Q<VisualElement>("refinement-card"), Is.Not.Null);
Assert.That(
root.Q<DropdownField>("selected-shot-dropdown"),
Is.Not.Null);
Assert.That(root.Q<Button>("regenerate-shot-button"), Is.Not.Null);
Assert.That(root.Q<VisualElement>("final-card"), Is.Not.Null);
Assert.That(root.Q<Button>("save-final-scene-button"), Is.Not.Null);
Assert.That(root.Q<Foldout>("advanced-foldout"), Is.Not.Null);
Assert.That(root.Q<ProgressBar>("generation-progress"), Is.Not.Null);
Assert.That(root.Q<Button>("status-log-button"), Is.Not.Null);
Assert.That(root.Q<Button>("cancel-generation-button"), Is.Not.Null);
}
[Test]
public void CreateGuiCanRunTwiceWithoutDuplicatingTheWindowRoot()
{
var window = ScriptableObject.CreateInstance<AICameraGeneratorWindow>();
try
{
window.CreateGUI();
window.CreateGUI();
var roots = window.rootVisualElement
.Query<VisualElement>("camera-generator-root")
.ToList();
Assert.That(roots, Has.Count.EqualTo(1));
Assert.That(
window.rootVisualElement.Q<Button>("generate-full-button"),
Is.Not.Null);
}
finally
{
Object.DestroyImmediate(window);
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ef03e2f61a24afab45e0e719a9098be

View File

@ -0,0 +1,867 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using Object = UnityEngine.Object;
namespace Streamingle.Editor.Tests
{
public sealed class AICameraTimelinePreviewImporterTests
{
[Test]
public void BrainResolutionUsesOnlySceneBrainWithoutCinemachineTrack()
{
var directorObject = new GameObject("Director");
var cameraObject = new GameObject("Render Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
cameraObject.AddComponent<Camera>();
var brain = cameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { brain }),
Is.SameAs(brain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionPrefersUniqueMainCamera()
{
var directorObject = new GameObject("Director");
var mainCameraObject = new GameObject("Main Camera");
var secondaryCameraObject = new GameObject("Secondary Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
mainCameraObject.tag = "MainCamera";
mainCameraObject.AddComponent<Camera>();
var mainBrain = mainCameraObject.AddComponent<CinemachineBrain>();
secondaryCameraObject.AddComponent<Camera>();
var secondaryBrain =
secondaryCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
AICameraTimelinePreviewImporter.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { secondaryBrain, mainBrain }),
Is.SameAs(mainBrain));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(mainCameraObject);
Object.DestroyImmediate(secondaryCameraObject);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void BrainResolutionRejectsAmbiguousSceneBrains()
{
var directorObject = new GameObject("Director");
var firstCameraObject = new GameObject("First Camera");
var secondCameraObject = new GameObject("Second Camera");
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
firstCameraObject.AddComponent<Camera>();
var firstBrain = firstCameraObject.AddComponent<CinemachineBrain>();
secondCameraObject.AddComponent<Camera>();
var secondBrain = secondCameraObject.AddComponent<CinemachineBrain>();
Assert.That(
() => AICameraTimelinePreviewImporter
.ResolveCinemachineBrainForEditor(
director,
timeline,
new[] { firstBrain, secondBrain }),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains("Multiple enabled CinemachineBrain"));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(firstCameraObject);
Object.DestroyImmediate(secondCameraObject);
Object.DestroyImmediate(timeline);
}
}
[TestCase(false)]
[TestCase(true)]
public void GeneratedDirectorySupportsLegacyAndDeclaredHashes(
bool includeHashes)
{
var directory = CreateGeneratedDirectory(includeHashes);
try
{
Assert.That(
AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Does.Contain("1 frames, 1 shots at 60 FPS"));
}
finally
{
Directory.Delete(directory, true);
}
}
[TestCase("generated_camera_world.f32", "worldCamera")]
[TestCase("time.f64", "time")]
[TestCase("shots.json", "shots")]
public void GeneratedDirectoryRejectsTamperedDeclaredPayloadBeforeParsing(
string fileName,
string payloadName)
{
var directory = CreateGeneratedDirectory(true);
try
{
File.AppendAllText(
Path.Combine(directory, fileName),
"tampered",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
$"{payloadName} payload SHA-256 mismatch"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsAbsolutePayloadPath()
{
var directory = CreateGeneratedDirectory(true);
try
{
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
Path.Combine(directory, "generated_camera_world.f32"));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("must be relative"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsParentTraversalEvenWhenHashMatches()
{
var directory = CreateGeneratedDirectory(true);
var outsidePath = Path.Combine(
Path.GetDirectoryName(directory) ?? Path.GetTempPath(),
$"cw_ai_outside_{Guid.NewGuid():N}.f32");
try
{
File.Copy(
Path.Combine(directory, "generated_camera_world.f32"),
outsidePath);
ReplaceMetadataPayloadPath(
directory,
"worldCameraFile",
"../" + Path.GetFileName(outsidePath));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains("invalid segment"));
}
finally
{
Directory.Delete(directory, true);
if (File.Exists(outsidePath))
{
File.Delete(outsidePath);
}
}
}
[Test]
public void GeneratedDirectoryRejectsExplicitNullHashObject()
{
var directory = CreateGeneratedDirectory(false);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
File.WriteAllText(
metadataPath,
json.Substring(0, json.Length - 1) +
",\"outputSha256\":null}",
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256 must be a JSON object"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void GeneratedDirectoryRejectsIncompleteDeclaredHashes()
{
var directory = CreateGeneratedDirectory(true);
try
{
var metadataPath = Path.Combine(directory, "metadata.json");
var timePath = Path.Combine(directory, "time.f64");
var json = File.ReadAllText(metadataPath).Replace(
$",\"time\":\"{ComputeSha256(timePath)}\"",
string.Empty);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
Assert.That(
() => AICameraTimelinePreviewImporter
.ValidateGeneratedDirectoryForCli(directory),
Throws.TypeOf<InvalidDataException>()
.With.Message.Contains(
"metadata.outputSha256.time is missing"));
}
finally
{
Directory.Delete(directory, true);
}
}
[Test]
public void PreviewProvenancePersistsHiddenRelativeAndAbsoluteFallback()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_root_{Guid.NewGuid():N}");
var generatedDirectory = Path.Combine(
cwAiRoot,
"examples",
"UnityGenerated",
"camera_test");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_Provenance_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var provenance = AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
AssetDatabase.ImportAsset(
timelinePath,
ImportAssetOptions.ForceSynchronousImport);
var reloaded = AssetDatabase.LoadAssetAtPath<TimelineAsset>(
timelinePath);
Assert.That(reloaded, Is.Not.Null);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
reloaded,
out var info),
Is.True);
Assert.That(
info.CwAiRelativeGeneratedFolder,
Is.EqualTo("examples/UnityGenerated/camera_test"));
Assert.That(
info.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(generatedDirectory)));
Assert.That(
info.MetadataSha256,
Is.EqualTo(ComputeSha256(
Path.Combine(generatedDirectory, "metadata.json"))));
Assert.That(
provenance.hideFlags,
Is.EqualTo(
HideFlags.HideInHierarchy |
HideFlags.HideInInspector));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void PreviewProvenanceUndoRedoRestoresMaterializedBase()
{
var cwAiRoot = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_undo_{Guid.NewGuid():N}");
var first = Path.Combine(cwAiRoot, "outputs", "first");
var second = Path.Combine(cwAiRoot, "outputs", "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
cwAiRoot,
false);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test AI Camera Provenance Undo");
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
cwAiRoot,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var changed),
Is.True);
Assert.That(
changed.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
Undo.PerformUndo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(cwAiRoot, true);
}
}
[Test]
public void FinalTimelineCleanupRemovesPreviewOnlyProvenanceSubasset()
{
var generatedDirectory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_provenance_final_{Guid.NewGuid():N}");
Directory.CreateDirectory(generatedDirectory);
File.WriteAllText(
Path.Combine(generatedDirectory, "metadata.json"),
"{\"generation\":1}",
new UTF8Encoding(false));
var timelinePath =
$"Assets/__CWAI_ProvenanceFinal_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
generatedDirectory,
string.Empty,
false);
AssetDatabase.SaveAssets();
AICameraTimelinePreviewImporter
.RemovePreviewGenerationProvenanceForTests(timeline);
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out _),
Is.False);
Assert.That(
AssetDatabase.LoadAllAssetsAtPath(timelinePath)
.OfType<AICameraPreviewGenerationProvenance>(),
Is.Empty);
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(generatedDirectory, true);
}
}
[Test]
public void ShotDisplayNameAndProvenanceRestoreInOneUndoGroup()
{
var root = Path.Combine(
Path.GetTempPath(),
$"cw_ai_track_undo_{Guid.NewGuid():N}");
var first = Path.Combine(root, "first");
var second = Path.Combine(root, "second");
Directory.CreateDirectory(first);
Directory.CreateDirectory(second);
File.WriteAllText(
Path.Combine(first, "metadata.json"),
"{\"generation\":1}");
File.WriteAllText(
Path.Combine(second, "metadata.json"),
"{\"generation\":2}");
var timelinePath =
$"Assets/__CWAI_TrackUndo_{Guid.NewGuid():N}.playable";
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
try
{
AssetDatabase.CreateAsset(timeline, timelinePath);
var track = timeline.CreateTrack<AnimationTrack>(null, "Shot");
var shot = track.CreateDefaultClip();
shot.displayName = "Before";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
first,
root,
false);
EditorUtility.SetDirty(track);
AssetDatabase.SaveAssets();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Test Shot and Provenance Undo");
Undo.RegisterCompleteObjectUndo(track, "Test Shot and Provenance Undo");
shot.displayName = "After";
AICameraTimelinePreviewImporter
.UpsertPreviewGenerationProvenanceForTests(
timeline,
second,
root,
true);
Undo.CollapseUndoOperations(undoGroup);
Undo.FlushUndoRecordObjects();
Undo.PerformUndo();
Assert.That(shot.displayName, Is.EqualTo("Before"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var undone),
Is.True);
Assert.That(
undone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(first)));
Undo.PerformRedo();
Assert.That(shot.displayName, Is.EqualTo("After"));
Assert.That(
AICameraTimelinePreviewImporter
.TryGetPreviewGenerationProvenanceForTests(
timeline,
out var redone),
Is.True);
Assert.That(
redone.AbsoluteGeneratedFolder,
Is.EqualTo(Path.GetFullPath(second)));
}
finally
{
AssetDatabase.DeleteAsset(timelinePath);
Directory.Delete(root, true);
}
}
[Test]
public void GeneratedMotionUsesOneAnimatorAndTrackPerCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
firstCamera.transform.SetLocalPositionAndRotation(
new Vector3(1.25f, 1.8f, -3.5f),
Quaternion.Euler(8f, 135f, 0f));
secondCamera.transform.SetLocalPositionAndRotation(
new Vector3(-2f, 0.9f, 6f),
Quaternion.Euler(-4f, 20f, 2f));
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var firstAnimator = firstCamera.GetComponent<Animator>();
var secondAnimator = secondCamera.GetComponent<Animator>();
Assert.That(cameraRoot.GetComponent<Animator>(), Is.Null);
Assert.That(firstAnimator, Is.Not.Null);
Assert.That(secondAnimator, Is.Not.Null);
Assert.That(firstAnimator, Is.Not.SameAs(secondAnimator));
Assert.That(
firstAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(
secondAnimator.cullingMode,
Is.EqualTo(AnimatorCullingMode.AlwaysAnimate));
Assert.That(director.GetGenericBinding(firstTrack), Is.SameAs(firstAnimator));
Assert.That(director.GetGenericBinding(secondTrack), Is.SameAs(secondAnimator));
Assert.That(firstTrack.parent, Is.SameAs(group));
Assert.That(secondTrack.parent, Is.SameAs(group));
Assert.That(
firstTrack.position,
Is.EqualTo(firstCamera.transform.localPosition));
Assert.That(
secondTrack.position,
Is.EqualTo(secondCamera.transform.localPosition));
Assert.That(
Quaternion.Angle(
firstTrack.rotation,
firstCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
Quaternion.Angle(
secondTrack.rotation,
secondCamera.transform.localRotation),
Is.LessThan(0.001f));
Assert.That(
System.Linq.Enumerable.Count(firstTrack.GetClips()),
Is.EqualTo(1));
Assert.That(
System.Linq.Enumerable.Count(secondTrack.GetClips()),
Is.EqualTo(1));
StringAssert.StartsWith(
AICameraTimelinePreviewImporter.GeneratedAnimationTrackPrefix,
firstTrack.name);
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void ShotSelectionResolvesTimelineClipAssetAndCamera()
{
var directorObject = new GameObject("Director");
var cameraRoot = new GameObject("AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
var group = timeline.CreateTrack<GroupTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
firstCamera,
firstClip,
0,
firstCamera.name,
0,
1);
var secondTrack = AICameraTimelinePreviewImporter
.CreateGeneratedAnimationTrackForCamera(
timeline,
group,
director,
secondCamera,
secondClip,
1,
secondCamera.name,
1,
2);
var selectedTimelineClip = System.Linq.Enumerable.Single(
secondTrack.GetClips());
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
selectedTimelineClip,
null,
null,
out var timelineIndex),
Is.True);
Assert.That(timelineIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
null,
secondClip,
out var assetIndex),
Is.True);
Assert.That(assetIndex, Is.EqualTo(1));
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
null,
firstCamera,
null,
out var cameraIndex),
Is.True);
Assert.That(cameraIndex, Is.EqualTo(0));
}
finally
{
Object.DestroyImmediate(directorObject);
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
[Test]
public void LegacySharedAnimationTrackStillResolvesShots()
{
var cameraRoot = new GameObject("Legacy AI Camera Root");
var firstCamera = new GameObject("Shot_000");
var secondCamera = new GameObject("Shot_001");
firstCamera.transform.SetParent(cameraRoot.transform, false);
secondCamera.transform.SetParent(cameraRoot.transform, false);
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
var firstClip = new AnimationClip();
var secondClip = new AnimationClip();
try
{
var track = timeline.CreateTrack<AnimationTrack>(
null,
AICameraTimelinePreviewImporter.GeneratedAnimationTrackName);
var firstTimelineClip = track.CreateClip(firstClip);
firstTimelineClip.start = 0;
firstTimelineClip.duration = 1;
var secondTimelineClip = track.CreateClip(secondClip);
secondTimelineClip.start = 1;
secondTimelineClip.duration = 1;
Assert.That(
AICameraTimelinePreviewImporter
.TryResolveGeneratedShotIndexForEditor(
timeline,
cameraRoot,
secondTimelineClip,
null,
null,
out var shotIndex),
Is.True);
Assert.That(shotIndex, Is.EqualTo(1));
}
finally
{
Object.DestroyImmediate(cameraRoot);
Object.DestroyImmediate(firstClip);
Object.DestroyImmediate(secondClip);
Object.DestroyImmediate(timeline);
}
}
private static string CreateGeneratedDirectory(bool includeHashes)
{
var directory = Path.Combine(
Path.GetTempPath(),
$"cw_ai_importer_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var cameraPath = Path.Combine(
directory,
"generated_camera_world.f32");
var timePath = Path.Combine(directory, "time.f64");
var shotsPath = Path.Combine(directory, "shots.json");
using (var writer = new BinaryWriter(File.Create(cameraPath)))
{
writer.Write(0f);
writer.Write(1.5f);
writer.Write(-3f);
writer.Write(0f);
writer.Write(0f);
writer.Write(0f);
writer.Write(1f);
writer.Write(60f);
writer.Write(0f);
}
using (var writer = new BinaryWriter(File.Create(timePath)))
{
writer.Write(0d);
}
File.WriteAllText(
shotsPath,
"{\"shots\":[{\"index\":0,\"cameraName\":\"Shot_000\"," +
"\"shotType\":\"medium\",\"startFrame\":0," +
"\"endFrameExclusive\":1}]}",
new UTF8Encoding(false));
var hashes = includeHashes
? ",\"outputSha256\":{" +
$"\"worldCamera\":\"{ComputeSha256(cameraPath)}\"," +
$"\"time\":\"{ComputeSha256(timePath)}\"," +
$"\"shots\":\"{ComputeSha256(shotsPath)}\"}}"
: string.Empty;
File.WriteAllText(
Path.Combine(directory, "metadata.json"),
"{\"songName\":\"Hash Validation Test\",\"sampleRate\":60," +
"\"frameCount\":1,\"duration\":0.016666666666666666," +
"\"worldCameraFile\":\"generated_camera_world.f32\"," +
"\"timeFile\":\"time.f64\",\"shotsFile\":\"shots.json\"," +
$"\"lookAtUsed\":false{hashes}}}",
new UTF8Encoding(false));
return directory;
}
private static void ReplaceMetadataPayloadPath(
string directory,
string propertyName,
string replacementPath)
{
var metadataPath = Path.Combine(directory, "metadata.json");
var json = File.ReadAllText(metadataPath);
var marker = $"\"{propertyName}\":\"";
var valueStart = json.IndexOf(marker, StringComparison.Ordinal);
Assert.That(valueStart, Is.GreaterThanOrEqualTo(0));
valueStart += marker.Length;
var valueEnd = json.IndexOf('"', valueStart);
Assert.That(valueEnd, Is.GreaterThan(valueStart));
var escapedReplacement = replacementPath
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
json = json.Substring(0, valueStart) +
escapedReplacement +
json.Substring(valueEnd);
File.WriteAllText(
metadataPath,
json,
new UTF8Encoding(false));
}
private static string ComputeSha256(string path)
{
using var algorithm = SHA256.Create();
return string.Concat(
algorithm.ComputeHash(File.ReadAllBytes(path))
.Select(value => value.ToString("x2")));
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 74ed53c76a0a4c6c82ea504d79132c63

View File

@ -0,0 +1,24 @@
{
"name": "Mingle.CWAI.Editor.Tests",
"rootNamespace": "Streamingle.Editor.Tests",
"references": [
"Mingle.CWAI.Editor",
"Unity.Cinemachine",
"Unity.Recorder.Editor",
"Unity.Timeline"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false,
"optionalUnityReferences": [
"TestAssemblies"
]
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: fb8474ed574f4f70aabc37357da9fef1
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,551 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
namespace Streamingle.Editor
{
public sealed class TimelineCameraDatasetExporterTests
{
[Test]
public void GenerationInputExportDoesNotRequireAnAuthoredCamera()
{
var outputDirectory = Path.Combine(
Path.GetTempPath(),
"CWAI_GenerationInput_" + Guid.NewGuid().ToString("N"));
var directorObject = new GameObject("GenerationInputDirector");
var characterObject = new GameObject("Character");
var hipsObject = new GameObject("Hips");
var headObject = new GameObject("Head");
var animator = characterObject.AddComponent<Animator>();
hipsObject.transform.SetParent(characterObject.transform, false);
headObject.transform.SetParent(hipsObject.transform, false);
headObject.transform.localPosition = Vector3.up;
var timeline = ScriptableObject.CreateInstance<TimelineAsset>();
timeline.name = "MotionAndAudioOnly";
var motionClip = new AnimationClip { name = "Motion" };
motionClip.SetCurve(
"Hips",
typeof(Transform),
"m_LocalPosition.x",
AnimationCurve.Linear(0f, 0f, 1f, 0.1f));
motionClip.SetCurve(
"Hips/Head",
typeof(Transform),
"m_LocalPosition.y",
AnimationCurve.Linear(0f, 1f, 1f, 1.05f));
var animationTrack = timeline.CreateTrack<AnimationTrack>();
var animationTimelineClip =
animationTrack.CreateClip<AnimationPlayableAsset>();
((AnimationPlayableAsset)animationTimelineClip.asset).clip = motionClip;
animationTimelineClip.duration = 1.0;
var audioClip = AudioClip.Create(
"Audio",
6000,
1,
6000,
false);
var audioSamples = Enumerable.Range(0, audioClip.samples)
.Select(index => 0.25f * Mathf.Sin(index * 0.1f))
.ToArray();
Assert.That(audioClip.SetData(audioSamples, 0), Is.True);
var audioTrack = timeline.CreateTrack<AudioTrack>();
var audioTimelineClip = audioTrack.CreateClip<AudioPlayableAsset>();
((AudioPlayableAsset)audioTimelineClip.asset).clip = audioClip;
audioTimelineClip.duration = 1.0;
var director = directorObject.AddComponent<PlayableDirector>();
director.playableAsset = timeline;
director.SetGenericBinding(animationTrack, animator);
directorObject.SetActive(false);
try
{
TimelineCameraDatasetExporter
.ExportGenerationInput60FpsToForCli(
director,
outputDirectory,
false);
Assert.That(directorObject.activeSelf, Is.False);
var manifest = File.ReadAllText(Path.Combine(
outputDirectory,
"dataset_manifest.json"));
StringAssert.Contains("\"inputKind\": \"generation_input\"", manifest);
StringAssert.Contains("\"hasAuthoredCamera\": false", manifest);
StringAssert.Contains("\"audioFingerprint\": \"", manifest);
var songDirectory = Directory.GetDirectories(outputDirectory).Single();
Assert.That(
new FileInfo(Path.Combine(songDirectory, "camera.f32")).Length,
Is.EqualTo(60L * 9 * sizeof(float)));
Assert.That(
new FileInfo(Path.Combine(songDirectory, "shot_index.i32")).Length,
Is.EqualTo(60L * sizeof(int)));
Assert.That(
new FileInfo(Path.Combine(songDirectory, "audio_features.f32")).Length,
Is.EqualTo(60L * 2 * sizeof(float)));
using (var cameraReader = new BinaryReader(File.OpenRead(
Path.Combine(songDirectory, "camera.f32"))))
{
Assert.That(float.IsNaN(cameraReader.ReadSingle()), Is.True);
}
using (var shotReader = new BinaryReader(File.OpenRead(
Path.Combine(songDirectory, "shot_index.i32"))))
{
Assert.That(shotReader.ReadInt32(), Is.EqualTo(-1));
}
using (var featureReader = new BinaryReader(File.OpenRead(
Path.Combine(songDirectory, "audio_features.f32"))))
{
var values = new float[120];
for (var index = 0; index < values.Length; index++)
{
values[index] = featureReader.ReadSingle();
}
Assert.That(values.All(float.IsFinite), Is.True);
Assert.That(values.Where((_, index) => index % 2 == 0).Max(),
Is.GreaterThan(0f));
}
var shots = File.ReadAllText(Path.Combine(songDirectory, "shots.json"));
StringAssert.Contains("\"shots\": []", shots);
}
finally
{
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, true);
}
UnityEngine.Object.DestroyImmediate(audioClip);
UnityEngine.Object.DestroyImmediate(motionClip);
UnityEngine.Object.DestroyImmediate(timeline);
UnityEngine.Object.DestroyImmediate(directorObject);
UnityEngine.Object.DestroyImmediate(characterObject);
}
}
[Test]
public void GenerationInputSummaryReportsMissingSelection()
{
var summary = TimelineCameraDatasetExporter
.GetGenerationInputSummaryForEditor(null);
Assert.That(summary.isValid, Is.False);
Assert.That(summary.error, Is.Not.Empty);
}
[Test]
public void GenerationInputFingerprintRefusesDirtyScene()
{
var directorObject = new GameObject("DirtySourceDirector");
var director = directorObject.AddComponent<PlayableDirector>();
try
{
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(
directorObject.scene);
Assert.That(
TimelineCameraDatasetExporter
.TryComputeGenerationInputSourceFingerprint(
director,
out var fingerprint,
out var error),
Is.False);
Assert.That(fingerprint, Is.Empty);
StringAssert.Contains("unsaved changes", error);
}
finally
{
UnityEngine.Object.DestroyImmediate(directorObject);
}
}
[Test]
public void ReusableGenerationInputValidationAcceptsCompleteExport()
{
var root = CreateTemporaryDirectory();
const string fingerprint =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
try
{
var candidate = CreateReusableGenerationInputFixture(
root,
"GenerationInput_60fps_20260802_120000_000",
fingerprint);
Assert.That(
TimelineCameraDatasetExporter
.TryValidateReusableGenerationInputExport(
candidate,
fingerprint,
out var error),
Is.True,
error);
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ReusableGenerationInputValidationRejectsChangedSourceAndAudio()
{
var root = CreateTemporaryDirectory();
const string fingerprint =
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
try
{
var candidate = CreateReusableGenerationInputFixture(
root,
"GenerationInput_60fps_20260802_120000_000",
fingerprint);
Assert.That(
TimelineCameraDatasetExporter
.TryValidateReusableGenerationInputExport(
candidate,
"changed-source",
out _),
Is.False);
File.AppendAllText(
Path.Combine(candidate, "01_Test", "audio_source.wav"),
"tampered");
Assert.That(
TimelineCameraDatasetExporter
.TryValidateReusableGenerationInputExport(
candidate,
fingerprint,
out var error),
Is.False);
StringAssert.Contains("fingerprint", error);
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ReusableGenerationInputSearchSkipsIncompleteNewestCandidate()
{
var root = CreateTemporaryDirectory();
const string fingerprint =
"cccccccccccccccccccccccccccccccc" +
"cccccccccccccccccccccccccccccccc";
try
{
var expected = CreateReusableGenerationInputFixture(
root,
"GenerationInput_60fps_20260802_120000_000",
fingerprint);
var incomplete = CreateReusableGenerationInputFixture(
root,
"GenerationInput_60fps_20260802_120001_000",
fingerprint);
File.Delete(Path.Combine(
incomplete,
"01_Test",
"root.f32"));
Assert.That(
TimelineCameraDatasetExporter
.TryFindReusableGenerationInputExport(
root,
fingerprint,
out var actual,
out var error),
Is.True,
error);
Assert.That(actual, Is.EqualTo(Path.GetFullPath(expected)));
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ReusableGenerationInputValidationRejectsSameSizeBinaryCorruption()
{
var root = CreateTemporaryDirectory();
const string fingerprint =
"dddddddddddddddddddddddddddddddd" +
"dddddddddddddddddddddddddddddddd";
try
{
var candidate = CreateReusableGenerationInputFixture(
root,
"GenerationInput_60fps_20260802_120000_000",
fingerprint);
var jointsPath = Path.Combine(
candidate,
"01_Test",
"joints_world.f32");
var bytes = File.ReadAllBytes(jointsPath);
bytes[0] ^= 0x01;
File.WriteAllBytes(jointsPath, bytes);
Assert.That(
TimelineCameraDatasetExporter
.TryValidateReusableGenerationInputExport(
candidate,
fingerprint,
out var error),
Is.False);
StringAssert.Contains("fingerprint", error);
}
finally
{
Directory.Delete(root, true);
}
}
[Test]
public void ReusableGenerationInputSearchFailsClosedOnMalformedManifest()
{
var root = CreateTemporaryDirectory();
try
{
var candidate = Path.Combine(
root,
"GenerationInput_60fps_20260802_120000_000");
Directory.CreateDirectory(candidate);
File.WriteAllText(
Path.Combine(candidate, "dataset_manifest.json"),
"{");
Assert.That(
TimelineCameraDatasetExporter
.TryFindReusableGenerationInputExport(
root,
"source",
out var actual,
out _),
Is.False);
Assert.That(actual, Is.Empty);
}
finally
{
Directory.Delete(root, true);
}
}
private static string CreateTemporaryDirectory()
{
var path = Path.Combine(
Path.GetTempPath(),
"CWAI_ReusableGenerationInput_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static string CreateReusableGenerationInputFixture(
string root,
string directoryName,
string sourceFingerprint)
{
const int frameCount = 2;
const int boneCount = 2;
var candidate = Path.Combine(root, directoryName);
var songDirectory = Path.Combine(candidate, "01_Test");
Directory.CreateDirectory(songDirectory);
var audioPath = Path.Combine(songDirectory, "audio_source.wav");
File.WriteAllBytes(audioPath, new byte[] { 1, 2, 3, 4, 5 });
var audioFingerprint = ComputeSha256(audioPath);
var song = new GenerationInputSongFixture
{
songName = "Test",
role = "generation_input",
inputKind = "generation_input",
hasAuthoredCamera = false,
folderName = "01_Test",
audioFile = "audio_source.wav",
audioFingerprint = audioFingerprint,
sampleRate = 60,
frameCount = frameCount,
sharedBoneCount = boneCount,
shotCount = 0,
missingCameraFrames = frameCount,
missingJointSamples = 0,
missingRootSamples = 0,
jointsFile = "joints_world.f32",
cameraFile = "camera.f32",
rootFile = "root.f32",
shotIndexFile = "shot_index.i32",
timeFile = "time.f64",
shotsFile = "shots.json",
previewFile = "preview.csv",
audioFeaturesFile = "audio_features.f32"
};
var manifest = new GenerationInputManifestFixture
{
schemaVersion = "1.3",
role = "generation_input",
inputKind = "generation_input",
hasAuthoredCamera = false,
sampleRate = 60,
sourceFingerprint = sourceFingerprint,
discoveredCameraTrackCount = 0,
exportedCameraTrackCount = 0,
skippedCameraTracks = new List<string>(),
sharedBones = new List<string> { "Hips", "Head" },
songs = new List<GenerationInputSongFixture> { song }
};
File.WriteAllText(
Path.Combine(songDirectory, "shots.json"),
JsonUtility.ToJson(new ShotFileFixture
{
timelineName = "Test",
shots = new List<ShotFixture>()
}, true));
File.WriteAllText(
Path.Combine(songDirectory, "preview.csv"),
"frame,time\n0,0\n");
WriteZeroBytes(
Path.Combine(songDirectory, song.jointsFile),
frameCount * boneCount * 3 * sizeof(float));
WriteZeroBytes(
Path.Combine(songDirectory, song.rootFile),
frameCount * 14 * sizeof(float));
WriteZeroBytes(
Path.Combine(songDirectory, song.timeFile),
frameCount * sizeof(double));
WriteZeroBytes(
Path.Combine(songDirectory, song.audioFeaturesFile),
frameCount * 2 * sizeof(float));
using (var writer = new BinaryWriter(File.Create(
Path.Combine(songDirectory, song.cameraFile))))
{
for (var index = 0; index < frameCount * 9; index++)
{
writer.Write(float.NaN);
}
}
using (var writer = new BinaryWriter(File.Create(
Path.Combine(songDirectory, song.shotIndexFile))))
{
for (var index = 0; index < frameCount; index++)
{
writer.Write(-1);
}
}
song.jointsFingerprint = ComputeSha256(
Path.Combine(songDirectory, song.jointsFile));
song.cameraFingerprint = ComputeSha256(
Path.Combine(songDirectory, song.cameraFile));
song.rootFingerprint = ComputeSha256(
Path.Combine(songDirectory, song.rootFile));
song.shotIndexFingerprint = ComputeSha256(
Path.Combine(songDirectory, song.shotIndexFile));
song.timeFingerprint = ComputeSha256(
Path.Combine(songDirectory, song.timeFile));
song.audioFeaturesFingerprint = ComputeSha256(
Path.Combine(songDirectory, song.audioFeaturesFile));
File.WriteAllText(
Path.Combine(candidate, "dataset_manifest.json"),
JsonUtility.ToJson(manifest, true));
File.WriteAllText(
Path.Combine(songDirectory, "metadata.json"),
JsonUtility.ToJson(song, true));
return candidate;
}
private static void WriteZeroBytes(string path, int count)
{
File.WriteAllBytes(path, new byte[count]);
}
private static string ComputeSha256(string path)
{
using var stream = File.OpenRead(path);
using var sha256 = SHA256.Create();
return string.Concat(
sha256.ComputeHash(stream)
.Select(value => value.ToString("x2")));
}
[Serializable]
private sealed class GenerationInputManifestFixture
{
public string schemaVersion;
public string role;
public string inputKind;
public bool hasAuthoredCamera;
public int sampleRate;
public string sourceFingerprint;
public int discoveredCameraTrackCount;
public int exportedCameraTrackCount;
public List<string> skippedCameraTracks;
public List<string> sharedBones;
public List<GenerationInputSongFixture> songs;
}
[Serializable]
private sealed class GenerationInputSongFixture
{
public string songName;
public string role;
public string inputKind;
public bool hasAuthoredCamera;
public string folderName;
public string audioFile;
public string audioFingerprint;
public int sampleRate;
public int frameCount;
public int sharedBoneCount;
public int shotCount;
public int missingCameraFrames;
public int missingJointSamples;
public int missingRootSamples;
public string jointsFile;
public string jointsFingerprint;
public string cameraFile;
public string cameraFingerprint;
public string rootFile;
public string rootFingerprint;
public string shotIndexFile;
public string shotIndexFingerprint;
public string timeFile;
public string timeFingerprint;
public string shotsFile;
public string previewFile;
public string audioFeaturesFile;
public string audioFeaturesFingerprint;
}
[Serializable]
private sealed class ShotFileFixture
{
public string timelineName;
public List<ShotFixture> shots;
}
[Serializable]
private sealed class ShotFixture
{
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b557e27541794769bd7798589e9c5a9b
timeCreated: 1785484800

BIN
CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/VCOMP140.DLL (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/VCRUNTIME140.dll (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/VCRUNTIME140_1.dll (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_asyncio.pyd (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_bz2.pyd (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_ctypes.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_decimal.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_elementtree.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_hashlib.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_lzma.pyd (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_overlapped.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_queue.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_socket.pyd (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,503 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_sqlite3.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_ssl.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_uuid.pyd (Stored with Git LFS) Normal file

Binary file not shown.

BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_zoneinfo.pyd (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More