diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c59abb..4d63006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.1.6 + +- Updated the separately installable Camera AI package to 0.4.2 and the frozen + Windows worker to 0.1.2. +- Fixed duplicate script GUIDs that could exclude Camera AI package sources from + the Unity editor assembly. +- Added YAMO-calibrated deceleration/acceleration around meaningful direction + changes and rendered end-to-start adjacent-shot transition scoring. +- Added C2-continuous braking seams, single-pass turn processing, and a + source-relative acceleration/jerk guard that demotes unsafe rewrites. +- Preserved generated braking curves in the editable Timeline simplification + preset and added regression coverage for 45-degree and 90-degree turns. + ## 0.1.5 - Added the separately installable `Mingle Camera Work AI` package under diff --git a/CameraAI~/CHANGELOG.md b/CameraAI~/CHANGELOG.md index b586066..7668bea 100644 --- a/CameraAI~/CHANGELOG.md +++ b/CameraAI~/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.4.2 - 2026-08-03 + +- Fixed Unity assembly exclusion caused by duplicate legacy/package script GUIDs. +- Added YAMO-calibrated, angle-aware camera translation kinematics: concentrated + direction changes slow before the turn and accelerate afterward, while smooth + arcs and naturally eased motion remain untouched. +- Applied the kinematic pass exactly once per shot: Orbit reversals are measured + in subject-relative space and other motion families in final world space. +- Made the braking window C2-continuous at its outer seams, removed cumulative- + minimum speed kinks, and rejected any rewrite that introduces an acceleration + or jerk regression instead of chasing newly-created seam events. +- Added rendered end-to-start transition scoring against the actually selected + adjacent cameras, including both neighbours during selected-shot regeneration. +- Hard-rejected only perceptible near-duplicate jump cuts and unbridged extreme + axis crossings; screen jumps and opposing motion remain soft ranking signals. +- Preserved the braking envelope around 40-degree-or-greater direction changes + when editable Unity animation curves are simplified. +- Bumped the packaged worker to 0.1.2 and invalidated stale candidate caches for + the new transition and trajectory policies. + ## 0.4.1 - 2026-08-03 - Added the UXML/USS camera-generation workflow with a compact narrow-dock diff --git a/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md index b2f4b39..2ef492e 100644 --- a/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md +++ b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md @@ -17,7 +17,7 @@ must remain together. 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 +https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.6 ``` The initial package download is large because the frozen Windows worker is diff --git a/CameraAI~/Editor/AICameraCliRunner.cs.meta b/CameraAI~/Editor/AICameraCliRunner.cs.meta index cbe3c71..81ffd73 100644 --- a/CameraAI~/Editor/AICameraCliRunner.cs.meta +++ b/CameraAI~/Editor/AICameraCliRunner.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: f8bdd831f8794ea18ca288f3745462b1 +guid: 572fff6d310f4e669243b957117466c1 diff --git a/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta b/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta index 757fe8d..0b3f495 100644 --- a/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta +++ b/CameraAI~/Editor/AICameraCutCorrectionRecorder.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 3ef4feff8a1c42a0be6affcf5d6429db +guid: c0bd869f0b114cca884580f0d6229b19 diff --git a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs index ae0901a..2d0279d 100644 --- a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs +++ b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs @@ -41,6 +41,15 @@ namespace Streamingle.Editor private const string PreviewGenerationProvenanceName = "AI Camera Preview Generation Provenance"; private const double CutBoundaryGuardSeconds = 0.000001; + private const int DirectionChangeDetectionFlankFrames = 5; + // Matches the worker's 0.35-second braking radius at 60 fps. + private const int DirectionChangePreserveRadiusFrames = 21; + private const float DirectionChangeMinimumSpeedMetersPerSecond = 0.04f; + // Preserve the generated braking envelope for every concentrated turn + // covered by the worker policy (40 degrees and above), not only an + // almost complete reversal. Linear curve simplification may otherwise + // collapse a mild 40--55 or clear 60/90-degree slowdown into a kink. + private const float DirectionChangeMaximumCosine = 0.76604444f; public const CurveSimplificationPreset DefaultCurveSimplificationPreset = CurveSimplificationPreset.Balanced; @@ -2451,6 +2460,7 @@ namespace Streamingle.Editor AddScalarExtrema(values.Count, index => values[index].x, required); AddScalarExtrema(values.Count, index => values[index].y, required); AddScalarExtrema(values.Count, index => values[index].z, required); + AddDirectionChangeNeighborhoods(times, values, required); return SimplifyIndices( times, maximumError, @@ -2593,6 +2603,61 @@ namespace Streamingle.Editor indices.Add(maximumIndex); } + private static void AddDirectionChangeNeighborhoods( + IReadOnlyList times, + IReadOnlyList values, + ISet indices) + { + var flank = DirectionChangeDetectionFlankFrames; + if (values.Count < flank * 2 + 1) + { + return; + } + + for (var center = flank; center + flank < values.Count; center++) + { + var beforeDuration = times[center] - times[center - flank]; + var afterDuration = times[center + flank] - times[center]; + if (beforeDuration <= double.Epsilon || + afterDuration <= double.Epsilon) + { + continue; + } + + var beforeVelocity = + (values[center] - values[center - flank]) / + (float)beforeDuration; + var afterVelocity = + (values[center + flank] - values[center]) / + (float)afterDuration; + var beforeSpeed = beforeVelocity.magnitude; + var afterSpeed = afterVelocity.magnitude; + if (Mathf.Min(beforeSpeed, afterSpeed) < + DirectionChangeMinimumSpeedMetersPerSecond) + { + continue; + } + + var cosine = Vector3.Dot(beforeVelocity, afterVelocity) / + (beforeSpeed * afterSpeed); + if (cosine > DirectionChangeMaximumCosine) + { + continue; + } + + var start = Mathf.Max( + 0, + center - DirectionChangePreserveRadiusFrames); + var end = Mathf.Min( + values.Count - 1, + center + DirectionChangePreserveRadiusFrames); + for (var index = start; index <= end; index++) + { + indices.Add(index); + } + } + } + private static Quaternion[] MakeQuaternionSequenceContinuous( IReadOnlyList rotations) { diff --git a/CameraAI~/README.md b/CameraAI~/README.md index 57fb20b..a477353 100644 --- a/CameraAI~/README.md +++ b/CameraAI~/README.md @@ -16,7 +16,7 @@ Cinemachine Track이나 기존 카메라 애니메이션은 필요하지 않으 Unity Package Manager의 `Add package from git URL`에는 다음 주소를 사용할 수 있습니다. -`https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.5` +`https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.6` 배포 패키지에는 Windows x64용 `CWCameraWorker` 폴더 전체가 포함됩니다. Python은 따로 설치하지 않아도 되지만, Git 패키지의 대용량 바이너리를 받으려면 diff --git a/CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs.meta b/CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs.meta index 3937d77..4aa9fd4 100644 --- a/CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs.meta +++ b/CameraAI~/Tests/Editor/AICameraCliRunnerTests.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 30c854c9afbd493f8b2e660c23584f83 +guid: f3fb35740c814042bce3842307136af2 diff --git a/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs b/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs index b7c84f9..a120da4 100644 --- a/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs +++ b/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs @@ -155,6 +155,168 @@ namespace Streamingle.Editor Is.LessThan(exact.DutchIndices.Length)); } + [Test] + public void EditablePresetPreservesStopThroughDirectionChangeKeys() + { + const int count = 121; + const int center = 60; + const int preserveRadius = 21; + var times = new double[count]; + var positions = new Vector3[count]; + var rotations = new Quaternion[count]; + var fieldOfView = new float[count]; + var dutch = new float[count]; + for (var index = 0; index < count; index++) + { + times[index] = index / 60.0; + float x; + if (index <= center) + { + var t = index / (float)center; + x = -t * t * t + t * t + t; + } + else + { + var t = (index - center) / + (float)(count - 1 - center); + var progress = -t * t * t + 2f * t * t; + x = 1f - progress; + } + + positions[index] = new Vector3(x, 1.6f, -4f); + rotations[index] = Quaternion.identity; + fieldOfView[index] = 40f; + dutch[index] = 0f; + } + + var result = Simplify( + new CameraSamples( + times, + positions, + rotations, + fieldOfView, + dutch), + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); + + for (var index = center - preserveRadius; + index <= center + preserveRadius; + index++) + { + Assert.That( + result.PositionIndices, + Does.Contain(index), + $"Direction-change key {index} was simplified away."); + } + } + + [Test] + public void EditablePresetPreservesBrakedRightAngleTurnKeys() + { + const int count = 121; + const int center = 60; + const int preserveRadius = 21; + var times = new double[count]; + var positions = new Vector3[count]; + var rotations = new Quaternion[count]; + var fieldOfView = new float[count]; + var dutch = new float[count]; + for (var index = 0; index < count; index++) + { + times[index] = index / 60.0; + if (index <= center) + { + var t = index / (float)center; + var progress = -t * t * t + t * t + t; + positions[index] = new Vector3(progress, 1.6f, -4f); + } + else + { + var t = (index - center) / + (float)(count - 1 - center); + var progress = -t * t * t + 2f * t * t; + positions[index] = new Vector3(1f, 1.6f, -4f + progress); + } + + rotations[index] = Quaternion.identity; + fieldOfView[index] = 40f; + dutch[index] = 0f; + } + + var result = Simplify( + new CameraSamples( + times, + positions, + rotations, + fieldOfView, + dutch), + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); + + for (var index = center - preserveRadius; + index <= center + preserveRadius; + index++) + { + Assert.That( + result.PositionIndices, + Does.Contain(index), + $"Right-angle braking key {index} was simplified away."); + } + } + + [Test] + public void EditablePresetPreservesBrakedFortyFiveDegreeTurnKeys() + { + const int count = 121; + const int center = 60; + const int preserveRadius = 21; + var times = new double[count]; + var positions = new Vector3[count]; + var rotations = new Quaternion[count]; + var fieldOfView = new float[count]; + var dutch = new float[count]; + var outgoing = new Vector3(1f, 0f, 1f).normalized; + for (var index = 0; index < count; index++) + { + times[index] = index / 60.0; + if (index <= center) + { + var t = index / (float)center; + var progress = -t * t * t + t * t + t; + positions[index] = new Vector3(progress, 1.6f, -4f); + } + else + { + var t = (index - center) / + (float)(count - 1 - center); + var progress = -t * t * t + 2f * t * t; + positions[index] = + new Vector3(1f, 1.6f, -4f) + outgoing * progress; + } + + rotations[index] = Quaternion.identity; + fieldOfView[index] = 40f; + dutch[index] = 0f; + } + + var result = Simplify( + new CameraSamples( + times, + positions, + rotations, + fieldOfView, + dutch), + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); + + for (var index = center - preserveRadius; + index <= center + preserveRadius; + index++) + { + Assert.That( + result.PositionIndices, + Does.Contain(index), + $"Forty-five-degree braking key {index} was simplified away."); + } + } + [Test] public void ExplicitDirectorScopesSeparateSameNamedSongDirectors() { diff --git a/CameraAI~/Tests/Editor/AICameraCutCorrectionRecorderTests.cs.meta b/CameraAI~/Tests/Editor/AICameraCutCorrectionRecorderTests.cs.meta index 1559669..b8cba47 100644 --- a/CameraAI~/Tests/Editor/AICameraCutCorrectionRecorderTests.cs.meta +++ b/CameraAI~/Tests/Editor/AICameraCutCorrectionRecorderTests.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 1b88c37fe4bd4e48818660d18810ae43 +guid: 8e37a17d5d9e451dad2a8bbc9f1d2f8a diff --git a/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs.meta b/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs.meta index 47ff159..c3203b7 100644 --- a/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs.meta +++ b/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 7a3ea2034f8a4ce1b45f1673472e5e6f +guid: 39679e5d695f47288b75d9429735481a diff --git a/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs.meta b/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs.meta index a5a19fd..fbdc5d2 100644 --- a/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs.meta +++ b/CameraAI~/Tests/Editor/AICameraTimelinePreviewImporterTests.cs.meta @@ -1,2 +1,2 @@ fileFormatVersion: 2 -guid: 74ed53c76a0a4c6c82ea504d79132c63 +guid: a959d1021a8c4fce976089fee89d1978 diff --git a/CameraAI~/Tests/Editor/TimelineCameraDatasetExporterTests.cs.meta b/CameraAI~/Tests/Editor/TimelineCameraDatasetExporterTests.cs.meta index 4080507..b741b99 100644 --- a/CameraAI~/Tests/Editor/TimelineCameraDatasetExporterTests.cs.meta +++ b/CameraAI~/Tests/Editor/TimelineCameraDatasetExporterTests.cs.meta @@ -1,3 +1,3 @@ fileFormatVersion: 2 -guid: b557e27541794769bd7798589e9c5a9b +guid: af51bc7a0bfd4d4492a7da670bb46a59 timeCreated: 1785484800 diff --git a/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe b/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe index ac31a7e..c3086a8 100644 --- a/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe +++ b/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eeeb7f65499ded6d9768628e60e8222ff34f3cadabd47ac367af82b2d8580759 -size 23420291 +oid sha256:c30ecd06354b3051e9a24c39d47e6124b4ca2aeaff6ec5735d3596fe4bf8d935 +size 23443704 diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip b/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip index a76dd6f..2b3e0bb 100644 Binary files a/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip and b/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip differ diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_runtime/cw_camera_worker_build_identity.json b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_runtime/cw_camera_worker_build_identity.json index 7c49d39..e468b96 100644 --- a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_runtime/cw_camera_worker_build_identity.json +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_runtime/cw_camera_worker_build_identity.json @@ -1,15 +1,17 @@ { "schemaVersion": "cw-camera-worker-build-identity-v1", - "workerVersion": "0.1.0", - "createdUtc": "2026-08-02T09:05:09.155787+00:00", + "workerVersion": "0.1.2", + "createdUtc": "2026-08-02T21:19:54.902869+00:00", "python": "3.10.11", "sourceRootRelative": "cwai_sources/repository", "sourceSha256": { + "MachineLearning/CameraDirector/adjacent_transition.py": "6c1b62c2996960af23d62e9c3acd3114600effda03273b006f7076c467c4bdfa", "MachineLearning/CameraDirector/build_hybrid_preparation_cache.py": "164a3574d5fad05a627bf6d0c6169d42039043bf9a2cd62effbd978359e828f2", + "MachineLearning/CameraDirector/camera_kinematics.py": "c7b85616ef44a0f28f11702459d67d78aad7cf26ed00c2548ecb78b06fc27025", "MachineLearning/CameraDirector/camera_runtime_data.py": "f787b84257aef4a71905198ab2601673fbe639ca5c37e3a957de12116dceab34", "MachineLearning/CameraDirector/cw_camera_runtime.py": "c2dff5cf41b8b487272d20e3847b556537f597b185a0f4b0d85ca577b4a51709", "MachineLearning/CameraDirector/data_driven_cut_planner.py": "b6d48d03f8725aa8327abe81afa483c43edc368e7dee9f9e7228e21982f71ad2", - "MachineLearning/CameraDirector/generate_hybrid.py": "928b20dae7414be577f193319c3816f464286e2ce20366a8e743995e6b6e4dd6", + "MachineLearning/CameraDirector/generate_hybrid.py": "4f3eeb278d9f516c1c4de30b312c2daf0fe33692d912cedd8a3240533e75d467", "MachineLearning/CameraDirector/hybrid_candidate_cache.py": "a0a5b6a8f612f18cb2875394f17ed89950e1e62e38c1226457d9e35848f6e380", "MachineLearning/CameraDirector/hybrid_cut_reference.py": "1f136f5514fddf04b21dfb9220d9559a1fff8cd7c17388d81875edbd82a672ac", "MachineLearning/CameraDirector/hybrid_preparation_cache.py": "1b0df53124109dee2d744777979a0553999cb276cb6f538a17ddce6516e3da8b", @@ -19,9 +21,11 @@ "MachineLearning/CameraDirector/train.py": "4e0fcfd88ec2c241e66497f109420a4dc3cf958d835f78bef4279f58e6d9a8b1" }, "preparationLogicIdentifier": "123407b6741f05418a4f3237790492d9b74e13b87090acff2b1a07cf9de2912f", - "candidateLogicIdentifier": "6514a8e9f0d25b310200ff62e914f9449814dfc24f2b50cfd1b5e417213e88cf", - "generationCodeIdentifier": "1f50c81bd9ecf865d08cd52d3c1c2ab1b910aa33e51752737e345fdd51856ee8", + "candidateLogicIdentifier": "77793481c02a9b4eda52d009a771444995bd59819717bb98aa46769ec61d4d88", + "generationCodeIdentifier": "95920673d29794c2a7e2866ff8aa3b57089d1cda4f4f0023ec555139efa97bb7", "generationCodeFiles": [ + "adjacent_transition.py", + "camera_kinematics.py", "camera_runtime_data.py", "data_driven_cut_planner.py", "generate_hybrid.py", diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/adjacent_transition.py b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/adjacent_transition.py new file mode 100644 index 0000000..5c5bbd3 --- /dev/null +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/adjacent_transition.py @@ -0,0 +1,450 @@ +"""Auditable pairwise camera-cut transition scoring. + +The generator already decides *when* to cut from music and performance data. +This module evaluates the complementary editorial question: whether the end of +one selected camera and the beginning of the next selected camera form a clear, +intentional cut. It deliberately uses only compact rendered boundary state so +the same policy can be applied to a full generation and to both neighbours of a +single-shot regeneration. +""" + +from __future__ import annotations + +import math +from typing import Mapping + +import numpy as np + + +TRANSITION_STATE_SCHEMA_VERSION = "camera-transition-boundary-state-v1" +TRANSITION_SCORE_SCHEMA_VERSION = "pairwise-camera-transition-score-v1" +TRANSITION_POLICY_VERSION = "rendered-end-start-editorial-continuity-v3" + +_MINIMUM_MOVING_SPEED_MPS = 0.12 +_OPPOSING_MOTION_START_DEGREES = 110.0 + + +def _finite_array( + value: object, + shape: tuple[int, ...], + label: str, +) -> np.ndarray: + result = np.asarray(value, dtype=np.float64) + if result.shape != shape or not np.isfinite(result).all(): + raise ValueError(f"{label} must be finite with shape {shape}.") + return result + + +def _normalized(value: np.ndarray) -> np.ndarray: + length = float(np.linalg.norm(value)) + if length <= 1e-9: + return np.zeros_like(value, dtype=np.float64) + return np.asarray(value, dtype=np.float64) / length + + +def _vector_angle_degrees(first: np.ndarray, second: np.ndarray) -> float: + first_unit = _normalized(first) + second_unit = _normalized(second) + if not np.any(first_unit) or not np.any(second_unit): + return 0.0 + cosine = float(np.clip(np.dot(first_unit, second_unit), -1.0, 1.0)) + return float(np.degrees(np.arccos(cosine))) + + +def _quaternion_angle_degrees(first: np.ndarray, second: np.ndarray) -> float: + first_unit = _normalized(first) + second_unit = _normalized(second) + if not np.any(first_unit) or not np.any(second_unit): + return 0.0 + cosine = float(np.clip(abs(np.dot(first_unit, second_unit)), 0.0, 1.0)) + return float(np.degrees(2.0 * np.arccos(cosine))) + + +def _robust_boundary_velocity( + values: np.ndarray, + sample_rate: float, + *, + entering: bool, + window_frames: int, +) -> np.ndarray: + if len(values) < 2: + return np.zeros(3, dtype=np.float64) + steps = np.diff(values, axis=0) * sample_rate + width = min(max(1, int(window_frames) - 1), len(steps)) + selected = steps[:width] if entering else steps[-width:] + return np.median(selected, axis=0).astype(np.float64) + + +def _boundary_summary( + position: np.ndarray, + rotation: np.ndarray, + fov: np.ndarray, + target: np.ndarray, + composition: np.ndarray, + sample_rate: float, + *, + entering: bool, + window_frames: int, +) -> dict[str, object]: + width = min(max(1, int(window_frames)), len(position)) + frame_slice = slice(0, width) if entering else slice(len(position) - width, None) + endpoint = 0 if entering else -1 + camera_position = np.median(position[frame_slice], axis=0) + target_position = np.median(target[frame_slice], axis=0) + relative_path = position - target + subject_values = composition[frame_slice] + valid_subject = ( + np.isfinite(subject_values[:, :3]).all(axis=1) + & (subject_values[:, 3] >= 0.5) + ) + if np.any(valid_subject): + subject_uv = np.median(subject_values[valid_subject, :2], axis=0) + subject_height = float(np.median(subject_values[valid_subject, 2])) + else: + subject_uv = np.asarray([0.5, 0.5], dtype=np.float64) + subject_height = 0.0 + world_velocity = _robust_boundary_velocity( + position, + sample_rate, + entering=entering, + window_frames=window_frames, + ) + relative_velocity = _robust_boundary_velocity( + relative_path, + sample_rate, + entering=entering, + window_frames=window_frames, + ) + return { + "cameraPosition": camera_position.tolist(), + "cameraRotation": _normalized(rotation[endpoint]).tolist(), + "targetPosition": target_position.tolist(), + "cameraRelativePosition": (camera_position - target_position).tolist(), + "fovDegrees": float(np.median(fov[frame_slice])), + "subjectUv": subject_uv.tolist(), + "subjectHeightRatio": subject_height, + "worldVelocityMps": world_velocity.tolist(), + "relativeVelocityMps": relative_velocity.tolist(), + "worldSpeedMps": float(np.linalg.norm(world_velocity)), + "relativeSpeedMps": float(np.linalg.norm(relative_velocity)), + } + + +def build_transition_state( + position: np.ndarray, + rotation: np.ndarray, + fov: np.ndarray, + target: np.ndarray, + composition: np.ndarray, + shot_type: str, + motion_type: str, + *, + sample_rate: float = 60.0, + boundary_window_frames: int = 6, +) -> dict[str, object]: + """Build JSON-safe rendered boundary state for one camera candidate.""" + + position = np.asarray(position, dtype=np.float64) + rotation = np.asarray(rotation, dtype=np.float64) + fov = np.asarray(fov, dtype=np.float64) + target = np.asarray(target, dtype=np.float64) + composition = np.asarray(composition, dtype=np.float64) + frame_count = len(position) + if frame_count < 1: + raise ValueError("A transition state requires at least one frame.") + expected = { + "position": (frame_count, 3), + "rotation": (frame_count, 4), + "fov": (frame_count,), + "target": (frame_count, 3), + "composition": (frame_count, 4), + } + observed = { + "position": position.shape, + "rotation": rotation.shape, + "fov": fov.shape, + "target": target.shape, + "composition": composition.shape, + } + if expected != observed: + raise ValueError( + "Transition arrays have incompatible shapes: " + repr(observed) + ) + if not all( + np.isfinite(values).all() + for values in (position, rotation, fov, target, composition) + ): + raise ValueError("Transition arrays must not contain NaN or Inf.") + if not math.isfinite(sample_rate) or sample_rate <= 0.0: + raise ValueError("sample_rate must be finite and positive.") + if int(boundary_window_frames) < 1: + raise ValueError("boundary_window_frames must be positive.") + return { + "schemaVersion": TRANSITION_STATE_SCHEMA_VERSION, + "shotType": str(shot_type), + "motionType": str(motion_type), + "frameCount": frame_count, + "start": _boundary_summary( + position, + rotation, + fov, + target, + composition, + sample_rate, + entering=True, + window_frames=boundary_window_frames, + ), + "end": _boundary_summary( + position, + rotation, + fov, + target, + composition, + sample_rate, + entering=False, + window_frames=boundary_window_frames, + ), + } + + +def _validated_endpoint(state: Mapping[str, object], endpoint: str) -> dict[str, object]: + if state.get("schemaVersion") != TRANSITION_STATE_SCHEMA_VERSION: + raise ValueError("Unsupported camera transition-state schema.") + value = state.get(endpoint) + if not isinstance(value, Mapping): + raise ValueError(f"Transition state is missing {endpoint!r}.") + result = dict(value) + for key, shape in ( + ("cameraPosition", (3,)), + ("cameraRotation", (4,)), + ("targetPosition", (3,)), + ("cameraRelativePosition", (3,)), + ("subjectUv", (2,)), + ("worldVelocityMps", (3,)), + ("relativeVelocityMps", (3,)), + ): + _finite_array(result.get(key), shape, key) + for key in ( + "fovDegrees", + "subjectHeightRatio", + "worldSpeedMps", + "relativeSpeedMps", + ): + raw = result.get(key) + if isinstance(raw, bool) or not isinstance(raw, (int, float)) or not math.isfinite(raw): + raise ValueError(f"{key} must be finite.") + return result + + +def score_pairwise_transition( + previous_state: Mapping[str, object], + next_state: Mapping[str, object], +) -> dict[str, object]: + """Score the rendered END -> START relationship of two hard-cut shots. + + A hard cut does not need physically continuous camera velocity. The score + instead penalizes edits that look accidental: same-size near-identical + geometry (jump cut), abrupt subject displacement, an unbridged 180-degree + axis flip, and two fast trajectories that reverse direction at the cut. + Subject scale contrast is measured but intentionally not penalized. + """ + + previous = _validated_endpoint(previous_state, "end") + following = _validated_endpoint(next_state, "start") + previous_relative = _finite_array( + previous["cameraRelativePosition"], (3,), "cameraRelativePosition" + ) + next_relative = _finite_array( + following["cameraRelativePosition"], (3,), "cameraRelativePosition" + ) + geometry_angle = _vector_angle_degrees(previous_relative, next_relative) + rotation_angle = _quaternion_angle_degrees( + _finite_array(previous["cameraRotation"], (4,), "cameraRotation"), + _finite_array(following["cameraRotation"], (4,), "cameraRotation"), + ) + fov_delta = abs(float(previous["fovDegrees"]) - float(following["fovDegrees"])) + subject_jump = float( + np.linalg.norm( + _finite_array(previous["subjectUv"], (2,), "subjectUv") + - _finite_array(following["subjectUv"], (2,), "subjectUv") + ) + ) + previous_height = max(float(previous["subjectHeightRatio"]), 1e-6) + next_height = max(float(following["subjectHeightRatio"]), 1e-6) + scale_ratio = max(previous_height, next_height) / min( + previous_height, next_height + ) + previous_velocity = _finite_array( + previous["relativeVelocityMps"], (3,), "relativeVelocityMps" + ) + next_velocity = _finite_array( + following["relativeVelocityMps"], (3,), "relativeVelocityMps" + ) + previous_speed = float(previous["relativeSpeedMps"]) + next_speed = float(following["relativeSpeedMps"]) + velocity_angle = _vector_angle_degrees(previous_velocity, next_velocity) + + same_shot_type = str(previous_state.get("shotType")) == str( + next_state.get("shotType") + ) + visual_change = max( + geometry_angle / 30.0, + rotation_angle / 30.0, + fov_delta / 8.0, + abs(math.log(scale_ratio)) / math.log(1.35), + ) + near_duplicate_penalty = max(0.0, 1.0 - visual_change) * ( + 2.0 if same_shot_type else 0.8 + ) + # Use what will actually be seen, not the declared W/M/C label. A declared + # medium -> close cut is still a jump cut when the rendered subject scale, + # camera axis, view rotation and screen position barely change. FOV is only + # diagnostic here because distance can compensate for a large lens change. + # A mathematically identical boundary is an invisible continuation, not a + # jump cut. In the YAMO authored audit, most broadly "similar" edges were + # in this sub-perceptual band. Keep them available (with a soft sameness + # cost) and hard-gate only a small-but-visible discontinuity. + invisible_continuation = bool( + geometry_angle < 2.0 + and rotation_angle < 2.0 + and fov_delta < 1.0 + and scale_ratio < 1.08 + and subject_jump < 0.03 + ) + jump_cut_risk = bool( + geometry_angle < 15.0 + and rotation_angle < 20.0 + and scale_ratio < 1.20 + and subject_jump < 0.12 + and not invisible_continuation + ) + if jump_cut_risk: + near_duplicate_penalty = max(near_duplicate_penalty, 1.75) + screen_position_penalty = max(0.0, subject_jump - 0.14) / 0.20 * 1.25 + neither_is_wide = "wide" not in { + str(previous_state.get("shotType", "")).lower(), + str(next_state.get("shotType", "")).lower(), + } + axis_crossing_risk = bool(neither_is_wide and geometry_angle > 150.0) + axis_crossing_penalty = ( + max(0.0, geometry_angle - 150.0) / 30.0 * 0.9 + if neither_is_wide + else 0.0 + ) + # Large scale contrast is often the editorial *reason* for a cut. YAMO's + # authored examples deliberately use roughly 2.6x--6.9x changes on the + # same axis, so retain the metric/flag without suppressing that energy. + extreme_scale_penalty = 0.0 + both_moving = ( + previous_speed >= _MINIMUM_MOVING_SPEED_MPS + and next_speed >= _MINIMUM_MOVING_SPEED_MPS + ) + opposing_motion_risk = bool( + both_moving and velocity_angle > _OPPOSING_MOTION_START_DEGREES + ) + opposing_motion_penalty = ( + max(0.0, velocity_angle - _OPPOSING_MOTION_START_DEGREES) + / (180.0 - _OPPOSING_MOTION_START_DEGREES) + * min(1.5, math.sqrt(previous_speed * next_speed) / 0.35) + * 1.35 + if both_moving + else 0.0 + ) + orbit_transition = "orbit" in str( + previous_state.get("motionType", "") + ).lower() or "orbit" in str(next_state.get("motionType", "")).lower() + if orbit_transition: + opposing_motion_penalty *= 0.35 + elif visual_change >= 1.0: + # A clear angle/rotation/FOV/scale contrast makes the edit legible; the + # velocity mismatch remains a soft preference instead of a hard brake. + opposing_motion_penalty *= 0.65 + total_penalty = float( + near_duplicate_penalty + + screen_position_penalty + + axis_crossing_penalty + + extreme_scale_penalty + + opposing_motion_penalty + ) + flags = [] + if jump_cut_risk: + flags.append("near_duplicate_jump_cut") + if axis_crossing_risk: + flags.append("unbridged_axis_crossing") + if opposing_motion_risk: + flags.append("opposing_high_speed_motion") + if subject_jump > 0.22: + flags.append("large_subject_screen_jump") + if scale_ratio > 2.5: + flags.append("extreme_subject_scale_jump") + return { + "schemaVersion": TRANSITION_SCORE_SCHEMA_VERSION, + "policyVersion": TRANSITION_POLICY_VERSION, + "penalty": total_penalty, + # A neither-wide 180-degree-axis crossing is not made acceptable by a + # low numeric penalty. It needs a bridging wide/neutral shot, so gate + # it exactly like a rendered near-duplicate jump cut. The caller's + # deterministic all-fail fallback still guarantees generation. + # Screen displacement and boundary-motion disagreement remain useful + # ranking preferences, but authored YAMO cuts use both expressively. + # Only the two categorical editorial failures are hard-gated. + "passed": bool(not jump_cut_risk and not axis_crossing_risk), + "flags": flags, + "metrics": { + "cameraGeometryAngleDegrees": geometry_angle, + "cameraRotationAngleDegrees": rotation_angle, + "fovDeltaDegrees": fov_delta, + "subjectUvJump": subject_jump, + "subjectScaleRatio": scale_ratio, + "relativeVelocityAngleDegrees": velocity_angle, + "previousRelativeSpeedMps": previous_speed, + "nextRelativeSpeedMps": next_speed, + "invisibleContinuation": invisible_continuation, + }, + "components": { + "nearDuplicate": float(near_duplicate_penalty), + "screenPosition": float(screen_position_penalty), + "axisCrossing": float(axis_crossing_penalty), + "extremeScale": float(extreme_scale_penalty), + "opposingMotion": float(opposing_motion_penalty), + }, + } + + +def score_transition_context( + candidate_state: Mapping[str, object], + *, + previous_state: Mapping[str, object] | None = None, + next_state: Mapping[str, object] | None = None, +) -> dict[str, object]: + """Score either or both neighbours of one candidate shot.""" + + previous_score = ( + score_pairwise_transition(previous_state, candidate_state) + if previous_state is not None + else None + ) + next_score = ( + score_pairwise_transition(candidate_state, next_state) + if next_state is not None + else None + ) + total = sum( + float(score["penalty"]) + for score in (previous_score, next_score) + if score is not None + ) + edge_scores = [ + score + for score in (previous_score, next_score) + if score is not None + ] + return { + "schemaVersion": "camera-transition-context-score-v1", + "policyVersion": TRANSITION_POLICY_VERSION, + "previous": previous_score, + "next": next_score, + "penalty": float(total), + "neighbourCount": len(edge_scores), + "passed": all(bool(score["passed"]) for score in edge_scores), + } diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/camera_kinematics.py b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/camera_kinematics.py new file mode 100644 index 0000000..e4115b9 --- /dev/null +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/camera_kinematics.py @@ -0,0 +1,843 @@ +"""Camera-motion kinematics shared by generation and offline evaluation. + +The retrieved camera templates are artist-authored curves, but resampling and +retargeting can turn an otherwise intentional direction change into a sharp +per-frame corner. This module rebuilds only a short neighbourhood around that +corner: the camera decelerates into the turn and accelerates away without a +compensating speed spike. + +A continuous one-direction orbit remains untouched. Only a persistent orbit +direction reversal (CW to CCW or the reverse) is treated as a turn event. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +import numpy as np + + +TRANSLATION_KINEMATIC_POLICY_VERSION = ( + "yamo-angle-aware-turn-v4-c2-continuity-guard" +) +DEFAULT_REVERSAL_ANGLE_DEGREES = 40.0 +DEFAULT_STOP_SPEED_RATIO = 0.12 +DEFAULT_REVERSAL_WINDOW_SECONDS = 0.35 +DEFAULT_PERSISTENCE_SECONDS = 0.08 +DEFAULT_MINIMUM_FLANK_SPEED_MPS = 0.08 +DEFAULT_MINIMUM_FLANK_TRAVEL_METERS = 0.006 +# With the five-frame persistence window, an ideal constant-curvature path has +# a concentration of 0.20 (one adjacent-frame turn divided by the five-frame +# flank-direction change). Keep a small margin above that value so a fast, +# intentional arc remains untouched while concentrated 40--55 degree corners +# such as the reviewed Shot_020 (0.22) are still eligible. +DEFAULT_MINIMUM_CORNER_CONCENTRATION = 0.21 +DEFAULT_MINIMUM_FLANK_DIRECTION_COHERENCE = 0.85 +DEFAULT_SPEED_RATIO_TOLERANCE = 0.025 +DEFAULT_ANGLE_COMPARISON_TOLERANCE_DEGREES = 1e-3 +DEFAULT_MINIMUM_ORBIT_ANGULAR_SPEED_RADIANS_PER_SECOND = math.radians(2.0) +DEFAULT_MAXIMUM_ENDPOINT_ADJUSTMENT_METERS = 0.75 +DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_RATIO = 1.25 +DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2 = 8.0 +DEFAULT_ACCELERATION_GUARD_FLOOR_MPS2 = 25.0 +DEFAULT_MAXIMUM_JERK_REGRESSION_RATIO = 1.35 +DEFAULT_MAXIMUM_JERK_REGRESSION_DELTA_MPS3 = 350.0 +DEFAULT_JERK_GUARD_FLOOR_MPS3 = 350.0 + + +def turn_speed_ratio_limit(angle_degrees: float) -> float: + """Return an artist-oriented speed target for a sharp direction change. + + A 40--55 degree corner receives only a mild slowdown, a right-angle corner + receives a clear slowdown, and turns at or above 120 degrees approach a + stop. The piecewise curve is deliberately monotonic and inspectable in + metadata. + """ + + if not math.isfinite(angle_degrees): + raise ValueError("angle_degrees must be finite.") + return float( + np.interp( + float(np.clip(angle_degrees, 0.0, 180.0)), + np.asarray( + [0.0, 40.0, 45.0, 50.0, 55.0, 60.0, 90.0, 120.0, 150.0, 180.0] + ), + np.asarray([1.0, 0.90, 0.87, 0.84, 0.80, 0.70, 0.45, 0.12, 0.0, 0.0]), + ) + ) + + +def natural_turn_speed_ratio_limit(angle_degrees: float) -> float: + """Return the preservation threshold for an already eased sharp turn.""" + + if not math.isfinite(angle_degrees): + raise ValueError("angle_degrees must be finite.") + return float( + np.interp( + float(np.clip(angle_degrees, 0.0, 180.0)), + np.asarray( + [0.0, 40.0, 45.0, 50.0, 55.0, 60.0, 90.0, 120.0, 150.0, 180.0] + ), + np.asarray([1.0, 0.90, 0.89, 0.87, 0.86, 0.85, 0.65, 0.40, 0.30, 0.25]), + ) + ) + + +@dataclass(frozen=True) +class TranslationReversal: + """One persistent camera-translation direction-change event. + + The legacy type name is retained because existing generated metadata and + downstream analysis use the ``translation*Reversal*`` field family. + """ + + frame: int + angle_degrees: float + stop_speed_ratio: float + flank_speed_mps: float + target_speed_ratio: float + corner_concentration: float + natural_easing_detected: bool + + @property + def abrupt(self) -> bool: + if self.natural_easing_detected and self.stop_speed_ratio <= ( + natural_turn_speed_ratio_limit(self.angle_degrees) + + DEFAULT_SPEED_RATIO_TOLERANCE + ): + return False + return self.stop_speed_ratio > ( + self.target_speed_ratio + DEFAULT_SPEED_RATIO_TOLERANCE + ) + + +def motion_family(motion_type: str) -> str: + """Return the local kinematic family without importing the shot planner.""" + + normalized = str(motion_type).strip().lower().replace("-", "_") + if normalized in {"static", "fixed", "locked", "stationary", "still"}: + return "static" + if normalized.startswith(("orbit", "arc")): + return "orbit" + if normalized.startswith(("dolly", "push", "pull", "zoom")): + return "dolly" + if normalized.startswith(("truck", "lateral")): + return "truck" + if normalized.startswith(("crane", "pedestal", "boom", "jib")): + return "crane" + if normalized in {"drift", "handheld", "float", "floating"}: + return "drift" + return "unsupported" + + +def _validated_positions(positions_meters: np.ndarray) -> np.ndarray: + positions = np.asarray(positions_meters, dtype=np.float64) + if positions.ndim != 2 or positions.shape[1] != 3 or len(positions) == 0: + raise ValueError("positions_meters must be a non-empty [frame, 3] array.") + if not np.isfinite(positions).all(): + raise ValueError("positions_meters must contain only finite values.") + return positions + + +def _primary_motion_signal(positions: np.ndarray, family: str) -> np.ndarray: + if family == "dolly": + return np.linalg.norm(positions[:, [0, 2]], axis=1) + if family == "truck": + return positions[:, 0] + if family == "crane": + return positions[:, 1] + + # Drift has no declared axis. The principal spatial axis is stable for a + # push-pull or side-to-side drift, including paths whose net delta is near + # zero because they return to their starting point. + centered = positions - np.mean(positions, axis=0, keepdims=True) + if float(np.max(np.linalg.norm(centered, axis=1))) <= 1e-9: + return np.zeros(len(positions), dtype=np.float64) + _, _, axes = np.linalg.svd(centered, full_matrices=False) + return centered @ axes[0] + + +def _angle_degrees(first: np.ndarray, second: np.ndarray) -> float: + denominator = float(np.linalg.norm(first) * np.linalg.norm(second)) + if denominator <= 1e-12: + return 0.0 + cosine = float(np.clip(np.dot(first, second) / denominator, -1.0, 1.0)) + return math.degrees(math.acos(cosine)) + + +def _group_reversal_candidates( + candidates: list[TranslationReversal], + persistence_frames: int, +) -> list[TranslationReversal]: + if not candidates: + return [] + groups: list[list[TranslationReversal]] = [[candidates[0]]] + for candidate in candidates[1:]: + if candidate.frame - groups[-1][-1].frame <= persistence_frames: + groups[-1].append(candidate) + else: + groups.append([candidate]) + events: list[TranslationReversal] = [] + for group in groups: + low_speed_event = min( + group, + key=lambda event: (event.stop_speed_ratio, event.frame), + ) + maximum_angle = max(event.angle_degrees for event in group) + events.append( + TranslationReversal( + frame=low_speed_event.frame, + angle_degrees=maximum_angle, + stop_speed_ratio=low_speed_event.stop_speed_ratio, + flank_speed_mps=low_speed_event.flank_speed_mps, + target_speed_ratio=turn_speed_ratio_limit(maximum_angle), + corner_concentration=max(event.corner_concentration for event in group), + natural_easing_detected=(low_speed_event.natural_easing_detected), + ) + ) + return events + + +def analyze_translational_reversals( + positions_meters: np.ndarray, + motion_type: str, + *, + sample_rate: float = 60.0, + reversal_angle_degrees: float = DEFAULT_REVERSAL_ANGLE_DEGREES, + persistence_seconds: float = DEFAULT_PERSISTENCE_SECONDS, + minimum_flank_speed_mps: float = DEFAULT_MINIMUM_FLANK_SPEED_MPS, + minimum_flank_travel_meters: float = (DEFAULT_MINIMUM_FLANK_TRAVEL_METERS), + minimum_corner_concentration: float = (DEFAULT_MINIMUM_CORNER_CONCENTRATION), + minimum_flank_direction_coherence: float = ( + DEFAULT_MINIMUM_FLANK_DIRECTION_COHERENCE + ), +) -> list[TranslationReversal]: + """Find persistent, concentrated translation turns in one shot. + + The detector requires both sides of a turn to sustain meaningful + travel. Small pose-follow jitter therefore does not become an authored + camera-direction event. Typed motions additionally have to reverse their + declared scalar axis (radius, lateral, or height). + """ + + positions = _validated_positions(positions_meters) + if not math.isfinite(sample_rate) or sample_rate <= 0.0: + raise ValueError("sample_rate must be finite and positive.") + if not 0.0 < reversal_angle_degrees <= 180.0: + raise ValueError("reversal_angle_degrees must be in (0, 180].") + if not math.isfinite(persistence_seconds) or persistence_seconds <= 0.0: + raise ValueError("persistence_seconds must be finite and positive.") + if minimum_flank_speed_mps < 0.0 or not math.isfinite(minimum_flank_speed_mps): + raise ValueError("minimum_flank_speed_mps must be finite and non-negative.") + if minimum_flank_travel_meters < 0.0 or not math.isfinite( + minimum_flank_travel_meters + ): + raise ValueError("minimum_flank_travel_meters must be finite and non-negative.") + if not 0.0 <= minimum_corner_concentration <= 1.0: + raise ValueError("minimum_corner_concentration must be between zero and one.") + if not 0.0 <= minimum_flank_direction_coherence <= 1.0: + raise ValueError( + "minimum_flank_direction_coherence must be between zero and one." + ) + + family = motion_family(motion_type) + if family in {"static", "unsupported"} or len(positions) < 7: + return [] + + persistence_frames = max(2, int(round(persistence_seconds * sample_rate))) + if len(positions) < persistence_frames * 2 + 1: + persistence_frames = max(2, (len(positions) - 1) // 2) + if persistence_frames < 2: + return [] + + velocity = np.diff(positions, axis=0) * sample_rate + speed = np.linalg.norm(velocity, axis=1) + centers = np.arange( + persistence_frames, + len(positions) - persistence_frames, + dtype=np.int64, + ) + velocity_cumulative = np.vstack( + ( + np.zeros((1, 3), dtype=np.float64), + np.cumsum(velocity, axis=0), + ) + ) + before_vectors = ( + velocity_cumulative[centers] - velocity_cumulative[centers - persistence_frames] + ) / persistence_frames + after_vectors = ( + velocity_cumulative[centers + persistence_frames] - velocity_cumulative[centers] + ) / persistence_frames + before_speeds = np.linalg.norm(before_vectors, axis=1) + after_speeds = np.linalg.norm(after_vectors, axis=1) + flank_speeds = np.minimum(before_speeds, after_speeds) + speed_cumulative = np.concatenate((np.zeros(1, dtype=np.float64), np.cumsum(speed))) + before_mean_speeds = ( + speed_cumulative[centers] - speed_cumulative[centers - persistence_frames] + ) / persistence_frames + after_mean_speeds = ( + speed_cumulative[centers + persistence_frames] - speed_cumulative[centers] + ) / persistence_frames + before_coherence = before_speeds / np.maximum(before_mean_speeds, 1e-12) + after_coherence = after_speeds / np.maximum(after_mean_speeds, 1e-12) + denominator = before_speeds * after_speeds + cosine = np.divide( + np.sum(before_vectors * after_vectors, axis=1), + denominator, + out=np.ones_like(denominator), + where=denominator > 1e-12, + ) + angles = np.degrees(np.arccos(np.clip(cosine, -1.0, 1.0))) + + unit_velocity = np.divide( + velocity, + speed[:, None], + out=np.zeros_like(velocity), + where=speed[:, None] > 1e-12, + ) + adjacent_turn_cosine = np.sum( + unit_velocity[:-1] * unit_velocity[1:], + axis=1, + ) + adjacent_turn_angles = np.degrees( + np.arccos(np.clip(adjacent_turn_cosine, -1.0, 1.0)) + ) + corner_window_size = persistence_frames * 2 - 1 + corner_windows = np.lib.stride_tricks.sliding_window_view( + adjacent_turn_angles, + corner_window_size, + ) + peak_corner_angles = np.max( + corner_windows[centers - persistence_frames], + axis=1, + ) + corner_concentration = peak_corner_angles / np.maximum(angles, 1e-12) + + step_distance = speed / sample_rate + distance_cumulative = np.concatenate( + (np.zeros(1, dtype=np.float64), np.cumsum(step_distance)) + ) + before_travel = ( + distance_cumulative[centers] - distance_cumulative[centers - persistence_frames] + ) + after_travel = ( + distance_cumulative[centers + persistence_frames] - distance_cumulative[centers] + ) + valid = ( + (flank_speeds >= minimum_flank_speed_mps) + & ( + angles + >= reversal_angle_degrees + - DEFAULT_ANGLE_COMPARISON_TOLERANCE_DEGREES + ) + & (before_coherence >= minimum_flank_direction_coherence) + & (after_coherence >= minimum_flank_direction_coherence) + & (corner_concentration >= minimum_corner_concentration) + & (np.minimum(before_travel, after_travel) >= minimum_flank_travel_meters) + ) + + if family == "orbit": + radius = np.linalg.norm(positions[:, [0, 2]], axis=1) + azimuth = np.unwrap(np.arctan2(positions[:, 0], positions[:, 2])) + angular_velocity = np.diff(azimuth) * sample_rate + angular_windows = np.lib.stride_tricks.sliding_window_view( + angular_velocity, + persistence_frames, + ) + incoming_angular_velocity = np.median( + angular_windows[centers - persistence_frames], + axis=1, + ) + outgoing_angular_velocity = np.median( + angular_windows[centers], + axis=1, + ) + valid &= incoming_angular_velocity * outgoing_angular_velocity < 0.0 + valid &= ( + np.minimum( + np.abs(incoming_angular_velocity), + np.abs(outgoing_angular_velocity), + ) + >= DEFAULT_MINIMUM_ORBIT_ANGULAR_SPEED_RADIANS_PER_SECOND + ) + valid &= radius[centers] >= 0.1 + + local_speed_windows = np.lib.stride_tricks.sliding_window_view(speed, 4) + local_minimum_speeds = np.min(local_speed_windows[centers - 2], axis=1) + ratios = local_minimum_speeds / np.maximum(flank_speeds, 1e-12) + persistence_speed_windows = np.lib.stride_tricks.sliding_window_view( + speed, + persistence_frames, + ) + before_speed_sequence = persistence_speed_windows[centers - persistence_frames] + after_speed_sequence = persistence_speed_windows[centers] + trend_tolerance = flank_speeds[:, None] * 0.03 + before_monotonic_ratio = np.mean( + np.diff(before_speed_sequence, axis=1) + <= trend_tolerance[:, : persistence_frames - 1], + axis=1, + ) + after_monotonic_ratio = np.mean( + np.diff(after_speed_sequence, axis=1) + >= -trend_tolerance[:, : persistence_frames - 1], + axis=1, + ) + natural_speed_limits = np.asarray( + [natural_turn_speed_ratio_limit(float(angle)) for angle in angles], + dtype=np.float64, + ) + natural_easing = ( + (before_monotonic_ratio >= 0.75) + & (after_monotonic_ratio >= 0.75) + & (ratios <= natural_speed_limits + DEFAULT_SPEED_RATIO_TOLERANCE) + ) + candidates = [ + TranslationReversal( + frame=int(frame), + angle_degrees=float(angle), + stop_speed_ratio=float(ratio), + flank_speed_mps=float(flank_speed), + target_speed_ratio=turn_speed_ratio_limit(float(angle)), + corner_concentration=float(concentration), + natural_easing_detected=bool(is_naturally_eased), + ) + for ( + frame, + angle, + ratio, + flank_speed, + concentration, + is_naturally_eased, + ) in zip( + centers[valid], + angles[valid], + ratios[valid], + flank_speeds[valid], + corner_concentration[valid], + natural_easing[valid], + ) + ] + + # Multiple adjacent frame centers describe the same physical turn. Keep + # the center with the clearest low-speed evidence so a naturally eased + # reversal is not falsely reported as abrupt. + return _group_reversal_candidates(candidates, persistence_frames) + + +def _smootherstep(values: np.ndarray) -> np.ndarray: + values = np.asarray(values, dtype=np.float64) + return values**3 * (10.0 + values * (-15.0 + 6.0 * values)) + + +def _slerp_unit_vectors( + first: np.ndarray, + second: np.ndarray, + progress: np.ndarray, +) -> np.ndarray: + first = np.asarray(first, dtype=np.float64) + second = np.asarray(second, dtype=np.float64) + first /= max(float(np.linalg.norm(first)), 1e-12) + second /= max(float(np.linalg.norm(second)), 1e-12) + progress = np.asarray(progress, dtype=np.float64) + dot = float(np.clip(np.dot(first, second), -1.0, 1.0)) + if dot > 0.9995: + values = (1.0 - progress[:, None]) * first + progress[:, None] * second + return values / np.maximum( + np.linalg.norm(values, axis=1, keepdims=True), + 1e-12, + ) + angle = math.acos(dot) + sine = math.sin(angle) + return ( + np.sin((1.0 - progress[:, None]) * angle) / sine * first + + np.sin(progress[:, None] * angle) / sine * second + ) + + +def _ease_through_stop( + positions: np.ndarray, + center: int, + radius: int, + *, + target_speed_ratio: float = 0.0, + turn_angle_degrees: float = 180.0, +) -> np.ndarray: + """Reintegrate one turn under a no-overspeed S-curve envelope. + + Preserving the window's start, turn and end positions while inserting a + stop is mathematically forced to create compensating overspeed. Instead, + this function preserves the incoming and outgoing velocity at the outer + window edges, reduces travel near the corner, and shifts the remaining tail + by the small positional difference. No reconstructed step can exceed its + source step, and constant-speed input becomes monotonic deceleration into + the turn followed by monotonic acceleration away from it. + """ + + if radius < 2: + raise ValueError("radius must be at least two frames.") + if not 0.0 <= target_speed_ratio <= 1.0: + raise ValueError("target_speed_ratio must be between zero and one.") + if not 0.0 <= turn_angle_degrees <= 180.0: + raise ValueError("turn_angle_degrees must be between zero and 180.") + left = center - radius + right = center + radius + if left < 0 or right >= len(positions): + raise ValueError("turn window must remain inside positions.") + + source = np.asarray(positions, dtype=np.float64) + source_steps = np.diff(source, axis=0) + source_speeds = np.linalg.norm(source_steps, axis=1) + source_directions = np.divide( + source_steps, + source_speeds[:, None], + out=np.zeros_like(source_steps), + where=source_speeds[:, None] > 1e-12, + ) + + progress = np.linspace(0.0, 1.0, radius) + easing = _smootherstep(progress) + before_factor = 1.0 - (1.0 - target_speed_ratio) * easing + after_factor = target_speed_ratio + (1.0 - target_speed_ratio) * easing + before_speed = source_speeds[left:center] * before_factor + after_speed = source_speeds[center:right] * after_factor + # Do not force monotonicity with cumulative minima. That operation creates + # a new slope discontinuity whenever an authored speed fluctuation becomes + # the running minimum. The C2 factor is already monotonic for a constant- + # speed corner and preserves the source curve's local dynamics otherwise. + + directions = source_directions[left:right].copy() + if turn_angle_degrees < 150.0: + flank = min(radius, max(2, int(round(radius * 0.25)))) + incoming = np.mean(source_steps[center - flank : center], axis=0) + outgoing = np.mean(source_steps[center : center + flank], axis=0) + if ( + float(np.linalg.norm(incoming)) > 1e-12 + and float(np.linalg.norm(outgoing)) > 1e-12 + ): + smoothed_directions = _slerp_unit_vectors( + incoming, + outgoing, + np.linspace(0.0, 1.0, radius * 2), + ) + # Preserve the authored direction at both window boundaries for + # every eligible angle, then hand control to the interpolated turn + # with a C2-continuous envelope. Replacing an entire 60--149 degree + # window created a velocity seam at its outer edge. The previous + # sin-squared mild-turn blend also retained a C2 seam there. + edge_progress = np.linspace(0.0, 1.0, radius * 2) + direction_blend = np.where( + edge_progress <= 0.5, + _smootherstep(edge_progress * 2.0), + _smootherstep((1.0 - edge_progress) * 2.0), + ) + directions = ( + source_directions[left:right] + * (1.0 - direction_blend[:, None]) + + smoothed_directions * direction_blend[:, None] + ) + directions /= np.maximum( + np.linalg.norm(directions, axis=1, keepdims=True), + 1e-12, + ) + + reconstructed_steps = ( + directions * np.concatenate((before_speed, after_speed))[:, None] + ) + output = source.copy() + output[left + 1 : right + 1] = output[left] + np.cumsum( + reconstructed_steps, + axis=0, + ) + tail_offset = output[right] - source[right] + if right + 1 < len(output): + output[right + 1 :] = source[right + 1 :] + tail_offset + return output + + +def translation_dynamics_metrics( + positions_meters: np.ndarray, + sample_rate: float, +) -> dict[str, float]: + """Return peak translation speed, acceleration and jerk for auditing.""" + + positions = _validated_positions(positions_meters) + velocity = np.diff(positions, axis=0) * sample_rate + acceleration = np.diff(velocity, axis=0) * sample_rate + jerk = np.diff(acceleration, axis=0) * sample_rate + + def peak(values: np.ndarray) -> float: + return float(np.max(np.linalg.norm(values, axis=1))) if len(values) else 0.0 + + return { + "speedMetersPerSecondMax": peak(velocity), + "accelerationMetersPerSecondSquaredMax": peak(acceleration), + "jerkMetersPerSecondCubedMax": peak(jerk), + } + + +def _dynamics_regression_exceeds_guard( + before: dict[str, float], + after: dict[str, float], +) -> bool: + """Reject a rewrite that introduces an edit-visible dynamics spike.""" + + acceleration_before = before["accelerationMetersPerSecondSquaredMax"] + acceleration_after = after["accelerationMetersPerSecondSquaredMax"] + acceleration_limit = max( + DEFAULT_ACCELERATION_GUARD_FLOOR_MPS2, + min( + acceleration_before + * DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_RATIO, + acceleration_before + + DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2, + ), + ) + jerk_before = before["jerkMetersPerSecondCubedMax"] + jerk_after = after["jerkMetersPerSecondCubedMax"] + jerk_limit = max( + DEFAULT_JERK_GUARD_FLOOR_MPS3, + min( + jerk_before * DEFAULT_MAXIMUM_JERK_REGRESSION_RATIO, + jerk_before + DEFAULT_MAXIMUM_JERK_REGRESSION_DELTA_MPS3, + ), + ) + return bool( + acceleration_after > acceleration_limit + 1e-9 + or jerk_after > jerk_limit + 1e-9 + ) + + +def _event_metadata(events: list[TranslationReversal]) -> dict[str, object]: + abrupt = [event for event in events if event.abrupt] + return { + "count": len(events), + "abruptCount": len(abrupt), + "frames": [event.frame for event in events], + "abruptFrames": [event.frame for event in abrupt], + "anglesDegrees": [event.angle_degrees for event in events], + "targetSpeedRatios": [event.target_speed_ratio for event in events], + "cornerConcentrations": [event.corner_concentration for event in events], + "worstStopSpeedRatio": ( + float(max(event.stop_speed_ratio for event in events)) if events else 0.0 + ), + "worstTargetSpeedRatioExcess": ( + float( + max( + event.stop_speed_ratio - event.target_speed_ratio + for event in abrupt + ) + ) + if abrupt + else 0.0 + ), + } + + +def regularize_translational_reversals( + positions_meters: np.ndarray, + motion_type: str, + *, + sample_rate: float = 60.0, + reversal_window_seconds: float = DEFAULT_REVERSAL_WINDOW_SECONDS, +) -> tuple[np.ndarray, dict[str, object]]: + """Apply angle-aware braking to abrupt turns and orbit reversals.""" + + positions = _validated_positions(positions_meters) + if not math.isfinite(reversal_window_seconds) or reversal_window_seconds <= 0.0: + raise ValueError("reversal_window_seconds must be finite and positive.") + family = motion_family(motion_type) + before = analyze_translational_reversals( + positions, + motion_type, + sample_rate=sample_rate, + ) + abrupt_before = [event for event in before if event.abrupt] + output = positions.copy() + applied_frames: list[int] = [] + applied_angles: list[float] = [] + applied_target_speed_ratios: list[float] = [] + + pass_count = 0 + pending_events = abrupt_before + if family not in {"static", "unsupported"} and pending_events: + requested_radius = max( + 6, + int(round(reversal_window_seconds * sample_rate)), + ) + # Only abrupt events detected on the untouched input are eligible. + # Re-detecting and rewriting new window-boundary events caused a + # three-pass cascade in real shots, multiplying acceleration and jerk + # even though the final turn counter eventually reached zero. + event_frames = [event.frame for event in pending_events] + for event_index, event in enumerate(pending_events): + neighbor_limit = requested_radius + if event_index > 0: + neighbor_limit = min( + neighbor_limit, + (event.frame - event_frames[event_index - 1]) // 2, + ) + if event_index + 1 < len(event_frames): + neighbor_limit = min( + neighbor_limit, + (event_frames[event_index + 1] - event.frame) // 2, + ) + radius = min( + neighbor_limit, + event.frame, + len(output) - 1 - event.frame, + ) + if radius < 4: + continue + output = _ease_through_stop( + output, + event.frame, + radius, + target_speed_ratio=event.target_speed_ratio, + turn_angle_degrees=event.angle_degrees, + ) + applied_frames.append(event.frame) + applied_angles.append(event.angle_degrees) + applied_target_speed_ratios.append(event.target_speed_ratio) + pass_count = int(bool(applied_frames)) + + endpoint_adjustment = output[-1] - positions[-1] + endpoint_adjustment_length = float(np.linalg.norm(endpoint_adjustment)) + endpoint_adjustment_clamped = ( + endpoint_adjustment_length > DEFAULT_MAXIMUM_ENDPOINT_ADJUSTMENT_METERS + ) + if endpoint_adjustment_clamped: + correction_scale = ( + DEFAULT_MAXIMUM_ENDPOINT_ADJUSTMENT_METERS / endpoint_adjustment_length + ) + # A convex blend between source and slowed steps cannot exceed the + # source peak speed. Pathological multi-turn candidates are therefore + # bounded spatially and remain eligible for the unresolved-turn penalty. + output = positions + (output - positions) * correction_scale + + dynamics_before = translation_dynamics_metrics(positions, sample_rate) + attempted_dynamics_after = translation_dynamics_metrics(output, sample_rate) + attempted_regularized_frames = list(applied_frames) + dynamics_guard_triggered = bool(applied_frames) and ( + _dynamics_regression_exceeds_guard( + dynamics_before, + attempted_dynamics_after, + ) + ) + if dynamics_guard_triggered: + # A rejected rewrite remains an abrupt candidate and is therefore + # demoted by the normal selection penalty. Keeping the untouched curve + # is safer than publishing a numerically "resolved" turn with an + # edit-visible acceleration or jerk seam. + output = positions.copy() + applied_frames.clear() + applied_angles.clear() + applied_target_speed_ratios.clear() + pass_count = 0 + endpoint_adjustment_clamped = False + + after = analyze_translational_reversals( + output, + motion_type, + sample_rate=sample_rate, + ) + before_metadata = _event_metadata(before) + after_metadata = _event_metadata(after) + dynamics_after = translation_dynamics_metrics(output, sample_rate) + path_length_before = float( + np.sum(np.linalg.norm(np.diff(positions, axis=0), axis=1)) + ) + path_length_after = float(np.sum(np.linalg.norm(np.diff(output, axis=0), axis=1))) + metadata: dict[str, object] = { + "translationKinematicPolicyVersion": (TRANSLATION_KINEMATIC_POLICY_VERSION), + "translationKinematicMotionFamily": family, + "translationKinematicPolicyEligible": family not in {"static", "unsupported"}, + "translationKinematicPolicyApplied": bool(applied_frames), + "translationKinematicDynamicsGuardTriggered": dynamics_guard_triggered, + "translationKinematicAttemptedRegularizationCount": len( + attempted_regularized_frames + ), + "translationKinematicAttemptedReversalFrames": ( + attempted_regularized_frames + ), + "translationDirectionReversalCountBefore": before_metadata["count"], + "translationAbruptReversalCountBefore": before_metadata["abruptCount"], + "translationDirectionReversalFramesBefore": before_metadata["frames"], + "translationAbruptReversalFramesBefore": before_metadata["abruptFrames"], + "translationWorstStopSpeedRatioBefore": before_metadata["worstStopSpeedRatio"], + "translationRegularizedReversalCount": len(applied_frames), + "translationKinematicRegularizationPassCount": pass_count, + "translationRegularizedReversalFrames": applied_frames, + "translationRegularizedTurnAnglesDegrees": applied_angles, + "translationRegularizedTargetSpeedRatios": (applied_target_speed_ratios), + "translationDirectionReversalCountAfter": after_metadata["count"], + "translationAbruptReversalCountAfter": after_metadata["abruptCount"], + "translationDirectionReversalFramesAfter": after_metadata["frames"], + "translationAbruptReversalFramesAfter": after_metadata["abruptFrames"], + "translationWorstStopSpeedRatioAfter": after_metadata["worstStopSpeedRatio"], + "translationWorstTargetSpeedRatioExcessAfter": after_metadata[ + "worstTargetSpeedRatioExcess" + ], + "translationStopSpeedRatioLimit": DEFAULT_STOP_SPEED_RATIO, + "translationReversalAngleMinimumDegrees": (DEFAULT_REVERSAL_ANGLE_DEGREES), + "translationMinimumCornerConcentration": (DEFAULT_MINIMUM_CORNER_CONCENTRATION), + "translationSpeedMetersPerSecondMaxBefore": dynamics_before[ + "speedMetersPerSecondMax" + ], + "translationSpeedMetersPerSecondMaxAfter": dynamics_after[ + "speedMetersPerSecondMax" + ], + "translationAccelerationMetersPerSecondSquaredMaxBefore": ( + dynamics_before["accelerationMetersPerSecondSquaredMax"] + ), + "translationAccelerationMetersPerSecondSquaredMaxAfter": ( + dynamics_after["accelerationMetersPerSecondSquaredMax"] + ), + "translationJerkMetersPerSecondCubedMaxBefore": dynamics_before[ + "jerkMetersPerSecondCubedMax" + ], + "translationJerkMetersPerSecondCubedMaxAfter": dynamics_after[ + "jerkMetersPerSecondCubedMax" + ], + "translationAttemptedAccelerationMetersPerSecondSquaredMax": ( + attempted_dynamics_after[ + "accelerationMetersPerSecondSquaredMax" + ] + ), + "translationAttemptedJerkMetersPerSecondCubedMax": ( + attempted_dynamics_after["jerkMetersPerSecondCubedMax"] + ), + "translationPathLengthRetentionRatio": ( + path_length_after / path_length_before + if path_length_before > 1e-12 + else 1.0 + ), + "translationEndpointAdjustmentMeters": float( + np.linalg.norm(output[-1] - positions[-1]) + ), + "translationEndpointAdjustmentLimitMeters": ( + DEFAULT_MAXIMUM_ENDPOINT_ADJUSTMENT_METERS + ), + "translationEndpointAdjustmentClamped": endpoint_adjustment_clamped, + } + return output.astype(np.float32), metadata + + +def translational_reversal_selection_penalty( + metrics: dict[str, object], +) -> float: + """Softly demote candidates whose short shot cannot fit a safe slowdown.""" + + abrupt_count = int(metrics.get("translationAbruptReversalCountAfter", 0)) + ratio_excess = float( + metrics.get("translationWorstTargetSpeedRatioExcessAfter", 0.0) + ) + dynamics_guard = bool( + metrics.get("translationKinematicDynamicsGuardTriggered", False) + ) + return ( + abrupt_count * 2.5 + + ratio_excess * 2.0 + + float(dynamics_guard) * 2.5 + ) diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py index f73fdf2..8e00539 100644 --- a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/generate_hybrid.py @@ -36,6 +36,8 @@ from sklearn.metrics import balanced_accuracy_score from sklearn.model_selection import GroupKFold import hybrid_candidate_cache as candidate_cache +import adjacent_transition +import camera_kinematics import data_driven_cut_planner as cut_planner import hybrid_cut_reference as cut_reference import hybrid_preparation_cache as preparation_cache @@ -81,6 +83,9 @@ MAXIMUM_DIRECTED_REFRAME_DEGREES = 12.0 CAMERA_DIRECTIVE_SCHEMA_VERSION = "camera-directives-v1" HYBRID_METADATA_SCHEMA_VERSION = "3.6" HYBRID_SHOTS_SCHEMA_VERSION = "1.2" +ADJACENT_TRANSITION_EDGE_LIST_SCHEMA_VERSION = ( + "authoritative-adjacent-transition-edges-v1" +) HYBRID_SAMPLE_RATE = 60 HYBRID_GENERATION_MODES_BY_PLANNER = { "hierarchical": ( @@ -105,7 +110,10 @@ SEED_VARIATION_POLICY_VERSION = ( "safe-distinct-motion-semantic-safety-seed-cycle-v5" ) SEED_VARIATION_SEQUENCE_CONTEXT_POLICY = ( - "seed-independent-best-safe-candidate-v1" + "seed-independent-pre-transition-prefix-v2" +) +SELECTED_TRANSITION_CONTEXT_POLICY = ( + adjacent_transition.TRANSITION_POLICY_VERSION ) SEED_VARIATION_SHOT_STRIDE = 104729 SEED_VARIATION_MAX_POOL_SIZE = 8 @@ -114,28 +122,31 @@ SEED_VARIATION_MAX_NORMAL_CANDIDATES = 16 SEED_VARIATION_MAX_SELECTION_SCORE_DELTA = 2.0 GENERATION_PERFORMANCE_SCHEMA_VERSION = "hybrid-generation-performance-v1" PREPARATION_LOGIC_CONTRACT_VERSION = "hybrid-preparation-logic-v2" -CANDIDATE_LOGIC_CONTRACT_VERSION = "hybrid-candidate-logic-v8" +CANDIDATE_LOGIC_CONTRACT_VERSION = "hybrid-candidate-logic-v15" CANDIDATE_CACHE_STAGE_VERSIONS = { "shotSegmentation": "data-driven-candidates-ranker-global-dag-v1", "shotPlanning": "hierarchical-or-legacy-reconciliation-replay-v3-static-budget", "candidateRanking": ( - "template-rank-v8-edge-safe-template-motion-static-budget" + "template-rank-v11-adjacent-transition-v3-no-forced-side-alternation" ), "trajectoryRetargeting": ( - "retarget-v13-velocity-limited-derived-semantic-label-baked-aim" + "retarget-v17-c2-yamo-turn-kinematics-semantic-baked-aim" ), "compositionSafety": "person-safety-v9-derived-semantic-envelope", - "variationPool": SEED_VARIATION_POLICY_VERSION + "-static-budget-v4", + "variationPool": ( + SEED_VARIATION_POLICY_VERSION + + "-static-budget-v5-seed-independent-base-selection-only-extra" + ), "sequenceContinuation": SEED_VARIATION_SEQUENCE_CONTEXT_POLICY, } TRAJECTORY_RETARGETING_VERSION = ( - "edge-safe-position-velocity-limited-derived-semantic-label-baked-aim-v5" + "edge-safe-position-single-space-c2-yamo-angle-turn-semantic-baked-aim-v9" ) TEMPLATE_MOTION_SMOOTHING_VERSION = ( "savgol61-nearest-edge-step-guard-v1" ) MOTION_SELECTION_VERSION = ( - "non-static-retention-expressiveness-velocity-budget-v3" + "non-static-retention-expressiveness-yamo-angle-kinematics-v7" ) FINAL_POSITION_SMOOTHING_WINDOW_FRAMES = 15 MAXIMUM_CAMERA_RELATIVE_POSITION_STEP_METERS = 0.12 @@ -160,7 +171,7 @@ ROLLING_STATIC_BUDGET_MAX_STATIC_SHOTS = 1 ROLLING_STATIC_BUDGET_POLICY_VERSION = ( "seed-independent-static-slot-reservation-v1" ) -AIM_POLICY_VERSION = "semantic-focus-low-frequency-baked-aim-v1" +AIM_POLICY_VERSION = "semantic-focus-braking-bounded-baked-aim-v2" AIM_SAMPLE_RATE = 60.0 AIM_TARGET_SMOOTHING_SECONDS = 0.60 AIM_TARGET_DEAD_ZONE_METERS = 0.055 @@ -1333,13 +1344,21 @@ def mirrored_motion_type(motion_type: str) -> str: def template_realization( template: ShotTemplate, previous: RealizedShotState | None, + mirrored_override: bool | None = None, ) -> tuple[bool, float, str]: + """Resolve one source template without mechanically alternating sides. + + Earlier generations mirrored every template found on the same side as the + previous shot. That made ``realizedSideSign`` alternate on virtually every + cut, regardless of camera geometry or editorial intent. Side changes are + now an explicit override; the default preserves the authored template and + pairwise rendered transition scoring decides which retrieved camera belongs + after the actually selected previous camera. + """ + + del previous # retained for call-site/API compatibility source_side = float(np.sign(template.anchor[0])) - mirrored = bool( - previous is not None - and source_side != 0.0 - and source_side == previous.side_sign - ) + mirrored = bool(mirrored_override) if mirrored_override is not None else False side_sign = -source_side if mirrored else source_side motion_type = ( mirrored_motion_type(template.motion_type) @@ -3542,9 +3561,8 @@ def limit_quaternion_kinematics( previous_velocity = np.zeros(3, dtype=np.float64) delta_time = 1.0 / sample_rate speed_limit = math.radians(maximum_speed_dps) - acceleration_step_limit = math.radians( - maximum_acceleration_dps2 - ) * delta_time + acceleration_limit = math.radians(maximum_acceleration_dps2) + acceleration_step_limit = acceleration_limit * delta_time velocities: list[np.ndarray] = [] for index in range(1, len(desired)): error_quaternion = train.quat_multiply( @@ -3554,10 +3572,23 @@ def limit_quaternion_kinematics( if error_quaternion[3] < 0.0: error_quaternion *= -1.0 error = Rotation.from_quat(error_quaternion).as_rotvec() - desired_velocity = error / delta_time - desired_speed = float(np.linalg.norm(desired_velocity)) - if desired_speed > speed_limit: - desired_velocity *= speed_limit / desired_speed + error_length = float(np.linalg.norm(error)) + if error_length > 1e-12: + # Start braking early enough to reach the requested orientation + # without the old one-frame "snap to target". That snap replaced + # the already acceleration-limited velocity with error / dt and + # could exceed the advertised acceleration by more than 10x. + braking_speed = math.sqrt( + 2.0 * acceleration_limit * error_length + ) + desired_speed = min( + speed_limit, + braking_speed, + error_length / delta_time, + ) + desired_velocity = error * (desired_speed / error_length) + else: + desired_velocity = np.zeros(3, dtype=np.float64) velocity_change = desired_velocity - previous_velocity change_length = float(np.linalg.norm(velocity_change)) if change_length > acceleration_step_limit: @@ -3567,15 +3598,25 @@ def limit_quaternion_kinematics( if velocity_length > speed_limit: velocity *= speed_limit / velocity_length step = velocity * delta_time - error_length = float(np.linalg.norm(error)) step_length = float(np.linalg.norm(step)) if ( error_length > 0.0 and step_length > error_length and float(np.dot(step, error)) > 0.0 ): - step = error - velocity = step / delta_time + stopping_velocity = error / delta_time + stopping_change = stopping_velocity - previous_velocity + # Landing exactly on the target is allowed only when that landing + # itself satisfies both kinematic bounds. Otherwise overshoot by + # a small, acceleration-limited amount and brake on later frames. + if ( + float(np.linalg.norm(stopping_change)) + <= acceleration_step_limit + 1e-12 + and float(np.linalg.norm(stopping_velocity)) + <= speed_limit + 1e-12 + ): + step = error + velocity = stopping_velocity step_quaternion = Rotation.from_rotvec(step).as_quat() output[index] = train.quat_multiply( output[index - 1 : index], @@ -3588,14 +3629,15 @@ def limit_quaternion_kinematics( if velocities: velocity_array = np.asarray(velocities) speeds = np.degrees(np.linalg.norm(velocity_array, axis=1)) - accelerations = ( - np.degrees( - np.linalg.norm(np.diff(velocity_array, axis=0), axis=1) - ) - * sample_rate - if len(velocity_array) > 1 - else np.asarray([0.0]) + velocity_with_initial_rest = np.vstack( + (np.zeros((1, 3), dtype=np.float64), velocity_array) ) + accelerations = np.degrees( + np.linalg.norm( + np.diff(velocity_with_initial_rest, axis=0), + axis=1, + ) + ) * sample_rate else: speeds = np.asarray([0.0]) accelerations = np.asarray([0.0]) @@ -4153,6 +4195,44 @@ def retarget_template_to_shot( target.body_scale, ) applied_motion_scale *= trajectory_velocity_limit_scale + declared_kinematic_motion_family = camera_kinematics.motion_family( + realized_motion_type + ) + relative_translation_kinematic_metrics: dict[str, object] | None = None + relative_kinematic_pass_executed = False + post_kinematic_velocity_limit_scale = 1.0 + trajectory_step_after_kinematic_meters = ( + trajectory_step_after_limit_meters + ) + if declared_kinematic_motion_family == "orbit": + relative_kinematic_pass_executed = True + ( + camera_local_meters, + relative_translation_kinematic_metrics, + ) = camera_kinematics.regularize_translational_reversals( + camera_local_normalized * target.body_scale, + realized_motion_type, + sample_rate=HYBRID_SAMPLE_RATE, + ) + camera_local_normalized = camera_local_meters / target.body_scale + ( + camera_local_normalized, + post_kinematic_velocity_limit_scale, + _, + trajectory_step_after_kinematic_meters, + ) = limit_camera_relative_trajectory_step( + camera_local_normalized, + target.body_scale, + ) + # Stop-through-reversal timing can move some travel away from the + # turning point. Re-apply the uniform cap only to an orbit that was + # regularized in this relative space. Non-orbit paths retain the exact + # first-limit result until their single final-world kinematic pass. + trajectory_velocity_limit_scale *= ( + post_kinematic_velocity_limit_scale + ) + applied_motion_scale *= post_kinematic_velocity_limit_scale + camera_local_meters = camera_local_normalized * target.body_scale clamped_distance_meters = ( np.linalg.norm(camera_local_normalized, axis=1) * target.body_scale ) @@ -4181,6 +4261,44 @@ def retarget_template_to_shot( camera_local_normalized * target.body_scale, ) ) + if declared_kinematic_motion_family == "orbit": + # Orbit direction is meaningful only around the subject/stage anchor. + # A world-origin atan2 pass produces false CW/CCW reversals whenever a + # perfectly continuous orbit is offset from (0, 0, 0). + if relative_translation_kinematic_metrics is None: + raise RuntimeError("Orbit relative kinematic pass did not run.") + translation_kinematic_metrics = relative_translation_kinematic_metrics + translation_kinematic_detection_space = "camera_relative_trajectory" + else: + world_kinematic_motion_type = ( + "drift" + if declared_kinematic_motion_family + in {"dolly", "truck", "crane", "drift"} + else realized_motion_type + ) + ( + camera_world_position, + translation_kinematic_metrics, + ) = camera_kinematics.regularize_translational_reversals( + camera_world_position, + world_kinematic_motion_type, + sample_rate=HYBRID_SAMPLE_RATE, + ) + translation_kinematic_detection_space = "final_world_position" + translation_kinematic_metrics.update( + { + "translationKinematicMotionFamily": ( + declared_kinematic_motion_family + ), + "translationKinematicDetectionSpace": ( + translation_kinematic_detection_space + ), + "translationKinematicPassCount": 1, + "translationRelativeKinematicPassExecuted": ( + relative_kinematic_pass_executed + ), + } + ) camera_world_rotation = train.quat_multiply( shot_rotation, camera_local_rotation, @@ -4566,6 +4684,29 @@ def retarget_template_to_shot( realized_motion_type, motion_intensity, ) + translation_kinematic_penalty = ( + camera_kinematics.translational_reversal_selection_penalty( + translation_kinematic_metrics + ) + ) + motion_quality.update(translation_kinematic_metrics) + motion_quality["translationKinematicSelectionPenalty"] = float( + translation_kinematic_penalty + ) + motion_quality["motionSelectionAdjustment"] = float( + motion_quality["motionSelectionAdjustment"] + + translation_kinematic_penalty + ) + transition_state = adjacent_transition.build_transition_state( + camera_world_position, + camera_world_rotation, + fov_values, + target_world_position, + final_composition, + realized_shot_type, + realized_motion_type, + sample_rate=HYBRID_SAMPLE_RATE, + ) return { "template": template, "position": camera_world_position, @@ -4574,17 +4715,27 @@ def retarget_template_to_shot( "dutch": dutch_values, "target": target_world_position, "stageAnchor": stage_anchor_path, + "transitionState": transition_state, "trajectoryScale": applied_motion_scale, "trajectoryLimitScale": float(trajectory_scale), "trajectoryVelocityLimitScale": float( trajectory_velocity_limit_scale ), + "trajectoryPostKinematicVelocityLimitScale": float( + post_kinematic_velocity_limit_scale + ), + "trajectoryRelativeKinematicPassExecuted": bool( + relative_kinematic_pass_executed + ), "trajectoryRelativeStepMetersBeforeVelocityLimit": float( trajectory_step_before_limit_meters ), "trajectoryRelativeStepMetersAfterVelocityLimit": float( trajectory_step_after_limit_meters ), + "trajectoryRelativeStepMetersAfterKinematicRegularization": float( + trajectory_step_after_kinematic_meters + ), "trajectoryRelativeStepLimitMeters": ( MAXIMUM_CAMERA_RELATIVE_POSITION_STEP_METERS ), @@ -4737,10 +4888,84 @@ def camera_candidates_are_visually_distinct( return direct_difference or typed_motion_difference +def effective_candidate_selection_score(result: dict) -> float: + """Return the rendered transition-aware score when it is available.""" + + return float( + result.get( + "transitionAdjustedSelectionScore", + result.get("selectionScore", float("inf")), + ) + ) + + +def apply_adjacent_transition_quality( + candidates: list[dict], + *, + previous_state: dict[str, object] | None, + next_state: dict[str, object] | None, +) -> None: + """Annotate candidates against the selected/fixed adjacent cameras. + + Full generation supplies the *actually selected* preceding candidate. + Single-shot regeneration also supplies the immutable next camera from the + base composite, so a replacement cannot improve one cut while breaking the + other. Reapplying the function is idempotent for cached candidate pools. + """ + + for candidate in candidates: + transition_state = candidate.get("transitionState") + if not isinstance(transition_state, dict): + raise ValueError("Candidate is missing rendered transition state.") + base_score = float( + candidate.get( + "selectionScoreBeforeTransition", + candidate.get("selectionScore", float("inf")), + ) + ) + if not math.isfinite(base_score): + raise ValueError("Candidate selection score must be finite.") + transition_quality = adjacent_transition.score_transition_context( + transition_state, + previous_state=previous_state, + next_state=next_state, + ) + candidate["selectionScoreBeforeTransition"] = base_score + candidate["transitionQuality"] = transition_quality + candidate["transitionAdjustedSelectionScore"] = float( + base_score + float(transition_quality["penalty"]) + ) + + +def transition_eligible_candidate_results( + candidates: list[dict], +) -> tuple[list[dict], bool]: + """Gate failed adjacent edges, with a deterministic best-effort fallback. + + If at least one candidate passes every known edge, failed candidates must + not re-enter through the Seed cycle. If every candidate fails, generation + remains available but falls back to the single lowest penalized option. + """ + + if not candidates: + raise ValueError("Transition selection requires at least one candidate.") + passed: list[dict] = [] + for candidate in candidates: + quality = candidate.get("transitionQuality") + if not isinstance(quality, dict) or type(quality.get("passed")) is not bool: + raise ValueError("Candidate transition-quality result is invalid.") + if quality["passed"]: + passed.append(candidate) + if passed: + return passed, False + best = min(candidates, key=_camera_variation_sort_key) + return [best], True + + def _camera_variation_sort_key(result: dict) -> tuple: template = result.get("template") return ( - float(result.get("selectionScore", float("inf"))), + effective_candidate_selection_score(result), int(result.get("candidateRank", 2**31 - 1)), str(getattr(template, "source_id", "")), int(getattr(template, "shot_index", -1)), @@ -4779,11 +5004,11 @@ def quality_eligible_safe_variation_candidates( ) if not safe_candidates: return [] - best_score = float(safe_candidates[0].get("selectionScore", float("inf"))) + best_score = effective_candidate_selection_score(safe_candidates[0]) return [ candidate for candidate in safe_candidates - if float(candidate.get("selectionScore", float("inf"))) + if effective_candidate_selection_score(candidate) <= best_score + maximum_score_delta ] @@ -4817,6 +5042,76 @@ def seed_variation_needs_more_candidates(candidates: list[dict]) -> bool: return len(build_distinct_safe_variation_pool(candidates)) < 2 +def transition_passing_distinct_variation_pool( + candidates: list[dict], + *, + previous_state: dict[str, object] | None, + next_state: dict[str, object] | None, +) -> list[dict]: + """Build a non-mutating pool that passes every currently known cut edge.""" + + if not candidates: + return [] + selection_copies = [dict(candidate) for candidate in candidates] + apply_adjacent_transition_quality( + selection_copies, + previous_state=previous_state, + next_state=next_state, + ) + passing = [ + candidate + for candidate in selection_copies + if candidate["transitionQuality"]["passed"] + ] + return build_distinct_safe_variation_pool(passing) + + +def transition_context_needs_more_candidates( + candidates: list[dict], + *, + previous_state: dict[str, object] | None, + next_state: dict[str, object] | None, +) -> bool: + """Whether fewer than two safe/distinct candidates pass adjacent cuts.""" + + return len( + transition_passing_distinct_variation_pool( + candidates, + previous_state=previous_state, + next_state=next_state, + ) + ) < 2 + + +def candidate_pool_may_have_unevaluated_normal_ranks( + candidates: list[dict], +) -> bool: + """Whether a cached lazy pool stopped before the normal rank cap.""" + + evaluated_ranks = { + int(candidate["candidateRank"]) + for candidate in candidates + if isinstance(candidate.get("candidateRank"), int) + and 0 <= int(candidate["candidateRank"]) + < SEED_VARIATION_MAX_NORMAL_CANDIDATES + } + return ( + not evaluated_ranks + or max(evaluated_ranks) + 1 + < SEED_VARIATION_MAX_NORMAL_CANDIDATES + ) + + +def cache_eligible_candidate_results(candidates: list[dict]) -> list[dict]: + """Return only the Seed-independent base candidates persisted in caches.""" + + return [ + candidate + for candidate in candidates + if candidate.get("cacheEligible", True) is True + ] + + def select_seeded_camera_variation( candidates: list[dict], seed: int, @@ -4845,9 +5140,11 @@ def select_seeded_camera_variation( for candidate in candidates ) quality_eligible = quality_eligible_safe_variation_candidates(candidates) - best_selection_score = float(quality_eligible[0]["selectionScore"]) + best_selection_score = effective_candidate_selection_score( + quality_eligible[0] + ) selected_score_delta = ( - float(pool[variation_index]["selectionScore"]) + effective_candidate_selection_score(pool[variation_index]) - best_selection_score ) return pool[variation_index], { @@ -4889,6 +5186,24 @@ def select_seed_independent_sequence_continuation( return pool[0] +def advance_sequence_context( + selected_result: dict, + planner_continuation_result: dict, +) -> tuple[RealizedShotState, dict[str, object]]: + """Split cached planner history from the rendered transition history.""" + + continuation_template = planner_continuation_result.get("template") + selected_transition = selected_result.get("transitionState") + if continuation_template is None or not isinstance(selected_transition, dict): + raise ValueError("Camera sequence continuation state is incomplete.") + planner_state = RealizedShotState( + template=continuation_template, + side_sign=float(planner_continuation_result["realizedSideSign"]), + motion_type=str(planner_continuation_result["realizedMotionType"]), + ) + return planner_state, selected_transition + + def selectable_safe_candidate_results( candidate_results: list[dict], moving_opening_required: bool, @@ -4977,15 +5292,34 @@ def evaluate_safe_candidate_pool( desired_motion_type: str | None, rolling_static_budget_required: bool = False, planned_non_static_required: bool = False, + previous_transition_state: dict[str, object] | None = None, + next_transition_state: dict[str, object] | None = None, + cached_base_candidate_results: list[dict] | None = None, ) -> tuple[list[dict], list[dict]]: - """Retarget the bounded adaptive pool used by Seed selection.""" + """Retarget a deterministic base plus lazy selection-only transition ranks. - candidate_results: list[dict] = [] + The base extent follows only the established safety/distinctness policy and + is therefore safe to cache under a Seed-independent key. Actual rendered + adjacent cameras may lazily add later ranks, but those candidates are marked + ``cacheEligible=False`` and may influence only the current output selection. + A cache hit can resume from its base without retargeting the first ranks. + """ + + candidate_results: list[dict] = ( + list(cached_base_candidate_results) + if cached_base_candidate_results is not None + else [] + ) + for candidate in candidate_results: + if candidate.get("cacheEligible", True) is not True: + raise ValueError("A resumed candidate pool must contain only base ranks.") def evaluate_candidate( candidate_rank: int, candidate_template: ShotTemplate, safety_distance_scale: float = 1.0, + *, + cache_eligible: bool, ) -> dict: candidate_result = retarget_template_to_shot( candidate_template, @@ -5010,6 +5344,7 @@ def evaluate_safe_candidate_pool( candidate_rank, safety_distance_scale, ) + candidate_result["cacheEligible"] = bool(cache_eligible) candidate_results.append(candidate_result) return candidate_result @@ -5017,53 +5352,20 @@ def evaluate_safe_candidate_pool( SEED_VARIATION_INITIAL_CANDIDATES, len(ranked_templates), ) - for candidate_rank, candidate_template in enumerate( - ranked_templates[:initial_count] - ): - evaluate_candidate(candidate_rank, candidate_template) - - safe_results = selectable_safe_candidate_results( - candidate_results, - moving_opening_required, - desired_motion_type, - rolling_static_budget_required, - planned_non_static_required, - ) maximum_normal_candidates = min( SEED_VARIATION_MAX_NORMAL_CANDIDATES, len(ranked_templates), ) - for candidate_rank, candidate_template in enumerate( - ranked_templates[initial_count:maximum_normal_candidates], - start=initial_count, - ): - if not seed_variation_needs_more_candidates(safe_results): - break - evaluate_candidate(candidate_rank, candidate_template) - safe_results = selectable_safe_candidate_results( - candidate_results, - moving_opening_required, - desired_motion_type, - rolling_static_budget_required, - planned_non_static_required, - ) - - # Preserve the old fail-avoidance path when the bounded diversity search - # has not found even one policy-eligible safe camera. - if ( - not policy_safe_candidate_results( - candidate_results, - moving_opening_required, - rolling_static_budget_required, - planned_non_static_required, - ) - and maximum_normal_candidates < len(ranked_templates) - ): + if cached_base_candidate_results is None: for candidate_rank, candidate_template in enumerate( - ranked_templates[maximum_normal_candidates:], - start=maximum_normal_candidates, + ranked_templates[:initial_count] ): - evaluate_candidate(candidate_rank, candidate_template) + evaluate_candidate( + candidate_rank, + candidate_template, + cache_eligible=True, + ) + safe_results = selectable_safe_candidate_results( candidate_results, moving_opening_required, @@ -5071,16 +5373,44 @@ def evaluate_safe_candidate_pool( rolling_static_budget_required, planned_non_static_required, ) + for candidate_rank, candidate_template in enumerate( + ranked_templates[initial_count:maximum_normal_candidates], + start=initial_count, + ): + if not seed_variation_needs_more_candidates(safe_results): + break + evaluate_candidate( + candidate_rank, + candidate_template, + cache_eligible=True, + ) + safe_results = selectable_safe_candidate_results( + candidate_results, + moving_opening_required, + desired_motion_type, + rolling_static_budget_required, + planned_non_static_required, + ) - if not safe_results: - for safety_distance_scale in (1.15, 1.35, 1.65, 2.0): - for fallback_rank, candidate_template in enumerate( - ranked_templates[:initial_count] + # Preserve the old fail-avoidance path when the bounded diversity + # search has not found even one policy-eligible safe camera. + if ( + not policy_safe_candidate_results( + candidate_results, + moving_opening_required, + rolling_static_budget_required, + planned_non_static_required, + ) + and maximum_normal_candidates < len(ranked_templates) + ): + for candidate_rank, candidate_template in enumerate( + ranked_templates[maximum_normal_candidates:], + start=maximum_normal_candidates, ): evaluate_candidate( - fallback_rank, + candidate_rank, candidate_template, - safety_distance_scale, + cache_eligible=True, ) safe_results = selectable_safe_candidate_results( candidate_results, @@ -5089,8 +5419,69 @@ def evaluate_safe_candidate_pool( rolling_static_budget_required, planned_non_static_required, ) - if not seed_variation_needs_more_candidates(safe_results): - break + + if not safe_results: + for safety_distance_scale in (1.15, 1.35, 1.65, 2.0): + for fallback_rank, candidate_template in enumerate( + ranked_templates[:initial_count] + ): + evaluate_candidate( + fallback_rank, + candidate_template, + safety_distance_scale, + cache_eligible=True, + ) + safe_results = selectable_safe_candidate_results( + candidate_results, + moving_opening_required, + desired_motion_type, + rolling_static_budget_required, + planned_non_static_required, + ) + if not seed_variation_needs_more_candidates(safe_results): + break + else: + safe_results = selectable_safe_candidate_results( + candidate_results, + moving_opening_required, + desired_motion_type, + rolling_static_budget_required, + planned_non_static_required, + ) + + base_candidate_count = len(candidate_results) + evaluated_normal_ranks = { + int(candidate["candidateRank"]) + for candidate in candidate_results + if abs(float(candidate.get("safetyDistanceScale", 1.0)) - 1.0) <= 1e-9 + } + next_normal_rank = ( + max(evaluated_normal_ranks) + 1 if evaluated_normal_ranks else 0 + ) + for candidate_rank in range(next_normal_rank, maximum_normal_candidates): + if not transition_context_needs_more_candidates( + safe_results, + previous_state=previous_transition_state, + next_state=next_transition_state, + ): + break + evaluate_candidate( + candidate_rank, + ranked_templates[candidate_rank], + cache_eligible=False, + ) + safe_results = selectable_safe_candidate_results( + candidate_results, + moving_opening_required, + desired_motion_type, + rolling_static_budget_required, + planned_non_static_required, + ) + if cached_base_candidate_results is not None and any( + candidate.get("cacheEligible", True) is not True + for candidate in candidate_results[:base_candidate_count] + ): + raise ValueError("Selection-only candidates contaminated the cached base.") return candidate_results, safe_results @@ -5388,6 +5779,8 @@ def candidate_sequence_continuation_value(result: dict) -> dict[str, object]: "realizedSideSign": float(result["realizedSideSign"]), "realizedShotType": str(result["realizedShotType"]), "realizedMotionType": str(result["realizedMotionType"]), + "mirrored": bool(result.get("mirrored", False)), + "safetyDistanceScale": float(result.get("safetyDistanceScale", 1.0)), } @@ -5426,7 +5819,7 @@ def candidate_sequence_continuation_state( ) canonical_ranked_templates[candidate_rank] = template_identity return { - "schemaVersion": "candidate-shot-state-v4", + "schemaVersion": "candidate-shot-state-v5", "continuation": candidate_sequence_continuation_value(result), "variationPoolCandidateIndices": pool_indices, "canonicalRankedTemplates": [ @@ -5448,7 +5841,7 @@ def cached_canonical_ranked_templates( if ( not isinstance(cached_state, dict) - or cached_state.get("schemaVersion") != "candidate-shot-state-v4" + or cached_state.get("schemaVersion") != "candidate-shot-state-v5" ): raise candidate_cache.CandidateCacheIntegrityError( "Cached candidate shot state has an unsupported schema." @@ -5625,7 +6018,7 @@ def validate_cached_continuation_state( ) -> None: if ( not isinstance(cached_state, dict) - or cached_state.get("schemaVersion") != "candidate-shot-state-v4" + or cached_state.get("schemaVersion") != "candidate-shot-state-v5" or cached_state.get("continuation") != candidate_sequence_continuation_value(continuation_result) ): @@ -6211,11 +6604,13 @@ def reconstruct_base_single_shot_context( raise ValueError( "Base sequence-continuation state is incomplete." ) from error - if not math.isfinite(side_sign) or abs(side_sign) < 1e-6: + if not math.isfinite(side_sign): raise ValueError("Base sequence-continuation side is invalid.") previous = RealizedShotState( template=template, - side_sign=1.0 if side_sign > 0.0 else -1.0, + side_sign=( + 1.0 if side_sign > 0.0 else -1.0 if side_sign < 0.0 else 0.0 + ), motion_type=motion_type, ) previous_value = { @@ -6245,6 +6640,120 @@ def reconstruct_base_single_shot_context( ) +def world_camera_shot_transition_state( + world_camera: np.ndarray, + shot: dict, + root: np.ndarray, + joints: np.ndarray, + body_scale: float, + target_aspect_ratio: float, +) -> dict[str, object]: + """Rebuild one rendered shot boundary state from final world-camera rows.""" + + start = int(shot["startFrame"]) + end = int(shot["endFrameExclusive"]) + if not 0 <= start < end <= len(world_camera): + raise ValueError("Shot transition span is outside the world camera.") + shot_type = planner.normalize_shot_type(str(shot["shotType"])) + local_joints = np.asarray(joints[start:end], dtype=np.float32) + root_position = np.asarray(root[start:end, :3], dtype=np.float32) + root_rotation = np.asarray(root[start:end, 3:7], dtype=np.float32) + target_local = smooth_segment(shot_focus_local(local_joints, shot_type)) + target_world = root_position + train.quat_rotate( + root_rotation, + target_local * float(body_scale), + ) + joint_world = root_position[:, None, :] + train.quat_rotate( + root_rotation[:, None, :], + local_joints * float(body_scale), + ) + camera = np.asarray(world_camera[start:end], dtype=np.float32) + composition = build_source_composition_path( + camera[:, :3], + camera[:, 3:7], + camera[:, 7], + camera[:, 8], + joint_world, + shot_type, + target_aspect_ratio, + ) + return adjacent_transition.build_transition_state( + camera[:, :3], + camera[:, 3:7], + camera[:, 7], + target_world, + composition, + shot_type, + str(shot.get("motionType", "static")), + sample_rate=HYBRID_SAMPLE_RATE, + ) + + +def base_shot_transition_state( + base: BaseGenerationComposite, + shot_index: int, + root: np.ndarray, + joints: np.ndarray, + body_scale: float, + target_aspect_ratio: float, +) -> dict[str, object]: + """Rebuild rendered boundary state for an immutable base-composite shot.""" + + if not 0 <= int(shot_index) < len(base.shots): + raise ValueError("Base transition shot index is outside the composite.") + return world_camera_shot_transition_state( + base.world_camera, + base.shots[int(shot_index)], + root, + joints, + body_scale, + target_aspect_ratio, + ) + + +def build_authoritative_adjacent_transition_edges( + shots: list[dict], + world_camera: np.ndarray, + root: np.ndarray, + joints: np.ndarray, + body_scale: float, + target_aspect_ratio: float, +) -> list[dict[str, object]]: + """Recompute every final cut edge after full or single-shot materialization.""" + + states = [ + world_camera_shot_transition_state( + world_camera, + shot, + root, + joints, + body_scale, + target_aspect_ratio, + ) + for shot in shots + ] + edges: list[dict[str, object]] = [] + for next_index in range(1, len(shots)): + previous_index = next_index - 1 + edges.append( + { + "edgeIndex": previous_index, + "previousShotIndex": previous_index, + "nextShotIndex": next_index, + "cutFrame": int(shots[next_index]["startFrame"]), + "previousCameraName": str( + shots[previous_index].get("cameraName", "") + ), + "nextCameraName": str(shots[next_index].get("cameraName", "")), + "transition": adjacent_transition.score_pairwise_transition( + states[previous_index], + states[next_index], + ), + } + ) + return edges + + def single_shot_cache_identity_for_generation( args: argparse.Namespace, target_record: train.SongRecord, @@ -6469,7 +6978,7 @@ def validate_loaded_candidate_pool( metadata = loaded.bundle_metadata if ( not isinstance(metadata, dict) - or metadata.get("contractVersion") != "hybrid-candidate-runtime-v6" + or metadata.get("contractVersion") != "hybrid-candidate-runtime-v8" or metadata.get("cutBoundaries") != boundaries ): raise candidate_cache.CandidateCacheIntegrityError( @@ -6850,6 +7359,14 @@ def candidate_logic_identifier() -> str: { "contractVersion": CANDIDATE_LOGIC_CONTRACT_VERSION, "generatorSourceSha256": sha256_file(Path(__file__).resolve()), + "policySourceSha256": { + "adjacentTransition": sha256_file( + Path(adjacent_transition.__file__).resolve() + ), + "cameraKinematics": sha256_file( + Path(camera_kinematics.__file__).resolve() + ), + }, "behaviorConstants": behavior_constants, } ) @@ -7663,10 +8180,13 @@ def main() -> None: templates_by_identity[identity_key] = template base_single_shot_context: BaseSingleShotContext | None = None + previous_transition_state: dict[str, object] | None = None + next_transition_state: dict[str, object] | None = None if base_generation is not None: + selected_shot_index = int(args.only_shot_index) base_single_shot_context = reconstruct_base_single_shot_context( base_generation, - int(args.only_shot_index), + selected_shot_index, templates_by_identity, ) recent = list(base_single_shot_context.recent) @@ -7674,6 +8194,24 @@ def main() -> None: static_budget_motion_history = list( base_single_shot_context.static_budget_motion_history ) + if selected_shot_index > 0: + previous_transition_state = base_shot_transition_state( + base_generation, + selected_shot_index - 1, + root, + joints, + target.body_scale, + target_aspect_ratio, + ) + if selected_shot_index + 1 < len(base_generation.shots): + next_transition_state = base_shot_transition_state( + base_generation, + selected_shot_index + 1, + root, + joints, + target.body_scale, + target_aspect_ratio, + ) generation_timer.mark("shot_planning") effective_preparation_cache_key = ( @@ -7714,6 +8252,7 @@ def main() -> None: candidate_planning_replay: list[dict[str, object]] = [] cached_candidate_runtime: dict[int, CachedCandidateRuntimeShot] = {} candidate_cache_hit_active = False + candidate_cache_selection_only_expansion_count = 0 single_shot_identity: dict[str, object] | None = None single_shot_cached_pool: candidate_cache.CandidatePoolShot | None = None single_shot_cache_hit_active = False @@ -8126,7 +8665,52 @@ def main() -> None: "rebuilding the selected shot.", file=sys.stderr, ) - if cached_runtime_shot is not None: + if ( + cached_runtime_shot is not None + and candidate_pool_may_have_unevaluated_normal_ranks( + cached_runtime_shot.candidate_results + ) + and transition_context_needs_more_candidates( + cached_runtime_shot.safe_results, + previous_state=previous_transition_state, + next_state=next_transition_state, + ) + ): + # Resume after the Seed-independent cached prefix. Extra ranks are + # selection-only and never change continuation/static history or + # the bytes subsequently encoded as the reusable candidate pool. + candidate_cache_selection_only_expansion_count += 1 + print( + "Candidate cache transition pool is too narrow for shot " + f"{shot_index}; evaluating selection-only later ranks.", + file=sys.stderr, + ) + candidate_results, safe_results = evaluate_safe_candidate_pool( + ensure_ranked_templates(), + previous, + target, + joints, + root, + start, + end, + target_aspect_ratio, + effective_composition, + control, + args.body_follow_smoothing_seconds, + args.body_follow_dead_zone_meters, + effective_motion_intensity, + moving_opening_required, + desired_motion_type, + rolling_static_budget_required, + planned_non_static_required, + previous_transition_state=previous_transition_state, + next_transition_state=next_transition_state, + cached_base_candidate_results=( + cached_runtime_shot.candidate_results + ), + ) + variation_pool = cached_runtime_shot.variation_pool + elif cached_runtime_shot is not None: candidate_results = cached_runtime_shot.candidate_results safe_results = cached_runtime_shot.safe_results variation_pool = cached_runtime_shot.variation_pool @@ -8149,9 +8733,19 @@ def main() -> None: desired_motion_type, rolling_static_budget_required, planned_non_static_required, + previous_transition_state=previous_transition_state, + next_transition_state=next_transition_state, ) - variation_pool = build_distinct_safe_variation_pool(safe_results) - + base_candidate_results = cache_eligible_candidate_results( + candidate_results + ) + base_safe_results = selectable_safe_candidate_results( + base_candidate_results, + moving_opening_required, + desired_motion_type, + rolling_static_budget_required, + planned_non_static_required, + ) if not safe_results: closest_result = min( candidate_results, @@ -8178,24 +8772,58 @@ def main() -> None: f"{closest['strictMinimumViewportMargin']:.6f}." ) + planner_variation_pool = ( + variation_pool + if cached_runtime_shot is not None + else build_distinct_safe_variation_pool(base_safe_results) + ) + transition_safe_results = [dict(result) for result in safe_results] + apply_adjacent_transition_quality( + transition_safe_results, + previous_state=previous_transition_state, + next_state=next_transition_state, + ) + ( + transition_eligible_results, + transition_gate_fallback_used, + ) = transition_eligible_candidate_results(transition_safe_results) + transition_variation_pool = build_distinct_safe_variation_pool( + transition_eligible_results + ) selected_result, variation_metadata = select_seeded_camera_variation( - safe_results, + transition_eligible_results, args.seed, shot_index, - variation_pool, + transition_variation_pool, + ) + variation_metadata.update( + { + "transitionCandidateCount": len(transition_safe_results), + "transitionBaseCandidateCount": len(base_safe_results), + "transitionSelectionOnlyCandidateCount": sum( + candidate.get("cacheEligible", True) is False + for candidate in safe_results + ), + "transitionEligibleCandidateCount": len( + transition_eligible_results + ), + "transitionGateFallbackUsed": ( + transition_gate_fallback_used + ), + } ) continuation_result = select_seed_independent_sequence_continuation( - safe_results, - variation_pool, + base_safe_results, + planner_variation_pool, ) candidate_pool_shots.append( encode_candidate_pool_shot( shot_index, start, end, - candidate_results, + base_candidate_results, continuation_result, - variation_pool, + planner_variation_pool, ) ) continuation_template = continuation_result["template"] @@ -8209,7 +8837,7 @@ def main() -> None: continuation_motion_type ) static_budget_history_family = variation_pool_static_budget_family( - variation_pool, + planner_variation_pool, continuation_motion_family, ) static_budget_slot_reserved = ( @@ -8499,6 +9127,18 @@ def main() -> None: "trajectoryVelocityLimitScale": selected_result[ "trajectoryVelocityLimitScale" ], + "trajectoryPostKinematicVelocityLimitScale": ( + selected_result.get( + "trajectoryPostKinematicVelocityLimitScale", + 1.0, + ) + ), + "trajectoryRelativeKinematicPassExecuted": bool( + selected_result.get( + "trajectoryRelativeKinematicPassExecuted", + False, + ) + ), "trajectoryRelativeStepMetersBeforeVelocityLimit": ( selected_result[ "trajectoryRelativeStepMetersBeforeVelocityLimit" @@ -8509,6 +9149,14 @@ def main() -> None: "trajectoryRelativeStepMetersAfterVelocityLimit" ] ), + "trajectoryRelativeStepMetersAfterKinematicRegularization": ( + selected_result.get( + "trajectoryRelativeStepMetersAfterKinematicRegularization", + selected_result[ + "trajectoryRelativeStepMetersAfterVelocityLimit" + ], + ) + ), "trajectoryRelativeStepLimitMeters": selected_result[ "trajectoryRelativeStepLimitMeters" ], @@ -8532,7 +9180,27 @@ def main() -> None: "realizedSideSign": selected_result["realizedSideSign"], "candidateCountEvaluated": len(candidate_results), "selectedCandidateRank": int(selected_result["candidateRank"]), - "selectionScore": float(selected_result["selectionScore"]), + "selectionScore": effective_candidate_selection_score( + selected_result + ), + "selectionScoreBeforeTransition": float( + selected_result["selectionScoreBeforeTransition"] + ), + "adjacentTransitionQuality": selected_result[ + "transitionQuality" + ], + "selectedTransitionContextPolicy": ( + SELECTED_TRANSITION_CONTEXT_POLICY + ), + "selectedTransitionCandidateRank": int( + selected_result["candidateRank"] + ), + "selectedTransitionUsesActualPreviousCamera": bool( + previous_transition_state is not None + ), + "selectedTransitionUsesFixedNextCamera": bool( + next_transition_state is not None + ), **variation_metadata, "sequenceContinuationPolicy": ( SEED_VARIATION_SEQUENCE_CONTEXT_POLICY @@ -8578,10 +9246,9 @@ def main() -> None: ) else: shots.append(generated_shot) - previous = RealizedShotState( - template=continuation_template, - side_sign=float(continuation_result["realizedSideSign"]), - motion_type=continuation_motion_type, + previous, previous_transition_state = advance_sequence_context( + selected_result, + continuation_result, ) if single_shot_mode: planner_replan_count = sum( @@ -8604,7 +9271,7 @@ def main() -> None: candidate_identity, candidate_pool_shots, bundle_metadata={ - "contractVersion": "hybrid-candidate-runtime-v6", + "contractVersion": "hybrid-candidate-runtime-v8", "cutBoundaries": [int(value) for value in boundaries], "planningReplay": [ { @@ -8757,6 +9424,17 @@ def main() -> None: if not np.isfinite(world_camera).all(): raise ValueError("Hybrid camera contains non-finite values.") + authoritative_adjacent_transitions = ( + build_authoritative_adjacent_transition_edges( + shots, + world_camera, + root, + joints, + target.body_scale, + target_aspect_ratio, + ) + ) + output_publisher = AtomicOutputPublisher(args.output) args.output = output_publisher.final_path staged_output = output_publisher.staging_path @@ -8769,6 +9447,14 @@ def main() -> None: { "schemaVersion": HYBRID_SHOTS_SCHEMA_VERSION, "shots": shots, + "adjacentTransitionSchemaVersion": ( + ADJACENT_TRANSITION_EDGE_LIST_SCHEMA_VERSION + ), + "adjacentTransitionPolicy": ( + SELECTED_TRANSITION_CONTEXT_POLICY + ), + "adjacentTransitionsAuthoritative": True, + "adjacentTransitions": authoritative_adjacent_transitions, }, ensure_ascii=False, indent=2, @@ -8878,6 +9564,16 @@ def main() -> None: "targetHasAuthoredCamera": not target_record.is_generation_input, "targetCameraDataLoaded": not target_record.is_generation_input, "targetCameraDataUsed": False, + "adjacentTransitionAudit": { + "schemaVersion": ADJACENT_TRANSITION_EDGE_LIST_SCHEMA_VERSION, + "policyVersion": SELECTED_TRANSITION_CONTEXT_POLICY, + "authoritativeLocation": "shots.json#adjacentTransitions", + "edgeCount": len(authoritative_adjacent_transitions), + "allEdgesPassed": all( + bool(edge["transition"]["passed"]) + for edge in authoritative_adjacent_transitions + ), + }, "targetTrainingIndexMembership": target_entry is not None, "cutPlanning": { **cut_plan_metadata, @@ -8922,6 +9618,10 @@ def main() -> None: ), "lookupStatus": candidate_cache_lookup_status, "effectiveStatus": ( + "partial_hit_transition_expansion" + if effective_candidate_cache_hit + and candidate_cache_selection_only_expansion_count > 0 + else "hit" if effective_candidate_cache_hit else "published_after_miss" @@ -8936,7 +9636,13 @@ def main() -> None: if candidate_cache_path is not None else None ), - "retargetingSkipped": effective_candidate_cache_hit, + "retargetingSkipped": bool( + effective_candidate_cache_hit + and candidate_cache_selection_only_expansion_count == 0 + ), + "selectionOnlyExpansionShotCount": ( + candidate_cache_selection_only_expansion_count + ), "seedIndependent": True, "published": published_candidate_bundle is not None, "candidateCount": sum( @@ -9131,6 +9837,68 @@ def main() -> None: MOTION_EXPRESSIVENESS_ROTATION_SPEED_DPS ), }, + "translationKinematicPolicy": { + "version": ( + camera_kinematics.TRANSLATION_KINEMATIC_POLICY_VERSION + ), + "eligibleMotionFamilies": [ + "dolly", + "truck", + "crane", + "drift", + "orbit_direction_reversal", + ], + "continuousSingleDirectionOrbitExcluded": True, + "orbitDirectionReversalDetectedFromUnwrappedAzimuth": True, + "reversalAngleMinimumDegrees": ( + camera_kinematics.DEFAULT_REVERSAL_ANGLE_DEGREES + ), + "stopSpeedRatioLimit": ( + camera_kinematics.DEFAULT_STOP_SPEED_RATIO + ), + "angleAwareSpeedRatioControlPoints": [ + [40.0, 0.90], + [45.0, 0.87], + [50.0, 0.84], + [55.0, 0.80], + [60.0, 0.70], + [90.0, 0.45], + [120.0, 0.12], + [150.0, 0.0], + ], + "decelerationAndAccelerationWindowSeconds": ( + camera_kinematics.DEFAULT_REVERSAL_WINDOW_SECONDS + ), + "detectionSpace": { + "orbit": "camera_relative_trajectory", + "otherEligibleFamilies": "final_world_position", + }, + "relativeTemplatePrepassApplied": True, + "relativeTemplatePrepassScope": "orbit_only", + "nonOrbitFinalWorldPassOnly": True, + "perShotKinematicPassCount": 1, + "turnWindowContinuity": "C2_quintic_outer_seams", + "initialInputEventsOnly": True, + "maximumAccelerationRegressionRatio": ( + camera_kinematics.DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_RATIO + ), + "maximumAccelerationRegressionDeltaMetersPerSecondSquared": ( + camera_kinematics.DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2 + ), + "accelerationGuardFloorMetersPerSecondSquared": ( + camera_kinematics.DEFAULT_ACCELERATION_GUARD_FLOOR_MPS2 + ), + "maximumJerkRegressionRatio": ( + camera_kinematics.DEFAULT_MAXIMUM_JERK_REGRESSION_RATIO + ), + "maximumJerkRegressionDeltaMetersPerSecondCubed": ( + camera_kinematics.DEFAULT_MAXIMUM_JERK_REGRESSION_DELTA_MPS3 + ), + "jerkGuardFloorMetersPerSecondCubed": ( + camera_kinematics.DEFAULT_JERK_GUARD_FLOOR_MPS3 + ), + "unresolvedReversalSelectionPenaltyApplied": True, + }, "cameraDirectiveSchemaVersion": CAMERA_DIRECTIVE_SCHEMA_VERSION, "directivesJson": ( portable_path(args.directives_json) @@ -9236,6 +10004,8 @@ def main() -> None: "generatorCodeSha256": code_identifier( [ Path(__file__), + Path(adjacent_transition.__file__), + Path(camera_kinematics.__file__), Path(cut_planner.__file__), Path(cut_reference.__file__), Path(planner.__file__), @@ -9557,6 +10327,101 @@ def main() -> None: "motionExpressivenessRewardFrameWeighted": frame_weighted( "motionExpressivenessReward" ), + "translationKinematicEligibleShotCount": sum( + bool(shot.get("translationKinematicPolicyEligible", False)) + for shot in shots + ), + "translationKinematicRegularizedShotCount": sum( + bool(shot.get("translationKinematicPolicyApplied", False)) + for shot in shots + ), + "translationKinematicDynamicsGuardShotCount": sum( + bool( + shot.get( + "translationKinematicDynamicsGuardTriggered", + False, + ) + ) + for shot in shots + ), + "translationDirectionReversalCountBeforeAfter": [ + sum( + int(shot.get("translationDirectionReversalCountBefore", 0)) + for shot in shots + ), + sum( + int(shot.get("translationDirectionReversalCountAfter", 0)) + for shot in shots + ), + ], + "translationAbruptReversalCountBeforeAfter": [ + sum( + int(shot.get("translationAbruptReversalCountBefore", 0)) + for shot in shots + ), + sum( + int(shot.get("translationAbruptReversalCountAfter", 0)) + for shot in shots + ), + ], + "translationAccelerationBeforeMinMedianP90Max": value_distribution( + [ + float( + shot.get( + "translationAccelerationMetersPerSecondSquaredMaxBefore", + 0.0, + ) + ) + for shot in shots + ] + ), + "translationAccelerationAfterMinMedianP90Max": value_distribution( + [ + float( + shot.get( + "translationAccelerationMetersPerSecondSquaredMaxAfter", + 0.0, + ) + ) + for shot in shots + ] + ), + "translationJerkBeforeMinMedianP90Max": value_distribution( + [ + float( + shot.get( + "translationJerkMetersPerSecondCubedMaxBefore", + 0.0, + ) + ) + for shot in shots + ] + ), + "translationJerkAfterMinMedianP90Max": value_distribution( + [ + float( + shot.get( + "translationJerkMetersPerSecondCubedMaxAfter", + 0.0, + ) + ) + for shot in shots + ] + ), + "translationKinematicSelectionPenaltyFrameWeighted": float( + np.average( + [ + float( + shot.get( + "translationKinematicSelectionPenalty", + 0.0, + ) + ) + for shot in shots + ], + weights=shot_frame_counts, + ) + ), "trajectoryLimitedShotCount": sum( float(shot["trajectoryLimitScale"]) < 0.999 for shot in shots @@ -9593,6 +10458,18 @@ def main() -> None: for shot in shots ) ), + "trajectoryRelativeStepMetersAfterKinematicRegularizationMax": float( + max( + shot.get( + "trajectoryRelativeStepMetersAfterKinematicRegularization", + shot.get( + "trajectoryRelativeStepMetersAfterVelocityLimit", + 0.0, + ), + ) + for shot in shots + ) + ), "sourceBoundaryTrimmedShotCount": sum( int(shot["sourcePoseTrimStartFrames"]) > 0 or int(shot["sourcePoseTrimEndFrames"]) > 0 diff --git a/CameraAI~/package.json b/CameraAI~/package.json index d8b64ba..d0889af 100644 --- a/CameraAI~/package.json +++ b/CameraAI~/package.json @@ -1,6 +1,6 @@ { "name": "com.mingle.cw-ai", - "version": "0.4.1", + "version": "0.4.2", "displayName": "Mingle Camera Work AI", "description": "High-quality Python CLI-backed Unity Timeline camera generation, per-shot editable clips, dataset export, and A/B review tools.", "unity": "6000.0", diff --git a/README.md b/README.md index f6a350a..21d6241 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Install **Git and Git LFS before opening Unity**, then use **Add package from git URL** with: ```text -https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.5 +https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.6 ``` The Camera AI package includes the complete Windows x64 diff --git a/package.json b/package.json index a4278a3..7d700e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.streamingle.utilities", "displayName": "Streamingle Utilities", - "version": "0.1.5", + "version": "0.1.6", "unity": "6000.0", "description": "Reusable Streamingle runtime components and Unity editor utilities.", "keywords": [