feat: add Camera Work AI package
This commit is contained in:
parent
26c578416f
commit
0a30156023
4
.gitattributes
vendored
4
.gitattributes
vendored
@ -1 +1,5 @@
|
||||
*.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
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
- Fixed player build compile error: `ScheduleEditorFinalEvaluation` call in `BlendshapeGazeMixerBehaviour.ProcessFrame` was not wrapped in `#if UNITY_EDITOR`.
|
||||
|
||||
185
CameraAI~/CHANGELOG.md
Normal file
185
CameraAI~/CHANGELOG.md
Normal 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.
|
||||
2
CameraAI~/CHANGELOG.md.meta
Normal file
2
CameraAI~/CHANGELOG.md.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ccccb305b4444bc4bcfd3d4fba5b0044
|
||||
65
CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md
Normal file
65
CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md
Normal 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
8
CameraAI~/Editor.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae8819ff3bf95914eadcfefcdabb48ad
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
633
CameraAI~/Editor/AICameraABRenderManifest.cs
Normal file
633
CameraAI~/Editor/AICameraABRenderManifest.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
CameraAI~/Editor/AICameraABRenderManifest.cs.meta
Normal file
2
CameraAI~/Editor/AICameraABRenderManifest.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e61af0fef774b80bbf7fc1121b9ae29
|
||||
1336
CameraAI~/Editor/AICameraABRenderRunner.cs
Normal file
1336
CameraAI~/Editor/AICameraABRenderRunner.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
CameraAI~/Editor/AICameraABRenderRunner.cs.meta
Normal file
2
CameraAI~/Editor/AICameraABRenderRunner.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 249ad3cd8eec495a8419e994e185a41b
|
||||
210
CameraAI~/Editor/AICameraABRenderWindow.cs
Normal file
210
CameraAI~/Editor/AICameraABRenderWindow.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
CameraAI~/Editor/AICameraABRenderWindow.cs.meta
Normal file
2
CameraAI~/Editor/AICameraABRenderWindow.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e32ae9d4a42347859c368bc701901af7
|
||||
604
CameraAI~/Editor/AICameraCliRunner.cs
Normal file
604
CameraAI~/Editor/AICameraCliRunner.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
2
CameraAI~/Editor/AICameraCliRunner.cs.meta
Normal file
2
CameraAI~/Editor/AICameraCliRunner.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f8bdd831f8794ea18ca288f3745462b1
|
||||
960
CameraAI~/Editor/AICameraCutCorrectionRecorder.cs
Normal file
960
CameraAI~/Editor/AICameraCutCorrectionRecorder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta
Normal file
2
CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ef4feff8a1c42a0be6affcf5d6429db
|
||||
951
CameraAI~/Editor/AICameraGeneratorWindow.UI.cs
Normal file
951
CameraAI~/Editor/AICameraGeneratorWindow.UI.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
CameraAI~/Editor/AICameraGeneratorWindow.UI.cs.meta
Normal file
2
CameraAI~/Editor/AICameraGeneratorWindow.UI.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91c38b82e8714b4da4f92c32e990cc51
|
||||
4387
CameraAI~/Editor/AICameraGeneratorWindow.cs
Normal file
4387
CameraAI~/Editor/AICameraGeneratorWindow.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
CameraAI~/Editor/AICameraGeneratorWindow.cs.meta
Normal file
2
CameraAI~/Editor/AICameraGeneratorWindow.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ff40a5b8a5b43e492463c46ba8f4995
|
||||
41
CameraAI~/Editor/AICameraPreviewGenerationProvenance.cs
Normal file
41
CameraAI~/Editor/AICameraPreviewGenerationProvenance.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8f3a1c2e4b54f6789012345abcde678
|
||||
4380
CameraAI~/Editor/AICameraTimelinePreviewImporter.cs
Normal file
4380
CameraAI~/Editor/AICameraTimelinePreviewImporter.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
CameraAI~/Editor/AICameraTimelinePreviewImporter.cs.meta
Normal file
2
CameraAI~/Editor/AICameraTimelinePreviewImporter.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31604c3bc92fd194484b9159429dd3c2
|
||||
3
CameraAI~/Editor/AssemblyInfo.cs
Normal file
3
CameraAI~/Editor/AssemblyInfo.cs
Normal file
@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Mingle.CWAI.Editor.Tests")]
|
||||
2
CameraAI~/Editor/AssemblyInfo.cs.meta
Normal file
2
CameraAI~/Editor/AssemblyInfo.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1a242e13969456688f2467d4acf4930
|
||||
21
CameraAI~/Editor/Mingle.CWAI.Editor.asmdef
Normal file
21
CameraAI~/Editor/Mingle.CWAI.Editor.asmdef
Normal 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
|
||||
}
|
||||
7
CameraAI~/Editor/Mingle.CWAI.Editor.asmdef.meta
Normal file
7
CameraAI~/Editor/Mingle.CWAI.Editor.asmdef.meta
Normal file
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c99c6ba712c380b43bd392841b1eb410
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
1345
CameraAI~/Editor/TimelineCameraBatchExporter.cs
Normal file
1345
CameraAI~/Editor/TimelineCameraBatchExporter.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
CameraAI~/Editor/TimelineCameraBatchExporter.cs.meta
Normal file
2
CameraAI~/Editor/TimelineCameraBatchExporter.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3ab4b71dae56314c981d64b2d3fd3d7
|
||||
3473
CameraAI~/Editor/TimelineCameraDatasetExporter.cs
Normal file
3473
CameraAI~/Editor/TimelineCameraDatasetExporter.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
CameraAI~/Editor/TimelineCameraDatasetExporter.cs.meta
Normal file
2
CameraAI~/Editor/TimelineCameraDatasetExporter.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47fb91e0c133d204d84638cf2b4a98ea
|
||||
8
CameraAI~/Editor/UI.meta
Normal file
8
CameraAI~/Editor/UI.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c8c76ce67b4489299ad2b2afb1cdf3d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
522
CameraAI~/Editor/UI/AICameraGeneratorWindow.uss
Normal file
522
CameraAI~/Editor/UI/AICameraGeneratorWindow.uss
Normal 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;
|
||||
}
|
||||
12
CameraAI~/Editor/UI/AICameraGeneratorWindow.uss.meta
Normal file
12
CameraAI~/Editor/UI/AICameraGeneratorWindow.uss.meta
Normal 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
|
||||
136
CameraAI~/Editor/UI/AICameraGeneratorWindow.uxml
Normal file
136
CameraAI~/Editor/UI/AICameraGeneratorWindow.uxml
Normal 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>
|
||||
10
CameraAI~/Editor/UI/AICameraGeneratorWindow.uxml.meta
Normal file
10
CameraAI~/Editor/UI/AICameraGeneratorWindow.uxml.meta
Normal 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
155
CameraAI~/README.md
Normal 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
7
CameraAI~/README.md.meta
Normal file
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 84da0cdf89e3da2439ed0ad761e2fe0d
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
16
CameraAI~/THIRD_PARTY_NOTICES.md
Normal file
16
CameraAI~/THIRD_PARTY_NOTICES.md
Normal 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.
|
||||
7
CameraAI~/THIRD_PARTY_NOTICES.md.meta
Normal file
7
CameraAI~/THIRD_PARTY_NOTICES.md.meta
Normal file
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 55f123ec8b7a4344b8468ea083861738
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
CameraAI~/Tests.meta
Normal file
8
CameraAI~/Tests.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c0ba2302b9c49cda5423455f6e7102b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
CameraAI~/Tests/Editor.meta
Normal file
8
CameraAI~/Tests/Editor.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c974ef7459d45caa99203db8ef311b1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
165
CameraAI~/Tests/Editor/AICameraABRenderTests.cs
Normal file
165
CameraAI~/Tests/Editor/AICameraABRenderTests.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
2
CameraAI~/Tests/Editor/AICameraABRenderTests.cs.meta
Normal file
2
CameraAI~/Tests/Editor/AICameraABRenderTests.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99556bdd71254adaa3f4d8654875a01d
|
||||
126
CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs
Normal file
126
CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs.meta
Normal file
2
CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs.meta
Normal file
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30c854c9afbd493f8b2e660c23584f83
|
||||
484
CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs
Normal file
484
CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5856da1abe05460b9e98bed6d6d2536d
|
||||
426
CameraAI~/Tests/Editor/AICameraCutCorrectionRecorderTests.cs
Normal file
426
CameraAI~/Tests/Editor/AICameraCutCorrectionRecorderTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b88c37fe4bd4e48818660d18810ae43
|
||||
844
CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs
Normal file
844
CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs
Normal 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("선택한 카메라를 다시 생성할 때만"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a3ea2034f8a4ce1b45f1673472e5e6f
|
||||
67
CameraAI~/Tests/Editor/AICameraGeneratorWindowUiTests.cs
Normal file
67
CameraAI~/Tests/Editor/AICameraGeneratorWindowUiTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ef03e2f61a24afab45e0e719a9098be
|
||||
867
CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs
Normal file
867
CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs
Normal 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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74ed53c76a0a4c6c82ea504d79132c63
|
||||
24
CameraAI~/Tests/Editor/Mingle.CWAI.Editor.Tests.asmdef
Normal file
24
CameraAI~/Tests/Editor/Mingle.CWAI.Editor.Tests.asmdef
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fb8474ed574f4f70aabc37357da9fef1
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
551
CameraAI~/Tests/Editor/TimelineCameraDatasetExporterTests.cs
Normal file
551
CameraAI~/Tests/Editor/TimelineCameraDatasetExporterTests.cs
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b557e27541794769bd7798589e9c5a9b
|
||||
timeCreated: 1785484800
|
||||
BIN
CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imaging.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imaging.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingcms.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingcms.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingft.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingft.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingmath.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingmath.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingtk.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_imagingtk.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_webp.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/PIL/_webp.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/Pythonwin/mfc140u.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/Pythonwin/mfc140u.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/Pythonwin/win32ui.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/Pythonwin/win32ui.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/VCOMP140.DLL
(Stored with Git LFS)
Normal file
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
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
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
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_asyncio.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_brotli.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_brotli.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_bz2.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_bz2.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_cffi_backend.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_cffi_backend.cp310-win_amd64.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_ctypes.pyd
(Stored with Git LFS)
Normal file
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
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
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
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
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_lzma.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_multiprocessing.pyd
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_multiprocessing.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_overlapped.pyd
(Stored with Git LFS)
Normal file
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
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
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_socket.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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!
|
||||
|
||||
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_soundfile_data/libsndfile_x64.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_soundfile_data/libsndfile_x64.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_sqlite3.pyd
(Stored with Git LFS)
Normal file
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
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
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
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/_zoneinfo.pyd
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-console-l1-1-0.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-console-l1-1-0.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-datetime-l1-1-0.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-datetime-l1-1-0.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-debug-l1-1-0.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-debug-l1-1-0.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-errorhandling-l1-1-0.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-errorhandling-l1-1-0.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-fibers-l1-1-0.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-fibers-l1-1-0.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-fibers-l1-1-1.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-fibers-l1-1-1.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-file-l1-1-0.dll
(Stored with Git LFS)
Normal file
BIN
CameraAI~/Tools~/CWCameraWorker/_internal/api-ms-win-core-file-l1-1-0.dll
(Stored with Git LFS)
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user