diff --git a/.gitattributes b/.gitattributes index bcc07d4..78ee106 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ *.meta -whitespace +CameraAI~/Tools~/CWCameraWorker/** -diff -whitespace +*.exe filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text +*.pyd filter=lfs diff=lfs merge=lfs -text diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a91252..5c59abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.5 + +- Added the separately installable `Mingle Camera Work AI` package under + `CameraAI~`, including the frozen Windows x64 `CWCameraWorker` distribution. +- Kept authored camera, character-motion, and source-audio reference data in + the access-controlled CW-AI data library rather than mirroring it here. +- Documented Git LFS, Unity Package Manager, data-root, and first-run setup. +- Moved Camera AI caches to the consuming Unity project's writable + `Library/CWAI` directory so the reference-data library can be read-only. + ## 0.1.4 - Fixed player build compile error: `ScheduleEditorFinalEvaluation` call in `BlendshapeGazeMixerBehaviour.ProcessFrame` was not wrapped in `#if UNITY_EDITOR`. diff --git a/CameraAI~/CHANGELOG.md b/CameraAI~/CHANGELOG.md new file mode 100644 index 0000000..b586066 --- /dev/null +++ b/CameraAI~/CHANGELOG.md @@ -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. diff --git a/CameraAI~/CHANGELOG.md.meta b/CameraAI~/CHANGELOG.md.meta new file mode 100644 index 0000000..1effa89 --- /dev/null +++ b/CameraAI~/CHANGELOG.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ccccb305b4444bc4bcfd3d4fba5b0044 diff --git a/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md new file mode 100644 index 0000000..b2f4b39 --- /dev/null +++ b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md @@ -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. diff --git a/CameraAI~/Editor.meta b/CameraAI~/Editor.meta new file mode 100644 index 0000000..949904e --- /dev/null +++ b/CameraAI~/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ae8819ff3bf95914eadcfefcdabb48ad +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/CameraAI~/Editor/AICameraABRenderManifest.cs b/CameraAI~/Editor/AICameraABRenderManifest.cs new file mode 100644 index 0000000..40f00ce --- /dev/null +++ b/CameraAI~/Editor/AICameraABRenderManifest.cs @@ -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 audio = new(); + public List 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 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( + 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(string path) + { + if (!File.Exists(path)) + { + return default; + } + + return JsonUtility.FromJson(File.ReadAllText(path)); + } + + internal static void WriteJsonAtomic(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."); + } + } + } +} diff --git a/CameraAI~/Editor/AICameraABRenderManifest.cs.meta b/CameraAI~/Editor/AICameraABRenderManifest.cs.meta new file mode 100644 index 0000000..ec6468c --- /dev/null +++ b/CameraAI~/Editor/AICameraABRenderManifest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e61af0fef774b80bbf7fc1121b9ae29 diff --git a/CameraAI~/Editor/AICameraABRenderRunner.cs b/CameraAI~/Editor/AICameraABRenderRunner.cs new file mode 100644 index 0000000..3836181 --- /dev/null +++ b/CameraAI~/Editor/AICameraABRenderRunner.cs @@ -0,0 +1,1336 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using Unity.Cinemachine.Editor; +using UnityEditor; +using UnityEditor.Recorder; +using UnityEditor.Recorder.Encoder; +using UnityEditor.Recorder.Input; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Playables; +using UnityEngine.SceneManagement; +using UnityEngine.Timeline; + +namespace Streamingle.Editor +{ + [InitializeOnLoad] + public static class AICameraABRenderRunner + { + internal const string PhaseBuildPreview = "build_preview"; + internal const string PhaseEnterPlayMode = "enter_play_mode"; + internal const string PhaseRecording = "recording"; + internal const string PhaseFinalizeCandidate = "finalize_candidate"; + internal const string PhaseComplete = "complete"; + internal const string PhaseFailed = "failed"; + internal const string PhaseCancelled = "cancelled"; + + private const string ManifestFileName = "manifest.json"; + private const string ActiveJobFileName = "active_job.json"; + private const double OutputFinalizationTimeoutSeconds = 60; + private const double OutputQuietPeriodSeconds = 0.25; + private const int RequiredStableOutputChecks = 2; + + private static AICameraABRenderJobState job; + private static AICameraABRenderManifest manifest; + private static RecorderController recorderController; + private static RecorderControllerSettings controllerSettings; + private static MovieRecorderSettings movieSettings; + private static bool updateInProgress; + + static AICameraABRenderRunner() + { + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + EditorApplication.update += OnEditorUpdate; + EditorApplication.delayCall += ResumePersistedJob; + } + + public static string StartABRenderForCli( + string candidateADirectory, + string candidateBDirectory, + string outputRoot, + int startFrame, + int endFrameExclusive, + string codec, + int width, + int height) + { + var request = new AICameraABRenderRequest + { + candidateADirectory = candidateADirectory, + candidateBDirectory = candidateBDirectory, + outputRoot = outputRoot, + startFrame = startFrame, + endFrameExclusive = endFrameExclusive, + codec = AICameraABRenderUtility.ParseCodec(codec), + width = width, + height = height + }; + var startedJob = Start(request); + return JsonUtility.ToJson( + new StartResponse + { + jobId = startedJob.jobId, + manifestPath = startedJob.manifestPath, + runDirectory = startedJob.runDirectory + }, + true); + } + + public static string GetABRenderStatusForCli(string jobId) + { + LoadPersistedState(); + if (job == null || + !string.Equals(job.jobId, jobId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"A/B render job '{jobId}' is not the active job."); + } + + if (!File.Exists(job.manifestPath)) + { + throw new FileNotFoundException( + "A/B render manifest was not found.", + job.manifestPath); + } + + return File.ReadAllText(job.manifestPath); + } + + public static string CancelABRenderForCli(string jobId) + { + LoadPersistedState(); + if (job == null || + !string.Equals(job.jobId, jobId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"A/B render job '{jobId}' is not the active job."); + } + + if (AICameraABRenderUtility.IsTerminalPhase(job.phase)) + { + return $"A/B render job '{jobId}' is already {job.phase}."; + } + + job.cancelRequested = true; + SaveJob(); + return $"Cancellation requested for A/B render job '{jobId}'."; + } + + public static string RetryFailedABRenderForCli(string jobId) + { + EnsureEditMode(); + LoadPersistedState(); + if (job == null || + !string.Equals(job.jobId, jobId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"A/B render job '{jobId}' is not the active job."); + } + + if (job.phase != PhaseFailed || + job.cleanupPending || + manifest == null || + job.candidateIndex < 0 || + job.candidateIndex >= manifest.candidates.Count) + { + throw new InvalidOperationException( + $"A/B render job '{jobId}' cannot be retried from its " + + $"current state."); + } + + RestoreCinemachineSaveDuringPlay(); + if (!job.cinemachineSaveDuringPlayStateRestored) + { + throw new InvalidOperationException( + "Cinemachine Save During Play must be restored before " + + "retrying a failed render."); + } + + EnsureCleanSavedScenes(); + for (var index = 0; index < job.candidateIndex; index++) + { + var completed = manifest.candidates[index]; + if (completed.status != "complete" || + !File.Exists(completed.outputPath) || + new FileInfo(completed.outputPath).Length != + completed.outputBytes || + !string.Equals( + AICameraABRenderUtility.ComputeSha256( + completed.outputPath), + completed.outputSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Completed candidate {completed.label} no longer " + + "matches its manifest."); + } + } + + var candidate = GetCurrentCandidate(); + var currentInput = AICameraABRenderUtility.ReadCandidate( + candidate.label, + candidate.generatedDirectory).Result; + if (!string.Equals( + currentInput.metadataSha256, + candidate.metadataSha256, + StringComparison.OrdinalIgnoreCase) || + !string.Equals( + currentInput.cameraSha256, + candidate.cameraSha256, + StringComparison.OrdinalIgnoreCase) || + !string.Equals( + currentInput.timeSha256, + candidate.timeSha256, + StringComparison.OrdinalIgnoreCase) || + !string.Equals( + currentInput.shotsSha256, + candidate.shotsSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Candidate {candidate.label} inputs changed after the " + + "original render attempt."); + } + + if (File.Exists(candidate.outputPath)) + { + throw new InvalidOperationException( + $"Candidate {candidate.label} has a partial output. " + + "Move or remove it before retrying: " + + $"'{candidate.outputPath}'."); + } + + candidate.status = "queued"; + candidate.startedUtc = string.Empty; + candidate.completedUtc = string.Empty; + candidate.outputBytes = 0; + candidate.outputSha256 = string.Empty; + candidate.error = string.Empty; + manifest.state = "running"; + manifest.completedUtc = string.Empty; + manifest.error = string.Empty; + + job.phase = PhaseBuildPreview; + job.cancelRequested = false; + job.cleanupPending = false; + job.stableOutputChecks = 0; + job.lastObservedOutputBytes = -1; + job.finalizationStartedUtc = string.Empty; + job.error = string.Empty; + job.cinemachineSaveDuringPlayStateCaptured = true; + job.cinemachineSaveDuringPlayOriginalEnabled = + SaveDuringPlay.Enabled; + job.cinemachineSaveDuringPlayStateRestored = false; + + SaveManifest(); + SaveJob(); + EnsureCinemachineSaveDuringPlayDisabled(); + EditorApplication.delayCall += ContinueInCurrentMode; + return JsonUtility.ToJson( + new StartResponse + { + jobId = job.jobId, + manifestPath = job.manifestPath, + runDirectory = job.runDirectory + }, + true); + } + + internal static bool TryGetActiveStatus( + out string jobId, + out string phase, + out string manifestPath) + { + LoadPersistedState(); + if (job == null) + { + jobId = string.Empty; + phase = string.Empty; + manifestPath = string.Empty; + return false; + } + + jobId = job.jobId; + phase = job.phase; + manifestPath = job.manifestPath; + return true; + } + + internal static AICameraABRenderJobState Start( + AICameraABRenderRequest request) + { + EnsureEditMode(); + ClearPreviousTerminalJob(); + EnsureNoActiveJob(); + EnsureCleanSavedScenes(); + + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.outputRoot)) + { + request.outputRoot = GetDefaultOutputRoot(); + } + + var candidateA = AICameraABRenderUtility.ReadCandidate( + "A", + request.candidateADirectory); + var candidateB = AICameraABRenderUtility.ReadCandidate( + "B", + request.candidateBDirectory); + var fairness = AICameraABRenderUtility.ValidateCandidatePair( + candidateA, + candidateB, + request); + + var activeScene = SceneManager.GetActiveScene(); + var originalDirector = FindDirector( + activeScene, + AICameraTimelinePreviewImporter.OriginalDirectorName); + var originalTimeline = originalDirector.playableAsset as TimelineAsset + ?? throw new InvalidOperationException( + $"'{AICameraTimelinePreviewImporter.OriginalDirectorName}' " + + "is not bound to a TimelineAsset."); + var timelineFrameRate = originalTimeline.editorSettings.frameRate; + if (Math.Abs(timelineFrameRate - candidateA.Result.sampleRate) > 0.001) + { + throw new InvalidDataException( + $"Timeline is {timelineFrameRate:F3} FPS but generated " + + $"camera data is {candidateA.Result.sampleRate} FPS."); + } + + var runIdSource = + $"{DateTime.UtcNow:yyyyMMdd_HHmmss_ffff}_{Guid.NewGuid():N}"; + var runId = runIdSource.Substring(0, 37); + var runDirectory = Path.Combine( + Path.GetFullPath(request.outputRoot), + $"cw_ai_ab_{runId}"); + if (Directory.Exists(runDirectory)) + { + throw new IOException( + $"A/B render directory already exists: '{runDirectory}'."); + } + + var extension = AICameraABRenderUtility.GetExtension(request.codec); + ConfigureCandidateOutput( + candidateA.Result, + runDirectory, + extension); + ConfigureCandidateOutput( + candidateB.Result, + runDirectory, + extension); + + var manifestPath = Path.Combine(runDirectory, ManifestFileName); + manifest = BuildManifest( + request, + activeScene, + originalTimeline, + candidateA.Result, + candidateB.Result, + fairness, + runId); + if (manifest.audio.Count == 0) + { + throw new InvalidOperationException( + "The original Timeline has no active AudioClip to use " + + "for the A/B render."); + } + + Directory.CreateDirectory(runDirectory); + job = new AICameraABRenderJobState + { + jobId = runId, + phase = PhaseBuildPreview, + manifestPath = manifestPath, + runDirectory = runDirectory, + candidateIndex = 0, + request = request, + sceneSetup = CaptureSceneSetup(), + cinemachineSaveDuringPlayStateCaptured = true, + cinemachineSaveDuringPlayOriginalEnabled = + SaveDuringPlay.Enabled + }; + + SaveManifest(); + SaveJob(); + EnsureCinemachineSaveDuringPlayDisabled(); + EditorApplication.delayCall += ContinueInCurrentMode; + return job; + } + + private static void ResumePersistedJob() + { + LoadPersistedState(); + if (job == null) + { + return; + } + + EnsureCinemachineSaveDuringPlayDisabled(); + if (AICameraABRenderUtility.IsTerminalPhase(job.phase) && + !job.cleanupPending && + job.cinemachineSaveDuringPlayStateRestored) + { + return; + } + + EditorApplication.delayCall += ContinueInCurrentMode; + } + + private static void OnEditorUpdate() + { + if (updateInProgress) + { + return; + } + + LoadPersistedState(); + if (job == null) + { + return; + } + + updateInProgress = true; + try + { + ContinueInCurrentMode(); + } + catch (Exception exception) + { + FailJob(exception); + } + finally + { + updateInProgress = false; + } + } + + private static void ContinueInCurrentMode() + { + if (job == null) + { + return; + } + + EnsureCinemachineSaveDuringPlayDisabled(); + if (Application.isPlaying) + { + ContinueInPlayMode(); + return; + } + + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return; + } + + ContinueInEditMode(); + } + + private static void ContinueInEditMode() + { + if (job.cleanupPending) + { + CleanupPreviewAndRestoreScene(); + job.cleanupPending = false; + SaveJob(); + } + + if (AICameraABRenderUtility.IsTerminalPhase(job.phase)) + { + RestoreCinemachineSaveDuringPlay(); + return; + } + + if (job.cancelRequested && + !AICameraABRenderUtility.IsTerminalPhase(job.phase)) + { + CancelJobInEditMode(); + return; + } + + switch (job.phase) + { + case PhaseBuildPreview: + BuildPreviewAndEnterPlayMode(); + break; + case PhaseEnterPlayMode: + EditorApplication.isPlaying = true; + break; + case PhaseRecording: + throw new InvalidOperationException( + "Play Mode exited before the Recorder completed."); + case PhaseFinalizeCandidate: + TryFinalizeCandidate(); + break; + case PhaseComplete: + case PhaseFailed: + case PhaseCancelled: + break; + } + } + + private static void ContinueInPlayMode() + { + if (job.cancelRequested) + { + CancelJobInPlayMode(); + return; + } + + switch (job.phase) + { + case PhaseEnterPlayMode: + StartRecordingInPlayMode(); + break; + case PhaseRecording: + MonitorRecordingInPlayMode(); + break; + case PhaseFinalizeCandidate: + EditorApplication.isPlaying = false; + break; + case PhaseFailed: + case PhaseCancelled: + EditorApplication.isPlaying = false; + break; + } + } + + private static void BuildPreviewAndEnterPlayMode() + { + RestoreCleanSceneSetup(); + var candidate = GetCurrentCandidate(); + candidate.status = "building_preview"; + SaveManifest(); + + var activeScene = SceneManager.GetActiveScene(); + var hasExistingPreview = Resources + .FindObjectsOfTypeAll() + .Any(director => + director.gameObject.scene == activeScene && + director.name == + AICameraTimelinePreviewImporter.PreviewDirectorName); + if (hasExistingPreview) + { + AICameraTimelinePreviewImporter.RemovePreviewForCli(); + } + + AICameraTimelinePreviewImporter.CreatePreviewForCli( + candidate.generatedDirectory); + activeScene = SceneManager.GetActiveScene(); + var previewDirector = FindDirector( + activeScene, + AICameraTimelinePreviewImporter.PreviewDirectorName); + if (previewDirector.playableAsset is not TimelineAsset previewTimeline) + { + throw new InvalidOperationException( + "AI preview Director is not bound to a TimelineAsset."); + } + + AICameraTimelinePreviewImporter.MuteCopiedRecorderTracks( + previewTimeline); + EditorUtility.SetDirty(previewTimeline); + AssetDatabase.SaveAssets(); + + candidate.status = "waiting_for_play_mode"; + job.phase = PhaseEnterPlayMode; + SaveManifest(); + SaveJob(); + EditorApplication.isPlaying = true; + } + + private static void StartRecordingInPlayMode() + { + if (recorderController != null) + { + return; + } + + var activeScene = SceneManager.GetActiveScene(); + var previewDirector = FindDirector( + activeScene, + AICameraTimelinePreviewImporter.PreviewDirectorName); + if (Camera.main == null) + { + throw new InvalidOperationException( + "The active scene has no enabled Camera tagged MainCamera."); + } + + var candidate = GetCurrentCandidate(); + var request = job.request; + var cameraInput = new CameraInputSettings + { + Source = ImageSource.MainCamera, + CaptureUI = false, + OutputWidth = request.width, + OutputHeight = request.height + }; + movieSettings = ScriptableObject.CreateInstance(); + movieSettings.name = $"CW-AI A/B {candidate.label}"; + movieSettings.Enabled = true; + movieSettings.EncoderSettings = new CoreEncoderSettings + { + Codec = request.codec == AICameraABRenderCodec.MP4 + ? CoreEncoderSettings.OutputCodec.MP4 + : CoreEncoderSettings.OutputCodec.WEBM, + EncodingQuality = + CoreEncoderSettings.VideoEncodingQuality.High + }; + movieSettings.CaptureAlpha = false; + movieSettings.CaptureAudio = true; + movieSettings.ImageInputSettings = cameraInput; + movieSettings.OutputFile = + candidate.outputBasePath.Replace('\\', '/'); + + controllerSettings = + ScriptableObject.CreateInstance(); + controllerSettings.AddRecorderSettings(movieSettings); + controllerSettings.FrameRate = candidate.sampleRate; + controllerSettings.FrameRatePlayback = + FrameRatePlayback.Constant; + controllerSettings.CapFrameRate = true; + controllerSettings.ExitPlayMode = false; + controllerSettings.SetRecordModeToFrameInterval( + request.startFrame, + AICameraABRenderUtility.ToInclusiveEndFrame( + request.startFrame, + request.endFrameExclusive, + candidate.frameCount)); + recorderController = new RecorderController(controllerSettings); + + previewDirector.time = 0; + previewDirector.RebuildGraph(); + previewDirector.Evaluate(); + RecorderOptions.VerboseMode = false; + recorderController.PrepareRecording(); + if (!recorderController.StartRecording()) + { + throw new InvalidOperationException( + "Unity Recorder could not start the movie recording."); + } + + previewDirector.Play(); + candidate.status = "recording"; + candidate.startedUtc = AICameraABRenderUtility.UtcNow(); + job.phase = PhaseRecording; + manifest.state = "recording"; + SaveManifest(); + SaveJob(); + } + + private static void MonitorRecordingInPlayMode() + { + if (recorderController == null) + { + throw new InvalidOperationException( + "Recorder state was lost during Play Mode."); + } + + if (recorderController.IsRecording()) + { + return; + } + + recorderController.StopRecording(); + ReleaseRecorderObjects(); + var candidate = GetCurrentCandidate(); + candidate.status = "finalizing"; + job.phase = PhaseFinalizeCandidate; + job.finalizationStartedUtc = AICameraABRenderUtility.UtcNow(); + job.stableOutputChecks = 0; + job.lastObservedOutputBytes = -1; + manifest.state = "finalizing"; + SaveManifest(); + SaveJob(); + EditorApplication.isPlaying = false; + } + + private static void TryFinalizeCandidate() + { + var candidate = GetCurrentCandidate(); + var outputPath = candidate.outputPath; + var started = ParseUtcOrNow(job.finalizationStartedUtc); + if (DateTime.UtcNow - started > + TimeSpan.FromSeconds(OutputFinalizationTimeoutSeconds)) + { + throw new TimeoutException( + $"Recorder output did not finalize within " + + $"{OutputFinalizationTimeoutSeconds:F0} seconds: " + + $"'{outputPath}'."); + } + + if (!File.Exists(outputPath)) + { + return; + } + + var file = new FileInfo(outputPath); + if (file.Length <= 0) + { + return; + } + + if (file.Length == job.lastObservedOutputBytes && + DateTime.UtcNow - file.LastWriteTimeUtc > + TimeSpan.FromSeconds(OutputQuietPeriodSeconds)) + { + job.stableOutputChecks++; + } + else + { + job.stableOutputChecks = 0; + job.lastObservedOutputBytes = file.Length; + } + + SaveJob(); + if (job.stableOutputChecks < RequiredStableOutputChecks) + { + return; + } + + try + { + candidate.outputSha256 = + AICameraABRenderUtility.ComputeSha256(outputPath); + } + catch (IOException) + { + job.stableOutputChecks = 0; + SaveJob(); + return; + } + + candidate.outputBytes = file.Length; + candidate.completedUtc = AICameraABRenderUtility.UtcNow(); + candidate.status = "complete"; + job.cleanupPending = true; + SaveManifest(); + CleanupPreviewAndRestoreScene(); + job.cleanupPending = false; + + job.candidateIndex++; + job.stableOutputChecks = 0; + job.lastObservedOutputBytes = -1; + job.finalizationStartedUtc = string.Empty; + if (job.candidateIndex < manifest.candidates.Count) + { + job.phase = PhaseBuildPreview; + manifest.state = "running"; + SaveManifest(); + SaveJob(); + EditorApplication.delayCall += ContinueInCurrentMode; + return; + } + + job.phase = PhaseComplete; + manifest.state = "complete"; + manifest.completedUtc = AICameraABRenderUtility.UtcNow(); + SaveManifest(); + RestoreCinemachineSaveDuringPlay(); + SaveJob(); + Debug.Log( + $"CW-AI A/B render complete. Manifest: {job.manifestPath}"); + } + + private static void CancelJobInPlayMode() + { + try + { + recorderController?.StopRecording(); + } + finally + { + ReleaseRecorderObjects(); + } + + SetCancelledState(); + job.cleanupPending = true; + SaveManifest(); + SaveJob(); + EditorApplication.isPlaying = false; + } + + private static void CancelJobInEditMode() + { + SetCancelledState(); + job.cleanupPending = true; + SaveManifest(); + CleanupPreviewAndRestoreScene(); + job.cleanupPending = false; + RestoreCinemachineSaveDuringPlay(); + SaveJob(); + } + + private static void SetCancelledState() + { + job.phase = PhaseCancelled; + job.error = "Cancelled by user."; + manifest.state = "cancelled"; + manifest.error = job.error; + manifest.completedUtc = AICameraABRenderUtility.UtcNow(); + if (job.candidateIndex >= 0 && + job.candidateIndex < manifest.candidates.Count) + { + var candidate = GetCurrentCandidate(); + if (candidate.status != "complete") + { + candidate.status = "cancelled"; + candidate.error = job.error; + } + } + } + + private static void FailJob(Exception exception) + { + try + { + LoadPersistedState(); + if (job == null) + { + Debug.LogException(exception); + return; + } + + try + { + recorderController?.StopRecording(); + } + catch (Exception stopException) + { + Debug.LogException(stopException); + } + finally + { + ReleaseRecorderObjects(); + } + + job.phase = PhaseFailed; + job.error = exception.ToString(); + job.cleanupPending = true; + if (manifest != null) + { + manifest.state = "failed"; + manifest.error = job.error; + manifest.completedUtc = AICameraABRenderUtility.UtcNow(); + if (job.candidateIndex >= 0 && + job.candidateIndex < manifest.candidates.Count) + { + var candidate = GetCurrentCandidate(); + if (candidate.status != "complete") + { + candidate.status = "failed"; + candidate.error = exception.Message; + } + } + + SaveManifest(); + } + + SaveJob(); + if (Application.isPlaying || + EditorApplication.isPlayingOrWillChangePlaymode) + { + EditorApplication.isPlaying = false; + } + else + { + CleanupPreviewAndRestoreScene(); + job.cleanupPending = false; + RestoreCinemachineSaveDuringPlay(); + SaveJob(); + } + } + finally + { + Debug.LogException(exception); + } + } + + private static void CleanupPreviewAndRestoreScene() + { + if (Application.isPlaying || + EditorApplication.isPlayingOrWillChangePlaymode) + { + return; + } + + var activeScene = SceneManager.GetActiveScene(); + var hasPreview = Resources.FindObjectsOfTypeAll() + .Any(director => + director.gameObject.scene == activeScene && + director.name == + AICameraTimelinePreviewImporter.PreviewDirectorName); + if (hasPreview) + { + AICameraTimelinePreviewImporter.RemovePreviewForCli(); + } + + RestoreCleanSceneSetup(); + } + + private static void RestoreCleanSceneSetup() + { + if (job?.sceneSetup == null || + string.IsNullOrWhiteSpace(job.sceneSetup.activeScenePath)) + { + throw new InvalidOperationException( + "The original Scene setup is unavailable."); + } + + var entries = job.sceneSetup.scenes + .Where(entry => !string.IsNullOrWhiteSpace(entry.path)) + .Select(entry => new SceneSetup + { + path = entry.path, + isLoaded = entry.isLoaded, + isActive = entry.isActive + }) + .ToArray(); + if (entries.Length == 0) + { + throw new InvalidOperationException( + "The original Scene setup contains no saved Scenes."); + } + + EditorSceneManager.OpenScene( + job.sceneSetup.activeScenePath, + OpenSceneMode.Single); + EditorSceneManager.RestoreSceneManagerSetup(entries); + } + + private static AICameraABRenderManifest BuildManifest( + AICameraABRenderRequest request, + Scene activeScene, + TimelineAsset timeline, + AICameraABCandidateResult candidateA, + AICameraABCandidateResult candidateB, + AICameraABFairnessInfo fairness, + string runId) + { + var timelinePath = AssetDatabase.GetAssetPath(timeline); + var result = new AICameraABRenderManifest + { + jobId = runId, + state = "running", + createdUtc = AICameraABRenderUtility.UtcNow(), + environment = new AICameraABEnvironmentInfo + { + unityVersion = Application.unityVersion, + recorderVersion = + GetPackageVersion(typeof(RecorderController).Assembly), + cwAiVersion = + GetPackageVersion(typeof(AICameraABRenderRunner).Assembly), + operatingSystem = SystemInfo.operatingSystem + }, + scene = new AICameraABSceneInfo + { + scenePath = activeScene.path, + sceneGuid = AssetDatabase.AssetPathToGUID(activeScene.path), + timelinePath = timelinePath, + timelineGuid = AssetDatabase.AssetPathToGUID(timelinePath), + timelineFrameRate = timeline.editorSettings.frameRate + }, + frameRange = new AICameraABFrameRange + { + startFrame = request.startFrame, + endFrameExclusive = request.endFrameExclusive, + expectedFrameCount = + request.endFrameExclusive - request.startFrame, + startSeconds = + request.startFrame / (double)candidateA.sampleRate, + endSecondsExclusive = + request.endFrameExclusive / + (double)candidateA.sampleRate + }, + movie = new AICameraABMovieSettings + { + codec = request.codec.ToString(), + encoder = request.codec == AICameraABRenderCodec.MP4 + ? "H.264" + : "VP8", + quality = "High", + width = request.width, + height = request.height, + aspectRatio = request.width / (double)request.height, + frameRate = candidateA.sampleRate, + cameraSource = "MainCamera", + captureAudio = true, + captureAlpha = false + }, + fairness = fairness, + audio = CollectAudioProvenance(timeline), + candidates = new List + { + candidateA, + candidateB + } + }; + return result; + } + + private static List CollectAudioProvenance( + TimelineAsset timeline) + { + var result = new List(); + foreach (var track in timeline.GetOutputTracks() + .OfType() + .Where(track => !track.mutedInHierarchy)) + { + foreach (var timelineClip in track.GetClips()) + { + if (timelineClip.asset is not AudioPlayableAsset audioAsset || + audioAsset.clip == null) + { + continue; + } + + var assetPath = AssetDatabase.GetAssetPath(audioAsset.clip); + var absolutePath = string.IsNullOrWhiteSpace(assetPath) + ? string.Empty + : Path.GetFullPath(Path.Combine( + GetProjectRoot(), + assetPath)); + result.Add(new AICameraABAudioClipInfo + { + trackName = track.name, + clipName = audioAsset.clip.name, + assetPath = assetPath, + assetGuid = string.IsNullOrWhiteSpace(assetPath) + ? string.Empty + : AssetDatabase.AssetPathToGUID(assetPath), + assetSha256 = + !string.IsNullOrWhiteSpace(absolutePath) && + File.Exists(absolutePath) + ? AICameraABRenderUtility.ComputeSha256( + absolutePath) + : string.Empty, + timelineStart = timelineClip.start, + clipIn = timelineClip.clipIn, + duration = timelineClip.duration + }); + } + } + + return result; + } + + private static AICameraABSceneSetupSnapshot CaptureSceneSetup() + { + var result = new AICameraABSceneSetupSnapshot + { + activeScenePath = SceneManager.GetActiveScene().path + }; + foreach (var scene in EditorSceneManager.GetSceneManagerSetup()) + { + result.scenes.Add(new AICameraABSceneEntry + { + path = scene.path, + isLoaded = scene.isLoaded, + isActive = scene.isActive + }); + } + + return result; + } + + private static void ConfigureCandidateOutput( + AICameraABCandidateResult candidate, + string runDirectory, + string extension) + { + var directoryName = new DirectoryInfo( + candidate.generatedDirectory) + .Name; + var safeName = AICameraABRenderUtility.SanitizeFileName( + directoryName); + candidate.outputBasePath = Path.Combine( + runDirectory, + $"{candidate.label}_{safeName}"); + candidate.outputPath = + $"{candidate.outputBasePath}.{extension}"; + if (File.Exists(candidate.outputPath)) + { + throw new IOException( + $"Recorder output already exists: '{candidate.outputPath}'."); + } + } + + private static AICameraABCandidateResult GetCurrentCandidate() + { + if (manifest?.candidates == null || + job.candidateIndex < 0 || + job.candidateIndex >= manifest.candidates.Count) + { + throw new InvalidOperationException( + "A/B render candidate index is invalid."); + } + + return manifest.candidates[job.candidateIndex]; + } + + private static PlayableDirector FindDirector(Scene scene, string name) + { + return Resources.FindObjectsOfTypeAll() + .Where(director => + director.gameObject.scene == scene && + director.name == name) + .SingleOrDefault() + ?? throw new InvalidOperationException( + $"Scene '{scene.name}' does not contain one " + + $"'{name}' PlayableDirector."); + } + + private static void OnPlayModeStateChanged( + PlayModeStateChange state) + { + LoadPersistedState(); + if (job == null) + { + return; + } + + switch (state) + { + case PlayModeStateChange.EnteredPlayMode: + case PlayModeStateChange.EnteredEditMode: + EditorApplication.delayCall += ContinueInCurrentMode; + break; + } + } + + private static void LoadPersistedState() + { + if (job == null) + { + job = AICameraABRenderUtility.ReadJson< + AICameraABRenderJobState>(GetActiveJobPath()); + } + + if (job != null && + manifest == null && + !string.IsNullOrWhiteSpace(job.manifestPath)) + { + manifest = AICameraABRenderUtility.ReadJson< + AICameraABRenderManifest>(job.manifestPath); + } + } + + private static void SaveJob() + { + if (job != null) + { + AICameraABRenderUtility.WriteJsonAtomic( + GetActiveJobPath(), + job); + } + } + + private static void SaveManifest() + { + if (job != null && manifest != null) + { + AICameraABRenderUtility.WriteJsonAtomic( + job.manifestPath, + manifest); + } + } + + private static void ReleaseRecorderObjects() + { + recorderController = null; + if (controllerSettings != null) + { + UnityEngine.Object.DestroyImmediate(controllerSettings); + controllerSettings = null; + } + + if (movieSettings != null) + { + UnityEngine.Object.DestroyImmediate(movieSettings); + movieSettings = null; + } + } + + private static void EnsureEditMode() + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + throw new InvalidOperationException( + "A/B rendering must be started in Edit Mode."); + } + } + + private static void EnsureNoActiveJob() + { + LoadPersistedState(); + if (job != null) + { + throw new InvalidOperationException( + $"A/B render job '{job.jobId}' is already {job.phase}."); + } + } + + private static void ClearPreviousTerminalJob() + { + LoadPersistedState(); + if (job == null) + { + return; + } + + if (!AICameraABRenderUtility.IsTerminalPhase(job.phase) || + job.cleanupPending) + { + return; + } + + EnsureCinemachineSaveDuringPlayDisabled(); + RestoreCinemachineSaveDuringPlay(); + if (!job.cinemachineSaveDuringPlayStateRestored) + { + throw new InvalidOperationException( + "Cinemachine Save During Play could not be restored."); + } + + var path = GetActiveJobPath(); + if (File.Exists(path)) + { + File.Delete(path); + } + + job = null; + manifest = null; + } + + private static void EnsureCinemachineSaveDuringPlayDisabled() + { + if (job == null || + job.cinemachineSaveDuringPlayStateRestored) + { + return; + } + + if (!job.cinemachineSaveDuringPlayStateCaptured) + { + job.cinemachineSaveDuringPlayOriginalEnabled = + SaveDuringPlay.Enabled; + job.cinemachineSaveDuringPlayStateCaptured = true; + SaveJob(); + } + + SaveDuringPlay.Enabled = false; + } + + private static void RestoreCinemachineSaveDuringPlay() + { + if (job == null || + job.cinemachineSaveDuringPlayStateRestored || + !job.cinemachineSaveDuringPlayStateCaptured || + job.cleanupPending || + !AICameraABRenderUtility.IsTerminalPhase(job.phase) || + Application.isPlaying || + EditorApplication.isPlayingOrWillChangePlaymode) + { + return; + } + + SaveDuringPlay.Enabled = + job.cinemachineSaveDuringPlayOriginalEnabled; + job.cinemachineSaveDuringPlayStateRestored = true; + SaveJob(); + } + + private static void EnsureCleanSavedScenes() + { + var sceneCount = SceneManager.sceneCount; + var dirtyScenes = new List(); + for (var index = 0; index < sceneCount; index++) + { + var scene = SceneManager.GetSceneAt(index); + if (string.IsNullOrWhiteSpace(scene.path)) + { + throw new InvalidOperationException( + $"Scene '{scene.name}' must be saved before A/B rendering."); + } + + if (scene.isDirty) + { + dirtyScenes.Add(scene.path); + } + } + + if (dirtyScenes.Count > 0) + { + throw new InvalidOperationException( + "Save or discard dirty Scenes before A/B rendering: " + + string.Join(", ", dirtyScenes)); + } + } + + private static string GetPackageVersion(Assembly assembly) + { + try + { + return UnityEditor.PackageManager.PackageInfo + .FindForAssembly(assembly)?.version ?? + assembly.GetName().Version?.ToString() ?? + "unknown"; + } + catch + { + return assembly.GetName().Version?.ToString() ?? "unknown"; + } + } + + private static DateTime ParseUtcOrNow(string value) + { + return DateTime.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var parsed) + ? parsed.ToUniversalTime() + : DateTime.UtcNow; + } + + private static string GetProjectRoot() + { + return Directory.GetParent(Application.dataPath)?.FullName + ?? throw new InvalidOperationException( + "Unable to resolve the Unity project root."); + } + + private static string GetDefaultOutputRoot() + { + return Path.Combine( + GetProjectRoot(), + "Recordings", + "CWAI_AB"); + } + + private static string GetActiveJobPath() + { + return Path.Combine( + GetProjectRoot(), + "Library", + "CWAI", + "ABRender", + ActiveJobFileName); + } + + [Serializable] + private sealed class StartResponse + { + public string jobId; + public string manifestPath; + public string runDirectory; + } + } +} diff --git a/CameraAI~/Editor/AICameraABRenderRunner.cs.meta b/CameraAI~/Editor/AICameraABRenderRunner.cs.meta new file mode 100644 index 0000000..3bd4438 --- /dev/null +++ b/CameraAI~/Editor/AICameraABRenderRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 249ad3cd8eec495a8419e994e185a41b diff --git a/CameraAI~/Editor/AICameraABRenderWindow.cs b/CameraAI~/Editor/AICameraABRenderWindow.cs new file mode 100644 index 0000000..9a59875 --- /dev/null +++ b/CameraAI~/Editor/AICameraABRenderWindow.cs @@ -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( + "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; + } + } + } + } + } +} diff --git a/CameraAI~/Editor/AICameraABRenderWindow.cs.meta b/CameraAI~/Editor/AICameraABRenderWindow.cs.meta new file mode 100644 index 0000000..d9ab57e --- /dev/null +++ b/CameraAI~/Editor/AICameraABRenderWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e32ae9d4a42347859c368bc701901af7 diff --git a/CameraAI~/Editor/AICameraCliRunner.cs b/CameraAI~/Editor/AICameraCliRunner.cs new file mode 100644 index 0000000..e6ddab6 --- /dev/null +++ b/CameraAI~/Editor/AICameraCliRunner.cs @@ -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 messages = + new ConcurrentQueue(); + + 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(); + 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 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(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 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 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 arguments, + string cancellationFile) + { + ExecutablePath = executablePath; + WorkingDirectory = workingDirectory; + Arguments = arguments; + CancellationFile = cancellationFile; + } + + internal string ExecutablePath { get; } + internal string WorkingDirectory { get; } + internal IReadOnlyList 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; } + } + } +} diff --git a/CameraAI~/Editor/AICameraCliRunner.cs.meta b/CameraAI~/Editor/AICameraCliRunner.cs.meta new file mode 100644 index 0000000..cbe3c71 --- /dev/null +++ b/CameraAI~/Editor/AICameraCliRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8bdd831f8794ea18ca288f3745462b1 diff --git a/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs b/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs new file mode 100644 index 0000000..e83edde --- /dev/null +++ b/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs @@ -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 +{ + /// + /// 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. + /// + 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:"; + + /// + /// Captures one correction snapshot for the active AI preview belonging + /// to . The returned value is the full + /// path of the newly written JSON file. + /// + 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( + 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( + 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() + .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( + 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 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; + } + } +} diff --git a/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta b/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta new file mode 100644 index 0000000..757fe8d --- /dev/null +++ b/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3ef4feff8a1c42a0be6affcf5d6429db diff --git a/CameraAI~/Editor/AICameraGeneratorWindow.UI.cs b/CameraAI~/Editor/AICameraGeneratorWindow.UI.cs new file mode 100644 index 0000000..3ee6de0 --- /dev/null +++ b/CameraAI~/Editor/AICameraGeneratorWindow.UI.cs @@ -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(); + 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(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(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( + candidate) + : AssetDatabase.LoadAssetAtPath(candidate); + if (asset != null) + { + return candidate; + } + } + + return string.Empty; + } + + private void CacheGeneratorUiElements() + { + _generatorUiRoot = RequireUi("camera-generator-root"); + _mainScroll = RequireUi("main-scroll"); + _readinessCard = RequireUi("readiness-card"); + _readinessTitle = RequireUi