feat: add Camera Work AI package
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ccccb305b4444bc4bcfd3d4fba5b0044
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae8819ff3bf95914eadcfefcdabb48ad
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,633 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Streamingle.Editor
|
||||
{
|
||||
public enum AICameraABRenderCodec
|
||||
{
|
||||
MP4,
|
||||
WEBM
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABRenderRequest
|
||||
{
|
||||
public string candidateADirectory;
|
||||
public string candidateBDirectory;
|
||||
public string outputRoot;
|
||||
public int startFrame;
|
||||
public int endFrameExclusive;
|
||||
public AICameraABRenderCodec codec = AICameraABRenderCodec.MP4;
|
||||
public int width = 1920;
|
||||
public int height = 1080;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABRenderManifest
|
||||
{
|
||||
public string schemaVersion = "1.0";
|
||||
public string jobId;
|
||||
public string state;
|
||||
public string createdUtc;
|
||||
public string completedUtc;
|
||||
public string error;
|
||||
public AICameraABEnvironmentInfo environment;
|
||||
public AICameraABSceneInfo scene;
|
||||
public AICameraABFrameRange frameRange;
|
||||
public AICameraABMovieSettings movie;
|
||||
public AICameraABFairnessInfo fairness;
|
||||
public List<AICameraABAudioClipInfo> audio = new();
|
||||
public List<AICameraABCandidateResult> candidates = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABEnvironmentInfo
|
||||
{
|
||||
public string unityVersion;
|
||||
public string recorderVersion;
|
||||
public string cwAiVersion;
|
||||
public string operatingSystem;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABSceneInfo
|
||||
{
|
||||
public string scenePath;
|
||||
public string sceneGuid;
|
||||
public string timelinePath;
|
||||
public string timelineGuid;
|
||||
public double timelineFrameRate;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABFrameRange
|
||||
{
|
||||
public int startFrame;
|
||||
public int endFrameExclusive;
|
||||
public int expectedFrameCount;
|
||||
public double startSeconds;
|
||||
public double endSecondsExclusive;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABMovieSettings
|
||||
{
|
||||
public string codec;
|
||||
public string encoder;
|
||||
public string quality;
|
||||
public int width;
|
||||
public int height;
|
||||
public double aspectRatio;
|
||||
public int frameRate;
|
||||
public string cameraSource;
|
||||
public bool captureAudio;
|
||||
public bool captureAlpha;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABFairnessInfo
|
||||
{
|
||||
public bool sameSampleRate;
|
||||
public bool sameFrameCount;
|
||||
public bool sameTimeSha256;
|
||||
public bool sameSongId;
|
||||
public bool sameDatasetId;
|
||||
public bool sameTargetAspectRatio;
|
||||
public bool sameSceneAndTimeline;
|
||||
public string sharedTimeSha256;
|
||||
public string sharedSongId;
|
||||
public string targetEvaluationGroupId;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABAudioClipInfo
|
||||
{
|
||||
public string trackName;
|
||||
public string clipName;
|
||||
public string assetPath;
|
||||
public string assetGuid;
|
||||
public string assetSha256;
|
||||
public double timelineStart;
|
||||
public double clipIn;
|
||||
public double duration;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class AICameraABCandidateResult
|
||||
{
|
||||
public string label;
|
||||
public string generatedDirectory;
|
||||
public string horizontalFramingPolicy;
|
||||
public string metadataSha256;
|
||||
public string cameraSha256;
|
||||
public string timeSha256;
|
||||
public string shotsSha256;
|
||||
public string declaredCameraSha256;
|
||||
public string declaredTimeSha256;
|
||||
public string declaredShotsSha256;
|
||||
public string songName;
|
||||
public string songId;
|
||||
public string datasetId;
|
||||
public string targetEvaluationGroupId;
|
||||
public double targetAspectRatio;
|
||||
public int sampleRate;
|
||||
public int frameCount;
|
||||
public double duration;
|
||||
public string outputBasePath;
|
||||
public string outputPath;
|
||||
public string status;
|
||||
public string startedUtc;
|
||||
public string completedUtc;
|
||||
public long outputBytes;
|
||||
public string outputSha256;
|
||||
public string error;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
internal sealed class AICameraABRenderJobState
|
||||
{
|
||||
public string schemaVersion = "1.0";
|
||||
public string jobId;
|
||||
public string phase;
|
||||
public string manifestPath;
|
||||
public string runDirectory;
|
||||
public int candidateIndex;
|
||||
public bool cancelRequested;
|
||||
public bool cleanupPending;
|
||||
public bool cinemachineSaveDuringPlayStateCaptured;
|
||||
public bool cinemachineSaveDuringPlayOriginalEnabled;
|
||||
public bool cinemachineSaveDuringPlayStateRestored;
|
||||
public int stableOutputChecks;
|
||||
public long lastObservedOutputBytes = -1;
|
||||
public string finalizationStartedUtc;
|
||||
public string error;
|
||||
public AICameraABRenderRequest request;
|
||||
public AICameraABSceneSetupSnapshot sceneSetup;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
internal sealed class AICameraABSceneSetupSnapshot
|
||||
{
|
||||
public string activeScenePath;
|
||||
public List<AICameraABSceneEntry> scenes = new();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
internal sealed class AICameraABSceneEntry
|
||||
{
|
||||
public string path;
|
||||
public bool isLoaded;
|
||||
public bool isActive;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
internal sealed class AICameraABGeneratedCameraMetadata
|
||||
{
|
||||
public string songName;
|
||||
public string songId;
|
||||
public string datasetId;
|
||||
public string targetEvaluationGroupId;
|
||||
public string horizontalFramingPolicy;
|
||||
public double targetAspectRatio;
|
||||
public double aspectRatioOverride;
|
||||
public int sampleRate;
|
||||
public int frameCount;
|
||||
public double duration;
|
||||
public string worldCameraFile;
|
||||
public string timeFile;
|
||||
public string shotsFile;
|
||||
public AICameraABGeneratedOutputSha256 outputSha256;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
internal sealed class AICameraABGeneratedOutputSha256
|
||||
{
|
||||
public string worldCamera;
|
||||
public string time;
|
||||
public string shots;
|
||||
}
|
||||
|
||||
internal sealed class AICameraABCandidateProbe
|
||||
{
|
||||
public AICameraABGeneratedCameraMetadata Metadata;
|
||||
public AICameraABCandidateResult Result;
|
||||
}
|
||||
|
||||
internal static class AICameraABRenderUtility
|
||||
{
|
||||
private static readonly StringComparison PathComparison =
|
||||
Application.platform == RuntimePlatform.WindowsEditor
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
internal static string UtcNow()
|
||||
{
|
||||
return DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
internal static AICameraABRenderCodec ParseCodec(string value)
|
||||
{
|
||||
if (Enum.TryParse(value, true, out AICameraABRenderCodec codec))
|
||||
{
|
||||
return codec;
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Unsupported movie codec '{value}'. Use MP4 or WEBM.",
|
||||
nameof(value));
|
||||
}
|
||||
|
||||
internal static string GetExtension(AICameraABRenderCodec codec)
|
||||
{
|
||||
return codec == AICameraABRenderCodec.MP4 ? "mp4" : "webm";
|
||||
}
|
||||
|
||||
internal static int ToInclusiveEndFrame(
|
||||
int startFrame,
|
||||
int endFrameExclusive,
|
||||
int frameCount)
|
||||
{
|
||||
ValidateFrameRange(startFrame, endFrameExclusive, frameCount);
|
||||
return endFrameExclusive - 1;
|
||||
}
|
||||
|
||||
internal static void ValidateFrameRange(
|
||||
int startFrame,
|
||||
int endFrameExclusive,
|
||||
int frameCount)
|
||||
{
|
||||
if (frameCount <= 0 ||
|
||||
startFrame < 0 ||
|
||||
endFrameExclusive <= startFrame ||
|
||||
endFrameExclusive > frameCount)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(endFrameExclusive),
|
||||
$"Frame range [{startFrame}, {endFrameExclusive}) is invalid " +
|
||||
$"for a {frameCount}-frame camera.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ValidateOutputSettings(
|
||||
AICameraABRenderCodec codec,
|
||||
int width,
|
||||
int height)
|
||||
{
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(width),
|
||||
"Movie width and height must both be positive.");
|
||||
}
|
||||
|
||||
if (codec == AICameraABRenderCodec.MP4 &&
|
||||
(width % 2 != 0 || height % 2 != 0))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"MP4/H.264 output requires even width and height.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static AICameraABCandidateProbe ReadCandidate(
|
||||
string label,
|
||||
string generatedDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(generatedDirectory))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Candidate {label} directory is empty.",
|
||||
nameof(generatedDirectory));
|
||||
}
|
||||
|
||||
var canonicalDirectory = Path.GetFullPath(generatedDirectory);
|
||||
var metadataPath = ResolveInputFile(
|
||||
canonicalDirectory,
|
||||
"metadata.json",
|
||||
true);
|
||||
var metadata = JsonUtility.FromJson<AICameraABGeneratedCameraMetadata>(
|
||||
File.ReadAllText(metadataPath));
|
||||
if (metadata == null ||
|
||||
metadata.sampleRate <= 0 ||
|
||||
metadata.frameCount <= 0 ||
|
||||
metadata.duration <= 0 ||
|
||||
string.IsNullOrWhiteSpace(metadata.worldCameraFile) ||
|
||||
string.IsNullOrWhiteSpace(metadata.timeFile))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Candidate {label} metadata is incomplete.");
|
||||
}
|
||||
|
||||
var cameraPath = ResolveInputFile(
|
||||
canonicalDirectory,
|
||||
metadata.worldCameraFile,
|
||||
true);
|
||||
var timePath = ResolveInputFile(
|
||||
canonicalDirectory,
|
||||
metadata.timeFile,
|
||||
true);
|
||||
var shotsPath = string.IsNullOrWhiteSpace(metadata.shotsFile)
|
||||
? string.Empty
|
||||
: ResolveInputFile(
|
||||
canonicalDirectory,
|
||||
metadata.shotsFile,
|
||||
true);
|
||||
|
||||
var result = new AICameraABCandidateResult
|
||||
{
|
||||
label = label,
|
||||
generatedDirectory = canonicalDirectory,
|
||||
horizontalFramingPolicy = metadata.horizontalFramingPolicy,
|
||||
metadataSha256 = ComputeSha256(metadataPath),
|
||||
cameraSha256 = ComputeSha256(cameraPath),
|
||||
timeSha256 = ComputeSha256(timePath),
|
||||
shotsSha256 = string.IsNullOrWhiteSpace(shotsPath)
|
||||
? string.Empty
|
||||
: ComputeSha256(shotsPath),
|
||||
declaredCameraSha256 =
|
||||
metadata.outputSha256?.worldCamera ?? string.Empty,
|
||||
declaredTimeSha256 =
|
||||
metadata.outputSha256?.time ?? string.Empty,
|
||||
declaredShotsSha256 =
|
||||
metadata.outputSha256?.shots ?? string.Empty,
|
||||
songName = metadata.songName,
|
||||
songId = metadata.songId,
|
||||
datasetId = metadata.datasetId,
|
||||
targetEvaluationGroupId = metadata.targetEvaluationGroupId,
|
||||
targetAspectRatio = GetTargetAspectRatio(metadata),
|
||||
sampleRate = metadata.sampleRate,
|
||||
frameCount = metadata.frameCount,
|
||||
duration = metadata.duration,
|
||||
status = "queued"
|
||||
};
|
||||
|
||||
ValidateDeclaredSha(
|
||||
label,
|
||||
"camera",
|
||||
result.declaredCameraSha256,
|
||||
result.cameraSha256);
|
||||
ValidateDeclaredSha(
|
||||
label,
|
||||
"time",
|
||||
result.declaredTimeSha256,
|
||||
result.timeSha256);
|
||||
ValidateDeclaredSha(
|
||||
label,
|
||||
"shots",
|
||||
result.declaredShotsSha256,
|
||||
result.shotsSha256);
|
||||
|
||||
return new AICameraABCandidateProbe
|
||||
{
|
||||
Metadata = metadata,
|
||||
Result = result
|
||||
};
|
||||
}
|
||||
|
||||
internal static AICameraABFairnessInfo ValidateCandidatePair(
|
||||
AICameraABCandidateProbe candidateA,
|
||||
AICameraABCandidateProbe candidateB,
|
||||
AICameraABRenderRequest request)
|
||||
{
|
||||
if (candidateA == null || candidateB == null)
|
||||
{
|
||||
throw new ArgumentNullException(
|
||||
candidateA == null ? nameof(candidateA) : nameof(candidateB));
|
||||
}
|
||||
|
||||
var a = candidateA.Result;
|
||||
var b = candidateB.Result;
|
||||
var sameSongId = string.Equals(
|
||||
a.songId ?? string.Empty,
|
||||
b.songId ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
var sameEvaluationGroup = string.Equals(
|
||||
a.targetEvaluationGroupId ?? string.Empty,
|
||||
b.targetEvaluationGroupId ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
var sameDatasetId = string.Equals(
|
||||
a.datasetId ?? string.Empty,
|
||||
b.datasetId ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
var sameAspect = Math.Abs(
|
||||
a.targetAspectRatio - b.targetAspectRatio) < 0.0001;
|
||||
var fairness = new AICameraABFairnessInfo
|
||||
{
|
||||
sameSampleRate = a.sampleRate == b.sampleRate,
|
||||
sameFrameCount = a.frameCount == b.frameCount,
|
||||
sameTimeSha256 = string.Equals(
|
||||
a.timeSha256,
|
||||
b.timeSha256,
|
||||
StringComparison.OrdinalIgnoreCase),
|
||||
sameSongId = sameSongId,
|
||||
sameDatasetId = sameDatasetId,
|
||||
sameTargetAspectRatio = sameAspect,
|
||||
sameSceneAndTimeline = true,
|
||||
sharedTimeSha256 = a.timeSha256,
|
||||
sharedSongId = a.songId,
|
||||
targetEvaluationGroupId = a.targetEvaluationGroupId
|
||||
};
|
||||
|
||||
if (!fairness.sameSampleRate ||
|
||||
!fairness.sameFrameCount ||
|
||||
!fairness.sameTimeSha256 ||
|
||||
!sameSongId ||
|
||||
!sameDatasetId ||
|
||||
!sameEvaluationGroup ||
|
||||
!sameAspect)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"A/B candidates must share sample rate, frame count, " +
|
||||
"time.f64, songId, datasetId, evaluation group, and " +
|
||||
"target aspect ratio.");
|
||||
}
|
||||
|
||||
ValidateFrameRange(
|
||||
request.startFrame,
|
||||
request.endFrameExclusive,
|
||||
a.frameCount);
|
||||
ValidateOutputSettings(request.codec, request.width, request.height);
|
||||
|
||||
if (a.targetAspectRatio > 0)
|
||||
{
|
||||
var outputAspect = request.width / (double)request.height;
|
||||
if (Math.Abs(outputAspect - a.targetAspectRatio) > 0.01)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Output aspect ratio {outputAspect:F4} does not match " +
|
||||
$"candidate target aspect {a.targetAspectRatio:F4}.");
|
||||
}
|
||||
}
|
||||
|
||||
return fairness;
|
||||
}
|
||||
|
||||
internal static string ComputeSha256(string path)
|
||||
{
|
||||
using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
1024 * 1024,
|
||||
FileOptions.SequentialScan);
|
||||
using var sha = SHA256.Create();
|
||||
return BitConverter.ToString(sha.ComputeHash(stream))
|
||||
.Replace("-", string.Empty)
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal static string SanitizeFileName(string value)
|
||||
{
|
||||
var source = string.IsNullOrWhiteSpace(value) ? "candidate" : value;
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var builder = new StringBuilder(source.Length);
|
||||
foreach (var character in source)
|
||||
{
|
||||
builder.Append(
|
||||
Array.IndexOf(invalid, character) >= 0 ? '_' : character);
|
||||
}
|
||||
|
||||
return builder.ToString().Trim().TrimEnd('.');
|
||||
}
|
||||
|
||||
internal static T ReadJson<T>(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonUtility.FromJson<T>(File.ReadAllText(path));
|
||||
}
|
||||
|
||||
internal static void WriteJsonAtomic<T>(string path, T value)
|
||||
{
|
||||
const int maximumReplaceAttempts = 8;
|
||||
var canonicalPath = Path.GetFullPath(path);
|
||||
var directory = Path.GetDirectoryName(canonicalPath)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Path '{canonicalPath}' has no parent directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var temporaryPath =
|
||||
$"{canonicalPath}.{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
temporaryPath,
|
||||
JsonUtility.ToJson(value, true),
|
||||
new UTF8Encoding(false));
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(canonicalPath))
|
||||
{
|
||||
File.Replace(temporaryPath, canonicalPath, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(temporaryPath, canonicalPath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
when (attempt + 1 < maximumReplaceAttempts)
|
||||
{
|
||||
Thread.Sleep(10 * (attempt + 1));
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
when (attempt + 1 < maximumReplaceAttempts)
|
||||
{
|
||||
Thread.Sleep(10 * (attempt + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsTerminalPhase(string phase)
|
||||
{
|
||||
return phase == AICameraABRenderRunner.PhaseComplete ||
|
||||
phase == AICameraABRenderRunner.PhaseFailed ||
|
||||
phase == AICameraABRenderRunner.PhaseCancelled;
|
||||
}
|
||||
|
||||
private static double GetTargetAspectRatio(
|
||||
AICameraABGeneratedCameraMetadata metadata)
|
||||
{
|
||||
return metadata.targetAspectRatio > 0
|
||||
? metadata.targetAspectRatio
|
||||
: metadata.aspectRatioOverride;
|
||||
}
|
||||
|
||||
private static string ResolveInputFile(
|
||||
string rootDirectory,
|
||||
string relativePath,
|
||||
bool requireFile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath) ||
|
||||
Path.IsPathRooted(relativePath))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Generated input path '{relativePath}' must be relative.");
|
||||
}
|
||||
|
||||
var root = Path.GetFullPath(rootDirectory);
|
||||
var resolved = Path.GetFullPath(Path.Combine(root, relativePath));
|
||||
var prefix = root.TrimEnd(
|
||||
Path.DirectorySeparatorChar,
|
||||
Path.AltDirectorySeparatorChar) +
|
||||
Path.DirectorySeparatorChar;
|
||||
if (!resolved.StartsWith(prefix, PathComparison))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Generated input '{relativePath}' escapes its directory.");
|
||||
}
|
||||
|
||||
if (requireFile && !File.Exists(resolved))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"Generated camera input was not found.",
|
||||
resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static void ValidateDeclaredSha(
|
||||
string label,
|
||||
string inputName,
|
||||
string declared,
|
||||
string actual)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(declared))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(actual) ||
|
||||
!string.Equals(
|
||||
declared,
|
||||
actual,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Candidate {label} {inputName} SHA256 does not match metadata.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e61af0fef774b80bbf7fc1121b9ae29
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 249ad3cd8eec495a8419e994e185a41b
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Streamingle.Editor
|
||||
{
|
||||
public sealed class AICameraABRenderWindow : EditorWindow
|
||||
{
|
||||
private string candidateADirectory = string.Empty;
|
||||
private string candidateBDirectory = string.Empty;
|
||||
private string outputRoot = string.Empty;
|
||||
private int startFrame;
|
||||
private int endFrameExclusive = 1;
|
||||
private AICameraABRenderCodec codec =
|
||||
AICameraABRenderCodec.MP4;
|
||||
private int width = 1920;
|
||||
private int height = 1080;
|
||||
private Vector2 scrollPosition;
|
||||
|
||||
[MenuItem("Tools/Streamingle/Timeline/Render AI Camera A-B")]
|
||||
public static void Open()
|
||||
{
|
||||
GetWindow<AICameraABRenderWindow>(
|
||||
"AI Camera A-B Render");
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outputRoot))
|
||||
{
|
||||
outputRoot = Path.GetFullPath(Path.Combine(
|
||||
Application.dataPath,
|
||||
"..",
|
||||
"Recordings",
|
||||
"CWAI_AB"));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInspectorUpdate()
|
||||
{
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
|
||||
EditorGUILayout.LabelField(
|
||||
"AI Camera A-B Movie Render",
|
||||
EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField(
|
||||
"Renders two generated camera candidates against the same " +
|
||||
"saved Scene, Timeline frame range, and Timeline audio.",
|
||||
EditorStyles.wordWrappedLabel);
|
||||
EditorGUILayout.Space();
|
||||
|
||||
DrawFolderField(
|
||||
"Candidate A",
|
||||
ref candidateADirectory,
|
||||
"Select generated camera candidate A");
|
||||
DrawFolderField(
|
||||
"Candidate B",
|
||||
ref candidateBDirectory,
|
||||
"Select generated camera candidate B");
|
||||
DrawFolderField(
|
||||
"Output Root",
|
||||
ref outputRoot,
|
||||
"Select A-B movie output directory");
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Frame Range", EditorStyles.boldLabel);
|
||||
startFrame = EditorGUILayout.IntField(
|
||||
"Start Frame",
|
||||
startFrame);
|
||||
endFrameExclusive = EditorGUILayout.IntField(
|
||||
"End Frame (Exclusive)",
|
||||
endFrameExclusive);
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Movie", EditorStyles.boldLabel);
|
||||
codec = (AICameraABRenderCodec)EditorGUILayout.EnumPopup(
|
||||
"Codec",
|
||||
codec);
|
||||
width = EditorGUILayout.IntField("Width", width);
|
||||
height = EditorGUILayout.IntField("Height", height);
|
||||
EditorGUILayout.HelpBox(
|
||||
"MP4 uses H.264 and requires even dimensions. WebM uses VP8. " +
|
||||
"Both candidates use MainCamera and include Timeline audio.",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space();
|
||||
DrawActiveJob();
|
||||
EditorGUILayout.Space();
|
||||
|
||||
var hasActive = AICameraABRenderRunner.TryGetActiveStatus(
|
||||
out _,
|
||||
out var phase,
|
||||
out _);
|
||||
var nonTerminalJob =
|
||||
hasActive &&
|
||||
!AICameraABRenderUtility.IsTerminalPhase(phase);
|
||||
using (new EditorGUI.DisabledScope(nonTerminalJob))
|
||||
{
|
||||
if (GUILayout.Button("Start A-B Render", GUILayout.Height(30)))
|
||||
{
|
||||
StartRender();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawActiveJob()
|
||||
{
|
||||
if (!AICameraABRenderRunner.TryGetActiveStatus(
|
||||
out var jobId,
|
||||
out var phase,
|
||||
out var manifestPath))
|
||||
{
|
||||
EditorGUILayout.LabelField("No active A-B render job.");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField("Current Job", EditorStyles.boldLabel);
|
||||
EditorGUILayout.SelectableLabel(
|
||||
jobId,
|
||||
EditorStyles.textField,
|
||||
GUILayout.Height(EditorGUIUtility.singleLineHeight));
|
||||
EditorGUILayout.LabelField("Phase", phase);
|
||||
EditorGUILayout.LabelField(
|
||||
"Manifest",
|
||||
manifestPath,
|
||||
EditorStyles.wordWrappedLabel);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Reveal Manifest") &&
|
||||
!string.IsNullOrWhiteSpace(manifestPath))
|
||||
{
|
||||
EditorUtility.RevealInFinder(manifestPath);
|
||||
}
|
||||
|
||||
using (new EditorGUI.DisabledScope(
|
||||
AICameraABRenderUtility.IsTerminalPhase(phase)))
|
||||
{
|
||||
if (GUILayout.Button("Cancel"))
|
||||
{
|
||||
AICameraABRenderRunner.CancelABRenderForCli(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartRender()
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new AICameraABRenderRequest
|
||||
{
|
||||
candidateADirectory = candidateADirectory,
|
||||
candidateBDirectory = candidateBDirectory,
|
||||
outputRoot = outputRoot,
|
||||
startFrame = startFrame,
|
||||
endFrameExclusive = endFrameExclusive,
|
||||
codec = codec,
|
||||
width = width,
|
||||
height = height
|
||||
};
|
||||
var started = AICameraABRenderRunner.Start(request);
|
||||
Debug.Log(
|
||||
$"CW-AI A/B render started. Manifest: " +
|
||||
$"{started.manifestPath}");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Debug.LogException(exception);
|
||||
EditorUtility.DisplayDialog(
|
||||
"Unable to start A-B render",
|
||||
exception.Message,
|
||||
"OK");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawFolderField(
|
||||
string label,
|
||||
ref string value,
|
||||
string dialogTitle)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
value = EditorGUILayout.TextField(label, value);
|
||||
if (GUILayout.Button("Browse", GUILayout.Width(70)))
|
||||
{
|
||||
var initialDirectory = Directory.Exists(value)
|
||||
? value
|
||||
: Directory.GetParent(Application.dataPath)?.FullName ??
|
||||
string.Empty;
|
||||
var selected = EditorUtility.OpenFolderPanel(
|
||||
dialogTitle,
|
||||
initialDirectory,
|
||||
string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(selected))
|
||||
{
|
||||
value = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e32ae9d4a42347859c368bc701901af7
|
||||
@@ -0,0 +1,604 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Streamingle.Editor
|
||||
{
|
||||
internal sealed class AICameraCliRunner : IDisposable
|
||||
{
|
||||
internal const string ExecutableName = "CWCameraWorker.exe";
|
||||
internal const string EventProtocolVersion = "1";
|
||||
|
||||
private readonly ConcurrentQueue<ProcessMessage> messages =
|
||||
new ConcurrentQueue<ProcessMessage>();
|
||||
|
||||
private Process process;
|
||||
private string cancellationFile = string.Empty;
|
||||
private bool cancellationRequested;
|
||||
private bool exitObserved;
|
||||
private int exitCode;
|
||||
|
||||
internal bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
if (process == null || exitObserved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return !process.HasExited;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal bool CancellationRequested => cancellationRequested;
|
||||
|
||||
internal void Start(ProcessRequest request)
|
||||
{
|
||||
if (process != null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A camera worker process is already attached to this runner.");
|
||||
}
|
||||
|
||||
ValidateRequest(request);
|
||||
cancellationFile = Path.GetFullPath(request.CancellationFile);
|
||||
var cancellationDirectory = Path.GetDirectoryName(cancellationFile);
|
||||
if (string.IsNullOrWhiteSpace(cancellationDirectory))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The camera worker cancellation directory is invalid.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(cancellationDirectory);
|
||||
if (File.Exists(cancellationFile))
|
||||
{
|
||||
File.Delete(cancellationFile);
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.GetFullPath(request.ExecutablePath),
|
||||
Arguments = JoinArguments(request.Arguments),
|
||||
WorkingDirectory = Path.GetFullPath(request.WorkingDirectory),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
startInfo.EnvironmentVariables["PYTHONUTF8"] = "1";
|
||||
startInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
|
||||
|
||||
process = new Process
|
||||
{
|
||||
StartInfo = startInfo,
|
||||
EnableRaisingEvents = false
|
||||
};
|
||||
process.OutputDataReceived += OnOutputDataReceived;
|
||||
process.ErrorDataReceived += OnErrorDataReceived;
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"CWCameraWorker could not be started.");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
}
|
||||
catch
|
||||
{
|
||||
DisposeProcess();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryDequeueMessage(out ProcessMessage message)
|
||||
{
|
||||
return messages.TryDequeue(out message);
|
||||
}
|
||||
|
||||
internal bool TryGetExitCode(out int completedExitCode)
|
||||
{
|
||||
completedExitCode = 0;
|
||||
if (process == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!exitObserved)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The parameterless overload also waits for redirected
|
||||
// asynchronous output handlers to receive their final line.
|
||||
process.WaitForExit();
|
||||
exitCode = process.ExitCode;
|
||||
exitObserved = true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
completedExitCode = exitCode;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool RequestCancellation()
|
||||
{
|
||||
if (process == null || exitObserved || cancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(cancellationFile);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The camera worker cancellation path is unavailable.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(
|
||||
cancellationFile,
|
||||
DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||
new UTF8Encoding(false));
|
||||
cancellationRequested = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void CompleteAndDispose()
|
||||
{
|
||||
if (process != null && !exitObserved)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A running camera worker cannot be disposed as completed.");
|
||||
}
|
||||
|
||||
DisposeProcess();
|
||||
TryDeleteCancellationFile();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeProcess();
|
||||
if (exitObserved)
|
||||
{
|
||||
TryDeleteCancellationFile();
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ResolveExecutable(
|
||||
string configuredPath,
|
||||
string cwAiRoot,
|
||||
string projectRoot,
|
||||
string packageRoot = "")
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
AddCandidate(candidates, configuredPath);
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Environment.GetEnvironmentVariable(
|
||||
"CWAI_WORKER_EXECUTABLE"));
|
||||
|
||||
AddRootCandidates(candidates, cwAiRoot);
|
||||
if (!string.IsNullOrWhiteSpace(projectRoot))
|
||||
{
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Path.Combine(
|
||||
projectRoot,
|
||||
"Tools",
|
||||
"CWCameraWorker",
|
||||
ExecutableName));
|
||||
}
|
||||
|
||||
var physicalPackageRoot = packageRoot;
|
||||
if (string.IsNullOrWhiteSpace(physicalPackageRoot))
|
||||
{
|
||||
try
|
||||
{
|
||||
physicalPackageRoot = FileUtil.GetPhysicalPath(
|
||||
"Packages/com.mingle.cw-ai");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
physicalPackageRoot = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(physicalPackageRoot))
|
||||
{
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Path.Combine(
|
||||
physicalPackageRoot,
|
||||
"Tools~",
|
||||
"CWCameraWorker",
|
||||
ExecutableName));
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Path.Combine(
|
||||
physicalPackageRoot,
|
||||
"Tools~",
|
||||
ExecutableName));
|
||||
}
|
||||
|
||||
return candidates
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(Path.GetFullPath)
|
||||
.FirstOrDefault(File.Exists) ?? string.Empty;
|
||||
}
|
||||
|
||||
internal static string JoinArguments(IEnumerable<string> arguments)
|
||||
{
|
||||
if (arguments == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(arguments));
|
||||
}
|
||||
|
||||
return string.Join(" ", arguments.Select(QuoteArgument));
|
||||
}
|
||||
|
||||
internal static string QuoteArgument(string argument)
|
||||
{
|
||||
argument ??= string.Empty;
|
||||
if (argument.Length > 0 &&
|
||||
argument.All(character =>
|
||||
!char.IsWhiteSpace(character) && character != '"'))
|
||||
{
|
||||
return argument;
|
||||
}
|
||||
|
||||
var result = new StringBuilder(argument.Length + 2);
|
||||
result.Append('"');
|
||||
var backslashCount = 0;
|
||||
foreach (var character in argument)
|
||||
{
|
||||
if (character == '\\')
|
||||
{
|
||||
backslashCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character == '"')
|
||||
{
|
||||
result.Append('\\', backslashCount * 2 + 1);
|
||||
result.Append('"');
|
||||
backslashCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Append('\\', backslashCount);
|
||||
backslashCount = 0;
|
||||
result.Append(character);
|
||||
}
|
||||
|
||||
result.Append('\\', backslashCount * 2);
|
||||
result.Append('"');
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
internal static bool TryParseEvent(
|
||||
string line,
|
||||
out CliEvent parsedEvent)
|
||||
{
|
||||
parsedEvent = default;
|
||||
var normalized = (line ?? string.Empty).Trim();
|
||||
const string prefix = "CWAI_EVENT ";
|
||||
if (normalized.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Substring(prefix.Length).TrimStart();
|
||||
}
|
||||
|
||||
if (normalized.Length < 2 ||
|
||||
normalized[0] != '{' ||
|
||||
normalized[normalized.Length - 1] != '}')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CliEventPayload payload;
|
||||
try
|
||||
{
|
||||
payload = JsonUtility.FromJson<CliEventPayload>(normalized);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var eventType = FirstNotEmpty(
|
||||
payload?.type,
|
||||
payload?.eventType,
|
||||
payload?.@event);
|
||||
if (string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasProgress = ContainsJsonProperty(normalized, "progress");
|
||||
var hasEta = ContainsJsonProperty(normalized, "etaSeconds");
|
||||
parsedEvent = new CliEvent(
|
||||
eventType.Trim(),
|
||||
payload.protocolVersion ?? string.Empty,
|
||||
payload.jobId ?? string.Empty,
|
||||
payload.phase ?? string.Empty,
|
||||
payload.message ?? string.Empty,
|
||||
hasProgress
|
||||
? Mathf.Clamp01(payload.progress)
|
||||
: -1f,
|
||||
hasEta && IsFinite(payload.etaSeconds)
|
||||
? Math.Max(0d, payload.etaSeconds)
|
||||
: -1d,
|
||||
payload.outputPath ?? string.Empty,
|
||||
payload.error ?? string.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ValidateRequest(ProcessRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ExecutablePath) ||
|
||||
!File.Exists(request.ExecutablePath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"CWCameraWorker.exe was not found.",
|
||||
request.ExecutablePath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.WorkingDirectory) ||
|
||||
!Directory.Exists(request.WorkingDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException(
|
||||
"The camera worker working directory was not found: " +
|
||||
request.WorkingDirectory);
|
||||
}
|
||||
|
||||
if (request.Arguments == null || request.Arguments.Count == 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The camera worker command is required.",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.CancellationFile))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The camera worker cancellation file is required.",
|
||||
nameof(request));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddRootCandidates(
|
||||
ICollection<string> candidates,
|
||||
string root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Path.Combine(root, "dist", "CWCameraWorker", ExecutableName));
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Path.Combine(root, "dist", ExecutableName));
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Path.Combine(root, "tools", "CWCameraWorker", ExecutableName));
|
||||
AddCandidate(
|
||||
candidates,
|
||||
Path.Combine(root, "CWCameraWorker", ExecutableName));
|
||||
AddCandidate(candidates, Path.Combine(root, ExecutableName));
|
||||
}
|
||||
|
||||
private static void AddCandidate(
|
||||
ICollection<string> candidates,
|
||||
string candidate)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
candidates.Add(candidate.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static string FirstNotEmpty(params string[] values)
|
||||
{
|
||||
return values.FirstOrDefault(
|
||||
value => !string.IsNullOrWhiteSpace(value)) ??
|
||||
string.Empty;
|
||||
}
|
||||
|
||||
private static bool ContainsJsonProperty(string json, string name)
|
||||
{
|
||||
return json.IndexOf(
|
||||
"\"" + name + "\"",
|
||||
StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
private static bool IsFinite(double value)
|
||||
{
|
||||
return !double.IsNaN(value) && !double.IsInfinity(value);
|
||||
}
|
||||
|
||||
private void OnOutputDataReceived(object sender, DataReceivedEventArgs args)
|
||||
{
|
||||
EnqueueLine(args.Data, false);
|
||||
}
|
||||
|
||||
private void OnErrorDataReceived(object sender, DataReceivedEventArgs args)
|
||||
{
|
||||
EnqueueLine(args.Data, true);
|
||||
}
|
||||
|
||||
private void EnqueueLine(string line, bool isError)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryParseEvent(line, out var parsedEvent))
|
||||
{
|
||||
messages.Enqueue(new ProcessMessage(parsedEvent));
|
||||
return;
|
||||
}
|
||||
|
||||
messages.Enqueue(new ProcessMessage(line, isError));
|
||||
}
|
||||
|
||||
private void DisposeProcess()
|
||||
{
|
||||
if (process == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
process.OutputDataReceived -= OnOutputDataReceived;
|
||||
process.ErrorDataReceived -= OnErrorDataReceived;
|
||||
process.Dispose();
|
||||
process = null;
|
||||
}
|
||||
|
||||
private void TryDeleteCancellationFile()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cancellationFile) ||
|
||||
!File.Exists(cancellationFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(cancellationFile);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A stale sentinel is deleted before the next job starts.
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Preserve the completed result even if local cleanup is denied.
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CliEventPayload
|
||||
{
|
||||
public string type;
|
||||
public string eventType;
|
||||
public string @event;
|
||||
public string protocolVersion;
|
||||
public string jobId;
|
||||
public string phase;
|
||||
public string message;
|
||||
public float progress;
|
||||
public double etaSeconds;
|
||||
public string outputPath;
|
||||
public string error;
|
||||
}
|
||||
|
||||
internal readonly struct ProcessRequest
|
||||
{
|
||||
internal ProcessRequest(
|
||||
string executablePath,
|
||||
string workingDirectory,
|
||||
IReadOnlyList<string> arguments,
|
||||
string cancellationFile)
|
||||
{
|
||||
ExecutablePath = executablePath;
|
||||
WorkingDirectory = workingDirectory;
|
||||
Arguments = arguments;
|
||||
CancellationFile = cancellationFile;
|
||||
}
|
||||
|
||||
internal string ExecutablePath { get; }
|
||||
internal string WorkingDirectory { get; }
|
||||
internal IReadOnlyList<string> Arguments { get; }
|
||||
internal string CancellationFile { get; }
|
||||
}
|
||||
|
||||
internal readonly struct ProcessMessage
|
||||
{
|
||||
internal ProcessMessage(string text, bool isError)
|
||||
{
|
||||
Text = text ?? string.Empty;
|
||||
IsError = isError;
|
||||
IsEvent = false;
|
||||
Event = default;
|
||||
}
|
||||
|
||||
internal ProcessMessage(CliEvent parsedEvent)
|
||||
{
|
||||
Text = string.Empty;
|
||||
IsError = false;
|
||||
IsEvent = true;
|
||||
Event = parsedEvent;
|
||||
}
|
||||
|
||||
internal string Text { get; }
|
||||
internal bool IsError { get; }
|
||||
internal bool IsEvent { get; }
|
||||
internal CliEvent Event { get; }
|
||||
}
|
||||
|
||||
internal readonly struct CliEvent
|
||||
{
|
||||
internal CliEvent(
|
||||
string eventType,
|
||||
string protocolVersion,
|
||||
string jobId,
|
||||
string phase,
|
||||
string message,
|
||||
float progress,
|
||||
double etaSeconds,
|
||||
string outputPath,
|
||||
string error)
|
||||
{
|
||||
EventType = eventType;
|
||||
ProtocolVersion = protocolVersion;
|
||||
JobId = jobId;
|
||||
Phase = phase;
|
||||
Message = message;
|
||||
Progress = progress;
|
||||
EtaSeconds = etaSeconds;
|
||||
OutputPath = outputPath;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
internal string EventType { get; }
|
||||
internal string ProtocolVersion { get; }
|
||||
internal string JobId { get; }
|
||||
internal string Phase { get; }
|
||||
internal string Message { get; }
|
||||
internal float Progress { get; }
|
||||
internal double EtaSeconds { get; }
|
||||
internal string OutputPath { get; }
|
||||
internal string Error { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f8bdd831f8794ea18ca288f3745462b1
|
||||
@@ -0,0 +1,960 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Unity.Cinemachine;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.Timeline;
|
||||
|
||||
namespace Streamingle.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Explicitly snapshots the difference between generated cut boundaries and
|
||||
/// the currently edited AI preview Timeline. It never mutates or saves Unity
|
||||
/// assets, scenes, or Timeline clips.
|
||||
/// </summary>
|
||||
public static class AICameraCutCorrectionRecorder
|
||||
{
|
||||
internal const string CurrentSchemaVersion =
|
||||
"ai-camera-cut-correction-snapshot-v1";
|
||||
internal const int RequiredSampleRate = 60;
|
||||
internal const int MoveMatchWindowFrames = 180;
|
||||
|
||||
private const double FrameAlignmentTolerance = 0.25d;
|
||||
private const double CostEpsilon = 0.000000001d;
|
||||
private const string EditorStyleHashPrefix =
|
||||
"editor-style-sha256:";
|
||||
|
||||
/// <summary>
|
||||
/// Captures one correction snapshot for the active AI preview belonging
|
||||
/// to <paramref name="sourceDirector"/>. The returned value is the full
|
||||
/// path of the newly written JSON file.
|
||||
/// </summary>
|
||||
public static string CaptureForCli(
|
||||
PlayableDirector sourceDirector,
|
||||
string generatedDirectory,
|
||||
string editorStyleId,
|
||||
string outputDirectory)
|
||||
{
|
||||
if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cut correction capture is available only in Edit Mode.");
|
||||
}
|
||||
|
||||
if (sourceDirector == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sourceDirector));
|
||||
}
|
||||
|
||||
if (!AICameraTimelinePreviewImporter
|
||||
.TryGetPreviewTimelineForEditor(
|
||||
sourceDirector,
|
||||
out var timeline))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The selected source Timeline has no AI camera preview.");
|
||||
}
|
||||
|
||||
if (!AICameraTimelinePreviewImporter
|
||||
.TryGetPreviewGenerationProvenanceForEditor(
|
||||
sourceDirector,
|
||||
out var provenance))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The AI preview has no generation provenance. Re-import " +
|
||||
"the generated camera before recording corrections.");
|
||||
}
|
||||
|
||||
var fullGeneratedDirectory = RequireDirectoryPath(
|
||||
generatedDirectory,
|
||||
nameof(generatedDirectory));
|
||||
|
||||
// Fail closed if any generated payload no longer matches metadata.
|
||||
AICameraTimelinePreviewImporter.ValidateGeneratedDirectoryForCli(
|
||||
fullGeneratedDirectory);
|
||||
|
||||
return CaptureTimeline(
|
||||
timeline,
|
||||
fullGeneratedDirectory,
|
||||
editorStyleId,
|
||||
outputDirectory,
|
||||
DateTime.UtcNow,
|
||||
provenance.MetadataSha256);
|
||||
}
|
||||
|
||||
internal static string CaptureTimelineForTests(
|
||||
TimelineAsset timeline,
|
||||
string generatedDirectory,
|
||||
string editorStyleId,
|
||||
string outputDirectory,
|
||||
DateTime capturedUtc,
|
||||
string expectedMetadataSha256 = null)
|
||||
{
|
||||
return CaptureTimeline(
|
||||
timeline,
|
||||
RequireDirectoryPath(
|
||||
generatedDirectory,
|
||||
nameof(generatedDirectory)),
|
||||
editorStyleId,
|
||||
outputDirectory,
|
||||
capturedUtc,
|
||||
expectedMetadataSha256);
|
||||
}
|
||||
|
||||
private static string CaptureTimeline(
|
||||
TimelineAsset timeline,
|
||||
string generatedDirectory,
|
||||
string editorStyleId,
|
||||
string outputDirectory,
|
||||
DateTime capturedUtc,
|
||||
string expectedMetadataSha256)
|
||||
{
|
||||
if (timeline == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(timeline));
|
||||
}
|
||||
|
||||
var input = ReadGenerationInput(generatedDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(expectedMetadataSha256))
|
||||
{
|
||||
var expected = NormalizeSha256(
|
||||
expectedMetadataSha256,
|
||||
"preview provenance metadata SHA-256");
|
||||
if (!string.Equals(
|
||||
expected,
|
||||
input.MetadataSha256,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The selected AI preview belongs to a different " +
|
||||
"generation. Its metadata SHA-256 does not match.");
|
||||
}
|
||||
}
|
||||
|
||||
var finalBoundaries = ExtractFinalBoundaryFrames(
|
||||
timeline,
|
||||
input.Metadata.frameCount);
|
||||
var corrections = BuildCorrections(
|
||||
input.OriginalBoundaryFrames,
|
||||
finalBoundaries);
|
||||
var anonymizedEditorStyleId = AnonymizeEditorStyleId(
|
||||
editorStyleId);
|
||||
|
||||
var timelineAssetPath = AssetDatabase.GetAssetPath(timeline);
|
||||
var snapshot = new CorrectionSnapshot
|
||||
{
|
||||
schemaVersion = CurrentSchemaVersion,
|
||||
capturedUtc = capturedUtc
|
||||
.ToUniversalTime()
|
||||
.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
|
||||
CultureInfo.InvariantCulture),
|
||||
sampleRate = RequiredSampleRate,
|
||||
songName = input.Metadata.songName,
|
||||
songId = input.Metadata.songId,
|
||||
datasetId = input.Metadata.datasetId,
|
||||
generationHashSha256 = input.MetadataSha256,
|
||||
generationOutputSha256 = new OutputSha256
|
||||
{
|
||||
worldCamera = NormalizeSha256(
|
||||
input.Metadata.outputSha256.worldCamera,
|
||||
"metadata outputSha256.worldCamera"),
|
||||
time = NormalizeSha256(
|
||||
input.Metadata.outputSha256.time,
|
||||
"metadata outputSha256.time"),
|
||||
shots = NormalizeSha256(
|
||||
input.Metadata.outputSha256.shots,
|
||||
"metadata outputSha256.shots")
|
||||
},
|
||||
editorStyleId = anonymizedEditorStyleId,
|
||||
previewTimelineGuid = string.IsNullOrWhiteSpace(
|
||||
timelineAssetPath)
|
||||
? string.Empty
|
||||
: AssetDatabase.AssetPathToGUID(timelineAssetPath),
|
||||
matchingPolicy = "ordered-dp-v1",
|
||||
moveMatchWindowFrames = MoveMatchWindowFrames,
|
||||
ordinalBase = 0,
|
||||
originalBoundaryFrames = input.OriginalBoundaryFrames,
|
||||
finalBoundaryFrames = finalBoundaries,
|
||||
originalBoundarySha256 = ComputeBoundarySha256(
|
||||
input.OriginalBoundaryFrames),
|
||||
finalBoundarySha256 = ComputeBoundarySha256(
|
||||
finalBoundaries),
|
||||
originalBoundaryCount = input.OriginalBoundaryFrames.Length,
|
||||
finalBoundaryCount = finalBoundaries.Length,
|
||||
corrections = corrections
|
||||
};
|
||||
|
||||
foreach (var correction in corrections)
|
||||
{
|
||||
switch (correction.action)
|
||||
{
|
||||
case "keep":
|
||||
snapshot.keepCount++;
|
||||
break;
|
||||
case "move":
|
||||
snapshot.moveCount++;
|
||||
break;
|
||||
case "add":
|
||||
snapshot.addCount++;
|
||||
break;
|
||||
case "delete":
|
||||
snapshot.deleteCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var json = JsonUtility.ToJson(snapshot, true) + Environment.NewLine;
|
||||
return WriteSnapshotAtomically(
|
||||
outputDirectory,
|
||||
snapshot,
|
||||
json);
|
||||
}
|
||||
|
||||
private static GenerationInput ReadGenerationInput(
|
||||
string generatedDirectory)
|
||||
{
|
||||
var metadataPath = Path.Combine(
|
||||
generatedDirectory,
|
||||
"metadata.json");
|
||||
if (!File.Exists(metadataPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"Generated camera metadata was not found.",
|
||||
metadataPath);
|
||||
}
|
||||
|
||||
var metadataBytes = File.ReadAllBytes(metadataPath);
|
||||
var metadataJson = Encoding.UTF8.GetString(metadataBytes);
|
||||
if (metadataJson.Length > 0 && metadataJson[0] == '\uFEFF')
|
||||
{
|
||||
metadataJson = metadataJson.Substring(1);
|
||||
}
|
||||
|
||||
var metadata = JsonUtility.FromJson<GeneratedMetadata>(
|
||||
metadataJson);
|
||||
if (metadata == null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Generated camera metadata is invalid JSON.");
|
||||
}
|
||||
|
||||
ValidateMetadata(metadata);
|
||||
var shotsPath = ResolveSafePayloadPath(
|
||||
generatedDirectory,
|
||||
metadata.shotsFile,
|
||||
"shotsFile");
|
||||
if (!File.Exists(shotsPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"Generated camera shot definitions were not found.",
|
||||
shotsPath);
|
||||
}
|
||||
|
||||
var shotFile = JsonUtility.FromJson<GeneratedShotFile>(
|
||||
File.ReadAllText(shotsPath));
|
||||
if (shotFile?.shots == null || shotFile.shots.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Generated shot definitions contain no shots.");
|
||||
}
|
||||
|
||||
var cursor = 0;
|
||||
for (var index = 0; index < shotFile.shots.Count; index++)
|
||||
{
|
||||
var shot = shotFile.shots[index];
|
||||
if (shot == null || shot.startFrame != cursor ||
|
||||
shot.endFrameExclusive <= shot.startFrame ||
|
||||
shot.endFrameExclusive > metadata.frameCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Generated shot {index} is not a contiguous, " +
|
||||
"positive frame range.");
|
||||
}
|
||||
|
||||
cursor = shot.endFrameExclusive;
|
||||
}
|
||||
|
||||
if (cursor != metadata.frameCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Generated shots do not cover the complete song.");
|
||||
}
|
||||
|
||||
var boundaries = shotFile.shots
|
||||
.Take(shotFile.shots.Count - 1)
|
||||
.Select(shot => shot.endFrameExclusive)
|
||||
.ToArray();
|
||||
ValidateBoundarySequence(
|
||||
boundaries,
|
||||
metadata.frameCount,
|
||||
"generated");
|
||||
|
||||
return new GenerationInput
|
||||
{
|
||||
Metadata = metadata,
|
||||
MetadataSha256 = ComputeSha256(metadataBytes),
|
||||
OriginalBoundaryFrames = boundaries
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateMetadata(GeneratedMetadata metadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(metadata.songName))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"metadata.songName is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(metadata.songId))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"metadata.songId is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(metadata.datasetId))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"metadata.datasetId is required.");
|
||||
}
|
||||
|
||||
if (metadata.sampleRate != RequiredSampleRate)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Cut correction capture requires exactly " +
|
||||
$"{RequiredSampleRate} FPS, but metadata declares " +
|
||||
$"{metadata.sampleRate} FPS.");
|
||||
}
|
||||
|
||||
if (metadata.frameCount <= 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"metadata.frameCount must be positive.");
|
||||
}
|
||||
|
||||
if (metadata.outputSha256 == null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"metadata.outputSha256 is required.");
|
||||
}
|
||||
|
||||
NormalizeSha256(
|
||||
metadata.outputSha256.worldCamera,
|
||||
"metadata outputSha256.worldCamera");
|
||||
NormalizeSha256(
|
||||
metadata.outputSha256.time,
|
||||
"metadata outputSha256.time");
|
||||
NormalizeSha256(
|
||||
metadata.outputSha256.shots,
|
||||
"metadata outputSha256.shots");
|
||||
}
|
||||
|
||||
private static int[] ExtractFinalBoundaryFrames(
|
||||
TimelineAsset timeline,
|
||||
int frameCount)
|
||||
{
|
||||
var matchingTracks = timeline.GetOutputTracks()
|
||||
.OfType<CinemachineTrack>()
|
||||
.Where(track =>
|
||||
track.name == AICameraTimelinePreviewImporter
|
||||
.GeneratedCinemachineTrackName)
|
||||
.ToArray();
|
||||
if (matchingTracks.Length != 1)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The preview Timeline must contain exactly one " +
|
||||
$"'{AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName}' " +
|
||||
"track.");
|
||||
}
|
||||
|
||||
var clips = matchingTracks[0]
|
||||
.GetClips()
|
||||
.OrderBy(clip => clip.start)
|
||||
.ThenBy(clip => clip.duration)
|
||||
.ToArray();
|
||||
if (clips.Length == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The generated Cinemachine track contains no clips.");
|
||||
}
|
||||
|
||||
var starts = new int[clips.Length];
|
||||
var ends = new int[clips.Length];
|
||||
for (var index = 0; index < clips.Length; index++)
|
||||
{
|
||||
var clip = clips[index];
|
||||
if (clip.easeInDuration > 0.0000001d ||
|
||||
clip.easeOutDuration > 0.0000001d)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Cinemachine clip {index} has an ease/blend. " +
|
||||
"Cut learning accepts hard cuts only.");
|
||||
}
|
||||
|
||||
starts[index] = ToAlignedFrame(
|
||||
clip.start,
|
||||
$"Cinemachine clip {index} start");
|
||||
ends[index] = ToAlignedFrame(
|
||||
clip.end,
|
||||
$"Cinemachine clip {index} end");
|
||||
if (ends[index] <= starts[index])
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Cinemachine clip {index} has no positive duration.");
|
||||
}
|
||||
}
|
||||
|
||||
if (starts[0] != 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The generated Cinemachine track must begin at frame 0.");
|
||||
}
|
||||
|
||||
for (var index = 1; index < clips.Length; index++)
|
||||
{
|
||||
if (starts[index] != ends[index - 1])
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Cut boundary before clip {index} has a gap or " +
|
||||
"overlap. Align both clip edges to the same frame " +
|
||||
"before recording corrections.");
|
||||
}
|
||||
}
|
||||
|
||||
if (ends[ends.Length - 1] != frameCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The generated Cinemachine track must end at frame " +
|
||||
$"{frameCount}.");
|
||||
}
|
||||
|
||||
var boundaries = starts.Skip(1).ToArray();
|
||||
ValidateBoundarySequence(boundaries, frameCount, "edited");
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
private static int ToAlignedFrame(double seconds, string label)
|
||||
{
|
||||
if (double.IsNaN(seconds) || double.IsInfinity(seconds))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{label} is not a finite Timeline time.");
|
||||
}
|
||||
|
||||
var frameValue = seconds * RequiredSampleRate;
|
||||
var rounded = Math.Round(
|
||||
frameValue,
|
||||
MidpointRounding.AwayFromZero);
|
||||
if (Math.Abs(frameValue - rounded) > FrameAlignmentTolerance)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{label} is not aligned to a {RequiredSampleRate} FPS " +
|
||||
"frame boundary.");
|
||||
}
|
||||
|
||||
if (rounded < int.MinValue || rounded > int.MaxValue)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{label} is outside the supported frame range.");
|
||||
}
|
||||
|
||||
return (int)rounded;
|
||||
}
|
||||
|
||||
private static CorrectionRecord[] BuildCorrections(
|
||||
int[] originalBoundaries,
|
||||
int[] finalBoundaries)
|
||||
{
|
||||
var originalCount = originalBoundaries.Length;
|
||||
var finalCount = finalBoundaries.Length;
|
||||
var costs = new double[originalCount + 1, finalCount + 1];
|
||||
var steps = new DiffStep[originalCount + 1, finalCount + 1];
|
||||
|
||||
for (var originalIndex = 0;
|
||||
originalIndex <= originalCount;
|
||||
originalIndex++)
|
||||
{
|
||||
for (var finalIndex = 0;
|
||||
finalIndex <= finalCount;
|
||||
finalIndex++)
|
||||
{
|
||||
costs[originalIndex, finalIndex] = double.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
costs[0, 0] = 0d;
|
||||
for (var originalIndex = 0;
|
||||
originalIndex <= originalCount;
|
||||
originalIndex++)
|
||||
{
|
||||
for (var finalIndex = 0;
|
||||
finalIndex <= finalCount;
|
||||
finalIndex++)
|
||||
{
|
||||
if (originalIndex == 0 && finalIndex == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bestCost = double.PositiveInfinity;
|
||||
var bestPriority = int.MaxValue;
|
||||
var bestStep = DiffStep.None;
|
||||
|
||||
if (originalIndex > 0 && finalIndex > 0)
|
||||
{
|
||||
var delta = Math.Abs(
|
||||
originalBoundaries[originalIndex - 1] -
|
||||
finalBoundaries[finalIndex - 1]);
|
||||
if (delta <= MoveMatchWindowFrames)
|
||||
{
|
||||
var matchCost = delta == 0
|
||||
? 0d
|
||||
: 0.25d +
|
||||
1.5d * delta / MoveMatchWindowFrames;
|
||||
SelectCandidate(
|
||||
costs[originalIndex - 1, finalIndex - 1] +
|
||||
matchCost,
|
||||
0,
|
||||
DiffStep.Match,
|
||||
ref bestCost,
|
||||
ref bestPriority,
|
||||
ref bestStep);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalIndex > 0)
|
||||
{
|
||||
SelectCandidate(
|
||||
costs[originalIndex - 1, finalIndex] + 1d,
|
||||
1,
|
||||
DiffStep.Delete,
|
||||
ref bestCost,
|
||||
ref bestPriority,
|
||||
ref bestStep);
|
||||
}
|
||||
|
||||
if (finalIndex > 0)
|
||||
{
|
||||
SelectCandidate(
|
||||
costs[originalIndex, finalIndex - 1] + 1d,
|
||||
2,
|
||||
DiffStep.Add,
|
||||
ref bestCost,
|
||||
ref bestPriority,
|
||||
ref bestStep);
|
||||
}
|
||||
|
||||
costs[originalIndex, finalIndex] = bestCost;
|
||||
steps[originalIndex, finalIndex] = bestStep;
|
||||
}
|
||||
}
|
||||
|
||||
var reverse = new List<CorrectionRecord>(
|
||||
originalCount + finalCount);
|
||||
var originalCursor = originalCount;
|
||||
var finalCursor = finalCount;
|
||||
while (originalCursor > 0 || finalCursor > 0)
|
||||
{
|
||||
switch (steps[originalCursor, finalCursor])
|
||||
{
|
||||
case DiffStep.Match:
|
||||
{
|
||||
var originalFrame =
|
||||
originalBoundaries[originalCursor - 1];
|
||||
var finalFrame = finalBoundaries[finalCursor - 1];
|
||||
reverse.Add(new CorrectionRecord
|
||||
{
|
||||
action = originalFrame == finalFrame
|
||||
? "keep"
|
||||
: "move",
|
||||
hasOriginal = true,
|
||||
originalFrame = originalFrame,
|
||||
originalOrdinal = originalCursor - 1,
|
||||
hasFinal = true,
|
||||
finalFrame = finalFrame,
|
||||
finalOrdinal = finalCursor - 1,
|
||||
deltaFrames = finalFrame - originalFrame
|
||||
});
|
||||
originalCursor--;
|
||||
finalCursor--;
|
||||
break;
|
||||
}
|
||||
case DiffStep.Delete:
|
||||
reverse.Add(new CorrectionRecord
|
||||
{
|
||||
action = "delete",
|
||||
hasOriginal = true,
|
||||
originalFrame =
|
||||
originalBoundaries[originalCursor - 1],
|
||||
originalOrdinal = originalCursor - 1,
|
||||
hasFinal = false,
|
||||
finalFrame = -1,
|
||||
finalOrdinal = -1,
|
||||
deltaFrames = 0
|
||||
});
|
||||
originalCursor--;
|
||||
break;
|
||||
case DiffStep.Add:
|
||||
reverse.Add(new CorrectionRecord
|
||||
{
|
||||
action = "add",
|
||||
hasOriginal = false,
|
||||
originalFrame = -1,
|
||||
originalOrdinal = -1,
|
||||
hasFinal = true,
|
||||
finalFrame = finalBoundaries[finalCursor - 1],
|
||||
finalOrdinal = finalCursor - 1,
|
||||
deltaFrames = 0
|
||||
});
|
||||
finalCursor--;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
"Unable to construct a deterministic cut diff.");
|
||||
}
|
||||
}
|
||||
|
||||
reverse.Reverse();
|
||||
return reverse.ToArray();
|
||||
}
|
||||
|
||||
private static void SelectCandidate(
|
||||
double candidateCost,
|
||||
int candidatePriority,
|
||||
DiffStep candidateStep,
|
||||
ref double bestCost,
|
||||
ref int bestPriority,
|
||||
ref DiffStep bestStep)
|
||||
{
|
||||
if (double.IsPositiveInfinity(candidateCost))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (candidateCost < bestCost - CostEpsilon ||
|
||||
(Math.Abs(candidateCost - bestCost) <= CostEpsilon &&
|
||||
candidatePriority < bestPriority))
|
||||
{
|
||||
bestCost = candidateCost;
|
||||
bestPriority = candidatePriority;
|
||||
bestStep = candidateStep;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBoundarySequence(
|
||||
int[] boundaries,
|
||||
int frameCount,
|
||||
string label)
|
||||
{
|
||||
var previous = 0;
|
||||
for (var index = 0; index < boundaries.Length; index++)
|
||||
{
|
||||
var frame = boundaries[index];
|
||||
if (frame <= previous || frame >= frameCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The {label} cut boundary at index {index} is " +
|
||||
"outside the song or not strictly increasing.");
|
||||
}
|
||||
|
||||
previous = frame;
|
||||
}
|
||||
}
|
||||
|
||||
private static string AnonymizeEditorStyleId(string editorStyleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(editorStyleId))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"An editor style ID is required. Use a stable nickname " +
|
||||
"that does not contain personal information.",
|
||||
nameof(editorStyleId));
|
||||
}
|
||||
|
||||
var normalized = editorStyleId.Trim();
|
||||
if (normalized.StartsWith(
|
||||
EditorStyleHashPrefix,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var existingHash = NormalizeSha256(
|
||||
normalized.Substring(EditorStyleHashPrefix.Length),
|
||||
"editor style ID");
|
||||
return EditorStyleHashPrefix + existingHash;
|
||||
}
|
||||
|
||||
return EditorStyleHashPrefix + ComputeSha256(
|
||||
Encoding.UTF8.GetBytes(normalized));
|
||||
}
|
||||
|
||||
private static string ComputeBoundarySha256(int[] boundaries)
|
||||
{
|
||||
var canonical = string.Join(
|
||||
",",
|
||||
boundaries.Select(frame =>
|
||||
frame.ToString(CultureInfo.InvariantCulture)));
|
||||
return ComputeSha256(Encoding.UTF8.GetBytes(canonical));
|
||||
}
|
||||
|
||||
private static string ComputeSha256(byte[] bytes)
|
||||
{
|
||||
using var algorithm = SHA256.Create();
|
||||
var hash = algorithm.ComputeHash(bytes);
|
||||
var builder = new StringBuilder(hash.Length * 2);
|
||||
foreach (var value in hash)
|
||||
{
|
||||
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string NormalizeSha256(string value, string label)
|
||||
{
|
||||
var normalized = value?.Trim().ToLowerInvariant();
|
||||
if (normalized == null || normalized.Length != 64 ||
|
||||
normalized.Any(character =>
|
||||
!(character >= '0' && character <= '9') &&
|
||||
!(character >= 'a' && character <= 'f')))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{label} must be a 64-character SHA-256 value.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ResolveSafePayloadPath(
|
||||
string rootDirectory,
|
||||
string relativePath,
|
||||
string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relativePath) ||
|
||||
Path.IsPathRooted(relativePath))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"metadata.{label} must be a relative path.");
|
||||
}
|
||||
|
||||
var segments = relativePath.Split(
|
||||
new[] { '/', '\\' },
|
||||
StringSplitOptions.None);
|
||||
if (segments.Any(segment =>
|
||||
string.IsNullOrWhiteSpace(segment) ||
|
||||
segment == "." ||
|
||||
segment == ".."))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"metadata.{label} contains an unsafe path segment.");
|
||||
}
|
||||
|
||||
var root = Path.GetFullPath(rootDirectory);
|
||||
var candidate = Path.GetFullPath(
|
||||
Path.Combine(root, relativePath));
|
||||
var rootPrefix = root.TrimEnd(
|
||||
Path.DirectorySeparatorChar,
|
||||
Path.AltDirectorySeparatorChar) +
|
||||
Path.DirectorySeparatorChar;
|
||||
if (!candidate.StartsWith(
|
||||
rootPrefix,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"metadata.{label} escapes the generated directory.");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static string RequireDirectoryPath(
|
||||
string value,
|
||||
string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A directory path is required.",
|
||||
parameterName);
|
||||
}
|
||||
|
||||
var path = Path.GetFullPath(value);
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
throw new DirectoryNotFoundException(
|
||||
$"Directory was not found: {path}");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string WriteSnapshotAtomically(
|
||||
string outputDirectory,
|
||||
CorrectionSnapshot snapshot,
|
||||
string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A correction log output directory is required.",
|
||||
nameof(outputDirectory));
|
||||
}
|
||||
|
||||
var outputRoot = Path.GetFullPath(outputDirectory);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
var songToken = SanitizeFileToken(snapshot.songId);
|
||||
var timestampToken = DateTime.Parse(
|
||||
snapshot.capturedUtc,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal |
|
||||
DateTimeStyles.AdjustToUniversal)
|
||||
.ToString(
|
||||
"yyyyMMdd'T'HHmmssfff'Z'",
|
||||
CultureInfo.InvariantCulture);
|
||||
var baseName =
|
||||
$"cut_correction_{songToken}_{timestampToken}_" +
|
||||
$"{snapshot.generationHashSha256.Substring(0, 8)}_" +
|
||||
$"{snapshot.finalBoundarySha256.Substring(0, 8)}";
|
||||
var outputPath = Path.Combine(outputRoot, baseName + ".json");
|
||||
for (var suffix = 2; File.Exists(outputPath); suffix++)
|
||||
{
|
||||
outputPath = Path.Combine(
|
||||
outputRoot,
|
||||
$"{baseName}_{suffix:D2}.json");
|
||||
}
|
||||
|
||||
var temporaryPath =
|
||||
outputPath + $".tmp-{Guid.NewGuid():N}";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
temporaryPath,
|
||||
json,
|
||||
new UTF8Encoding(false));
|
||||
File.Move(temporaryPath, outputPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private static string SanitizeFileToken(string value)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var character in value ?? string.Empty)
|
||||
{
|
||||
builder.Append(
|
||||
char.IsLetterOrDigit(character) ||
|
||||
character == '-' || character == '_'
|
||||
? character
|
||||
: '_');
|
||||
if (builder.Length >= 48)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "song" : builder.ToString();
|
||||
}
|
||||
|
||||
private enum DiffStep
|
||||
{
|
||||
None,
|
||||
Match,
|
||||
Delete,
|
||||
Add
|
||||
}
|
||||
|
||||
private sealed class GenerationInput
|
||||
{
|
||||
internal GeneratedMetadata Metadata;
|
||||
internal string MetadataSha256;
|
||||
internal int[] OriginalBoundaryFrames;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class GeneratedMetadata
|
||||
{
|
||||
public string songName;
|
||||
public string songId;
|
||||
public string datasetId;
|
||||
public int sampleRate;
|
||||
public int frameCount;
|
||||
public string shotsFile;
|
||||
public OutputSha256 outputSha256;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class GeneratedShotFile
|
||||
{
|
||||
public List<GeneratedShot> shots;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class GeneratedShot
|
||||
{
|
||||
public int startFrame;
|
||||
public int endFrameExclusive;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CorrectionSnapshot
|
||||
{
|
||||
public string schemaVersion;
|
||||
public string capturedUtc;
|
||||
public int sampleRate;
|
||||
public string songName;
|
||||
public string songId;
|
||||
public string datasetId;
|
||||
public string generationHashSha256;
|
||||
public OutputSha256 generationOutputSha256;
|
||||
public string editorStyleId;
|
||||
public string previewTimelineGuid;
|
||||
public string matchingPolicy;
|
||||
public int moveMatchWindowFrames;
|
||||
public int ordinalBase;
|
||||
public int[] originalBoundaryFrames;
|
||||
public int[] finalBoundaryFrames;
|
||||
public string originalBoundarySha256;
|
||||
public string finalBoundarySha256;
|
||||
public int originalBoundaryCount;
|
||||
public int finalBoundaryCount;
|
||||
public int keepCount;
|
||||
public int moveCount;
|
||||
public int addCount;
|
||||
public int deleteCount;
|
||||
public CorrectionRecord[] corrections;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class OutputSha256
|
||||
{
|
||||
public string worldCamera;
|
||||
public string time;
|
||||
public string shots;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class CorrectionRecord
|
||||
{
|
||||
public string action;
|
||||
public bool hasOriginal;
|
||||
public int originalFrame;
|
||||
public int originalOrdinal;
|
||||
public bool hasFinal;
|
||||
public int finalFrame;
|
||||
public int finalOrdinal;
|
||||
public int deltaFrames;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ef4feff8a1c42a0be6affcf5d6429db
|
||||
@@ -0,0 +1,951 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace Streamingle.Editor
|
||||
{
|
||||
public sealed partial class AICameraGeneratorWindow
|
||||
{
|
||||
private static readonly string[] GeneratorUxmlPaths =
|
||||
{
|
||||
"Packages/com.mingle.cw-ai/Editor/UI/AICameraGeneratorWindow.uxml",
|
||||
"Assets/Scripts/Editor/TimelineTools/UI/AICameraGeneratorWindow.uxml"
|
||||
};
|
||||
|
||||
private static readonly string[] GeneratorUssPaths =
|
||||
{
|
||||
"Packages/com.mingle.cw-ai/Editor/UI/AICameraGeneratorWindow.uss",
|
||||
"Assets/Scripts/Editor/TimelineTools/UI/AICameraGeneratorWindow.uss"
|
||||
};
|
||||
|
||||
private VisualElement _generatorUiRoot;
|
||||
private ScrollView _mainScroll;
|
||||
private VisualElement _readinessCard;
|
||||
private Label _readinessTitle;
|
||||
private Label _readinessDetail;
|
||||
private DropdownField _sourceDirectorDropdown;
|
||||
private HelpBox _sourceSummaryBox;
|
||||
private DropdownField _cutRhythmDropdown;
|
||||
private Toggle _freshCandidateToggle;
|
||||
private HelpBox _freshCandidateWarning;
|
||||
private Button _generateFullButton;
|
||||
private VisualElement _refinementCard;
|
||||
private VisualElement _selectedShotPickerRow;
|
||||
private HelpBox _selectedShotInfo;
|
||||
private DropdownField _selectedShotDropdown;
|
||||
private Foldout _shotOptionsFoldout;
|
||||
private DropdownField _shotSizeDropdown;
|
||||
private DropdownField _motionDropdown;
|
||||
private DropdownField _compositionDropdown;
|
||||
private Slider _motionIntensitySlider;
|
||||
private FloatField _distanceField;
|
||||
private Slider _bodyFollowSlider;
|
||||
private Button _resetShotOptionsButton;
|
||||
private Button _regenerateShotButton;
|
||||
private Button _removeResultButton;
|
||||
private VisualElement _finalCard;
|
||||
private Foldout _feedbackFoldout;
|
||||
private TextField _editorStyleIdField;
|
||||
private Button _recordCorrectionButton;
|
||||
private Button _saveFinalSceneButton;
|
||||
private Foldout _advancedFoldout;
|
||||
private VisualElement _advancedConfigurationHost;
|
||||
private IMGUIContainer _advancedConfigurationGui;
|
||||
private Button _extractDatasetButton;
|
||||
private Button _reapplyFullButton;
|
||||
private Button _reapplyShotButton;
|
||||
private Button _buildCacheButton;
|
||||
private Button _refreshDiagnosticsButton;
|
||||
private Button _clearLogButton;
|
||||
private TextField _logField;
|
||||
private VisualElement _statusFooter;
|
||||
private Label _statusMessage;
|
||||
private Label _generationStageLabel;
|
||||
private ProgressBar _generationProgress;
|
||||
private Label _generationTimingLabel;
|
||||
private HelpBox _appliedSeedBox;
|
||||
private Button _statusLogButton;
|
||||
private Button _cancelGenerationButton;
|
||||
private IVisualElementScheduledItem _generatorUiRefreshItem;
|
||||
private PlayableDirector[] _uiSourceDirectors =
|
||||
Array.Empty<PlayableDirector>();
|
||||
private PlayableDirector _uiRenderedSourceDirector;
|
||||
private string _uiShotSignature = string.Empty;
|
||||
private string _uiRenderedLog = string.Empty;
|
||||
private bool _uiSourceReady;
|
||||
private bool _uiLibraryReady;
|
||||
private string _uiLibraryIssue = string.Empty;
|
||||
private double _nextSourceValidationTime;
|
||||
private double _nextLibraryValidationTime;
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
_generatorUiRefreshItem?.Pause();
|
||||
var root = rootVisualElement;
|
||||
root.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var uxmlPath = ResolveUiAssetPathForTests(false);
|
||||
var visualTree = string.IsNullOrWhiteSpace(uxmlPath)
|
||||
? null
|
||||
: AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath);
|
||||
if (visualTree == null)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"AI 카메라 생성 UXML을 찾을 수 없습니다.",
|
||||
uxmlPath);
|
||||
}
|
||||
|
||||
visualTree.CloneTree(root);
|
||||
var ussPath = ResolveUiAssetPathForTests(true);
|
||||
var styleSheet = string.IsNullOrWhiteSpace(ussPath)
|
||||
? null
|
||||
: AssetDatabase.LoadAssetAtPath<StyleSheet>(ussPath);
|
||||
if (styleSheet != null && !root.styleSheets.Contains(styleSheet))
|
||||
{
|
||||
root.styleSheets.Add(styleSheet);
|
||||
}
|
||||
|
||||
CacheGeneratorUiElements();
|
||||
BuildAdvancedConfigurationUi();
|
||||
RegisterGeneratorUiCallbacks();
|
||||
RefreshGeneratorUi(true);
|
||||
|
||||
if (styleSheet == null)
|
||||
{
|
||||
_generatorUiRoot.Insert(
|
||||
0,
|
||||
new HelpBox(
|
||||
"스타일 파일을 찾지 못해 기본 Unity 스타일로 표시합니다.",
|
||||
HelpBoxMessageType.Warning));
|
||||
}
|
||||
|
||||
_generatorUiRefreshItem = root.schedule
|
||||
.Execute(() => RefreshGeneratorUi(false))
|
||||
.Every(200);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
BuildGeneratorUiFallback(exception);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ResolveUiAssetPathForTests(bool styleSheet)
|
||||
{
|
||||
var candidates = styleSheet
|
||||
? GeneratorUssPaths
|
||||
: GeneratorUxmlPaths;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var asset = styleSheet
|
||||
? (UnityEngine.Object)AssetDatabase.LoadAssetAtPath<StyleSheet>(
|
||||
candidate)
|
||||
: AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(candidate);
|
||||
if (asset != null)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private void CacheGeneratorUiElements()
|
||||
{
|
||||
_generatorUiRoot = RequireUi<VisualElement>("camera-generator-root");
|
||||
_mainScroll = RequireUi<ScrollView>("main-scroll");
|
||||
_readinessCard = RequireUi<VisualElement>("readiness-card");
|
||||
_readinessTitle = RequireUi<Label>("readiness-title");
|
||||
_readinessDetail = RequireUi<Label>("readiness-detail");
|
||||
_sourceDirectorDropdown =
|
||||
RequireUi<DropdownField>("source-director-dropdown");
|
||||
_sourceSummaryBox = RequireUi<HelpBox>("source-summary-box");
|
||||
_cutRhythmDropdown =
|
||||
RequireUi<DropdownField>("cut-rhythm-dropdown");
|
||||
_freshCandidateToggle =
|
||||
RequireUi<Toggle>("fresh-candidate-toggle");
|
||||
_freshCandidateWarning =
|
||||
RequireUi<HelpBox>("fresh-candidate-warning");
|
||||
_generateFullButton = RequireUi<Button>("generate-full-button");
|
||||
_refinementCard = RequireUi<VisualElement>("refinement-card");
|
||||
_selectedShotPickerRow =
|
||||
RequireUi<VisualElement>("selected-shot-picker-row");
|
||||
_selectedShotInfo = RequireUi<HelpBox>("selected-shot-info");
|
||||
_selectedShotDropdown =
|
||||
RequireUi<DropdownField>("selected-shot-dropdown");
|
||||
_shotOptionsFoldout = RequireUi<Foldout>("shot-options-foldout");
|
||||
_shotSizeDropdown = RequireUi<DropdownField>("shot-size-dropdown");
|
||||
_motionDropdown = RequireUi<DropdownField>("motion-dropdown");
|
||||
_compositionDropdown =
|
||||
RequireUi<DropdownField>("composition-dropdown");
|
||||
_motionIntensitySlider =
|
||||
RequireUi<Slider>("motion-intensity-slider");
|
||||
_distanceField = RequireUi<FloatField>("distance-field");
|
||||
_bodyFollowSlider = RequireUi<Slider>("body-follow-slider");
|
||||
_resetShotOptionsButton =
|
||||
RequireUi<Button>("reset-shot-options-button");
|
||||
_regenerateShotButton =
|
||||
RequireUi<Button>("regenerate-shot-button");
|
||||
_removeResultButton = RequireUi<Button>("remove-result-button");
|
||||
_finalCard = RequireUi<VisualElement>("final-card");
|
||||
_feedbackFoldout = RequireUi<Foldout>("feedback-foldout");
|
||||
_editorStyleIdField = RequireUi<TextField>("editor-style-id-field");
|
||||
_recordCorrectionButton =
|
||||
RequireUi<Button>("record-correction-button");
|
||||
_saveFinalSceneButton = RequireUi<Button>("save-final-scene-button");
|
||||
_advancedFoldout = RequireUi<Foldout>("advanced-foldout");
|
||||
_advancedConfigurationHost =
|
||||
RequireUi<VisualElement>("advanced-configuration-host");
|
||||
_extractDatasetButton = RequireUi<Button>("extract-dataset-button");
|
||||
_reapplyFullButton = RequireUi<Button>("reapply-full-button");
|
||||
_reapplyShotButton = RequireUi<Button>("reapply-shot-button");
|
||||
_buildCacheButton = RequireUi<Button>("build-cache-button");
|
||||
_refreshDiagnosticsButton =
|
||||
RequireUi<Button>("refresh-diagnostics-button");
|
||||
_clearLogButton = RequireUi<Button>("clear-log-button");
|
||||
_logField = RequireUi<TextField>("log-field");
|
||||
_statusFooter = RequireUi<VisualElement>("status-footer");
|
||||
_statusMessage = RequireUi<Label>("status-message");
|
||||
_generationStageLabel =
|
||||
RequireUi<Label>("generation-stage-label");
|
||||
_generationProgress = RequireUi<ProgressBar>("generation-progress");
|
||||
_generationTimingLabel =
|
||||
RequireUi<Label>("generation-timing-label");
|
||||
_appliedSeedBox = RequireUi<HelpBox>("applied-seed-box");
|
||||
_statusLogButton = RequireUi<Button>("status-log-button");
|
||||
_cancelGenerationButton =
|
||||
RequireUi<Button>("cancel-generation-button");
|
||||
}
|
||||
|
||||
private T RequireUi<T>(string name) where T : VisualElement
|
||||
{
|
||||
var element = rootVisualElement.Q<T>(name);
|
||||
if (element == null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"AICameraGeneratorWindow.uxml에 '{name}' 요소가 없습니다.");
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
private void BuildAdvancedConfigurationUi()
|
||||
{
|
||||
_advancedConfigurationHost.Clear();
|
||||
_advancedConfigurationGui = new IMGUIContainer(
|
||||
DrawAdvancedConfigurationForUi);
|
||||
_advancedConfigurationGui.AddToClassList("advanced-imgui");
|
||||
_advancedConfigurationHost.Add(_advancedConfigurationGui);
|
||||
|
||||
_logField.multiline = true;
|
||||
_logField.isReadOnly = true;
|
||||
}
|
||||
|
||||
private void RegisterGeneratorUiCallbacks()
|
||||
{
|
||||
_sourceDirectorDropdown.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
var selectedIndex =
|
||||
_sourceDirectorDropdown.choices.IndexOf(evt.newValue) - 1;
|
||||
_sourceDirector = selectedIndex >= 0 &&
|
||||
selectedIndex < _uiSourceDirectors.Length
|
||||
? _uiSourceDirectors[selectedIndex]
|
||||
: null;
|
||||
RefreshShotNames();
|
||||
RefreshGeneratorUi(true);
|
||||
});
|
||||
|
||||
_cutRhythmDropdown.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
var selectedIndex =
|
||||
_cutRhythmDropdown.choices.IndexOf(evt.newValue);
|
||||
if (selectedIndex >= 0)
|
||||
{
|
||||
_cutRhythm = (CutRhythmPreference)selectedIndex;
|
||||
}
|
||||
});
|
||||
|
||||
_freshCandidateToggle.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
_forceFreshCandidatePool = evt.newValue;
|
||||
RefreshGeneratorUi(false);
|
||||
});
|
||||
|
||||
_generateFullButton.clicked += () =>
|
||||
{
|
||||
ExportGenerateAndImport();
|
||||
RefreshGeneratorUi(true);
|
||||
};
|
||||
|
||||
_selectedShotDropdown.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
var selectedIndex =
|
||||
_selectedShotDropdown.choices.IndexOf(evt.newValue);
|
||||
if (selectedIndex >= 0 && selectedIndex < _shotNames.Length)
|
||||
{
|
||||
_selectedShotIndex = selectedIndex;
|
||||
}
|
||||
});
|
||||
|
||||
_shotOptionsFoldout.RegisterValueChangedCallback(evt =>
|
||||
_showDirectionSettings = evt.newValue);
|
||||
_shotSizeDropdown.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
var selectedIndex = _shotSizeDropdown.choices.IndexOf(evt.newValue);
|
||||
if (selectedIndex >= 0)
|
||||
{
|
||||
_shotSize = (ShotSizePreference)selectedIndex;
|
||||
}
|
||||
});
|
||||
_motionDropdown.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
var selectedIndex = _motionDropdown.choices.IndexOf(evt.newValue);
|
||||
if (selectedIndex >= 0)
|
||||
{
|
||||
_motion = (MotionPreference)selectedIndex;
|
||||
}
|
||||
});
|
||||
_compositionDropdown.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
var selectedIndex =
|
||||
_compositionDropdown.choices.IndexOf(evt.newValue);
|
||||
if (selectedIndex >= 0)
|
||||
{
|
||||
_composition = (CompositionPreference)selectedIndex;
|
||||
}
|
||||
});
|
||||
_motionIntensitySlider.RegisterValueChangedCallback(evt =>
|
||||
_motionIntensity = evt.newValue);
|
||||
_distanceField.RegisterValueChangedCallback(evt =>
|
||||
_distanceMeters = Mathf.Max(0f, evt.newValue));
|
||||
_bodyFollowSlider.RegisterValueChangedCallback(evt =>
|
||||
_bodyFollowStrength = evt.newValue);
|
||||
|
||||
_resetShotOptionsButton.clicked += ResetSelectedShotOptions;
|
||||
_regenerateShotButton.clicked += () =>
|
||||
{
|
||||
StartGeneration(ProcessCompletionAction.ReplaceSelectedShot);
|
||||
RefreshGeneratorUi(false);
|
||||
};
|
||||
_removeResultButton.clicked += () =>
|
||||
{
|
||||
RunEditorAction(
|
||||
"AI 카메라 결과를 제거하는 중...",
|
||||
delegate
|
||||
{
|
||||
var result = RemoveScopedPreview();
|
||||
RefreshShotNames();
|
||||
return result;
|
||||
});
|
||||
RefreshGeneratorUi(true);
|
||||
};
|
||||
|
||||
_editorStyleIdField.RegisterValueChangedCallback(evt =>
|
||||
_editorStyleId = evt.newValue);
|
||||
_recordCorrectionButton.clicked += () =>
|
||||
{
|
||||
RunEditorAction(
|
||||
"컷 수정 기록을 저장하는 중...",
|
||||
CaptureCutCorrectionSnapshot);
|
||||
RefreshGeneratorUi(false);
|
||||
};
|
||||
_saveFinalSceneButton.clicked += SaveActiveSceneCopy;
|
||||
|
||||
_feedbackFoldout.SetValueWithoutNotify(false);
|
||||
_advancedFoldout.SetValueWithoutNotify(_showAdvancedSettings);
|
||||
_advancedFoldout.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
_showAdvancedSettings = evt.newValue;
|
||||
if (evt.newValue)
|
||||
{
|
||||
_advancedConfigurationGui?.MarkDirtyRepaint();
|
||||
RefreshGeneratorUi(true);
|
||||
}
|
||||
});
|
||||
|
||||
_extractDatasetButton.clicked += ExtractDatasetFromUi;
|
||||
_reapplyFullButton.clicked += () =>
|
||||
{
|
||||
ImportFullResult(_generatedFolder);
|
||||
RefreshGeneratorUi(true);
|
||||
};
|
||||
_reapplyShotButton.clicked += () =>
|
||||
{
|
||||
ImportSelectedShot(_generatedFolder, _selectedShotIndex);
|
||||
RefreshGeneratorUi(true);
|
||||
};
|
||||
_buildCacheButton.clicked += () =>
|
||||
{
|
||||
StartPreparationCacheBuild();
|
||||
RefreshGeneratorUi(false);
|
||||
};
|
||||
_refreshDiagnosticsButton.clicked += () =>
|
||||
{
|
||||
RefreshDatasetSongs(true);
|
||||
ValidateOrAutoSelectSourceDirector();
|
||||
RefreshShotNames();
|
||||
RefreshGeneratorUi(true);
|
||||
};
|
||||
_clearLogButton.clicked += () =>
|
||||
{
|
||||
_log = string.Empty;
|
||||
_uiRenderedLog = string.Empty;
|
||||
_logField.SetValueWithoutNotify("아직 로그가 없습니다.");
|
||||
};
|
||||
_statusLogButton.clicked += () =>
|
||||
{
|
||||
_showAdvancedSettings = true;
|
||||
_advancedFoldout.SetValueWithoutNotify(true);
|
||||
RefreshGeneratorUi(true);
|
||||
_mainScroll.schedule.Execute(() =>
|
||||
_mainScroll.ScrollTo(_advancedFoldout));
|
||||
};
|
||||
_cancelGenerationButton.clicked += CancelGeneration;
|
||||
_generatorUiRoot.RegisterCallback<GeometryChangedEvent>(evt =>
|
||||
SetClassState(
|
||||
_generatorUiRoot,
|
||||
"narrow-layout",
|
||||
evt.newRect.width > 0f && evt.newRect.width < 680f));
|
||||
}
|
||||
|
||||
private void ResetSelectedShotOptions()
|
||||
{
|
||||
_shotSize = ShotSizePreference.Auto;
|
||||
_motion = MotionPreference.Auto;
|
||||
_composition = CompositionPreference.Auto;
|
||||
_motionIntensity = 1f;
|
||||
_distanceMeters = 0f;
|
||||
_bodyFollowStrength = 0.18f;
|
||||
RefreshGeneratorUi(false);
|
||||
}
|
||||
|
||||
private void ExtractDatasetFromUi()
|
||||
{
|
||||
RunEditorAction(
|
||||
"Timeline 데이터를 추출하는 중...",
|
||||
delegate
|
||||
{
|
||||
var output = TimelineCameraDatasetExporter
|
||||
.ExportAll60FpsForCli();
|
||||
ConfigureExportedDataset(output, _songId.Trim());
|
||||
return $"추출 완료: {output}\n" +
|
||||
$"곡 {_datasetSongNames.Length:N0}개를 찾았습니다.";
|
||||
});
|
||||
RefreshGeneratorUi(true);
|
||||
}
|
||||
|
||||
private void DrawAdvancedConfigurationForUi()
|
||||
{
|
||||
EditorGUILayout.LabelField(
|
||||
"경로와 생성 세부 설정",
|
||||
EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"일반 생성에는 변경할 필요가 없습니다. Worker 경로, 입력 ID, " +
|
||||
"Seed와 키 단순화를 직접 조정할 때만 사용하세요.",
|
||||
MessageType.Info);
|
||||
using (new EditorGUI.DisabledScope(IsProcessRunning))
|
||||
{
|
||||
DrawPathSettings();
|
||||
EditorGUILayout.Space(10f);
|
||||
DrawTargetSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshGeneratorUi(bool refreshLists)
|
||||
{
|
||||
if (_generatorUiRoot == null || _generatorUiRoot.panel == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (refreshLists || _uiRenderedSourceDirector != _sourceDirector)
|
||||
{
|
||||
RefreshSourceDirectorDropdown();
|
||||
_nextSourceValidationTime = 0d;
|
||||
}
|
||||
|
||||
var shotSignature = string.Join("\u001f", _shotNames);
|
||||
if (refreshLists || !string.Equals(
|
||||
shotSignature,
|
||||
_uiShotSignature,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
RefreshShotDropdown();
|
||||
_uiShotSignature = shotSignature;
|
||||
}
|
||||
|
||||
RefreshSourceSummary(refreshLists);
|
||||
RefreshGenerationLibraryStatus(refreshLists);
|
||||
RefreshReadiness();
|
||||
RefreshGenerationControls();
|
||||
RefreshRefinementControls();
|
||||
RefreshStatusFooter();
|
||||
RefreshAdvancedControls();
|
||||
}
|
||||
|
||||
private void RefreshSourceDirectorDropdown()
|
||||
{
|
||||
_uiSourceDirectors = GetAvailableSourceDirectors();
|
||||
if (_sourceDirector != null &&
|
||||
!_uiSourceDirectors.Contains(_sourceDirector))
|
||||
{
|
||||
_sourceDirector = null;
|
||||
}
|
||||
|
||||
if (_sourceDirector == null && _uiSourceDirectors.Length == 1)
|
||||
{
|
||||
_sourceDirector = _uiSourceDirectors[0];
|
||||
RefreshShotNames();
|
||||
}
|
||||
|
||||
var choices = new List<string>();
|
||||
if (_uiSourceDirectors.Length == 0)
|
||||
{
|
||||
choices.Add("현재 씬에 사용할 Timeline이 없습니다");
|
||||
}
|
||||
else
|
||||
{
|
||||
choices.Add("Timeline을 선택하세요");
|
||||
choices.AddRange(_uiSourceDirectors.Select(GetSourceDirectorLabel));
|
||||
}
|
||||
|
||||
_sourceDirectorDropdown.choices = choices;
|
||||
var currentIndex = Array.IndexOf(_uiSourceDirectors, _sourceDirector);
|
||||
var choiceIndex = _uiSourceDirectors.Length == 0
|
||||
? 0
|
||||
: Mathf.Clamp(currentIndex + 1, 0, choices.Count - 1);
|
||||
_sourceDirectorDropdown.SetValueWithoutNotify(choices[choiceIndex]);
|
||||
_uiRenderedSourceDirector = _sourceDirector;
|
||||
}
|
||||
|
||||
private void RefreshSourceSummary(bool force)
|
||||
{
|
||||
var now = EditorApplication.timeSinceStartup;
|
||||
if (!force && now < _nextSourceValidationTime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_nextSourceValidationTime = now + 1d;
|
||||
if (_sourceDirector == null)
|
||||
{
|
||||
_uiSourceReady = false;
|
||||
_sourceSummaryBox.text =
|
||||
"모션과 음원이 들어 있는 원본 Timeline을 선택하세요.";
|
||||
_sourceSummaryBox.messageType = HelpBoxMessageType.Warning;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var summary = TimelineCameraDatasetExporter
|
||||
.GetGenerationInputSummaryForEditor(_sourceDirector);
|
||||
_uiSourceReady = summary.isValid;
|
||||
if (!summary.isValid)
|
||||
{
|
||||
_sourceSummaryBox.text = string.IsNullOrWhiteSpace(summary.error)
|
||||
? "캐릭터 모션과 음원을 확인할 수 없습니다."
|
||||
: summary.error;
|
||||
_sourceSummaryBox.messageType = HelpBoxMessageType.Error;
|
||||
return;
|
||||
}
|
||||
|
||||
_sourceSummaryBox.text =
|
||||
$"캐릭터 {summary.characterName}\n" +
|
||||
$"음원 {summary.audioClipName} · " +
|
||||
$"{summary.duration:F1}초";
|
||||
_sourceSummaryBox.messageType = HelpBoxMessageType.Info;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_uiSourceReady = false;
|
||||
_sourceSummaryBox.text = exception.Message;
|
||||
_sourceSummaryBox.messageType = HelpBoxMessageType.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshReadiness()
|
||||
{
|
||||
var running = IsProcessRunning;
|
||||
var ready = _uiSourceReady && _uiLibraryReady;
|
||||
SetClassState(_readinessCard, "readiness--ready", !running && ready);
|
||||
SetClassState(_readinessCard, "readiness--running", running);
|
||||
SetClassState(
|
||||
_readinessCard,
|
||||
"readiness--warning",
|
||||
!running && !ready);
|
||||
|
||||
if (running)
|
||||
{
|
||||
_readinessTitle.text = "카메라를 생성하고 있습니다";
|
||||
_readinessDetail.text =
|
||||
"현재 단계와 예상 남은 시간은 아래 상태 영역에서 확인할 수 있습니다.";
|
||||
}
|
||||
else if (!_uiLibraryReady)
|
||||
{
|
||||
_readinessTitle.text = "생성 도구 설정이 필요합니다";
|
||||
_readinessDetail.text = _uiLibraryIssue;
|
||||
}
|
||||
else if (_uiSourceReady)
|
||||
{
|
||||
_readinessTitle.text = "Timeline 입력 확인 완료";
|
||||
_readinessDetail.text =
|
||||
"모션과 음원이 확인되었습니다. 전체 생성 버튼을 눌러 시작하세요.";
|
||||
}
|
||||
else if (_sourceDirector == null)
|
||||
{
|
||||
_readinessTitle.text = "Timeline 선택이 필요합니다";
|
||||
_readinessDetail.text =
|
||||
"현재 씬에서 모션과 음원이 있는 Timeline을 선택하세요.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_readinessTitle.text = "입력 구성을 확인해주세요";
|
||||
_readinessDetail.text =
|
||||
"선택한 Timeline에서 캐릭터 모션 또는 음원을 찾지 못했습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshGenerationLibraryStatus(bool force)
|
||||
{
|
||||
var now = EditorApplication.timeSinceStartup;
|
||||
if (!force && now < _nextLibraryValidationTime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_nextLibraryValidationTime = now + 1d;
|
||||
var repaired = TryAutoRepairGenerationLibraryRoot();
|
||||
var root = string.IsNullOrWhiteSpace(_cwAiRoot)
|
||||
? string.Empty
|
||||
: Path.GetFullPath(_cwAiRoot);
|
||||
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||||
{
|
||||
_uiLibraryReady = false;
|
||||
_uiLibraryIssue =
|
||||
"참조 데이터 폴더를 찾지 못했습니다. 고급·진단에서 경로를 확인하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(Path.Combine(root, "DatasetExports")) ||
|
||||
!File.Exists(Path.Combine(
|
||||
root,
|
||||
"reports",
|
||||
"training_index.json")) ||
|
||||
!File.Exists(Path.Combine(
|
||||
root,
|
||||
"models",
|
||||
"cut_ranker_v2.json")))
|
||||
{
|
||||
_uiLibraryReady = false;
|
||||
_uiLibraryIssue =
|
||||
"참조 데이터가 완전하지 않습니다. 고급·진단에서 CW-AI 루트를 확인하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
|
||||
!File.Exists(_cliExecutable))
|
||||
{
|
||||
_cliExecutable = ResolveCliExecutable(true);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
|
||||
!File.Exists(_cliExecutable))
|
||||
{
|
||||
_uiLibraryReady = false;
|
||||
_uiLibraryIssue =
|
||||
"CWCameraWorker를 찾지 못했습니다. 고급·진단에서 실행 파일을 확인하세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
_uiLibraryReady = true;
|
||||
_uiLibraryIssue = string.Empty;
|
||||
if (repaired &&
|
||||
!string.IsNullOrWhiteSpace(_status) &&
|
||||
_status.IndexOf(
|
||||
"training_index",
|
||||
StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
_status =
|
||||
"참조 데이터 경로를 자동 복구했습니다. 전체 생성을 다시 실행하세요.";
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshGenerationControls()
|
||||
{
|
||||
var running = IsProcessRunning;
|
||||
_sourceDirectorDropdown.SetEnabled(
|
||||
!running && _uiSourceDirectors.Length > 0);
|
||||
_cutRhythmDropdown.choices =
|
||||
new List<string>(SimpleCutRhythmNames);
|
||||
_cutRhythmDropdown.SetValueWithoutNotify(
|
||||
SimpleCutRhythmNames[Mathf.Clamp(
|
||||
(int)_cutRhythm,
|
||||
0,
|
||||
SimpleCutRhythmNames.Length - 1)]);
|
||||
_cutRhythmDropdown.SetEnabled(!running);
|
||||
_freshCandidateToggle.SetValueWithoutNotify(_forceFreshCandidatePool);
|
||||
_freshCandidateToggle.SetEnabled(!running);
|
||||
SetDisplayed(_freshCandidateWarning, _forceFreshCandidatePool);
|
||||
_generateFullButton.text = running
|
||||
? "카메라 생성 중..."
|
||||
: FullAutonomousGenerationButtonLabel;
|
||||
_generateFullButton.SetEnabled(
|
||||
!running && _uiSourceReady && _uiLibraryReady);
|
||||
}
|
||||
|
||||
private void RefreshShotDropdown()
|
||||
{
|
||||
var choices = _shotNames.Length > 0
|
||||
? new List<string>(_shotNames)
|
||||
: new List<string> { "가져온 AI 카메라가 없습니다" };
|
||||
_selectedShotDropdown.choices = choices;
|
||||
_selectedShotIndex = _shotNames.Length == 0
|
||||
? 0
|
||||
: Mathf.Clamp(_selectedShotIndex, 0, _shotNames.Length - 1);
|
||||
_selectedShotDropdown.SetValueWithoutNotify(
|
||||
choices[Mathf.Clamp(_selectedShotIndex, 0, choices.Count - 1)]);
|
||||
}
|
||||
|
||||
private void RefreshRefinementControls()
|
||||
{
|
||||
var hasShots = _shotNames.Length > 0;
|
||||
var running = IsProcessRunning;
|
||||
_editorStyleIdField.SetValueWithoutNotify(_editorStyleId);
|
||||
_recordCorrectionButton.SetEnabled(
|
||||
hasShots &&
|
||||
!running &&
|
||||
!string.IsNullOrWhiteSpace(_editorStyleId) &&
|
||||
_sourceDirector != null);
|
||||
_saveFinalSceneButton.SetEnabled(hasShots && !running);
|
||||
SetDisplayed(_refinementCard, hasShots);
|
||||
SetDisplayed(_finalCard, hasShots);
|
||||
SetDisplayed(_feedbackFoldout, hasShots);
|
||||
if (!hasShots)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedFromTimeline =
|
||||
TrySyncSelectedShotFromEditor(out var selectedCameraName);
|
||||
_selectedShotIndex = Mathf.Clamp(
|
||||
_selectedShotIndex,
|
||||
0,
|
||||
_shotNames.Length - 1);
|
||||
_selectedShotDropdown.SetValueWithoutNotify(
|
||||
_shotNames[_selectedShotIndex]);
|
||||
SetDisplayed(_selectedShotPickerRow, !selectedFromTimeline);
|
||||
_selectedShotInfo.text = selectedFromTimeline
|
||||
? $"Timeline/Hierarchy 선택 {selectedCameraName} · " +
|
||||
$"카메라 {_selectedShotIndex:D3}"
|
||||
: "Timeline의 AI 카메라를 선택하면 자동으로 연결됩니다. " +
|
||||
"아래 목록에서 직접 선택할 수도 있습니다.";
|
||||
_selectedShotInfo.messageType = selectedFromTimeline
|
||||
? HelpBoxMessageType.Info
|
||||
: HelpBoxMessageType.None;
|
||||
|
||||
_shotOptionsFoldout.SetValueWithoutNotify(_showDirectionSettings);
|
||||
_shotOptionsFoldout.SetEnabled(!running);
|
||||
_shotSizeDropdown.choices = new List<string>(SimpleShotSizeNames);
|
||||
_shotSizeDropdown.SetValueWithoutNotify(
|
||||
SimpleShotSizeNames[Mathf.Clamp(
|
||||
(int)_shotSize,
|
||||
0,
|
||||
SimpleShotSizeNames.Length - 1)]);
|
||||
_motionDropdown.choices = new List<string>(SimpleMotionNames);
|
||||
_motionDropdown.SetValueWithoutNotify(
|
||||
SimpleMotionNames[Mathf.Clamp(
|
||||
(int)_motion,
|
||||
0,
|
||||
SimpleMotionNames.Length - 1)]);
|
||||
_compositionDropdown.choices =
|
||||
new List<string>(SimpleCompositionNames);
|
||||
_compositionDropdown.SetValueWithoutNotify(
|
||||
SimpleCompositionNames[Mathf.Clamp(
|
||||
(int)_composition,
|
||||
0,
|
||||
SimpleCompositionNames.Length - 1)]);
|
||||
_motionIntensitySlider.SetValueWithoutNotify(_motionIntensity);
|
||||
_distanceField.SetValueWithoutNotify(_distanceMeters);
|
||||
_bodyFollowSlider.SetValueWithoutNotify(_bodyFollowStrength);
|
||||
_regenerateShotButton.text = selectedFromTimeline
|
||||
? "선택한 카메라 재생성"
|
||||
: "목록의 카메라 재생성";
|
||||
_regenerateShotButton.tooltip = selectedFromTimeline
|
||||
? SelectedCameraRegenerationButtonLabel
|
||||
: ListedCameraRegenerationButtonLabel;
|
||||
_regenerateShotButton.SetEnabled(!running);
|
||||
_removeResultButton.SetEnabled(!running);
|
||||
|
||||
}
|
||||
|
||||
private void RefreshStatusFooter()
|
||||
{
|
||||
var running = IsProcessRunning;
|
||||
var status = string.IsNullOrWhiteSpace(_status)
|
||||
? "준비됨"
|
||||
: _status.Trim();
|
||||
_statusMessage.text = status;
|
||||
|
||||
var isError = status.IndexOf(
|
||||
"실패",
|
||||
StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
status.IndexOf(
|
||||
"오류",
|
||||
StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
status.IndexOf(
|
||||
"찾을 수 없",
|
||||
StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
SetClassState(_statusFooter, "status--running", running);
|
||||
SetClassState(_statusFooter, "status--error", !running && isError);
|
||||
SetClassState(_statusFooter, "status--ready", !running && !isError);
|
||||
SetDisplayed(_statusLogButton, !running && isError);
|
||||
|
||||
if (_generationOperationActive)
|
||||
{
|
||||
var stageText = _generationStageIndex > 0
|
||||
? $"{_generationStageIndex}/{_generationStageCount}단계 · " +
|
||||
_generationStage
|
||||
: _generationStage;
|
||||
_generationStageLabel.text = stageText;
|
||||
SetDisplayed(_generationStageLabel, true);
|
||||
SetDisplayed(
|
||||
_generationProgress,
|
||||
_generationStageProgress >= 0f);
|
||||
if (_generationStageProgress >= 0f)
|
||||
{
|
||||
_generationProgress.value = Mathf.Clamp01(
|
||||
_generationStageProgress);
|
||||
_generationProgress.title = stageText;
|
||||
}
|
||||
|
||||
_generationTimingLabel.text = BuildRunningTimingText();
|
||||
SetDisplayed(_generationTimingLabel, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetDisplayed(_generationStageLabel, false);
|
||||
SetDisplayed(_generationProgress, false);
|
||||
var recentDuration = _lastCompletedDurationSeconds > 0d
|
||||
? _lastCompletedDurationSeconds
|
||||
: EditorPrefs.GetFloat(
|
||||
RuntimeHistoryKey("LastSeconds"),
|
||||
-1f);
|
||||
_generationTimingLabel.text = recentDuration > 0d
|
||||
? "최근 생성 " + FormatObservedDuration(recentDuration)
|
||||
: string.Empty;
|
||||
SetDisplayed(
|
||||
_generationTimingLabel,
|
||||
!isError && recentDuration > 0d);
|
||||
}
|
||||
|
||||
SetDisplayed(_cancelGenerationButton, running);
|
||||
_cancelGenerationButton.SetEnabled(
|
||||
running && _cliRunner?.CancellationRequested != true);
|
||||
|
||||
SetDisplayed(_appliedSeedBox, _hasLastAppliedSeed);
|
||||
if (_hasLastAppliedSeed)
|
||||
{
|
||||
_appliedSeedBox.text = BuildAppliedSeedSummary(
|
||||
_lastAppliedSeed,
|
||||
_seed,
|
||||
_lastAppliedSeedWasSelectedShot,
|
||||
_lastAppliedSeedShotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshAdvancedControls()
|
||||
{
|
||||
_advancedFoldout.SetValueWithoutNotify(_showAdvancedSettings);
|
||||
_extractDatasetButton.SetEnabled(!IsProcessRunning);
|
||||
_reapplyFullButton.SetEnabled(!IsProcessRunning);
|
||||
_reapplyShotButton.SetEnabled(
|
||||
!IsProcessRunning && _shotNames.Length > 0);
|
||||
_buildCacheButton.SetEnabled(
|
||||
!IsProcessRunning && _uiLibraryReady);
|
||||
_refreshDiagnosticsButton.SetEnabled(!IsProcessRunning);
|
||||
|
||||
if (_showAdvancedSettings)
|
||||
{
|
||||
_advancedConfigurationGui?.MarkDirtyRepaint();
|
||||
var logText = string.IsNullOrWhiteSpace(_log)
|
||||
? "아직 로그가 없습니다."
|
||||
: _log;
|
||||
if (!string.Equals(
|
||||
logText,
|
||||
_uiRenderedLog,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
_uiRenderedLog = logText;
|
||||
_logField.SetValueWithoutNotify(logText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildGeneratorUiFallback(Exception exception)
|
||||
{
|
||||
var root = rootVisualElement;
|
||||
root.Clear();
|
||||
var helpBox = new HelpBox(
|
||||
"새 UI를 불러오지 못해 호환 화면으로 전환했습니다.\n" +
|
||||
exception.Message,
|
||||
HelpBoxMessageType.Error);
|
||||
helpBox.style.marginLeft = 8f;
|
||||
helpBox.style.marginRight = 8f;
|
||||
helpBox.style.marginTop = 8f;
|
||||
root.Add(helpBox);
|
||||
var legacyContainer = new IMGUIContainer(DrawLegacyGui);
|
||||
legacyContainer.style.flexGrow = 1f;
|
||||
root.Add(legacyContainer);
|
||||
UnityEngine.Debug.LogException(exception);
|
||||
}
|
||||
|
||||
private void OnSelectionChange()
|
||||
{
|
||||
RefreshGeneratorUi(false);
|
||||
}
|
||||
|
||||
private void OnHierarchyChange()
|
||||
{
|
||||
if (_generatorUiRoot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ValidateOrAutoSelectSourceDirector();
|
||||
RefreshShotNames();
|
||||
RefreshGeneratorUi(true);
|
||||
}
|
||||
|
||||
private static void SetDisplayed(VisualElement element, bool displayed)
|
||||
{
|
||||
if (element != null)
|
||||
{
|
||||
element.style.display = displayed
|
||||
? DisplayStyle.Flex
|
||||
: DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetClassState(
|
||||
VisualElement element,
|
||||
string className,
|
||||
bool enabled)
|
||||
{
|
||||
if (enabled)
|
||||
{
|
||||
element.AddToClassList(className);
|
||||
}
|
||||
else
|
||||
{
|
||||
element.RemoveFromClassList(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91c38b82e8714b4da4f92c32e990cc51
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ff40a5b8a5b43e492463c46ba8f4995
|
||||
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Streamingle.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Preview-only lineage stored as a hidden Timeline subasset. This lives in
|
||||
/// the Editor assembly and is removed when a durable Final Timeline is made.
|
||||
/// </summary>
|
||||
public sealed class AICameraPreviewGenerationProvenance : ScriptableObject
|
||||
{
|
||||
internal const string CurrentSchemaVersion =
|
||||
"ai-camera-preview-generation-provenance-v1";
|
||||
|
||||
[SerializeField, HideInInspector]
|
||||
private string _schemaVersion = CurrentSchemaVersion;
|
||||
[SerializeField, HideInInspector]
|
||||
private string _cwAiRelativeGeneratedFolder = string.Empty;
|
||||
[SerializeField, HideInInspector]
|
||||
private string _absoluteGeneratedFolder = string.Empty;
|
||||
[SerializeField, HideInInspector]
|
||||
private string _metadataSha256 = string.Empty;
|
||||
|
||||
internal string SchemaVersion => _schemaVersion;
|
||||
internal string CwAiRelativeGeneratedFolder =>
|
||||
_cwAiRelativeGeneratedFolder;
|
||||
internal string AbsoluteGeneratedFolder => _absoluteGeneratedFolder;
|
||||
internal string MetadataSha256 => _metadataSha256;
|
||||
|
||||
internal void SetValues(
|
||||
string cwAiRelativeGeneratedFolder,
|
||||
string absoluteGeneratedFolder,
|
||||
string metadataSha256)
|
||||
{
|
||||
_schemaVersion = CurrentSchemaVersion;
|
||||
_cwAiRelativeGeneratedFolder =
|
||||
cwAiRelativeGeneratedFolder ?? string.Empty;
|
||||
_absoluteGeneratedFolder = absoluteGeneratedFolder ?? string.Empty;
|
||||
_metadataSha256 = metadataSha256 ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8f3a1c2e4b54f6789012345abcde678
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31604c3bc92fd194484b9159429dd3c2
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Mingle.CWAI.Editor.Tests")]
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1a242e13969456688f2467d4acf4930
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Mingle.CWAI.Editor",
|
||||
"rootNamespace": "Streamingle.Editor",
|
||||
"references": [
|
||||
"Unity.Cinemachine",
|
||||
"Unity.Cinemachine.Editor",
|
||||
"Unity.Recorder.Editor",
|
||||
"Unity.Timeline"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c99c6ba712c380b43bd392841b1eb410
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3ab4b71dae56314c981d64b2d3fd3d7
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47fb91e0c133d204d84638cf2b4a98ea
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c8c76ce67b4489299ad2b2afb1cdf3d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,522 @@
|
||||
.camera-generator-root {
|
||||
flex-grow: 1;
|
||||
background-color: #17191d;
|
||||
color: #e7eaf0;
|
||||
}
|
||||
|
||||
.main-scroll {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.page {
|
||||
align-self: center;
|
||||
max-width: 920px;
|
||||
padding: 12px 16px 18px 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background-color: #20242a;
|
||||
border-color: #343941;
|
||||
border-radius: 10px;
|
||||
border-width: 1px;
|
||||
margin-bottom: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.hero-row,
|
||||
.card-heading,
|
||||
.button-row,
|
||||
.field-pair,
|
||||
.log-heading,
|
||||
.status-footer,
|
||||
.readiness-card,
|
||||
.flow-strip {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.hero-row,
|
||||
.card-heading,
|
||||
.log-heading,
|
||||
.status-footer,
|
||||
.readiness-card {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.card-heading-copy,
|
||||
.readiness-copy,
|
||||
.status-copy {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #67d5bd;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1.6px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
margin-top: 2px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
color: #b8bec8;
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.backend-badge,
|
||||
.recommended-badge {
|
||||
background-color: #173d36;
|
||||
border-color: #2b6c5f;
|
||||
border-radius: 10px;
|
||||
border-width: 1px;
|
||||
color: #8ce9d3;
|
||||
font-size: 10px;
|
||||
margin-left: 12px;
|
||||
padding: 5px 9px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.flow-strip {
|
||||
flex-wrap: nowrap;
|
||||
margin-top: 11px;
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
background-color: #181b20;
|
||||
border-radius: 4px;
|
||||
color: #aeb5c0;
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
font-size: 9px;
|
||||
margin-right: 3px;
|
||||
min-width: 0;
|
||||
padding: 5px 3px;
|
||||
-unity-text-align: middle-center;
|
||||
}
|
||||
|
||||
.readiness-card {
|
||||
background-color: #22262b;
|
||||
border-color: #454b54;
|
||||
border-radius: 7px;
|
||||
border-width: 1px;
|
||||
border-left-width: 3px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.readiness-dot {
|
||||
background-color: #d5a84a;
|
||||
border-radius: 5px;
|
||||
height: 10px;
|
||||
margin-right: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.readiness-title {
|
||||
color: #f2f4f7;
|
||||
font-size: 12px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.readiness-detail {
|
||||
color: #a8afb9;
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.readiness--ready {
|
||||
border-left-color: #4fba93;
|
||||
}
|
||||
|
||||
.readiness--ready .readiness-dot {
|
||||
background-color: #56d3a6;
|
||||
}
|
||||
|
||||
.readiness--ready .readiness-detail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.readiness--running {
|
||||
border-left-color: #5d9fe8;
|
||||
}
|
||||
|
||||
.readiness--running .readiness-dot {
|
||||
background-color: #69adf4;
|
||||
}
|
||||
|
||||
.readiness--warning {
|
||||
border-left-color: #d5a84a;
|
||||
}
|
||||
|
||||
.workflow-card,
|
||||
.utility-foldout {
|
||||
background-color: #22252a;
|
||||
border-color: #343941;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
margin-bottom: 8px;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.generation-card {
|
||||
border-color: #343941;
|
||||
}
|
||||
|
||||
.refinement-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
background-color: #2a7968;
|
||||
border-radius: 13px;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
height: 26px;
|
||||
margin-right: 10px;
|
||||
width: 26px;
|
||||
-unity-font-style: bold;
|
||||
-unity-text-align: middle-center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: #f3f5f7;
|
||||
font-size: 14px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.card-description,
|
||||
.foldout-description,
|
||||
.inline-note {
|
||||
color: #b0b7c1;
|
||||
font-size: 11px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.form-field,
|
||||
.compact-field,
|
||||
.summary-box,
|
||||
.fresh-toggle,
|
||||
.inline-note {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.camera-generator-root .unity-base-field__label {
|
||||
color: #c6ccd5;
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.camera-generator-root .unity-base-field__input,
|
||||
.camera-generator-root .unity-popup-field__input {
|
||||
background-color: #17191d;
|
||||
border-color: #3a4049;
|
||||
color: #e7eaf0;
|
||||
}
|
||||
|
||||
.field-pair {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.compact-field {
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
.inner-foldout {
|
||||
background-color: #1c1f23;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.inner-foldout > .unity-foldout__toggle,
|
||||
.utility-foldout > .unity-foldout__toggle {
|
||||
color: #e3e6eb;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.fresh-toggle {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hidden-box,
|
||||
.applied-seed-box,
|
||||
.generation-stage,
|
||||
.generation-progress,
|
||||
.footer-action,
|
||||
.cancel-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action,
|
||||
.quiet-action,
|
||||
.danger-action,
|
||||
.cancel-action,
|
||||
.mini-action {
|
||||
border-radius: 5px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
background-color: #2b8a74;
|
||||
border-color: #43a890;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
height: 42px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.primary-action:hover {
|
||||
background-color: #329a82;
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
background-color: #315f87;
|
||||
border-color: #477aa4;
|
||||
color: #ffffff;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.secondary-action:hover {
|
||||
background-color: #3a6f9c;
|
||||
}
|
||||
|
||||
.quiet-action {
|
||||
background-color: #2a2e34;
|
||||
border-color: #424851;
|
||||
color: #d7dbe1;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.quiet-action:hover {
|
||||
background-color: #343941;
|
||||
}
|
||||
|
||||
.danger-action,
|
||||
.cancel-action {
|
||||
background-color: #512d32;
|
||||
border-color: #764049;
|
||||
color: #f1c2c8;
|
||||
height: 34px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.danger-action:hover,
|
||||
.cancel-action:hover {
|
||||
background-color: #66363d;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.button-row > Button {
|
||||
margin-right: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.grow-action {
|
||||
flex-grow: 1;
|
||||
min-width: 185px;
|
||||
}
|
||||
|
||||
.final-card .secondary-action {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.utility-foldout {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.foldout-description {
|
||||
margin-bottom: 10px;
|
||||
margin-left: 18px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.advanced-configuration-host {
|
||||
background-color: #1a1c20;
|
||||
border-radius: 5px;
|
||||
margin: 8px 0 10px 18px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.advanced-imgui {
|
||||
min-height: 330px;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
color: #dce0e6;
|
||||
font-size: 11px;
|
||||
margin-left: 18px;
|
||||
margin-top: 8px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.advanced-actions,
|
||||
.log-field {
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
||||
.log-heading {
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mini-action {
|
||||
background-color: #292d32;
|
||||
border-color: #40464f;
|
||||
color: #cdd2d9;
|
||||
font-size: 9px;
|
||||
height: 22px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.log-field {
|
||||
height: 190px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status-footer {
|
||||
background-color: #1d2025;
|
||||
border-color: #343941;
|
||||
border-top-width: 1px;
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
padding: 7px 14px;
|
||||
}
|
||||
|
||||
.status-copy {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.status-kicker {
|
||||
color: #7f8995;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.6px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: #e6e9ee;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
max-height: 38px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.generation-stage {
|
||||
color: #7fb7f3;
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.generation-progress {
|
||||
height: 17px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.generation-timing {
|
||||
color: #929ba7;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.applied-seed-box {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status--ready {
|
||||
border-top-color: #346b5d;
|
||||
}
|
||||
|
||||
.status--running {
|
||||
border-top-color: #4d84bd;
|
||||
}
|
||||
|
||||
.status--error {
|
||||
background-color: #251d20;
|
||||
border-top-color: #8c4650;
|
||||
}
|
||||
|
||||
.status--error .status-message {
|
||||
color: #f2b9c1;
|
||||
}
|
||||
|
||||
.cancel-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-action {
|
||||
background-color: #2b3037;
|
||||
border-color: #474e58;
|
||||
border-radius: 5px;
|
||||
color: #d8dde5;
|
||||
flex-shrink: 0;
|
||||
height: 30px;
|
||||
margin-right: 6px;
|
||||
min-width: 74px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.narrow-layout .backend-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.narrow-layout .page {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.narrow-layout .field-pair,
|
||||
.narrow-layout .button-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.narrow-layout .compact-field,
|
||||
.narrow-layout .button-row > Button {
|
||||
margin-right: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.narrow-layout .camera-generator-root .unity-base-field__label,
|
||||
.camera-generator-root.narrow-layout .unity-base-field__label {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.summary-box .unity-help-box__icon {
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.summary-box .unity-help-box__label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.camera-generator-root Button:disabled,
|
||||
.camera-generator-root .unity-base-field:disabled {
|
||||
opacity: 0.46;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f39e900b5f7d47ab9f05d60ac33a6c68
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||
disableValidation: 0
|
||||
unsupportedSelectorAction: 0
|
||||
@@ -0,0 +1,136 @@
|
||||
<ui:UXML xmlns:ui="UnityEngine.UIElements">
|
||||
<ui:VisualElement name="camera-generator-root" class="camera-generator-root">
|
||||
<ui:ScrollView name="main-scroll" class="main-scroll">
|
||||
<ui:VisualElement class="page">
|
||||
<ui:VisualElement class="hero">
|
||||
<ui:VisualElement class="hero-row">
|
||||
<ui:VisualElement class="hero-copy">
|
||||
<ui:Label text="CAMERA WORK AI" class="eyebrow" />
|
||||
<ui:Label text="AI 카메라 생성" class="hero-title" />
|
||||
<ui:Label text="Timeline의 모션과 음원으로 편집 가능한 Cinemachine 카메라를 생성합니다." class="hero-description" />
|
||||
</ui:VisualElement>
|
||||
<ui:Label text="고품질 Worker" class="backend-badge" />
|
||||
</ui:VisualElement>
|
||||
<ui:VisualElement class="flow-strip">
|
||||
<ui:Label text="1 입력" class="flow-item" />
|
||||
<ui:Label text="2 전체 생성" class="flow-item" />
|
||||
<ui:Label text="3 샷 다듬기" class="flow-item" />
|
||||
<ui:Label text="4 저장" class="flow-item" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement name="readiness-card" class="readiness-card readiness--warning">
|
||||
<ui:VisualElement class="readiness-dot" />
|
||||
<ui:VisualElement class="readiness-copy">
|
||||
<ui:Label name="readiness-title" text="Timeline 선택이 필요합니다" class="readiness-title" />
|
||||
<ui:Label name="readiness-detail" text="현재 씬에서 모션과 음원이 있는 Timeline을 선택하세요." class="readiness-detail" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="workflow-card">
|
||||
<ui:VisualElement class="card-heading">
|
||||
<ui:Label text="1" class="step-badge" />
|
||||
<ui:VisualElement class="card-heading-copy">
|
||||
<ui:Label text="입력 Timeline" class="card-title" />
|
||||
<ui:Label text="모션과 음원이 있는 Timeline을 선택합니다. 기존 카메라는 사용하지 않습니다." class="card-description" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
<ui:DropdownField name="source-director-dropdown" label="원본 Timeline" class="form-field" />
|
||||
<ui:HelpBox name="source-summary-box" message-type="Warning" text="모션과 음원이 들어 있는 원본 Timeline을 선택하세요." class="summary-box" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement class="workflow-card generation-card">
|
||||
<ui:VisualElement class="card-heading">
|
||||
<ui:Label text="2" class="step-badge" />
|
||||
<ui:VisualElement class="card-heading-copy">
|
||||
<ui:Label text="전체 카메라 자율 생성" class="card-title" />
|
||||
<ui:Label text="학습된 컷 리듬과 카메라 문법으로 전체 구간을 자유롭게 구성합니다." class="card-description" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
<ui:DropdownField name="cut-rhythm-dropdown" label="컷 리듬" tooltip="자동은 음악, 동작과 작업자 컷 데이터를 함께 분석합니다." class="form-field" />
|
||||
<ui:Label text="거리·무빙·구도는 전체 생성 후 원하는 샷만 따로 조절할 수 있습니다." class="inline-note" />
|
||||
<ui:Toggle name="fresh-candidate-toggle" text="후보 풀도 새로 계산 (느림)" tooltip="캐시를 사용하지 않고 이번 후보를 처음부터 다시 계산합니다." class="fresh-toggle" />
|
||||
<ui:HelpBox name="fresh-candidate-warning" message-type="Warning" text="후보 풀을 다시 계산하므로 이번 생성은 더 오래 걸립니다." class="hidden-box" />
|
||||
<ui:Button name="generate-full-button" text="전체 카메라 자율 생성" class="primary-action" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement name="refinement-card" class="workflow-card refinement-card">
|
||||
<ui:VisualElement class="card-heading">
|
||||
<ui:Label text="3" class="step-badge" />
|
||||
<ui:VisualElement class="card-heading-copy">
|
||||
<ui:Label text="선택한 카메라 다듬기" class="card-title" />
|
||||
<ui:Label text="Timeline 또는 Hierarchy에서 카메라를 선택한 뒤 필요한 샷만 다시 생성합니다." class="card-description" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
<ui:HelpBox name="selected-shot-info" message-type="None" text="Timeline의 AI 카메라를 선택하면 자동으로 연결됩니다." class="summary-box" />
|
||||
<ui:VisualElement name="selected-shot-picker-row">
|
||||
<ui:DropdownField name="selected-shot-dropdown" label="카메라 직접 선택" class="form-field" />
|
||||
</ui:VisualElement>
|
||||
<ui:Foldout name="shot-options-foldout" text="재생성 옵션" value="false" class="inner-foldout">
|
||||
<ui:Label text="아래 설정은 선택한 카메라 한 개에만 적용됩니다." class="inline-note" />
|
||||
<ui:VisualElement class="field-pair">
|
||||
<ui:DropdownField name="shot-size-dropdown" label="샷 거리" class="compact-field" />
|
||||
<ui:DropdownField name="composition-dropdown" label="인물 구도" class="compact-field" />
|
||||
</ui:VisualElement>
|
||||
<ui:DropdownField name="motion-dropdown" label="카메라 움직임" class="form-field" />
|
||||
<ui:Slider name="motion-intensity-slider" label="움직임 강도" low-value="0" high-value="2" show-input-field="true" class="form-field" />
|
||||
<ui:FloatField name="distance-field" label="직접 거리 (m)" tooltip="0이면 선택한 샷 거리와 캐릭터 크기에 맞춰 자동 결정합니다." class="form-field" />
|
||||
<ui:Slider name="body-follow-slider" label="몸 추종 강도" low-value="0" high-value="1" show-input-field="true" tooltip="0이면 무대 기준으로 안정적이고, 1이면 캐릭터 이동을 강하게 따라갑니다." class="form-field" />
|
||||
<ui:Button name="reset-shot-options-button" text="옵션을 자동으로 되돌리기" class="quiet-action" />
|
||||
</ui:Foldout>
|
||||
<ui:VisualElement class="button-row">
|
||||
<ui:Button name="regenerate-shot-button" text="선택한 카메라 재생성" class="secondary-action grow-action" />
|
||||
<ui:Button name="remove-result-button" text="생성 결과 지우기" class="danger-action" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:VisualElement name="final-card" class="workflow-card final-card">
|
||||
<ui:VisualElement class="card-heading">
|
||||
<ui:Label text="4" class="step-badge" />
|
||||
<ui:VisualElement class="card-heading-copy">
|
||||
<ui:Label text="완성본 보존" class="card-title" />
|
||||
<ui:Label text="현재 씬을 원본과 별개의 AI_Camera_Final 씬으로 저장합니다." class="card-description" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
<ui:Button name="save-final-scene-button" text="완성본 씬으로 저장" class="secondary-action" />
|
||||
</ui:VisualElement>
|
||||
|
||||
<ui:Foldout name="feedback-foldout" text="선택 사항 · 컷 수정 기록" value="false" class="utility-foldout">
|
||||
<ui:Label text="AI 카메라 컷을 직접 수정한 뒤 기록하면 유지한 컷과 변경한 컷을 익명 데이터로 저장합니다. 씬과 Timeline 자체는 저장하지 않습니다." class="foldout-description" />
|
||||
<ui:TextField name="editor-style-id-field" label="익명 편집 스타일 ID" class="form-field" />
|
||||
<ui:Button name="record-correction-button" text="컷 수정 기록 저장" class="quiet-action" />
|
||||
</ui:Foldout>
|
||||
|
||||
<ui:Foldout name="advanced-foldout" text="고급 · 진단" value="false" class="utility-foldout advanced-foldout">
|
||||
<ui:Label text="경로, Worker, Seed, 캐시와 수동 복구는 문제가 있거나 세부 조정이 필요할 때만 사용하세요." class="foldout-description" />
|
||||
<ui:VisualElement name="advanced-configuration-host" class="advanced-configuration-host" />
|
||||
<ui:Label text="수동 작업" class="subsection-title" />
|
||||
<ui:VisualElement class="button-row advanced-actions">
|
||||
<ui:Button name="extract-dataset-button" text="Timeline 데이터만 추출" class="quiet-action grow-action" />
|
||||
<ui:Button name="reapply-full-button" text="현재 결과 전체 다시 적용" class="quiet-action grow-action" />
|
||||
<ui:Button name="reapply-shot-button" text="현재 결과로 선택 카메라 교체" class="quiet-action grow-action" />
|
||||
<ui:Button name="build-cache-button" text="준비 캐시 만들기·갱신" class="quiet-action grow-action" />
|
||||
<ui:Button name="refresh-diagnostics-button" text="목록·상태 새로고침" class="quiet-action grow-action" />
|
||||
</ui:VisualElement>
|
||||
<ui:VisualElement class="log-heading">
|
||||
<ui:Label text="실행 로그" class="subsection-title" />
|
||||
<ui:Button name="clear-log-button" text="지우기" class="mini-action" />
|
||||
</ui:VisualElement>
|
||||
<ui:TextField name="log-field" multiline="true" is-read-only="true" class="log-field" />
|
||||
</ui:Foldout>
|
||||
</ui:VisualElement>
|
||||
</ui:ScrollView>
|
||||
|
||||
<ui:VisualElement name="status-footer" class="status-footer status--ready">
|
||||
<ui:VisualElement class="status-copy">
|
||||
<ui:Label name="status-message" text="모션과 음원이 들어 있는 Timeline을 선택하세요." class="status-message" />
|
||||
<ui:Label name="generation-stage-label" text="" class="generation-stage" />
|
||||
<ui:ProgressBar name="generation-progress" low-value="0" high-value="1" value="0" class="generation-progress" />
|
||||
<ui:Label name="generation-timing-label" text="생성을 시작하면 단계와 예상 시간을 여기에 표시합니다." class="generation-timing" />
|
||||
<ui:HelpBox name="applied-seed-box" message-type="Info" text="" class="applied-seed-box" />
|
||||
</ui:VisualElement>
|
||||
<ui:Button name="status-log-button" text="로그 보기" class="footer-action" />
|
||||
<ui:Button name="cancel-generation-button" text="생성 취소" class="cancel-action" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
</ui:UXML>
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c71572bfd3749108603d0fe48f1f20b
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||
@@ -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)`
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 55f123ec8b7a4344b8468ea083861738
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c0ba2302b9c49cda5423455f6e7102b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c974ef7459d45caa99203db8ef311b1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99556bdd71254adaa3f4d8654875a01d
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30c854c9afbd493f8b2e660c23584f83
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,503 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
not price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and charge
|
||||
for this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser General Public License from time to time.
|
||||
Such new versions will be similar in spirit to the present version,
|
||||
but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the library's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1990
|
||||
Ty Coon, President of Vice
|
||||
|
||||
That's all there is to it!
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user