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