From b78c6319b600bc5de1a2aefbabb8ae3ea6d31111 Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Sun, 9 Aug 2026 16:33:02 +0900 Subject: [PATCH] fix(camera-ai): smooth generated camera curves --- CameraAI~/CHANGELOG.md | 23 + .../Documentation~/EXTERNAL_INSTALLATION.md | 6 +- CameraAI~/Editor/AICameraGeneratorWindow.cs | 206 +- .../Editor/AICameraTimelinePreviewImporter.cs | 2129 +++++++++++++++-- CameraAI~/README.md | 2 +- CameraAI~/THIRD_PARTY_NOTICES.md | 2 +- .../Editor/AICameraCurveSimplifierTests.cs | 698 +++++- .../Editor/AICameraGeneratorWindowTests.cs | 164 +- .../Tools~/CWCameraWorker/CWCameraWorker.exe | 4 +- .../CWCameraWorker/_internal/base_library.zip | Bin 1333490 -> 1333490 bytes .../cw_camera_worker_build_identity.json | 14 +- .../CameraDirector/camera_kinematics.py | 200 +- .../CameraDirector/cw_camera_runtime.py | 2 +- .../CameraDirector/generate_hybrid.py | 1030 +++++++- .../_internal/wheel-0.45.1.dist-info/RECORD | 2 +- ...w_camera_worker_distribution_manifest.json | 10 +- CameraAI~/package.json | 2 +- README.md | 2 +- 18 files changed, 4078 insertions(+), 418 deletions(-) diff --git a/CameraAI~/CHANGELOG.md b/CameraAI~/CHANGELOG.md index dab6e73..42e5254 100644 --- a/CameraAI~/CHANGELOG.md +++ b/CameraAI~/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.4.10 - 2026-08-09 + +- Replaced the loose first-passing angular limiter with an authored-target + minimum-jerk selector. The Arisa regression output keeps all 69 music-shaped + cuts while angular jerk P90 falls from about 852 to 235 deg/s^3 and P99 from + about 4,455 to 912 deg/s^3. +- Made final float32 quaternion tracking authoritative, removed shot-entry + pre-echo, added a C2 semantic-focus dead zone, and recorded explicit angular + and dynamics fallback shot indices. +- Hardened translation rewrites and the final radial clamp so a repair cannot + hide or introduce a local acceleration/jerk seam. Intentional dolly and orbit + motion remains available. +- Baked position and quaternion animation as audited not-a-knot C2 curves + instead of piecewise-linear or C1-only curves. The importer verifies C2 seams, + 240/480 Hz motion, quaternion continuity, and the post-Unity clip curves, with + adaptive key refinement and fail-closed dense C2 fallback. +- Resolved the active package through `FileUtil.GetPhysicalPath` so serialized + paths to an older `Library/PackageCache` revision can no longer keep running a + stale worker after a package update. Valid explicit external paths are kept. +- Rebuilt the self-contained Windows worker as 0.1.6 with Python 3.12.13. The + clean 647-file build passes `doctor`; camera, time, and shot outputs are + byte-identical to source generation for the 12,269-frame regression input. + ## 0.4.9 - 2026-08-09 - Restored the authored same-audio cut prior for production generation inputs diff --git a/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md index 2a55053..c410768 100644 --- a/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md +++ b/CameraAI~/Documentation~/EXTERNAL_INSTALLATION.md @@ -7,7 +7,7 @@ - Git 2.14 or newer available on `PATH` - Git LFS installed before Unity resolves the package -The bundled `CWCameraWorker` contains Python 3.10 and its inference +The bundled `CWCameraWorker` contains Python 3.12.13 and its inference dependencies. Do not install Python on an artist workstation for this package. The worker is an onedir build: `CWCameraWorker.exe` and its `_internal` folder must remain together. @@ -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.15 +https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.16 ``` The initial package download is large because the frozen Windows worker is @@ -26,7 +26,7 @@ it, remove the package lock entry, and add the package again. ## Reference data -Version 0.1.15 includes a compact, read-only `RuntimeData~` bundle with the +Version 0.1.16 includes a compact, read-only `RuntimeData~` bundle with the 263 prepared reference songs, cut policy, and ranker model. An artist workstation does not need a separate `CW-AI` checkout or Python installation. If a newer access-controlled library is available, it remains an optional diff --git a/CameraAI~/Editor/AICameraGeneratorWindow.cs b/CameraAI~/Editor/AICameraGeneratorWindow.cs index d6266fb..7708d7a 100644 --- a/CameraAI~/Editor/AICameraGeneratorWindow.cs +++ b/CameraAI~/Editor/AICameraGeneratorWindow.cs @@ -4169,31 +4169,122 @@ namespace Streamingle.Editor { var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Environment.CurrentDirectory; - return AICameraCliRunner.ResolveExecutable( + return ResolveCliExecutablePath( ignoreConfigured ? string.Empty : _cliExecutable, _cwAiRoot, - projectRoot); + projectRoot, + ResolvePhysicalPackageRoot()); + } + + internal static string ResolveCliExecutablePath( + string configuredExecutable, + string cwAiRoot, + string projectRoot, + string packageRoot) + { + var normalizedConfigured = TryGetFullPath(configuredExecutable); + if (!string.IsNullOrWhiteSpace(normalizedConfigured) && + !IsCameraAiPackageCachePath(normalizedConfigured) && + File.Exists(normalizedConfigured)) + { + // A valid path outside PackageCache was explicitly selected by + // the user and must remain authoritative. + return normalizedConfigured; + } + + var normalizedPackageRoot = TryGetFullPath(packageRoot); + if (!string.IsNullOrWhiteSpace(normalizedPackageRoot)) + { + var packagedWorker = Path.Combine( + normalizedPackageRoot, + "Tools~", + "CWCameraWorker", + AICameraCliRunner.ExecutableName); + if (File.Exists(packagedWorker)) + { + return Path.GetFullPath(packagedWorker); + } + + packagedWorker = Path.Combine( + normalizedPackageRoot, + "Tools~", + AICameraCliRunner.ExecutableName); + if (File.Exists(packagedWorker)) + { + return Path.GetFullPath(packagedWorker); + } + } + + // Never fall back to a serialized worker or data root from an old + // Git package revision merely because Unity has not evicted that + // PackageCache directory yet. + var safeConfigured = IsCameraAiPackageCachePath(configuredExecutable) + ? string.Empty + : normalizedConfigured; + var safeCwAiRoot = IsCameraAiPackageCachePath(cwAiRoot) + ? string.Empty + : TryGetFullPath(cwAiRoot); + return AICameraCliRunner.ResolveExecutable( + safeConfigured, + safeCwAiRoot, + projectRoot, + normalizedPackageRoot); } internal static string ResolveDefaultCwAiRoot( string projectRoot, string configuredRoot) + { + return ResolveDefaultCwAiRoot( + projectRoot, + configuredRoot, + ResolvePhysicalPackageRoot()); + } + + internal static string ResolveDefaultCwAiRoot( + string projectRoot, + string configuredRoot, + string packageRoot) { var normalizedProjectRoot = string.IsNullOrWhiteSpace(projectRoot) ? Environment.CurrentDirectory : Path.GetFullPath(projectRoot); + + var normalizedConfigured = TryGetFullPath(configuredRoot); + if (!string.IsNullOrWhiteSpace(normalizedConfigured) && + !IsCameraAiPackageCachePath(normalizedConfigured) && + IsGenerationLibraryRoot(normalizedConfigured)) + { + // Preserve a valid repository or data library that the user + // explicitly selected outside Unity's PackageCache. + return normalizedConfigured; + } + var candidates = new List(); - // Prefer the self-contained package, including Unity's resolved - // Git PackageCache path. A stale serialized external CW-AI root - // must not silently override the bundled reference library. - candidates.AddRange(EnumeratePackagedRuntimeRoots(normalizedProjectRoot)); - candidates.Add(configuredRoot); - candidates.Add(normalizedProjectRoot); + var normalizedPackageRoot = TryGetFullPath(packageRoot); + if (!string.IsNullOrWhiteSpace(normalizedPackageRoot)) + { + candidates.Add(Path.Combine( + normalizedPackageRoot, + "RuntimeData~")); + } + + // This logical package path supports embedded/local packages when + // FileUtil cannot resolve a physical path during early domain load. candidates.Add(Path.Combine( - Directory.GetParent(normalizedProjectRoot)?.FullName ?? normalizedProjectRoot, - "CW-AI")); - candidates.Add(Environment.CurrentDirectory); + "Packages", + "com.mingle.cw-ai", + "RuntimeData~")); + candidates.AddRange(new[] + { + normalizedProjectRoot, + Path.Combine( + Directory.GetParent(normalizedProjectRoot)?.FullName ?? + normalizedProjectRoot, + "CW-AI"), + Environment.CurrentDirectory + }); foreach (var candidate in candidates) { @@ -4224,34 +4315,47 @@ namespace Streamingle.Editor "CW-AI"); } - private static IEnumerable EnumeratePackagedRuntimeRoots( - string projectRoot) + private static string ResolvePhysicalPackageRoot() { - yield return Path.Combine( - projectRoot, - "Packages", - "com.mingle.cw-ai", - "RuntimeData~"); - var packageCache = Path.Combine(projectRoot, "Library", "PackageCache"); - if (!Directory.Exists(packageCache)) - { - yield break; - } - IEnumerable matches; try { - matches = Directory.EnumerateDirectories( - packageCache, - "com.mingle.cw-ai@*", - SearchOption.TopDirectoryOnly); + return TryGetFullPath(FileUtil.GetPhysicalPath( + "Packages/com.mingle.cw-ai")); } catch (Exception) { - yield break; + return string.Empty; } - foreach (var match in matches.OrderByDescending(path => path)) + } + + private static bool IsCameraAiPackageCachePath(string candidate) + { + var fullPath = TryGetFullPath(candidate); + if (string.IsNullOrWhiteSpace(fullPath)) { - yield return Path.Combine(match, "RuntimeData~"); + return false; + } + + var normalized = fullPath.Replace('\\', '/'); + return normalized.IndexOf( + "/Library/PackageCache/com.mingle.cw-ai@", + StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string TryGetFullPath(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return string.Empty; + } + + try + { + return Path.GetFullPath(candidate.Trim()); + } + catch (Exception) + { + return string.Empty; } } @@ -4322,26 +4426,32 @@ namespace Streamingle.Editor currentRoot = string.Empty; } } - if (string.Equals( - currentRoot.TrimEnd('\\', '/'), - resolvedRoot.TrimEnd('\\', '/'), + var previousRoot = _cwAiRoot; + var repairedLibraryRoot = !string.Equals( + currentRoot.TrimEnd('\\', '/'), + resolvedRoot.TrimEnd('\\', '/'), + StringComparison.OrdinalIgnoreCase); + if (repairedLibraryRoot) + { + _cwAiRoot = resolvedRoot; + AppendLog( + "[library-root-auto-repair] " + + $"{previousRoot} -> {_cwAiRoot}"); + } + + var previousExecutable = _cliExecutable; + var resolvedExecutable = ResolveCliExecutable(); + if (!string.Equals( + TryGetFullPath(previousExecutable), + TryGetFullPath(resolvedExecutable), StringComparison.OrdinalIgnoreCase)) { - return false; + _cliExecutable = resolvedExecutable; + AppendLog( + "[worker-path-auto-repair] " + + $"{previousExecutable} -> {_cliExecutable}"); } - - var previousRoot = _cwAiRoot; - _cwAiRoot = resolvedRoot; - if (string.IsNullOrWhiteSpace(_cliExecutable) || - !File.Exists(_cliExecutable)) - { - _cliExecutable = ResolveCliExecutable(true); - } - - AppendLog( - "[library-root-auto-repair] " + - $"{previousRoot} -> {_cwAiRoot}"); - return true; + return repairedLibraryRoot; } private static string ExistingDirectoryOrFallback( diff --git a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs index 6a13fe5..d55db1f 100644 --- a/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs +++ b/CameraAI~/Editor/AICameraTimelinePreviewImporter.cs @@ -67,6 +67,45 @@ namespace Streamingle.Editor private const float PositionCurveAuditMotionNoiseDeadbandMeters = 0.0005f; private const float PositionCurveAuditMotionRetentionMinimumMeters = 0.002f; private const float PositionCurveAuditMotionRetentionRatio = 0.65f; + private const float RotationCurveAuditAngleErrorEpsilonDegrees = 0.01f; + private const float RotationCurveAuditSpeedRatio = 1.15f; + private const float RotationCurveAuditSpeedDeltaDegreesPerSecond = 2f; + private const float RotationCurveAuditSpeedFloorDegreesPerSecond = 15f; + private const float RotationCurveAuditAccelerationRatio = 1.25f; + private const float RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared = + 60f; + private const float RotationCurveAuditAccelerationFloorDegreesPerSecondSquared = + 240f; + // Reconstructing a continuous quaternion path from 60 Hz samples can + // legitimately reveal a higher finite between-frame peak. These limits + // still reject the former C1 seam spikes (2.3x-3.8x on shots 13/66/68) + // without treating bounded C2 interpolation as a regression. + private const float RotationCurveAuditJerkRatio = 1.85f; + private const float RotationCurveAuditJerkDeltaDegreesPerSecondCubed = 1200f; + private const float RotationCurveAuditJerkFloorDegreesPerSecondCubed = 1800f; + private const float RotationCurveAuditMotionNoiseDeadbandDegrees = 0.02f; + private const float RotationCurveAuditMotionRetentionMinimumDegrees = 0.15f; + private const float RotationCurveAuditMotionRetentionRatio = 0.75f; + private const int CurveAuditPrimarySamplesPerSecond = 240; + private const int CurveAuditConvergenceSamplesPerSecond = 480; + private const float CurveAuditConvergenceRatio = 1.35f; + private const double CurveAuditLocalWindowSeconds = 0.125; + private const float CurveC1AbsoluteTolerance = 0.0001f; + // AnimationCurve stores key times, values and tangents as floats. At a + // 60 Hz knot this is below the acceleration represented by one float + // ULP at ordinary stage scale, while still rejecting a visible C1-only + // corner by several orders of magnitude. + private const float CurveC2AbsoluteTolerance = 0.05f; + private const float CurveC2RelativeTolerance = 0.005f; + private const float PositionCurveAuditConvergenceSpeedFloor = 0.1f; + private const float PositionCurveAuditConvergenceAccelerationFloor = 2f; + // Third finite differences amplify AnimationCurve's float evaluation + // noise by dt^-3. The analytic C2 seam audit remains the primary guard; + // these floors reject rate-divergent impulses above the numeric floor. + private const float PositionCurveAuditConvergenceJerkFloor = 700f; + private const float RotationCurveAuditConvergenceSpeedFloor = 5f; + private const float RotationCurveAuditConvergenceAccelerationFloor = 120f; + private const float RotationCurveAuditConvergenceJerkFloor = 3600f; public const CurveSimplificationPreset DefaultCurveSimplificationPreset = CurveSimplificationPreset.Balanced; @@ -439,7 +478,7 @@ namespace Streamingle.Editor var animationGroup = copiedTimeline.CreateTrack( null, GeneratedAnimationTrackName); - var positionExactFallbackCount = 0; + var positionDenseFallbackCount = 0; foreach (var shotCamera in shotCameras) { var generatedClip = BuildShotAnimationClip( @@ -453,11 +492,11 @@ namespace Streamingle.Editor out var positionFallbackReason); if (positionUsedExactFallback) { - positionExactFallbackCount++; + positionDenseFallbackCount++; Debug.LogWarning( $"AI camera shot {shotCamera.Definition.index:D3} " + - $"({shotCamera.Definition.cameraName}) used Exact position " + - $"curves after the reduced-curve audit: " + + $"({shotCamera.Definition.cameraName}) used dense C2 " + + $"position curves after the reduced-curve audit: " + $"{positionFallbackReason}"); } var animationClipPath = AssetDatabase.GenerateUniqueAssetPath( @@ -553,7 +592,7 @@ namespace Streamingle.Editor $"Frames: {metadata.frameCount:N0} at {metadata.sampleRate} FPS", $"Shots: {shotDefinitions.Count:N0}", $"Curve Simplification: {curvePreset}", - $"Position Exact Fallbacks: {positionExactFallbackCount:N0}", + $"Position Dense C2 Fallbacks: {positionDenseFallbackCount:N0}", $"Preview Director: {GetHierarchyPath(previewDirector.transform)}", $"Preview Camera Root: {GetHierarchyPath(previewCameraObject.transform)}", $"Timeline Asset: {copiedTimelinePath}", @@ -815,8 +854,9 @@ namespace Streamingle.Editor if (positionUsedExactFallback) { Debug.LogWarning( - $"AI camera shot {shotIndex:D3} used Exact position curves " + - $"after the reduced-curve audit: {positionFallbackReason}"); + $"AI camera shot {shotIndex:D3} used dense C2 position " + + $"curves after the reduced-curve audit: " + + $"{positionFallbackReason}"); } var previousDirectorTime = previewDirector.time; @@ -914,7 +954,8 @@ namespace Streamingle.Editor $"{definitionForReplacement.endFrameExclusive - 1:N0}", $"Curve Simplification: {curvePreset}", positionUsedExactFallback - ? $"Position Curves: Exact fallback ({positionFallbackReason})" + ? $"Position Curves: Dense C2 fallback " + + $"({positionFallbackReason})" : "Position Curves: requested preset passed motion audit", $"Animation Asset: {AssetDatabase.GetAssetPath(existingClip)}", "All other generated camera clips were preserved.", @@ -2356,42 +2397,8 @@ namespace Streamingle.Editor typeof(Transform), "m_LocalPosition.z", positionCurves.ZCurve); - SetLinearCurve( - clip, - relativePath, - typeof(Transform), - "m_LocalRotation.x", - BuildKeys( - simplified.Times, - simplified.RotationIndices, - index => simplified.Rotations[index].x)); - SetLinearCurve( - clip, - relativePath, - typeof(Transform), - "m_LocalRotation.y", - BuildKeys( - simplified.Times, - simplified.RotationIndices, - index => simplified.Rotations[index].y)); - SetLinearCurve( - clip, - relativePath, - typeof(Transform), - "m_LocalRotation.z", - BuildKeys( - simplified.Times, - simplified.RotationIndices, - index => simplified.Rotations[index].z)); - SetLinearCurve( - clip, - relativePath, - typeof(Transform), - "m_LocalRotation.w", - BuildKeys( - simplified.Times, - simplified.RotationIndices, - index => simplified.Rotations[index].w)); + var rotationCurves = BuildRotationCurves(simplified, curvePreset); + SetRotationCurves(clip, relativePath, rotationCurves); SetLinearCurve( clip, relativePath, @@ -2412,6 +2419,20 @@ namespace Streamingle.Editor index => simplified.Dutch[index])); clip.EnsureQuaternionContinuity(); + rotationCurves = EnsureAppliedRotationCurveSafety( + clip, + relativePath, + simplified, + curvePreset, + rotationCurves); + if (rotationCurves.UsedDenseFallback) + { + Debug.LogWarning( + $"AI camera shot {shotCamera.Definition.index:D3} " + + $"({shotCamera.Definition.cameraName}) used dense C2 rotation " + + $"curves after the final imported-clip audit: " + + $"{rotationCurves.FallbackReason}"); + } return clip; } @@ -2481,15 +2502,30 @@ namespace Streamingle.Editor } PositionCurveBuildResult result; + var settings = GetCurveSimplificationSettings(preset); if (preset == CurveSimplificationPreset.Exact || simplified.Times.Length < 2) { - result = BuildLinearPositionCurves( + result = BuildVelocityAwarePositionCurves( simplified.Times, simplified.Positions, CreateAllSampleIndices(simplified.Times.Length), false, string.Empty); + var exactAudit = AuditPositionCurves( + simplified.Times, + simplified.Positions, + result.XCurve, + result.YCurve, + result.ZCurve, + settings.PositionErrorMeters); + if (!exactAudit.Passed) + { + throw new InvalidOperationException( + "Exact dense C2 position curves failed their final " + + "raw-derived high-rate motion audit: " + + exactAudit.FailureReason); + } } else { @@ -2503,15 +2539,28 @@ namespace Streamingle.Editor candidate.XCurve, candidate.YCurve, candidate.ZCurve, - GetCurveSimplificationSettings(preset).PositionErrorMeters); - result = audit.Passed - ? candidate - : BuildLinearPositionCurves( + settings.PositionErrorMeters); + if (audit.Passed) + { + result = candidate; + } + else if (TryBuildRefinedPositionCurves( + simplified.Times, + simplified.Positions, + simplified.PositionIndices, + settings.PositionErrorMeters, + out var refined)) + { + result = refined; + } + else + { + result = BuildAuditedDensePositionFallback( simplified.Times, simplified.Positions, - CreateAllSampleIndices(simplified.Times.Length), - true, + settings.PositionErrorMeters, audit.FailureReason); + } } simplified.SetPositionCurveDiagnostics( @@ -2564,97 +2613,60 @@ namespace Streamingle.Editor } } - var allIndices = CreateAllSampleIndices(times.Count); - var reference = BuildVelocityAwarePositionCurves( + if (TryDescribeC2CurveFailure(xCurve, "position x", out var c2Failure) || + TryDescribeC2CurveFailure(yCurve, "position y", out c2Failure) || + TryDescribeC2CurveFailure(zCurve, "position z", out c2Failure)) + { + return PositionCurveAuditResult.Fail(c2Failure); + } + + var primaryAuditTimes = BuildUniformCurveAuditTimes( times, - positions, - allIndices); - var auditTimes = BuildPositionCurveAuditTimes(times); - var evaluatedDense = EvaluatePositionCurves( - auditTimes, + CurveAuditPrimarySamplesPerSecond); + var convergenceAuditTimes = BuildUniformCurveAuditTimes( + times, + CurveAuditConvergenceSamplesPerSecond); + var evaluatedPrimary = EvaluatePositionCurves( + primaryAuditTimes, + xCurve, + yCurve, + zCurve); + var evaluatedConvergence = EvaluatePositionCurves( + convergenceAuditTimes, xCurve, yCurve, zCurve); - var referenceDense = EvaluatePositionCurves( - auditTimes, - reference.XCurve, - reference.YCurve, - reference.ZCurve); - for (var index = 0; index < auditTimes.Count; index++) - { - var error = Vector3.Distance( - referenceDense[index], - evaluatedDense[index]); - if (!IsFinite(error) || error > allowedPositionError) - { - return PositionCurveAuditResult.Fail( - $"source + midpoint position error {error:F6} m exceeded " + - $"{allowedPositionError:F6} m at {auditTimes[index]:F4} s"); - } - } - var sourceMotion = MeasurePositionMotion(times, positions); - var evaluatedSourceMotion = MeasurePositionMotion( - times, - evaluatedAtSourceTimes); - if (TryDescribeAlignedMotionRegression( - "source-grid", + var primaryMotion = MeasurePositionMotion( + primaryAuditTimes, + evaluatedPrimary); + var convergenceMotion = MeasurePositionMotion( + convergenceAuditTimes, + evaluatedConvergence); + if (TryDescribeTimeWindowPositionMotionRegression( + "240 Hz raw-derived contract", sourceMotion, - evaluatedSourceMotion, - out var sourceLocalFailure)) + primaryMotion, + out var rawContractFailure)) { - return PositionCurveAuditResult.Fail(sourceLocalFailure); + return PositionCurveAuditResult.Fail(rawContractFailure); } - if (TryDescribeMotionRegression( - "source-grid", - sourceMotion.Metrics, - evaluatedSourceMotion.Metrics, - out var sourceFailure)) + if (TryDescribePositionSamplingConvergence( + primaryMotion, + convergenceMotion, + out var convergenceFailure)) { - return PositionCurveAuditResult.Fail(sourceFailure); + return PositionCurveAuditResult.Fail(convergenceFailure); } if (TryDescribeMotionRetentionRegression( - "source-grid", + "240 Hz", positions, - evaluatedAtSourceTimes, - out var sourceRetentionFailure)) + evaluatedPrimary, + out var retentionFailure)) { - return PositionCurveAuditResult.Fail(sourceRetentionFailure); - } - - var referenceDenseMotion = MeasurePositionMotion( - auditTimes, - referenceDense); - var evaluatedDenseMotion = MeasurePositionMotion( - auditTimes, - evaluatedDense); - if (TryDescribeAlignedMotionRegression( - "source + midpoint", - referenceDenseMotion, - evaluatedDenseMotion, - out var denseLocalFailure)) - { - return PositionCurveAuditResult.Fail(denseLocalFailure); - } - - if (TryDescribeMotionRegression( - "source + midpoint", - referenceDenseMotion.Metrics, - evaluatedDenseMotion.Metrics, - out var denseFailure)) - { - return PositionCurveAuditResult.Fail(denseFailure); - } - - if (TryDescribeMotionRetentionRegression( - "source + midpoint", - referenceDense, - evaluatedDense, - out var denseRetentionFailure)) - { - return PositionCurveAuditResult.Fail(denseRetentionFailure); + return PositionCurveAuditResult.Fail(retentionFailure); } return PositionCurveAuditResult.Pass(); @@ -2663,69 +2675,851 @@ namespace Streamingle.Editor private static PositionCurveBuildResult BuildVelocityAwarePositionCurves( IReadOnlyList times, IReadOnlyList positions, - IReadOnlyList indices) + IReadOnlyList indices, + bool usedExactFallback = false, + string fallbackReason = "") { return new PositionCurveBuildResult( - BuildVelocityAwareHermiteCurve( + BuildC2SplineCurve( times, positions.Select(value => value.x).ToArray(), indices), - BuildVelocityAwareHermiteCurve( + BuildC2SplineCurve( times, positions.Select(value => value.y).ToArray(), indices), - BuildVelocityAwareHermiteCurve( + BuildC2SplineCurve( times, positions.Select(value => value.z).ToArray(), indices), - false, - string.Empty); - } - - private static PositionCurveBuildResult BuildLinearPositionCurves( - IReadOnlyList times, - IReadOnlyList positions, - IReadOnlyList indices, - bool usedExactFallback, - string fallbackReason) - { - return new PositionCurveBuildResult( - BuildLinearCurve(BuildKeys( - times, - indices, - index => positions[index].x)), - BuildLinearCurve(BuildKeys( - times, - indices, - index => positions[index].y)), - BuildLinearCurve(BuildKeys( - times, - indices, - index => positions[index].z)), usedExactFallback, fallbackReason); } - private static AnimationCurve BuildVelocityAwareHermiteCurve( + private static PositionCurveBuildResult BuildAuditedDensePositionFallback( + IReadOnlyList times, + IReadOnlyList sourcePositions, + float maximumPositionErrorMeters, + string initialFailureReason) + { + var allIndices = CreateAllSampleIndices(times.Count); + var exactDense = BuildVelocityAwarePositionCurves( + times, + sourcePositions, + allIndices, + true, + initialFailureReason); + var lastAudit = AuditPositionCurves( + times, + sourcePositions, + exactDense.XCurve, + exactDense.YCurve, + exactDense.ZCurve, + maximumPositionErrorMeters); + if (lastAudit.Passed) + { + return exactDense; + } + + var exactFailureReason = lastAudit.FailureReason; + + var smoothingPassCounts = new[] { 1, 2, 4, 8, 16, 32, 64 }; + foreach (var passCount in smoothingPassCounts) + { + var smoothed = SmoothPositionsWithinErrorBudget( + sourcePositions, + maximumPositionErrorMeters * 0.9f, + passCount); + var candidate = BuildVelocityAwarePositionCurves( + times, + smoothed, + allIndices, + true, + initialFailureReason + + $"; bounded C2 smoothing passes={passCount}"); + lastAudit = AuditPositionCurves( + times, + sourcePositions, + candidate.XCurve, + candidate.YCurve, + candidate.ZCurve, + maximumPositionErrorMeters); + if (lastAudit.Passed) + { + return candidate; + } + } + + throw new InvalidOperationException( + "Dense C2 position fallback failed its final jerk-bounded " + + $"motion audit (exact: {exactFailureReason}; final: " + + $"{lastAudit.FailureReason})"); + } + + private static bool TryBuildRefinedPositionCurves( + IReadOnlyList times, + IReadOnlyList sourcePositions, + IReadOnlyList initialIndices, + float maximumPositionErrorMeters, + out PositionCurveBuildResult result) + { + const int maximumAddedKeys = 64; + var indices = new SortedSet(initialIndices); + var allowedError = maximumPositionErrorMeters + + PositionCurveAuditErrorEpsilonMeters; + for (var iteration = 0; + iteration <= maximumAddedKeys && indices.Count < times.Count; + iteration++) + { + var candidate = BuildVelocityAwarePositionCurves( + times, + sourcePositions, + indices.ToArray()); + var evaluated = EvaluatePositionCurves( + times, + candidate.XCurve, + candidate.YCurve, + candidate.ZCurve); + var worstIndex = -1; + var worstError = 0f; + for (var index = 0; index < times.Count; index++) + { + var error = Vector3.Distance( + sourcePositions[index], + evaluated[index]); + if (error > worstError) + { + worstError = error; + worstIndex = index; + } + } + + if (worstError <= allowedError) + { + var audit = AuditPositionCurves( + times, + sourcePositions, + candidate.XCurve, + candidate.YCurve, + candidate.ZCurve, + maximumPositionErrorMeters); + if (audit.Passed) + { + result = candidate; + return true; + } + + break; + } + + if (worstIndex < 0 || !indices.Add(worstIndex)) + { + break; + } + } + + result = default; + return false; + } + + internal static RotationCurveBuildResult BuildRotationCurves( + SimplifiedCameraCurves simplified, + CurveSimplificationPreset preset) + { + if (simplified == null) + { + throw new ArgumentNullException(nameof(simplified)); + } + + var allIndices = CreateAllSampleIndices(simplified.Times.Length); + IReadOnlyList requestedIndices = + preset == CurveSimplificationPreset.Exact || + simplified.Times.Length < 2 + ? allIndices + : simplified.RotationIndices; + var candidate = BuildVelocityAwareRotationCurves( + simplified.Times, + simplified.Rotations, + requestedIndices, + false, + string.Empty); + var allowedAngleError = GetCurveSimplificationSettings(preset) + .QuaternionAngleErrorDegrees; + var audit = AuditRotationCurves( + simplified.Times, + simplified.Rotations, + candidate.XCurve, + candidate.YCurve, + candidate.ZCurve, + candidate.WCurve, + allowedAngleError); + + RotationCurveBuildResult result; + if (audit.Passed) + { + result = candidate; + } + else if (preset != CurveSimplificationPreset.Exact && + TryBuildRefinedRotationCurves( + simplified.Times, + simplified.Rotations, + requestedIndices, + allowedAngleError, + out var refined)) + { + result = refined; + } + else if (preset != CurveSimplificationPreset.Exact) + { + result = BuildAuditedDenseRotationFallback( + simplified.Times, + simplified.Rotations, + allowedAngleError, + audit.FailureReason); + } + else + { + throw new InvalidOperationException( + "Dense C2 rotation curves failed their final angular " + + $"motion audit: {audit.FailureReason}"); + } + + simplified.SetRotationCurveDiagnostics( + result.UsedDenseFallback, + result.FallbackReason); + return result; + } + + internal static RotationCurveAuditResult AuditRotationCurves( + IReadOnlyList times, + IReadOnlyList rotations, + AnimationCurve xCurve, + AnimationCurve yCurve, + AnimationCurve zCurve, + AnimationCurve wCurve, + float maximumAngleErrorDegrees) + { + if (times == null || rotations == null) + { + throw new ArgumentNullException(nameof(times)); + } + + if (xCurve == null || yCurve == null || + zCurve == null || wCurve == null) + { + throw new ArgumentNullException(nameof(xCurve)); + } + + if (times.Count == 0 || rotations.Count != times.Count) + { + throw new ArgumentException( + "Rotation audit samples must have the same non-zero length."); + } + + var continuousRotations = MakeQuaternionSequenceContinuous(rotations); + if (!TryEvaluateRotationCurves( + times, + xCurve, + yCurve, + zCurve, + wCurve, + out var evaluatedAtSourceTimes, + out var evaluationFailure)) + { + return RotationCurveAuditResult.Fail(evaluationFailure); + } + + var allowedAngleError = + maximumAngleErrorDegrees + + RotationCurveAuditAngleErrorEpsilonDegrees; + for (var index = 0; index < times.Count; index++) + { + var error = Quaternion.Angle( + continuousRotations[index], + evaluatedAtSourceTimes[index]); + if (!IsFinite(error) || error > allowedAngleError) + { + return RotationCurveAuditResult.Fail( + $"source-sample angle error {error:F4} deg exceeded " + + $"{allowedAngleError:F4} deg at frame {index}"); + } + } + + if (TryDescribeC2CurveFailure(xCurve, "rotation x", out var c2Failure) || + TryDescribeC2CurveFailure(yCurve, "rotation y", out c2Failure) || + TryDescribeC2CurveFailure(zCurve, "rotation z", out c2Failure) || + TryDescribeC2CurveFailure(wCurve, "rotation w", out c2Failure)) + { + return RotationCurveAuditResult.Fail(c2Failure); + } + + var primaryAuditTimes = BuildUniformCurveAuditTimes( + times, + CurveAuditPrimarySamplesPerSecond); + var convergenceAuditTimes = BuildUniformCurveAuditTimes( + times, + CurveAuditConvergenceSamplesPerSecond); + if (!TryEvaluateRotationCurves( + primaryAuditTimes, + xCurve, + yCurve, + zCurve, + wCurve, + out var evaluatedPrimary, + out evaluationFailure)) + { + return RotationCurveAuditResult.Fail(evaluationFailure); + } + + if (!TryEvaluateRotationCurves( + convergenceAuditTimes, + xCurve, + yCurve, + zCurve, + wCurve, + out var evaluatedConvergence, + out evaluationFailure)) + { + return RotationCurveAuditResult.Fail(evaluationFailure); + } + + var sourceMotion = MeasureAngularMotion(times, continuousRotations); + var primaryMotion = MeasureAngularMotion( + primaryAuditTimes, + evaluatedPrimary); + var convergenceMotion = MeasureAngularMotion( + convergenceAuditTimes, + evaluatedConvergence); + if (TryDescribeTimeWindowAngularMotionRegression( + "240 Hz raw-derived contract", + sourceMotion, + primaryMotion, + out var rawContractFailure)) + { + return RotationCurveAuditResult.Fail(rawContractFailure); + } + + if (TryDescribeAngularSamplingConvergence( + primaryMotion, + convergenceMotion, + out var convergenceFailure)) + { + return RotationCurveAuditResult.Fail(convergenceFailure); + } + + if (TryDescribeAngularMotionRetentionRegression( + "240 Hz", + continuousRotations, + evaluatedPrimary, + out var retentionFailure)) + { + return RotationCurveAuditResult.Fail(retentionFailure); + } + + return RotationCurveAuditResult.Pass(); + } + + private static RotationCurveBuildResult BuildVelocityAwareRotationCurves( + IReadOnlyList times, + IReadOnlyList rotations, + IReadOnlyList indices, + bool usedDenseFallback, + string fallbackReason) + { + var continuousRotations = MakeQuaternionSequenceContinuous(rotations); + return new RotationCurveBuildResult( + BuildC2SplineCurve( + times, + continuousRotations.Select(value => value.x).ToArray(), + indices), + BuildC2SplineCurve( + times, + continuousRotations.Select(value => value.y).ToArray(), + indices), + BuildC2SplineCurve( + times, + continuousRotations.Select(value => value.z).ToArray(), + indices), + BuildC2SplineCurve( + times, + continuousRotations.Select(value => value.w).ToArray(), + indices), + usedDenseFallback, + fallbackReason); + } + + private static RotationCurveBuildResult BuildAuditedDenseRotationFallback( + IReadOnlyList times, + IReadOnlyList sourceRotations, + float maximumAngleErrorDegrees, + string initialFailureReason) + { + var continuousSource = MakeQuaternionSequenceContinuous(sourceRotations); + var allIndices = CreateAllSampleIndices(times.Count); + var exactDense = BuildVelocityAwareRotationCurves( + times, + continuousSource, + allIndices, + true, + initialFailureReason); + var lastAudit = AuditRotationCurves( + times, + continuousSource, + exactDense.XCurve, + exactDense.YCurve, + exactDense.ZCurve, + exactDense.WCurve, + maximumAngleErrorDegrees); + if (lastAudit.Passed) + { + return exactDense; + } + + var exactFailureReason = lastAudit.FailureReason; + + var smoothingPassCounts = new[] { 1, 2, 4, 8, 16, 32, 64 }; + foreach (var passCount in smoothingPassCounts) + { + var smoothed = SmoothRotationsWithinErrorBudget( + continuousSource, + maximumAngleErrorDegrees * 0.65f, + passCount); + var candidate = BuildVelocityAwareRotationCurves( + times, + smoothed, + allIndices, + true, + initialFailureReason + + $"; bounded C2 smoothing passes={passCount}"); + lastAudit = AuditRotationCurves( + times, + continuousSource, + candidate.XCurve, + candidate.YCurve, + candidate.ZCurve, + candidate.WCurve, + maximumAngleErrorDegrees); + if (lastAudit.Passed) + { + return candidate; + } + } + + throw new InvalidOperationException( + "Dense C2 rotation fallback failed its final angular " + + $"jerk-bounded audit (exact: {exactFailureReason}; final: " + + $"{lastAudit.FailureReason})"); + } + + private static bool TryBuildRefinedRotationCurves( + IReadOnlyList times, + IReadOnlyList sourceRotations, + IReadOnlyList initialIndices, + float maximumAngleErrorDegrees, + out RotationCurveBuildResult result) + { + const int maximumAddedKeys = 64; + var continuousSource = MakeQuaternionSequenceContinuous(sourceRotations); + var indices = new SortedSet(initialIndices); + var allowedError = maximumAngleErrorDegrees + + RotationCurveAuditAngleErrorEpsilonDegrees; + for (var iteration = 0; + iteration <= maximumAddedKeys && indices.Count < times.Count; + iteration++) + { + var candidate = BuildVelocityAwareRotationCurves( + times, + continuousSource, + indices.ToArray(), + false, + string.Empty); + var evaluated = EvaluateRotationCurves(times, candidate); + var worstIndex = -1; + var worstError = 0f; + for (var index = 0; index < times.Count; index++) + { + var error = Quaternion.Angle( + continuousSource[index], + evaluated[index]); + if (error > worstError) + { + worstError = error; + worstIndex = index; + } + } + + if (worstError <= allowedError) + { + var audit = AuditRotationCurves( + times, + continuousSource, + candidate.XCurve, + candidate.YCurve, + candidate.ZCurve, + candidate.WCurve, + maximumAngleErrorDegrees); + if (audit.Passed) + { + result = candidate; + return true; + } + + break; + } + + if (worstIndex < 0 || !indices.Add(worstIndex)) + { + break; + } + } + + result = default; + return false; + } + + private static RotationCurveBuildResult EnsureAppliedRotationCurveSafety( + AnimationClip clip, + string relativePath, + SimplifiedCameraCurves simplified, + CurveSimplificationPreset preset, + RotationCurveBuildResult requested) + { + var applied = ReadAppliedRotationCurves( + clip, + relativePath, + requested.UsedDenseFallback, + requested.FallbackReason); + var allowedAngleError = GetCurveSimplificationSettings(preset) + .QuaternionAngleErrorDegrees; + var audit = AuditRotationCurves( + simplified.Times, + simplified.Rotations, + applied.XCurve, + applied.YCurve, + applied.ZCurve, + applied.WCurve, + allowedAngleError); + if (audit.Passed) + { + return applied; + } + + var fallbackReason = + $"post-EnsureQuaternionContinuity clip audit failed: " + + $"{audit.FailureReason}"; + var fallback = BuildAuditedDenseRotationFallback( + simplified.Times, + simplified.Rotations, + allowedAngleError, + string.IsNullOrEmpty(requested.FallbackReason) + ? fallbackReason + : $"{requested.FallbackReason}; {fallbackReason}"); + + // The source quaternions were already sign-normalized before the C2 + // spline was built. If Unity's legacy continuity pass changed the + // audited curve tangents, overwrite it with the dense C2 contract + // and validate the exact curves that remain stored in the clip. + SetRotationCurves(clip, relativePath, fallback); + applied = ReadAppliedRotationCurves( + clip, + relativePath, + true, + fallback.FallbackReason); + var fallbackAudit = AuditRotationCurves( + simplified.Times, + simplified.Rotations, + applied.XCurve, + applied.YCurve, + applied.ZCurve, + applied.WCurve, + allowedAngleError); + if (!fallbackAudit.Passed) + { + throw new InvalidOperationException( + "The final AnimationClip rotation curves failed the dense " + + $"C2 safety contract: {fallbackAudit.FailureReason}"); + } + + simplified.SetRotationCurveDiagnostics(true, applied.FallbackReason); + return applied; + } + + private static void SetRotationCurves( + AnimationClip clip, + string relativePath, + RotationCurveBuildResult curves) + { + SetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.x", + curves.XCurve); + SetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.y", + curves.YCurve); + SetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.z", + curves.ZCurve); + SetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.w", + curves.WCurve); + } + + private static RotationCurveBuildResult ReadAppliedRotationCurves( + AnimationClip clip, + string relativePath, + bool usedDenseFallback, + string fallbackReason) + { + return new RotationCurveBuildResult( + GetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.x"), + GetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.y"), + GetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.z"), + GetCurve( + clip, + relativePath, + typeof(Transform), + "m_LocalRotation.w"), + usedDenseFallback, + fallbackReason); + } + + private static Vector3[] SmoothPositionsWithinErrorBudget( + IReadOnlyList source, + float maximumDeviationMeters, + int passCount) + { + var original = source.ToArray(); + var current = original.ToArray(); + if (current.Length < 3 || maximumDeviationMeters <= 0f) + { + return current; + } + + for (var pass = 0; pass < passCount; pass++) + { + var filteredValues = current.ToArray(); + var maximumFilteredDeviation = 0f; + for (var index = 1; index + 1 < current.Length; index++) + { + var filtered = + GetClamped(current, index - 2) * (1f / 16f) + + GetClamped(current, index - 1) * (4f / 16f) + + current[index] * (6f / 16f) + + GetClamped(current, index + 1) * (4f / 16f) + + GetClamped(current, index + 2) * (1f / 16f); + var endpointWeight = GetEndpointSmoothingWeight( + index, + current.Length); + filteredValues[index] = Vector3.LerpUnclamped( + original[index], + filtered, + endpointWeight); + maximumFilteredDeviation = Mathf.Max( + maximumFilteredDeviation, + Vector3.Distance( + original[index], + filteredValues[index])); + } + + var budgetScale = maximumFilteredDeviation <= + maximumDeviationMeters + ? 1f + : maximumDeviationMeters / maximumFilteredDeviation; + var next = original.ToArray(); + for (var index = 1; index + 1 < current.Length; index++) + { + next[index] = Vector3.LerpUnclamped( + original[index], + filteredValues[index], + budgetScale); + } + next[0] = original[0]; + next[next.Length - 1] = original[original.Length - 1]; + current = next; + } + + return current; + } + + private static Quaternion[] SmoothRotationsWithinErrorBudget( + IReadOnlyList source, + float maximumDeviationDegrees, + int passCount) + { + var original = MakeQuaternionSequenceContinuous(source); + var current = original.ToArray(); + if (current.Length < 3 || maximumDeviationDegrees <= 0f) + { + return current; + } + + for (var pass = 0; pass < passCount; pass++) + { + var filteredValues = current.ToArray(); + var maximumFilteredDeviation = 0f; + for (var index = 1; index + 1 < current.Length; index++) + { + var reference = current[index]; + var weighted = Vector4.zero; + AccumulateQuaternion( + ref weighted, + GetClamped(current, index - 2), + reference, + 1f); + AccumulateQuaternion( + ref weighted, + GetClamped(current, index - 1), + reference, + 4f); + AccumulateQuaternion( + ref weighted, + reference, + reference, + 6f); + AccumulateQuaternion( + ref weighted, + GetClamped(current, index + 1), + reference, + 4f); + AccumulateQuaternion( + ref weighted, + GetClamped(current, index + 2), + reference, + 1f); + var filtered = NormalizeQuaternion(new Quaternion( + weighted.x, + weighted.y, + weighted.z, + weighted.w)); + if (Quaternion.Dot(original[index], filtered) < 0f) + { + filtered = NegateQuaternion(filtered); + } + + filteredValues[index] = Quaternion.Slerp( + original[index], + filtered, + GetEndpointSmoothingWeight(index, current.Length)); + maximumFilteredDeviation = Mathf.Max( + maximumFilteredDeviation, + Quaternion.Angle( + original[index], + filteredValues[index])); + } + + var budgetScale = maximumFilteredDeviation <= + maximumDeviationDegrees + ? 1f + : maximumDeviationDegrees / maximumFilteredDeviation; + var next = original.ToArray(); + for (var index = 1; index + 1 < current.Length; index++) + { + next[index] = Quaternion.Slerp( + original[index], + filteredValues[index], + budgetScale); + } + next[0] = original[0]; + next[next.Length - 1] = original[original.Length - 1]; + current = MakeQuaternionSequenceContinuous(next); + } + + return current; + } + + private static float GetEndpointSmoothingWeight(int index, int count) + { + const float fadeFrames = 8f; + var distanceFromEndpoint = Mathf.Min(index, count - 1 - index); + var t = Mathf.Clamp01(distanceFromEndpoint / fadeFrames); + // Quintic smootherstep makes both velocity and acceleration of the + // smoothing displacement vanish at the preserved shot endpoints. + return t * t * t * (t * (t * 6f - 15f) + 10f); + } + + private static T GetClamped(IReadOnlyList values, int index) + { + return values[Mathf.Clamp(index, 0, values.Count - 1)]; + } + + private static void AccumulateQuaternion( + ref Vector4 total, + Quaternion value, + Quaternion reference, + float weight) + { + if (Quaternion.Dot(reference, value) < 0f) + { + value = NegateQuaternion(value); + } + + total.x += value.x * weight; + total.y += value.y * weight; + total.z += value.z * weight; + total.w += value.w * weight; + } + + private static AnimationCurve BuildC2SplineCurve( IReadOnlyList times, IReadOnlyList values, IReadOnlyList indices) { - var sourceTangents = EstimateSourceTangents(times, values); - var tangents = new float[indices.Count]; - for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++) + if (indices.Count == 1) { - tangents[keyIndex] = sourceTangents[indices[keyIndex]]; + var sampleIndex = indices[0]; + return new AnimationCurve( + new Keyframe((float)times[sampleIndex], values[sampleIndex], 0f, 0f)); } - ClampMonotoneHermiteTangents(times, values, indices, tangents); + var keyCount = indices.Count; + var keyTimes = new double[keyCount]; + var keyValues = new double[keyCount]; + for (var keyIndex = 0; keyIndex < keyCount; keyIndex++) + { + var sampleIndex = indices[keyIndex]; + // Solve against the exact float data that AnimationCurve will + // store. Solving against the original doubles and rounding only + // at the end creates measurable acceleration seams at 60 Hz. + keyTimes[keyIndex] = (float)times[sampleIndex]; + keyValues[keyIndex] = values[sampleIndex]; + } + + var secondDerivatives = SolveC2SplineSecondDerivatives( + keyTimes, + keyValues); + var tangents = CalculateC2SplineTangents( + keyTimes, + keyValues, + secondDerivatives); var keys = new Keyframe[indices.Count]; for (var keyIndex = 0; keyIndex < indices.Count; keyIndex++) { - var sampleIndex = indices[keyIndex]; keys[keyIndex] = new Keyframe( - (float)times[sampleIndex], - values[sampleIndex], + (float)keyTimes[keyIndex], + (float)keyValues[keyIndex], tangents[keyIndex], tangents[keyIndex]); } @@ -2733,96 +3527,139 @@ namespace Streamingle.Editor return new AnimationCurve(keys); } - private static float[] EstimateSourceTangents( + private static double[] SolveC2SplineSecondDerivatives( IReadOnlyList times, - IReadOnlyList values) + IReadOnlyList values) { - var tangents = new float[values.Count]; - if (values.Count < 2) + var count = values.Count; + var secondDerivatives = new double[count]; + if (count <= 2) { - return tangents; + return secondDerivatives; } - tangents[0] = CalculateSecant( - times[0], - values[0], - times[1], - values[1]); - tangents[values.Count - 1] = CalculateSecant( - times[values.Count - 2], - values[values.Count - 2], - times[values.Count - 1], - values[values.Count - 1]); - for (var index = 1; index < values.Count - 1; index++) + if (count == 3) { - tangents[index] = CalculateSecant( - times[index - 1], - values[index - 1], - times[index + 1], - values[index + 1]); + var leftDuration = times[1] - times[0]; + var rightDuration = times[2] - times[1]; + var acceleration = 2.0 * + ((values[2] - values[1]) / rightDuration - + (values[1] - values[0]) / leftDuration) / + (leftDuration + rightDuration); + secondDerivatives[0] = acceleration; + secondDerivatives[1] = acceleration; + secondDerivatives[2] = acceleration; + return secondDerivatives; + } + + var interiorCount = count - 2; + var lower = new double[interiorCount]; + var diagonal = new double[interiorCount]; + var upper = new double[interiorCount]; + var rightHandSide = new double[interiorCount]; + for (var interior = 0; interior < interiorCount; interior++) + { + var index = interior + 1; + var leftDuration = times[index] - times[index - 1]; + var rightDuration = times[index + 1] - times[index]; + lower[interior] = leftDuration; + diagonal[interior] = 2.0 * (leftDuration + rightDuration); + upper[interior] = rightDuration; + rightHandSide[interior] = 6.0 * + ((values[index + 1] - values[index]) / rightDuration - + (values[index] - values[index - 1]) / leftDuration); + } + + // Not-a-knot boundaries make jerk continuous at the first and last + // interior key. Unlike a natural spline, this does not inject an + // artificial endpoint acceleration reset into every camera shot. + var firstDuration = times[1] - times[0]; + var secondDuration = times[2] - times[1]; + lower[0] = 0.0; + diagonal[0] = + 3.0 * firstDuration + + 2.0 * secondDuration + + firstDuration * firstDuration / secondDuration; + upper[0] = + secondDuration - + firstDuration * firstDuration / secondDuration; + + var previousDuration = + times[count - 2] - times[count - 3]; + var lastDuration = + times[count - 1] - times[count - 2]; + lower[interiorCount - 1] = + previousDuration - + lastDuration * lastDuration / previousDuration; + diagonal[interiorCount - 1] = + 2.0 * previousDuration + + 3.0 * lastDuration + + lastDuration * lastDuration / previousDuration; + upper[interiorCount - 1] = 0.0; + + for (var index = 1; index < interiorCount; index++) + { + var scale = lower[index] / diagonal[index - 1]; + diagonal[index] -= scale * upper[index - 1]; + rightHandSide[index] -= scale * rightHandSide[index - 1]; + } + + secondDerivatives[count - 2] = + rightHandSide[interiorCount - 1] / + diagonal[interiorCount - 1]; + for (var interior = interiorCount - 2; interior >= 0; interior--) + { + secondDerivatives[interior + 1] = + (rightHandSide[interior] - + upper[interior] * secondDerivatives[interior + 2]) / + diagonal[interior]; + } + + secondDerivatives[0] = + (1.0 + firstDuration / secondDuration) * + secondDerivatives[1] - + firstDuration / secondDuration * secondDerivatives[2]; + secondDerivatives[count - 1] = + (1.0 + lastDuration / previousDuration) * + secondDerivatives[count - 2] - + lastDuration / previousDuration * + secondDerivatives[count - 3]; + + return secondDerivatives; + } + + private static float[] CalculateC2SplineTangents( + IReadOnlyList times, + IReadOnlyList values, + IReadOnlyList secondDerivatives) + { + var count = values.Count; + var tangents = new float[count]; + for (var segment = 0; segment < count - 1; segment++) + { + var duration = times[segment + 1] - times[segment]; + var secant = (values[segment + 1] - values[segment]) / duration; + var leftTangent = secant - + duration * + (2.0 * secondDerivatives[segment] + + secondDerivatives[segment + 1]) / + 6.0; + var rightTangent = secant + + duration * + (secondDerivatives[segment] + + 2.0 * secondDerivatives[segment + 1]) / + 6.0; + if (segment == 0) + { + tangents[0] = (float)leftTangent; + } + + tangents[segment + 1] = (float)rightTangent; } return tangents; } - private static void ClampMonotoneHermiteTangents( - IReadOnlyList times, - IReadOnlyList values, - IReadOnlyList indices, - float[] tangents) - { - const float flatEpsilon = 0.0000001f; - for (var segment = 1; segment < indices.Count; segment++) - { - var leftKey = segment - 1; - var rightKey = segment; - var leftSample = indices[leftKey]; - var rightSample = indices[rightKey]; - var secant = CalculateSecant( - times[leftSample], - values[leftSample], - times[rightSample], - values[rightSample]); - if (Mathf.Abs(secant) <= flatEpsilon) - { - tangents[leftKey] = 0f; - tangents[rightKey] = 0f; - continue; - } - - if (tangents[leftKey] * secant <= 0f) - { - tangents[leftKey] = 0f; - } - - if (tangents[rightKey] * secant <= 0f) - { - tangents[rightKey] = 0f; - } - - var alpha = tangents[leftKey] / secant; - var beta = tangents[rightKey] / secant; - var squaredMagnitude = alpha * alpha + beta * beta; - if (squaredMagnitude <= 9f) - { - continue; - } - - var scale = 3f / Mathf.Sqrt(squaredMagnitude); - tangents[leftKey] = scale * alpha * secant; - tangents[rightKey] = scale * beta * secant; - } - } - - private static float CalculateSecant( - double leftTime, - float leftValue, - double rightTime, - float rightValue) - { - return (rightValue - leftValue) / (float)(rightTime - leftTime); - } - private static int[] CreateAllSampleIndices(int count) { return Enumerable.Range(0, count).ToArray(); @@ -2901,6 +3738,134 @@ namespace Streamingle.Editor result.Add(time); } + internal static List BuildUniformCurveAuditTimes( + IReadOnlyList sourceTimes, + int samplesPerSecond) + { + if (sourceTimes == null) + { + throw new ArgumentNullException(nameof(sourceTimes)); + } + + if (sourceTimes.Count == 0) + { + throw new ArgumentException( + "Curve audit requires at least one source time.", + nameof(sourceTimes)); + } + + if (samplesPerSecond <= 0) + { + throw new ArgumentOutOfRangeException(nameof(samplesPerSecond)); + } + + var start = sourceTimes[0]; + var end = sourceTimes[sourceTimes.Count - 1]; + var duration = end - start; + if (duration <= PositionCurveAuditTimeToleranceSeconds) + { + return new List { (double)(float)start }; + } + + var intervalCount = Math.Max( + 1, + (int)Math.Ceiling(duration * samplesPerSecond)); + var result = new List(intervalCount + 1); + for (var index = 0; index <= intervalCount; index++) + { + var time = (double)(float)( + start + duration * index / intervalCount); + if (result.Count == 0 || time > result[result.Count - 1]) + { + result.Add(time); + } + } + + return result; + } + + internal static bool TryDescribeC2CurveFailure( + AnimationCurve curve, + string label, + out string failureReason) + { + if (curve == null) + { + throw new ArgumentNullException(nameof(curve)); + } + + var keys = curve.keys; + for (var index = 0; index < keys.Length; index++) + { + if (!IsFinite(keys[index].inTangent) || + !IsFinite(keys[index].outTangent)) + { + failureReason = + $"{label} key {index} has a non-finite tangent"; + return true; + } + + if (Mathf.Abs(keys[index].inTangent - keys[index].outTangent) > + CurveC1AbsoluteTolerance) + { + failureReason = + $"{label} key {index} is not C1: in/out velocity " + + $"{keys[index].inTangent:F6}/" + + $"{keys[index].outTangent:F6}"; + return true; + } + } + + for (var index = 1; index + 1 < keys.Length; index++) + { + var leftAcceleration = CalculateHermiteSecondDerivativeAtRight( + keys[index - 1], + keys[index]); + var rightAcceleration = CalculateHermiteSecondDerivativeAtLeft( + keys[index], + keys[index + 1]); + var scale = Mathf.Max( + Mathf.Abs(leftAcceleration), + Mathf.Abs(rightAcceleration)); + var allowed = Mathf.Max( + CurveC2AbsoluteTolerance, + scale * CurveC2RelativeTolerance); + if (!IsFinite(leftAcceleration) || + !IsFinite(rightAcceleration) || + Mathf.Abs(leftAcceleration - rightAcceleration) > allowed) + { + failureReason = + $"{label} key {index} is not C2: left/right " + + $"acceleration {leftAcceleration:F4}/" + + $"{rightAcceleration:F4}; allowed delta {allowed:F4}"; + return true; + } + } + + failureReason = string.Empty; + return false; + } + + private static float CalculateHermiteSecondDerivativeAtLeft( + Keyframe left, + Keyframe right) + { + var duration = right.time - left.time; + var displacement = right.value - left.value; + return 6f * displacement / (duration * duration) - + (4f * left.outTangent + 2f * right.inTangent) / duration; + } + + private static float CalculateHermiteSecondDerivativeAtRight( + Keyframe left, + Keyframe right) + { + var duration = right.time - left.time; + var displacement = right.value - left.value; + return -6f * displacement / (duration * duration) + + (2f * left.outTangent + 4f * right.inTangent) / duration; + } + private static Vector3[] EvaluatePositionCurves( IReadOnlyList times, AnimationCurve xCurve, @@ -2985,6 +3950,609 @@ namespace Streamingle.Editor new PositionMetricSeries(jerkTimes, jerkMagnitudes)); } + internal static Quaternion[] EvaluateRotationCurves( + IReadOnlyList times, + RotationCurveBuildResult curves) + { + if (curves == null) + { + throw new ArgumentNullException(nameof(curves)); + } + + if (!TryEvaluateRotationCurves( + times, + curves.XCurve, + curves.YCurve, + curves.ZCurve, + curves.WCurve, + out var result, + out var failureReason)) + { + throw new InvalidOperationException(failureReason); + } + + return result; + } + + private static bool TryEvaluateRotationCurves( + IReadOnlyList times, + AnimationCurve xCurve, + AnimationCurve yCurve, + AnimationCurve zCurve, + AnimationCurve wCurve, + out Quaternion[] rotations, + out string failureReason) + { + rotations = new Quaternion[times.Count]; + for (var index = 0; index < times.Count; index++) + { + var time = (float)times[index]; + var rotation = new Quaternion( + xCurve.Evaluate(time), + yCurve.Evaluate(time), + zCurve.Evaluate(time), + wCurve.Evaluate(time)); + if (!IsFinite(rotation.x) || + !IsFinite(rotation.y) || + !IsFinite(rotation.z) || + !IsFinite(rotation.w)) + { + failureReason = + $"rotation curve evaluated to a non-finite quaternion " + + $"at {times[index]:F4} s"; + return false; + } + + var squaredMagnitude = + rotation.x * rotation.x + + rotation.y * rotation.y + + rotation.z * rotation.z + + rotation.w * rotation.w; + if (squaredMagnitude < 0.000000000001f) + { + failureReason = + $"rotation curve evaluated to a zero quaternion at " + + $"{times[index]:F4} s"; + return false; + } + + rotation = NormalizeQuaternion(rotation); + if (index > 0 && Quaternion.Dot(rotations[index - 1], rotation) < 0f) + { + rotation = NegateQuaternion(rotation); + } + + rotations[index] = rotation; + } + + failureReason = string.Empty; + return true; + } + + private static PositionMotionSeries MeasureAngularMotion( + IReadOnlyList times, + IReadOnlyList rotations) + { + if (times.Count < 2) + { + return PositionMotionSeries.Empty; + } + + var continuousRotations = MakeQuaternionSequenceContinuous(rotations); + var velocities = new Vector3[times.Count - 1]; + var velocityTimes = new double[velocities.Length]; + var speeds = new float[velocities.Length]; + for (var index = 0; index < velocities.Length; index++) + { + var duration = times[index + 1] - times[index]; + velocities[index] = CalculateAngularVelocityDegrees( + continuousRotations[index], + continuousRotations[index + 1], + duration); + velocityTimes[index] = (times[index + 1] + times[index]) * 0.5; + speeds[index] = velocities[index].magnitude; + } + + if (velocities.Length < 2) + { + return new PositionMotionSeries( + new PositionMetricSeries(velocityTimes, speeds), + PositionMetricSeries.Empty, + PositionMetricSeries.Empty); + } + + var accelerations = new Vector3[velocities.Length - 1]; + var accelerationTimes = new double[accelerations.Length]; + var accelerationMagnitudes = new float[accelerations.Length]; + for (var index = 0; index < accelerations.Length; index++) + { + var duration = velocityTimes[index + 1] - velocityTimes[index]; + accelerations[index] = + (velocities[index + 1] - velocities[index]) / (float)duration; + accelerationTimes[index] = + (velocityTimes[index + 1] + velocityTimes[index]) * 0.5; + accelerationMagnitudes[index] = accelerations[index].magnitude; + } + + var jerkTimes = new double[Math.Max(0, accelerations.Length - 1)]; + var jerkMagnitudes = new float[jerkTimes.Length]; + for (var index = 0; index < accelerations.Length - 1; index++) + { + var duration = + accelerationTimes[index + 1] - accelerationTimes[index]; + var jerk = + (accelerations[index + 1] - accelerations[index]) / + (float)duration; + jerkTimes[index] = + (accelerationTimes[index + 1] + accelerationTimes[index]) * + 0.5; + jerkMagnitudes[index] = jerk.magnitude; + } + + return new PositionMotionSeries( + new PositionMetricSeries(velocityTimes, speeds), + new PositionMetricSeries( + accelerationTimes, + accelerationMagnitudes), + new PositionMetricSeries(jerkTimes, jerkMagnitudes)); + } + + private static Vector3 CalculateAngularVelocityDegrees( + Quaternion start, + Quaternion end, + double duration) + { + if (duration <= double.Epsilon) + { + return Vector3.zero; + } + + var delta = NormalizeQuaternion(end * Quaternion.Inverse(start)); + if (delta.w < 0f) + { + delta = NegateQuaternion(delta); + } + + var sineHalfAngle = Mathf.Sqrt( + delta.x * delta.x + + delta.y * delta.y + + delta.z * delta.z); + if (sineHalfAngle < 0.0000001f) + { + return Vector3.zero; + } + + var halfAngle = Mathf.Atan2( + sineHalfAngle, + Mathf.Clamp(delta.w, -1f, 1f)); + var degreesPerSecond = + 2f * halfAngle * Mathf.Rad2Deg / (float)duration; + return new Vector3(delta.x, delta.y, delta.z) / + sineHalfAngle * degreesPerSecond; + } + + private static bool TryDescribeTimeWindowPositionMotionRegression( + string sampleLabel, + PositionMotionSeries reference, + PositionMotionSeries candidate, + out string failureReason) + { + if (TryDescribeTimeWindowMetricRegression( + sampleLabel, + "speed", + "m/s", + reference.Speed, + candidate.Speed, + CurveAuditLocalWindowSeconds, + PositionCurveAuditSpeedRatio, + PositionCurveAuditSpeedDeltaMetersPerSecond, + PositionCurveAuditSpeedFloorMetersPerSecond, + out failureReason)) + { + return true; + } + + if (TryDescribeTimeWindowMetricRegression( + sampleLabel, + "acceleration", + "m/s^2", + reference.Acceleration, + candidate.Acceleration, + CurveAuditLocalWindowSeconds, + PositionCurveAuditAccelerationRatio, + PositionCurveAuditAccelerationDeltaMetersPerSecondSquared, + PositionCurveAuditAccelerationFloorMetersPerSecondSquared, + out failureReason)) + { + return true; + } + + return TryDescribeTimeWindowMetricRegression( + sampleLabel, + "jerk", + "m/s^3", + reference.Jerk, + candidate.Jerk, + CurveAuditLocalWindowSeconds, + PositionCurveAuditJerkRatio, + PositionCurveAuditJerkDeltaMetersPerSecondCubed, + PositionCurveAuditJerkFloorMetersPerSecondCubed, + out failureReason); + } + + private static bool TryDescribePositionSamplingConvergence( + PositionMotionSeries primary, + PositionMotionSeries convergence, + out string failureReason) + { + var window = 1.0 / 60.0; + if (TryDescribeTimeWindowMetricRegression( + "240/480 Hz convergence", + "speed", + "m/s", + primary.Speed, + convergence.Speed, + window, + CurveAuditConvergenceRatio, + 0f, + PositionCurveAuditConvergenceSpeedFloor, + out failureReason)) + { + return true; + } + + if (TryDescribeTimeWindowMetricRegression( + "240/480 Hz convergence", + "acceleration", + "m/s^2", + primary.Acceleration, + convergence.Acceleration, + window, + CurveAuditConvergenceRatio, + 0f, + PositionCurveAuditConvergenceAccelerationFloor, + out failureReason)) + { + return true; + } + + return TryDescribeTimeWindowMetricRegression( + "240/480 Hz convergence", + "jerk", + "m/s^3", + primary.Jerk, + convergence.Jerk, + window, + CurveAuditConvergenceRatio, + 0f, + PositionCurveAuditConvergenceJerkFloor, + out failureReason); + } + + private static bool TryDescribeTimeWindowAngularMotionRegression( + string sampleLabel, + PositionMotionSeries reference, + PositionMotionSeries candidate, + out string failureReason) + { + if (TryDescribeTimeWindowMetricRegression( + sampleLabel, + "angular speed", + "deg/s", + reference.Speed, + candidate.Speed, + CurveAuditLocalWindowSeconds, + RotationCurveAuditSpeedRatio, + RotationCurveAuditSpeedDeltaDegreesPerSecond, + RotationCurveAuditSpeedFloorDegreesPerSecond, + out failureReason)) + { + return true; + } + + if (TryDescribeTimeWindowMetricRegression( + sampleLabel, + "angular acceleration", + "deg/s^2", + reference.Acceleration, + candidate.Acceleration, + CurveAuditLocalWindowSeconds, + RotationCurveAuditAccelerationRatio, + RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared, + RotationCurveAuditAccelerationFloorDegreesPerSecondSquared, + out failureReason)) + { + return true; + } + + return TryDescribeTimeWindowMetricRegression( + sampleLabel, + "angular jerk", + "deg/s^3", + reference.Jerk, + candidate.Jerk, + CurveAuditLocalWindowSeconds, + RotationCurveAuditJerkRatio, + RotationCurveAuditJerkDeltaDegreesPerSecondCubed, + RotationCurveAuditJerkFloorDegreesPerSecondCubed, + out failureReason); + } + + private static bool TryDescribeAngularSamplingConvergence( + PositionMotionSeries primary, + PositionMotionSeries convergence, + out string failureReason) + { + var window = 1.0 / 60.0; + if (TryDescribeTimeWindowMetricRegression( + "240/480 Hz convergence", + "angular speed", + "deg/s", + primary.Speed, + convergence.Speed, + window, + CurveAuditConvergenceRatio, + 0f, + RotationCurveAuditConvergenceSpeedFloor, + out failureReason)) + { + return true; + } + + if (TryDescribeTimeWindowMetricRegression( + "240/480 Hz convergence", + "angular acceleration", + "deg/s^2", + primary.Acceleration, + convergence.Acceleration, + window, + CurveAuditConvergenceRatio, + 0f, + RotationCurveAuditConvergenceAccelerationFloor, + out failureReason)) + { + return true; + } + + return TryDescribeTimeWindowMetricRegression( + "240/480 Hz convergence", + "angular jerk", + "deg/s^3", + primary.Jerk, + convergence.Jerk, + window, + CurveAuditConvergenceRatio, + 0f, + RotationCurveAuditConvergenceJerkFloor, + out failureReason); + } + + private static bool TryDescribeTimeWindowMetricRegression( + string sampleLabel, + string metricLabel, + string unit, + PositionMetricSeries reference, + PositionMetricSeries candidate, + double windowSeconds, + float ratio, + float absoluteDelta, + float absoluteFloor, + out string failureReason) + { + if (candidate.Values.Length == 0) + { + failureReason = string.Empty; + return false; + } + + var firstReference = 0; + var pastLastReference = 0; + for (var candidateIndex = 0; + candidateIndex < candidate.Values.Length; + candidateIndex++) + { + var time = candidate.Times[candidateIndex]; + while (firstReference < reference.Times.Length && + reference.Times[firstReference] < time - windowSeconds) + { + firstReference++; + } + + if (pastLastReference < firstReference) + { + pastLastReference = firstReference; + } + + while (pastLastReference < reference.Times.Length && + reference.Times[pastLastReference] <= time + windowSeconds) + { + pastLastReference++; + } + + var referenceMaximum = 0f; + for (var referenceIndex = firstReference; + referenceIndex < pastLastReference; + referenceIndex++) + { + referenceMaximum = Mathf.Max( + referenceMaximum, + reference.Values[referenceIndex]); + } + + var allowed = Mathf.Max( + absoluteFloor, + Mathf.Max( + referenceMaximum * ratio, + referenceMaximum + absoluteDelta)); + var candidateValue = candidate.Values[candidateIndex]; + if (IsFinite(candidateValue) && candidateValue <= allowed) + { + continue; + } + + failureReason = + $"{sampleLabel} {metricLabel} regressed near {time:F4} s " + + $"from local raw {referenceMaximum:F3} to " + + $"{candidateValue:F3} {unit}; allowed {allowed:F3} {unit}"; + return true; + } + + failureReason = string.Empty; + return false; + } + + private static bool TryDescribeAlignedAngularMotionRegression( + string sampleLabel, + PositionMotionSeries reference, + PositionMotionSeries candidate, + out string failureReason) + { + if (TryDescribeAlignedMetricRegression( + sampleLabel, + "angular speed", + "deg/s", + reference.Speed, + candidate.Speed, + RotationCurveAuditSpeedRatio, + RotationCurveAuditSpeedDeltaDegreesPerSecond, + RotationCurveAuditSpeedFloorDegreesPerSecond, + out failureReason)) + { + return true; + } + + if (TryDescribeAlignedMetricRegression( + sampleLabel, + "angular acceleration", + "deg/s^2", + reference.Acceleration, + candidate.Acceleration, + RotationCurveAuditAccelerationRatio, + RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared, + RotationCurveAuditAccelerationFloorDegreesPerSecondSquared, + out failureReason)) + { + return true; + } + + return TryDescribeAlignedMetricRegression( + sampleLabel, + "angular jerk", + "deg/s^3", + reference.Jerk, + candidate.Jerk, + RotationCurveAuditJerkRatio, + RotationCurveAuditJerkDeltaDegreesPerSecondCubed, + RotationCurveAuditJerkFloorDegreesPerSecondCubed, + out failureReason); + } + + private static bool TryDescribeAngularMotionRegression( + string sampleLabel, + PositionMotionMetrics reference, + PositionMotionMetrics candidate, + out string failureReason) + { + if (TryDescribeMetricRegression( + sampleLabel, + "angular speed", + "deg/s", + reference.MaximumSpeed, + candidate.MaximumSpeed, + RotationCurveAuditSpeedRatio, + RotationCurveAuditSpeedDeltaDegreesPerSecond, + RotationCurveAuditSpeedFloorDegreesPerSecond, + out failureReason)) + { + return true; + } + + if (TryDescribeMetricRegression( + sampleLabel, + "angular acceleration", + "deg/s^2", + reference.MaximumAcceleration, + candidate.MaximumAcceleration, + RotationCurveAuditAccelerationRatio, + RotationCurveAuditAccelerationDeltaDegreesPerSecondSquared, + RotationCurveAuditAccelerationFloorDegreesPerSecondSquared, + out failureReason)) + { + return true; + } + + return TryDescribeMetricRegression( + sampleLabel, + "angular jerk", + "deg/s^3", + reference.MaximumJerk, + candidate.MaximumJerk, + RotationCurveAuditJerkRatio, + RotationCurveAuditJerkDeltaDegreesPerSecondCubed, + RotationCurveAuditJerkFloorDegreesPerSecondCubed, + out failureReason); + } + + private static bool TryDescribeAngularMotionRetentionRegression( + string sampleLabel, + IReadOnlyList reference, + IReadOnlyList candidate, + out string failureReason) + { + var referencePathLength = MeasureMeaningfulAngularPathLength(reference); + if (referencePathLength < RotationCurveAuditMotionRetentionMinimumDegrees) + { + failureReason = string.Empty; + return false; + } + + var candidatePathLength = MeasureMeaningfulAngularPathLength(candidate); + var requiredPathLength = Math.Max( + 0f, + referencePathLength * RotationCurveAuditMotionRetentionRatio - + RotationCurveAuditMotionNoiseDeadbandDegrees); + if (IsFinite(candidatePathLength) && + candidatePathLength >= requiredPathLength) + { + failureReason = string.Empty; + return false; + } + + failureReason = + $"{sampleLabel} meaningful angular motion retention fell from " + + $"{referencePathLength:F3} deg to {candidatePathLength:F3} deg; " + + $"required {requiredPathLength:F3} deg after the " + + $"{RotationCurveAuditMotionNoiseDeadbandDegrees:F3} deg noise " + + "deadband"; + return true; + } + + private static float MeasureMeaningfulAngularPathLength( + IReadOnlyList rotations) + { + if (rotations.Count < 2) + { + return 0f; + } + + var continuousRotations = MakeQuaternionSequenceContinuous(rotations); + var pathLength = 0f; + var anchor = continuousRotations[0]; + for (var index = 1; index < continuousRotations.Length; index++) + { + var angle = Quaternion.Angle(anchor, continuousRotations[index]); + if (angle < RotationCurveAuditMotionNoiseDeadbandDegrees) + { + continue; + } + + pathLength += angle; + anchor = continuousRotations[index]; + } + + return pathLength; + } + internal static bool TryDescribeAlignedMotionRegression( IReadOnlyList times, IReadOnlyList referencePositions, @@ -3623,10 +5191,24 @@ namespace Streamingle.Editor EditorCurveBinding.FloatCurve( relativePath, componentType, - propertyName), + propertyName), curve); } + private static AnimationCurve GetCurve( + AnimationClip clip, + string relativePath, + Type componentType, + string propertyName) + { + return AnimationUtility.GetEditorCurve( + clip, + EditorCurveBinding.FloatCurve( + relativePath, + componentType, + propertyName)); + } + private static void SetClipExtrapolationNone(TimelineClip clip) { SetNonPublicTimelineClipProperty( @@ -5133,6 +6715,32 @@ namespace Streamingle.Editor internal string FallbackReason { get; } } + internal sealed class RotationCurveBuildResult + { + internal RotationCurveBuildResult( + AnimationCurve xCurve, + AnimationCurve yCurve, + AnimationCurve zCurve, + AnimationCurve wCurve, + bool usedDenseFallback, + string fallbackReason) + { + XCurve = xCurve ?? throw new ArgumentNullException(nameof(xCurve)); + YCurve = yCurve ?? throw new ArgumentNullException(nameof(yCurve)); + ZCurve = zCurve ?? throw new ArgumentNullException(nameof(zCurve)); + WCurve = wCurve ?? throw new ArgumentNullException(nameof(wCurve)); + UsedDenseFallback = usedDenseFallback; + FallbackReason = fallbackReason ?? string.Empty; + } + + internal AnimationCurve XCurve { get; } + internal AnimationCurve YCurve { get; } + internal AnimationCurve ZCurve { get; } + internal AnimationCurve WCurve { get; } + internal bool UsedDenseFallback { get; } + internal string FallbackReason { get; } + } + internal readonly struct PositionCurveAuditResult { private PositionCurveAuditResult(bool passed, string failureReason) @@ -5155,6 +6763,28 @@ namespace Streamingle.Editor } } + internal readonly struct RotationCurveAuditResult + { + private RotationCurveAuditResult(bool passed, string failureReason) + { + Passed = passed; + FailureReason = failureReason ?? string.Empty; + } + + internal bool Passed { get; } + internal string FailureReason { get; } + + internal static RotationCurveAuditResult Pass() + { + return new RotationCurveAuditResult(true, string.Empty); + } + + internal static RotationCurveAuditResult Fail(string failureReason) + { + return new RotationCurveAuditResult(false, failureReason); + } + } + private readonly struct PositionMetricSeries { internal static readonly PositionMetricSeries Empty = @@ -5255,6 +6885,9 @@ namespace Streamingle.Editor internal bool PositionUsedExactFallback { get; private set; } internal string PositionFallbackReason { get; private set; } = string.Empty; + internal bool RotationUsedDenseFallback { get; private set; } + internal string RotationFallbackReason { get; private set; } = + string.Empty; internal void SetPositionCurveDiagnostics( bool usedExactFallback, @@ -5263,6 +6896,14 @@ namespace Streamingle.Editor PositionUsedExactFallback = usedExactFallback; PositionFallbackReason = fallbackReason ?? string.Empty; } + + internal void SetRotationCurveDiagnostics( + bool usedDenseFallback, + string fallbackReason) + { + RotationUsedDenseFallback = usedDenseFallback; + RotationFallbackReason = fallbackReason ?? string.Empty; + } } private sealed class PreviewShotCamera diff --git a/CameraAI~/README.md b/CameraAI~/README.md index f97c69f..7adfe93 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.15` +`https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.16` 배포 패키지에는 Windows x64용 `CWCameraWorker` 폴더 전체가 포함됩니다. Python은 따로 설치하지 않아도 되지만, Git 패키지의 대용량 바이너리를 받으려면 diff --git a/CameraAI~/THIRD_PARTY_NOTICES.md b/CameraAI~/THIRD_PARTY_NOTICES.md index bff570a..6d8ac7e 100644 --- a/CameraAI~/THIRD_PARTY_NOTICES.md +++ b/CameraAI~/THIRD_PARTY_NOTICES.md @@ -1,7 +1,7 @@ # Third-party notices The bundled Windows x64 `CWCameraWorker` is a frozen Python application. Its -runtime includes Python 3.10 and open-source packages including NumPy, SciPy, +runtime includes Python 3.12.13 and open-source packages including NumPy, SciPy, scikit-learn, librosa, python-soundfile, joblib, PyInstaller, and their transitive dependencies. diff --git a/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs b/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs index e04ac4e..5857136 100644 --- a/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs +++ b/CameraAI~/Tests/Editor/AICameraCurveSimplifierTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using UnityEditor; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; @@ -157,7 +158,7 @@ namespace Streamingle.Editor [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)] [TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)] - public void ReducedPositionCurvesUseAuditedC1HermiteWithoutOvershoot( + public void ReducedPositionCurvesUseAuditedC2SplineWithoutOvershoot( AICameraTimelinePreviewImporter.CurveSimplificationPreset preset) { var samples = CreateQuadraticMotionSamples(241); @@ -172,9 +173,9 @@ namespace Streamingle.Editor Assert.That( curves.XCurve.length, Is.LessThan(samples.Times.Length)); - AssertCurveIsC1(curves.XCurve); - AssertCurveIsC1(curves.YCurve); - AssertCurveIsC1(curves.ZCurve); + AssertCurveIsC2(curves.XCurve); + AssertCurveIsC2(curves.YCurve); + AssertCurveIsC2(curves.ZCurve); AssertCurveDoesNotOvershootSelectedSegments( curves.XCurve, simplified.Times, @@ -214,7 +215,7 @@ namespace Streamingle.Editor } [Test] - public void ReducedPositionCurveAuditFallsBackToExactPositionOnly() + public void SparsePositionErrorIsAdaptivelyRefinedWithoutDenseFallback() { const int count = 61; var times = new double[count]; @@ -237,23 +238,69 @@ namespace Streamingle.Editor simplified, AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); - Assert.That(curves.UsedExactFallback, Is.True); - Assert.That(curves.FallbackReason, Does.Contain("position error")); - Assert.That(simplified.PositionUsedExactFallback, Is.True); - Assert.That( - simplified.PositionFallbackReason, - Is.EqualTo(curves.FallbackReason)); - Assert.That(curves.XCurve.length, Is.EqualTo(count)); - Assert.That(curves.YCurve.length, Is.EqualTo(count)); - Assert.That(curves.ZCurve.length, Is.EqualTo(count)); + Assert.That(curves.UsedExactFallback, Is.False, curves.FallbackReason); + Assert.That(simplified.PositionUsedExactFallback, Is.False); + Assert.That(curves.XCurve.length, Is.GreaterThan(2)); + Assert.That(curves.XCurve.length, Is.LessThan(count)); + AssertCurveIsC2(curves.XCurve); + AssertCurveIsC2(curves.YCurve); + AssertCurveIsC2(curves.ZCurve); + var settings = AICameraTimelinePreviewImporter + .GetCurveSimplificationSettings( + AICameraTimelinePreviewImporter + .CurveSimplificationPreset.Editable); for (var index = 0; index < count; index++) { Assert.That( - curves.XCurve.Evaluate((float)times[index]), - Is.EqualTo(positions[index].x).Within(0.000001f)); + Mathf.Abs( + curves.XCurve.Evaluate((float)times[index]) - + positions[index].x), + Is.LessThanOrEqualTo( + settings.PositionErrorMeters + 0.0001f)); } } + [Test] + public void HighFrequencyPositionExceedingRefinementBudgetUsesAuditedDenseFallback() + { + const int count = 1201; + var times = new double[count]; + var positions = new Vector3[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60f; + times[index] = time; + positions[index] = new Vector3( + Mathf.Sin(time * Mathf.PI * 6f), + 1.6f, + -4f); + } + + var simplified = CreatePositionOnlySimplifiedCurves( + times, + positions, + new[] { 0, count - 1 }); + var curves = AICameraTimelinePreviewImporter.BuildPositionCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); + + Assert.That(curves.UsedExactFallback, Is.True); + Assert.That(curves.XCurve.length, Is.EqualTo(count)); + AssertCurveIsC2(curves.XCurve); + var audit = AICameraTimelinePreviewImporter.AuditPositionCurves( + times, + positions, + curves.XCurve, + curves.YCurve, + curves.ZCurve, + AICameraTimelinePreviewImporter + .GetCurveSimplificationSettings( + AICameraTimelinePreviewImporter + .CurveSimplificationPreset.Editable) + .PositionErrorMeters); + Assert.That(audit.Passed, Is.True, audit.FailureReason); + } + [Test] public void MotionAuditRejectsPathologicalMidpointVelocityRegression() { @@ -281,7 +328,9 @@ namespace Streamingle.Editor 100f); Assert.That(audit.Passed, Is.False); - Assert.That(audit.FailureReason, Does.Contain("regressed")); + Assert.That( + audit.FailureReason, + Does.Contain("regressed").Or.Contain("not C1").Or.Contain("not C2")); } [Test] @@ -412,7 +461,7 @@ namespace Streamingle.Editor } [Test] - public void ExactPositionCurvesRetainEveryLinearSampleWithoutFallback() + public void ExactPositionCurvesRetainEverySampleWithC2Tangents() { var samples = CreateSamples(91); var simplified = Simplify( @@ -427,6 +476,9 @@ namespace Streamingle.Editor Assert.That(curves.XCurve.length, Is.EqualTo(samples.Times.Length)); Assert.That(curves.YCurve.length, Is.EqualTo(samples.Times.Length)); Assert.That(curves.ZCurve.length, Is.EqualTo(samples.Times.Length)); + AssertCurveIsC2(curves.XCurve); + AssertCurveIsC2(curves.YCurve); + AssertCurveIsC2(curves.ZCurve); for (var index = 0; index < samples.Times.Length; index++) { var time = (float)samples.Times[index]; @@ -442,6 +494,40 @@ namespace Streamingle.Editor } } + [Test] + public void ExactPositionCurvesRejectUnboundedBetweenFrameJerk() + { + var times = new[] + { + 0.0, + 1.0 / 60.0, + 2.0 / 60.0, + 2.0 + }; + var positions = new[] + { + new Vector3(0f, 1.6f, -4f), + new Vector3(0f, 1.6f, -4f), + new Vector3(1f, 1.6f, -4f), + new Vector3(1f, 1.6f, -4f) + }; + var simplified = CreatePositionOnlySimplifiedCurves( + times, + positions, + Enumerable.Range(0, times.Length).ToArray()); + + var exception = Assert.Throws(() => + AICameraTimelinePreviewImporter.BuildPositionCurves( + simplified, + AICameraTimelinePreviewImporter + .CurveSimplificationPreset.Exact)); + + Assert.That( + exception.Message, + Does.Contain("Exact dense C2 position curves failed")); + Assert.That(exception.Message, Does.Contain("high-rate motion audit")); + } + [Test] public void EditablePresetPreservesStopThroughDirectionChangeKeys() { @@ -637,6 +723,37 @@ namespace Streamingle.Editor } } + [Test] + public void C1OnlyCurveIsRejectedAndItsJerkDivergesWithSampleRate() + { + var curve = new AnimationCurve( + new Keyframe(0f, 0f, 0f, 0f), + new Keyframe(1f, 1f, 0f, 0f), + new Keyframe(2f, 1f, 0f, 0f)); + AssertCurveIsC1(curve); + + Assert.That( + AICameraTimelinePreviewImporter.TryDescribeC2CurveFailure( + curve, + "C1-only fixture", + out var failureReason), + Is.True); + Assert.That(failureReason, Does.Contain("not C2")); + + var jerk120 = MeasureMaximumJerk( + CreateUniformTimes(0f, 2f, 120), + EvaluateScalarCurveAsPositions(curve, 0f, 2f, 120)); + var jerk240 = MeasureMaximumJerk( + CreateUniformTimes(0f, 2f, 240), + EvaluateScalarCurveAsPositions(curve, 0f, 2f, 240)); + var jerk480 = MeasureMaximumJerk( + CreateUniformTimes(0f, 2f, 480), + EvaluateScalarCurveAsPositions(curve, 0f, 2f, 480)); + + Assert.That(jerk240, Is.GreaterThan(jerk120 * 1.7f)); + Assert.That(jerk480, Is.GreaterThan(jerk240 * 1.7f)); + } + [Test] public void PreviewDirectorDetectionAcceptsScopedGeneratedPreviewOnly() { @@ -789,6 +906,115 @@ namespace Streamingle.Editor allIndices); } + private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves + CreateRotationOnlySimplifiedCurves( + double[] times, + Quaternion[] rotations, + AICameraTimelinePreviewImporter.CurveSimplificationPreset preset) + { + var positions = Enumerable.Repeat(Vector3.zero, times.Length).ToArray(); + var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray(); + var dutch = new float[times.Length]; + return AICameraTimelinePreviewImporter.SimplifyCameraCurves( + times, + positions, + rotations, + fieldOfView, + dutch, + preset); + } + + private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves + CreateRotationOnlySimplifiedCurves( + double[] times, + Quaternion[] rotations, + int[] rotationIndices) + { + var positions = Enumerable.Repeat(Vector3.zero, times.Length).ToArray(); + var fieldOfView = Enumerable.Repeat(40f, times.Length).ToArray(); + var dutch = new float[times.Length]; + var allIndices = Enumerable.Range(0, times.Length).ToArray(); + return new AICameraTimelinePreviewImporter.SimplifiedCameraCurves( + times, + positions, + rotations, + fieldOfView, + dutch, + allIndices, + rotationIndices, + allIndices, + allIndices); + } + + private static AnimationCurve BuildQuaternionCornerCurve( + IReadOnlyList times, + IReadOnlyList values) + { + var firstSlope = + (values[1] - values[0]) / (times[1] - times[0]); + var secondSlope = + (values[2] - values[1]) / (times[2] - times[1]); + return new AnimationCurve( + new Keyframe( + times[0], + values[0], + firstSlope, + firstSlope), + new Keyframe( + times[1], + values[1], + firstSlope, + secondSlope), + new Keyframe( + times[2], + values[2], + secondSlope, + secondSlope)); + } + + private static void SetRotationCurve( + AnimationClip clip, + string propertyName, + AnimationCurve curve) + { + AnimationUtility.SetEditorCurve( + clip, + EditorCurveBinding.FloatCurve( + string.Empty, + typeof(Transform), + propertyName), + curve); + } + + private static AnimationCurve GetRotationCurve( + AnimationClip clip, + string propertyName) + { + return AnimationUtility.GetEditorCurve( + clip, + EditorCurveBinding.FloatCurve( + string.Empty, + typeof(Transform), + propertyName)); + } + + private static void AssertQuaternionEndpoints( + IReadOnlyList times, + IReadOnlyList rotations, + AICameraTimelinePreviewImporter.RotationCurveBuildResult curves) + { + var endpointTimes = new[] { times[0], times[times.Count - 1] }; + var evaluated = AICameraTimelinePreviewImporter.EvaluateRotationCurves( + endpointTimes, + curves); + Assert.That( + Quaternion.Angle(rotations[0], evaluated[0]), + Is.LessThan(0.05f)); + Assert.That( + Quaternion.Angle(rotations[rotations.Count - 1], evaluated[1]), + Is.LessThan(0.05f)); + } + private static void AssertCurveIsC1(AnimationCurve curve) { var keys = curve.keys; @@ -801,6 +1027,16 @@ namespace Streamingle.Editor } } + private static void AssertCurveIsC2(AnimationCurve curve) + { + AssertCurveIsC1(curve); + var failed = AICameraTimelinePreviewImporter.TryDescribeC2CurveFailure( + curve, + "test curve", + out var failureReason); + Assert.That(failed, Is.False, failureReason); + } + private static void AssertCurveDoesNotOvershootSelectedSegments( AnimationCurve curve, IReadOnlyList times, @@ -829,6 +1065,390 @@ namespace Streamingle.Editor } } + [Test] + public void Shot68StyleLongRotationUsesAuditedSparseC2Curves() + { + const int count = 1081; + var times = new double[count]; + var rotations = new Quaternion[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60f; + var normalized = index / (float)(count - 1); + var eased = normalized * normalized * (3f - 2f * normalized); + times[index] = time; + rotations[index] = Quaternion.Euler( + -3f + 1.25f * Mathf.Sin(time * 0.22f), + -24f + 48f * eased + 1.5f * Mathf.Sin(time * 0.3f), + 0.6f * Mathf.Sin(time * 0.27f)); + } + + var simplified = CreateRotationOnlySimplifiedCurves( + times, + rotations, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced); + var curves = AICameraTimelinePreviewImporter.BuildRotationCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced); + + Assert.That( + simplified.RotationIndices.Length, + Is.LessThan(count / 4), + "The long smooth shot should remain meaningfully editable."); + Assert.That(curves.UsedDenseFallback, Is.False, curves.FallbackReason); + Assert.That(curves.XCurve.length, Is.LessThan(count / 4)); + AssertCurveIsC2(curves.XCurve); + AssertCurveIsC2(curves.YCurve); + AssertCurveIsC2(curves.ZCurve); + AssertCurveIsC2(curves.WCurve); + AssertQuaternionEndpoints(times, rotations, curves); + + var audit = AICameraTimelinePreviewImporter.AuditRotationCurves( + times, + rotations, + curves.XCurve, + curves.YCurve, + curves.ZCurve, + curves.WCurve, + AICameraTimelinePreviewImporter + .GetCurveSimplificationSettings( + AICameraTimelinePreviewImporter + .CurveSimplificationPreset.Balanced) + .QuaternionAngleErrorDegrees); + Assert.That(audit.Passed, Is.True, audit.FailureReason); + } + + [Test] + public void AngularMotionAuditRejectsSparseLinearVelocityCorner() + { + const int count = 121; + var times = new double[count]; + var rotations = new Quaternion[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60f; + times[index] = time; + rotations[index] = Quaternion.Euler(0f, 15f * time, 0f); + } + + var cornerRotations = new[] + { + Quaternion.Euler(0f, 0f, 0f), + Quaternion.Euler(0f, 10f, 0f), + Quaternion.Euler(0f, 30f, 0f) + }; + var cornerTimes = new[] { 0f, 1f, 2f }; + var xCurve = BuildQuaternionCornerCurve( + cornerTimes, + cornerRotations.Select(value => value.x).ToArray()); + var yCurve = BuildQuaternionCornerCurve( + cornerTimes, + cornerRotations.Select(value => value.y).ToArray()); + var zCurve = BuildQuaternionCornerCurve( + cornerTimes, + cornerRotations.Select(value => value.z).ToArray()); + var wCurve = BuildQuaternionCornerCurve( + cornerTimes, + cornerRotations.Select(value => value.w).ToArray()); + + var audit = AICameraTimelinePreviewImporter.AuditRotationCurves( + times, + rotations, + xCurve, + yCurve, + zCurve, + wCurve, + 180f); + + Assert.That(audit.Passed, Is.False); + Assert.That( + audit.FailureReason, + Does.Contain("angular").Or.Contain("rotation")); + } + + [Test] + public void Shot66StyleMissedCorrectionIsAdaptivelyRefinedAsC2Rotation() + { + const int count = 121; + var times = new double[count]; + var rotations = new Quaternion[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60f; + var correctionDistance = (index - 82f) / 8f; + var correction = 3f * Mathf.Exp( + -correctionDistance * correctionDistance); + times[index] = time; + rotations[index] = Quaternion.Euler( + 0.4f * Mathf.Sin(time), + 8f * time + correction, + 0f); + } + + var simplified = CreateRotationOnlySimplifiedCurves( + times, + rotations, + new[] { 0, count - 1 }); + var curves = AICameraTimelinePreviewImporter.BuildRotationCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); + + Assert.That(curves.UsedDenseFallback, Is.False, curves.FallbackReason); + Assert.That(simplified.RotationUsedDenseFallback, Is.False); + Assert.That(curves.XCurve.length, Is.GreaterThan(2)); + Assert.That(curves.XCurve.length, Is.LessThan(count)); + AssertCurveIsC2(curves.XCurve); + AssertCurveIsC2(curves.YCurve); + AssertCurveIsC2(curves.ZCurve); + AssertCurveIsC2(curves.WCurve); + AssertQuaternionEndpoints(times, rotations, curves); + + var evaluated = AICameraTimelinePreviewImporter.EvaluateRotationCurves( + times, + curves); + for (var index = 0; index < count; index++) + { + Assert.That( + Quaternion.Angle(rotations[index], evaluated[index]), + Is.LessThan(0.51f), + $"Adaptive C2 refinement missed source rotation {index}."); + } + } + + [Test] + public void HighFrequencyRotationExceedingRefinementBudgetUsesDenseC2Fallback() + { + const int count = 1201; + var times = new double[count]; + var rotations = new Quaternion[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60f; + times[index] = time; + rotations[index] = Quaternion.Euler( + 0f, + 10f * Mathf.Sin(time * Mathf.PI * 6f), + 0f); + } + + var simplified = CreateRotationOnlySimplifiedCurves( + times, + rotations, + new[] { 0, count - 1 }); + var curves = AICameraTimelinePreviewImporter.BuildRotationCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable); + + Assert.That(curves.UsedDenseFallback, Is.True); + Assert.That(curves.XCurve.length, Is.EqualTo(count)); + AssertCurveIsC2(curves.XCurve); + AssertCurveIsC2(curves.YCurve); + AssertCurveIsC2(curves.ZCurve); + AssertCurveIsC2(curves.WCurve); + var audit = AICameraTimelinePreviewImporter.AuditRotationCurves( + times, + rotations, + curves.XCurve, + curves.YCurve, + curves.ZCurve, + curves.WCurve, + AICameraTimelinePreviewImporter + .GetCurveSimplificationSettings( + AICameraTimelinePreviewImporter + .CurveSimplificationPreset.Editable) + .QuaternionAngleErrorDegrees); + Assert.That(audit.Passed, Is.True, audit.FailureReason); + } + + [Test] + public void UnityQuaternionContinuityPassPreservesAuditedC2Curves() + { + const int count = 241; + var times = new double[count]; + var rotations = new Quaternion[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60f; + times[index] = time; + rotations[index] = Quaternion.Euler( + 2f * Mathf.Sin(time * 0.45f), + 12f * Mathf.Sin(time * 0.3f), + 0.7f * Mathf.Sin(time * 0.6f)); + } + + var simplified = CreateRotationOnlySimplifiedCurves( + times, + rotations, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced); + var curves = AICameraTimelinePreviewImporter.BuildRotationCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced); + var clip = new AnimationClip(); + try + { + SetRotationCurve(clip, "m_LocalRotation.x", curves.XCurve); + SetRotationCurve(clip, "m_LocalRotation.y", curves.YCurve); + SetRotationCurve(clip, "m_LocalRotation.z", curves.ZCurve); + SetRotationCurve(clip, "m_LocalRotation.w", curves.WCurve); + clip.EnsureQuaternionContinuity(); + + var xCurve = GetRotationCurve(clip, "m_LocalRotation.x"); + var yCurve = GetRotationCurve(clip, "m_LocalRotation.y"); + var zCurve = GetRotationCurve(clip, "m_LocalRotation.z"); + var wCurve = GetRotationCurve(clip, "m_LocalRotation.w"); + AssertCurveIsC2(xCurve); + AssertCurveIsC2(yCurve); + AssertCurveIsC2(zCurve); + AssertCurveIsC2(wCurve); + + var audit = AICameraTimelinePreviewImporter.AuditRotationCurves( + times, + rotations, + xCurve, + yCurve, + zCurve, + wCurve, + AICameraTimelinePreviewImporter + .GetCurveSimplificationSettings( + AICameraTimelinePreviewImporter + .CurveSimplificationPreset.Balanced) + .QuaternionAngleErrorDegrees); + Assert.That(audit.Passed, Is.True, audit.FailureReason); + } + finally + { + UnityEngine.Object.DestroyImmediate(clip); + } + } + + [Test] + public void Crossing180WithSignFlipsStaysC2AndSamplesWithoutNearZero() + { + const int count = 241; + var times = new double[count]; + var rotations = new Quaternion[count]; + for (var index = 0; index < count; index++) + { + var time = index / 60f; + times[index] = time; + var rotation = Quaternion.Euler( + 1.5f * Mathf.Sin(time * 0.4f), + 170f + 10f * time, + 0.5f * Mathf.Sin(time * 0.7f)); + rotations[index] = index % 7 == 0 + ? new Quaternion( + -rotation.x, + -rotation.y, + -rotation.z, + -rotation.w) + : rotation; + } + + var simplified = CreateRotationOnlySimplifiedCurves( + times, + rotations, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced); + var curves = AICameraTimelinePreviewImporter.BuildRotationCurves( + simplified, + AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced); + var clip = new AnimationClip(); + var target = new GameObject("QuaternionSampleTarget"); + try + { + SetRotationCurve(clip, "m_LocalRotation.x", curves.XCurve); + SetRotationCurve(clip, "m_LocalRotation.y", curves.YCurve); + SetRotationCurve(clip, "m_LocalRotation.z", curves.ZCurve); + SetRotationCurve(clip, "m_LocalRotation.w", curves.WCurve); + clip.EnsureQuaternionContinuity(); + + var xCurve = GetRotationCurve(clip, "m_LocalRotation.x"); + var yCurve = GetRotationCurve(clip, "m_LocalRotation.y"); + var zCurve = GetRotationCurve(clip, "m_LocalRotation.z"); + var wCurve = GetRotationCurve(clip, "m_LocalRotation.w"); + AssertCurveIsC2(xCurve); + AssertCurveIsC2(yCurve); + AssertCurveIsC2(zCurve); + AssertCurveIsC2(wCurve); + + var previous = Quaternion.identity; + var auditTimes = AICameraTimelinePreviewImporter + .BuildUniformCurveAuditTimes(times, 240); + for (var index = 0; index < auditTimes.Count; index++) + { + var time = (float)auditTimes[index]; + var raw = new Quaternion( + xCurve.Evaluate(time), + yCurve.Evaluate(time), + zCurve.Evaluate(time), + wCurve.Evaluate(time)); + var magnitude = Mathf.Sqrt( + raw.x * raw.x + + raw.y * raw.y + + raw.z * raw.z + + raw.w * raw.w); + Assert.That( + magnitude, + Is.GreaterThan(0.9f), + $"Quaternion curve approached zero at {time:F4} s."); + var expected = Normalize(raw); + if (index > 0 && Quaternion.Dot(previous, expected) < 0f) + { + expected = new Quaternion( + -expected.x, + -expected.y, + -expected.z, + -expected.w); + } + + clip.SampleAnimation(target, time); + Assert.That( + Quaternion.Angle(expected, target.transform.localRotation), + Is.LessThan(0.1f), + $"SampleAnimation diverged at {time:F4} s."); + previous = expected; + } + + var audit = AICameraTimelinePreviewImporter.AuditRotationCurves( + times, + rotations, + xCurve, + yCurve, + zCurve, + wCurve, + AICameraTimelinePreviewImporter + .GetCurveSimplificationSettings( + AICameraTimelinePreviewImporter + .CurveSimplificationPreset.Balanced) + .QuaternionAngleErrorDegrees); + Assert.That(audit.Passed, Is.True, audit.FailureReason); + } + finally + { + UnityEngine.Object.DestroyImmediate(target); + UnityEngine.Object.DestroyImmediate(clip); + } + } + + [Test] + public void RotationAuditRejectsNearZeroQuaternionCurve() + { + var times = new[] { 0.0, 1.0 }; + var rotations = new[] { Quaternion.identity, Quaternion.identity }; + var zero = AnimationCurve.Linear(0f, 0f, 1f, 0f); + + var audit = AICameraTimelinePreviewImporter.AuditRotationCurves( + times, + rotations, + zero, + zero, + zero, + zero, + 180f); + + Assert.That(audit.Passed, Is.False); + Assert.That(audit.FailureReason, Does.Contain("zero quaternion")); + } + private static Vector3[] EvaluatePositionCurves( IReadOnlyList times, AICameraTimelinePreviewImporter.PositionCurveBuildResult curves) @@ -889,6 +1509,48 @@ namespace Streamingle.Editor return maximumJerk; } + private static double[] CreateUniformTimes( + float startTime, + float endTime, + int samplesPerSecond) + { + var intervalCount = Mathf.Max( + 1, + Mathf.CeilToInt((endTime - startTime) * samplesPerSecond)); + var times = new double[intervalCount + 1]; + for (var index = 0; index <= intervalCount; index++) + { + times[index] = (float)Mathf.Lerp( + startTime, + endTime, + index / (float)intervalCount); + } + + return times; + } + + private static Vector3[] EvaluateScalarCurveAsPositions( + AnimationCurve curve, + float startTime, + float endTime, + int samplesPerSecond) + { + var times = CreateUniformTimes( + startTime, + endTime, + samplesPerSecond); + var positions = new Vector3[times.Length]; + for (var index = 0; index < times.Length; index++) + { + positions[index] = new Vector3( + curve.Evaluate((float)times[index]), + 0f, + 0f); + } + + return positions; + } + private static CameraSamples CreateSamples(int count) { var times = new double[count]; diff --git a/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs b/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs index e8ebbb8..715301e 100644 --- a/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs +++ b/CameraAI~/Tests/Editor/AICameraGeneratorWindowTests.cs @@ -81,12 +81,14 @@ namespace Streamingle.Editor.Tests Assert.That( AICameraGeneratorWindow.ResolveDefaultCwAiRoot( project, - configured), + configured, + Path.Combine(root, "MissingPackage")), Is.EqualTo(Path.GetFullPath(configured))); Assert.That( AICameraGeneratorWindow.ResolveDefaultCwAiRoot( project, - string.Empty), + string.Empty, + Path.Combine(root, "MissingPackage")), Is.EqualTo(Path.GetFullPath(sibling))); } finally @@ -117,7 +119,8 @@ namespace Streamingle.Editor.Tests Assert.That( AICameraGeneratorWindow.ResolveDefaultCwAiRoot( project, - project), + project, + Path.Combine(root, "MissingPackage")), Is.EqualTo(Path.GetFullPath(sibling))); } finally @@ -141,7 +144,8 @@ namespace Streamingle.Editor.Tests Assert.That( AICameraGeneratorWindow.ResolveDefaultCwAiRoot( project, - "\0invalid"), + "\0invalid", + Path.Combine(root, "MissingPackage")), Is.EqualTo(Path.GetFullPath(sibling))); } finally @@ -190,6 +194,158 @@ namespace Streamingle.Editor.Tests "{}"); } + [Test] + public void DefaultCwAiRootReplacesStalePackageCacheRevision() + { + var root = Path.Combine( + Path.GetTempPath(), + "cw-ai-package-root-tests-" + Guid.NewGuid().ToString("N")); + var project = Path.Combine(root, "UnityProject"); + var staleRuntime = Path.Combine( + project, + "Library", + "PackageCache", + "com.mingle.cw-ai@old-revision", + "RuntimeData~"); + var currentPackage = Path.Combine( + project, + "Library", + "PackageCache", + "com.mingle.cw-ai@current-revision"); + var currentRuntime = Path.Combine(currentPackage, "RuntimeData~"); + CreateCwAiRootMarker(staleRuntime); + CreateCwAiRootMarker(currentRuntime); + try + { + Assert.That( + AICameraGeneratorWindow.ResolveDefaultCwAiRoot( + project, + staleRuntime, + currentPackage), + Is.EqualTo(Path.GetFullPath(currentRuntime))); + } + finally + { + Directory.Delete(root, true); + } + } + + [Test] + public void DefaultCwAiRootPreservesValidExternalSelection() + { + var root = Path.Combine( + Path.GetTempPath(), + "cw-ai-external-root-tests-" + Guid.NewGuid().ToString("N")); + var project = Path.Combine(root, "UnityProject"); + var external = Path.Combine(root, "ExternalCWAI"); + var currentPackage = Path.Combine( + project, + "Library", + "PackageCache", + "com.mingle.cw-ai@current-revision"); + CreateCwAiRootMarker(external); + CreateCwAiRootMarker(Path.Combine(currentPackage, "RuntimeData~")); + try + { + Assert.That( + AICameraGeneratorWindow.ResolveDefaultCwAiRoot( + project, + external, + currentPackage), + Is.EqualTo(Path.GetFullPath(external))); + } + finally + { + Directory.Delete(root, true); + } + } + + [Test] + public void CliExecutableReplacesStalePackageCacheRevision() + { + var root = Path.Combine( + Path.GetTempPath(), + "cw-ai-worker-path-tests-" + Guid.NewGuid().ToString("N")); + var project = Path.Combine(root, "UnityProject"); + var staleWorker = Path.Combine( + project, + "Library", + "PackageCache", + "com.mingle.cw-ai@old-revision", + "Tools~", + "CWCameraWorker", + AICameraCliRunner.ExecutableName); + var currentPackage = Path.Combine( + project, + "Library", + "PackageCache", + "com.mingle.cw-ai@current-revision"); + var currentWorker = Path.Combine( + currentPackage, + "Tools~", + "CWCameraWorker", + AICameraCliRunner.ExecutableName); + Directory.CreateDirectory(Path.GetDirectoryName(staleWorker)); + Directory.CreateDirectory(Path.GetDirectoryName(currentWorker)); + File.WriteAllText(staleWorker, string.Empty); + File.WriteAllText(currentWorker, string.Empty); + try + { + Assert.That( + AICameraGeneratorWindow.ResolveCliExecutablePath( + staleWorker, + string.Empty, + project, + currentPackage), + Is.EqualTo(Path.GetFullPath(currentWorker))); + } + finally + { + Directory.Delete(root, true); + } + } + + [Test] + public void CliExecutablePreservesValidExternalSelection() + { + var root = Path.Combine( + Path.GetTempPath(), + "cw-ai-external-worker-tests-" + Guid.NewGuid().ToString("N")); + var project = Path.Combine(root, "UnityProject"); + var externalWorker = Path.Combine( + root, + "ExternalWorker", + AICameraCliRunner.ExecutableName); + var currentPackage = Path.Combine( + project, + "Library", + "PackageCache", + "com.mingle.cw-ai@current-revision"); + var currentWorker = Path.Combine( + currentPackage, + "Tools~", + "CWCameraWorker", + AICameraCliRunner.ExecutableName); + Directory.CreateDirectory(Path.GetDirectoryName(externalWorker)); + Directory.CreateDirectory(Path.GetDirectoryName(currentWorker)); + File.WriteAllText(externalWorker, string.Empty); + File.WriteAllText(currentWorker, string.Empty); + try + { + Assert.That( + AICameraGeneratorWindow.ResolveCliExecutablePath( + externalWorker, + string.Empty, + project, + currentPackage), + Is.EqualTo(Path.GetFullPath(externalWorker))); + } + finally + { + Directory.Delete(root, true); + } + } + [Test] public void CliWorkerIsTheOnlyHighQualityBackend() { diff --git a/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe b/CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe index 4f4f85a..0812503 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:055fb16b60454b0d131ed2201eccad8d11977bd99ef011468d44638b33e4a1ef -size 16752516 +oid sha256:09901a07746f98e94d3a4307df83b37044732e15b5001cdc9c7a67133d520847 +size 16764949 diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip b/CameraAI~/Tools~/CWCameraWorker/_internal/base_library.zip index 65446cfaf370bb06abbabf968d6ccbf460480481..df336e90dc7a8067f56d57d2e7a3f8c06340092f 100644 GIT binary patch delta 2188 zcmY+F3s98T7018*?(Tm3W?8O0mhe!=XHsQZmp3eL6fuYvA!&plu1e7*1;mjVQEZHw z5ll0!e>&L2OruakB}DYYnIr=yY9b*`Y!d_37Mf^CTcfefq_I|eF6>MxGne`O?>+aP zbI-Z^ox6P{cl%2A-yb#7Fwrp6u+Xs5u+a#jVOQ^u245Ri+wClTQ=jeWW|+#kU7bAc z_^GV!y=pult0$V+p?#L%C^hOgM&FYCoyU}} z&SM6@YW=a?dP2SbytX1`|7*K+>TA37Eh*}kK6$=YT{NJtpTrqAInm~8|COuGid*JIel?fXJBXF_C0?CHZ-8yBiiUeJ~d?62*1sI}c8{Rc3T z$em1*B>Xjz=di!oCB!B1EasXkp)rYPvn%Nm&L!~-wkBV~Jpvhzqj#Yx0>{Zas*`E^ zyXz$ENhafODkNM;=3Y5$7nYWhO-u@3%zF1pC`zH_f_4c%OQGe@dT{4CQ#9^~Wl5C~ zl}gKx3`y9K$}{DUMljh$%Tpfy*lV&|DZr_}J(`a{k#IYe7qBbWB_yZOws&qwXi4K< zHt#zLgK2yv^Jz4&rSm+dw`mYFX$r%(baJG3ld_sa@w!lBUJb6N)3)Lwn$F;9Y;3It z6&XC8&3;OQmoj)R>)xWlP_{Yph?h1bZl4kySzf+`oom&g-b*PPZr5PIOU5Og8a(t; zK@! zmq_+)Ipn6RCNMQ34C0ys%8^T|ZErx83H4QbKphpTs577@gzD~^QW1D8kIuC8PbrRV zd9>yq45-sW-S{A&z89)+G@!isq)azu{3M@Jvh{9YdN`l*``!Z?6Q5Zkkl>+bHaz}+ z7aYD1a7adUX%W3Vy`2p1|)X0*6I=`I9 zv5N@|-!7*oaC|+3X9Z7Xtv-f^6;#ZwdWOLwYdAhyLGBK;F#Kl)J(|tWG3* zDb6RhSZ5ZZxSgS;kgA+=gkh+VsyOE*2DXyGbb=voB|Qo4DTX!y56&=rvXTn?`CAO3 zMFj6%px9ova?}=)%ioSLbcks-#xN@2+D!)MDuQ!PEq)|m>uN2I3fTLs7S{y~4QLVl zIKl7#u0^GQE3}a#9jXO=HKCa-LfM541__u)Yw-vZLf@e+t3V!}- z@XX{te_6rqAClF>PZbOaDE?dlD}1nxB4@|mQD#;QiV z;^rLD;tSv0Y(i=xol9LO!n|t8ByINjef2ffh<5XEPy?Eox}a+&ypTYCOKEQq|K223 z?(kLB*84U!_yVU=o3g{{;U>0off)-tJXvWTHcR!srFgWQM`NFdJ8gyIQ!|y-b=#h) z*@`$155+sQz$OYT829iL<|wuxDuKu1BHiA&^A{G0a^kB-@G?FNx5d@&rCMQG#+^7y z{|x0#R?6G)`Ux z;nXo_S~vYA2zBuk_ylE;B`vh$fe5RIl=k9cYKv8F9)@zNin+AVF72qQ_fzSnU}<@}fb1)fN*{r~^~ delta 2124 zcmY+FdsI}_9><5-2s7u*Idjgik#Pt?EitnYM~0`oM=ewso?3<)PC_am0-0&bS`hY7 zE2?kVN^4~zJC{YP!f(>j zTa1f)teea&YMUC;rkdMS)~1HGsiWG|ur@WkTeX}xZj`=e?J&15-|aA#?~d5F2~YaC ziv&X`OM{`!U$HFphLsdG)C(4o)9 zke{!bFtvhbGC3p!t19@7m_oO)s5mojE$3rFgBj`G>o;S28Ra}z%3adc8ndaYZmGX2 zm~kPM6}C}hrrJgnmUAa&71LeMG&3wM<~M+bsYPB(ef>?Lre*$Ge?v{xu=ijYdH?7Q zg}t1|fW|#CbN|DkNF)c)0!G^Bd>D#>3ZBA#{U#JQ&8L!SH!jQb>qnXVwM{FBn|_Z> z*64S_Oe^ZDmaIU)%_GIOTx|0T;W%2tQ^U99TWC%R==aeL^S_fvrl?$CK~5=8XFvB? zu%VQvg-!fOcq7Y4b<&zO$F;6Dt5(zM#wCrziQOWJYl0O;B{a01VzjZ@RvZ$;q1WJ2 z_bej!2LGa}x|Q`c!O9Mbsb$<{#kpc0jg=*Q46}Dx@lXkmgN@G2jb|{an8zYEksSZ% zwLTs*2KRfo*72LBxvrIRvCm2~paqFHkI$S1QCQ4-{7 z^akd|Nth#`W0Hi;(|9iHnJRUhFL$DE8u|S%MM9LDXR(M12@BkG{OTeJ+XduROZb6~ z)Kd>iBXVM#hmS|`bW!GG66&UttMfSt`=;}3_GzbtoFsavauRp5&)yhPv)BnEXx@>; zZ(#=yO87j9a*q2#g2TfL*pja$EcS3OGhdRh+rx91S&{Ky58ZfrjEsb2axR!CV^uQG zV5e`Eu{W8|U@;yU1Iav}?aY;toI>I5LKz!UsG#mUWE@H1dF)uV3@Md-<~GUjrP9%X z78&1mhB&b&mHH3gBID~+3VmdojMsWXqLGO4y3cY`{4S%H5z!#6Tp(Wq!L|F!z-=93BA}~;79))ef zl$9Jboz370?4`V*ik(4fN|6n|8C32=3xnn^Vg65bQ1uHnyD6yLnWT2M*zi*(&Hg^2 zEL(!=piuX08&Y}f{H~z6AdAd1-wLY7g$nBlsxv}We;QOV*|f7GU)iuKheu$1J|B&H zvuXSLzZ)`*#w*!8k$rF}s4izy@Zo_W6@?i&bawpLA?0PCm=tl#Xq?O;r;jZPLUL)y zhcyMWa%rP4CM#&qrElf>ECt*B%!z;J(x6_Nqd>}|VYJRyP?$$=plY##EqUTqR4e#A zkM`rKR}hg;ma%ITWNl}WxF?@N!!{~FKJC}ENx`^1EE<(bT!ZAL27ceFAl^%x`WDL5 z2N=gXFGU>Lp`c4xyE+y03Ap-}g1EDEo;8!szv@x2VkVipFDrOmK=ou5{Q@4SRFN=? zVE+>;>IIxUsv;oZ#1$2T0xl%j;hs%!zRHe90clU$(Ji3wm>s_eXgOs^@~s3mNy8ce zm6;me74T)fhTjE5y{sYqHi8e%YFH;={Y4G?1)ToBhQ9<1#Ou)Kg>#f8QqT9@9zOCH zaM^Sn8w<$m&1@Zq3TSdu=IUT`2zD>f;T3S*-8$L@>}t^Qv4EHjI^?+oJ08)|69|tS zzI)<>VN?ERUN$odSwTq3#p=MP6rki z(o&y@cVI^$E&fo70~Z9mneD)hMf6mjob5nO5iPc@)PX<|MYUEsFjz!U(OVrz^6_|l YCjL@CdD($JAN^#}U#*4(^ZB!X0}VcjlmGw# 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 427c018..8dbafa8 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,18 +1,18 @@ { "schemaVersion": "cw-camera-worker-build-identity-v1", - "workerVersion": "0.1.5", - "createdUtc": "2026-08-08T20:03:04.908909+00:00", + "workerVersion": "0.1.6", + "createdUtc": "2026-08-09T07:15:01.579387+00:00", "python": "3.12.13", "sourceRootRelative": "cwai_sources/repository", "sourceSha256": { "MachineLearning/CameraDirector/adjacent_transition.py": "6c1b62c2996960af23d62e9c3acd3114600effda03273b006f7076c467c4bdfa", "MachineLearning/CameraDirector/build_hybrid_preparation_cache.py": "f948b1a68352893b078ac1f6ef458df2107565378eed32890276776ca0dfdad6", - "MachineLearning/CameraDirector/camera_kinematics.py": "75816a55ac1dc655731432f061c2f4356cc3b583f3ce33797578431adcd9ba39", + "MachineLearning/CameraDirector/camera_kinematics.py": "e94eefcb56c4ebdccbc57ae8d3650109b2c912e3642892ac13add9409db21a7f", "MachineLearning/CameraDirector/camera_runtime_data.py": "29fd30a1c97d384f7bb22d024a4f88178f6245858925c302b059a7a1afdeaac1", "MachineLearning/CameraDirector/cw_camera_cli.py": "943870a4cdec7e4b830b9c945d690e700902cf2a37d1e49987bba85bf0b0ea87", - "MachineLearning/CameraDirector/cw_camera_runtime.py": "c4cfbc61173cb541932632e3b0a0751ae75c9d6ca3b56680722caa78f74e797d", + "MachineLearning/CameraDirector/cw_camera_runtime.py": "62421300af470389b5e356267d91e4dcb31626762705cd1c1d20c3fa11d271f3", "MachineLearning/CameraDirector/data_driven_cut_planner.py": "36cf86a80577278d2e966cb0ebf7c3110b5b1dda5abd1d8a6b0a76e31d10e32b", - "MachineLearning/CameraDirector/generate_hybrid.py": "4bf19ffdd8e244a82b4ea0656bad030a30470a9e2065bdccde8bfb9d99922fea", + "MachineLearning/CameraDirector/generate_hybrid.py": "b6b1e7d02d96794e57c0ae9867ad18de7c6698d7bac05da1834490e2c0f3b4e3", "MachineLearning/CameraDirector/hybrid_candidate_cache.py": "a0a5b6a8f612f18cb2875394f17ed89950e1e62e38c1226457d9e35848f6e380", "MachineLearning/CameraDirector/hybrid_cut_reference.py": "53521d9e31d315c011892961887baf0cde6451020bf39c9d080a5072d4707fcb", "MachineLearning/CameraDirector/hybrid_preparation_cache.py": "1b0df53124109dee2d744777979a0553999cb276cb6f538a17ddce6516e3da8b", @@ -23,8 +23,8 @@ "MachineLearning/CameraDirector/trajectory_quality.py": "20cd2ddc1b2349f8c33f6d1460580f37964a521eaa86ceee5b8ae457512ba49d" }, "preparationLogicIdentifier": "acba2a46d9d979b013b7154bada6b6a8a7adfd7acd1721876ce6699ed4e03e84", - "candidateLogicIdentifier": "7c771be40d4c6ebc6b15a96c64ed05062f6109d20605670be9b0e07587f2b25a", - "generationCodeIdentifier": "470c77fe5d69046aa7bcadfbbb1337d6f0025fe0be374cb2302ea41ae00cdc47", + "candidateLogicIdentifier": "bb9e883085df6a95de09f2181191c83c13cf1b3adff5b64e41b3d60d99350522", + "generationCodeIdentifier": "8973f4642cd18f5645d33871c544e5394bb538ec5db86d4abc0536f50a00a9ed", "generationCodeFiles": [ "adjacent_transition.py", "camera_kinematics.py", 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 index 950c1b4..e3bc0fe 100644 --- 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 @@ -19,7 +19,7 @@ import numpy as np TRANSLATION_KINEMATIC_POLICY_VERSION = ( - "yamo-angle-aware-turn-v6-natural-easing-impulse-c2" + "yamo-angle-aware-turn-v7-local-impulse-authored-dynamics-guard" ) DEFAULT_REVERSAL_ANGLE_DEGREES = 40.0 DEFAULT_STOP_SPEED_RATIO = 0.12 @@ -38,25 +38,33 @@ 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 +# A regularizer is a repair pass, not another motion authoring stage. The old +# 25 m/s2 and 350 m/s3 absolute floors let a quiet authored curve become several +# times rougher while still reporting a successful repair (the reviewed +# Shot_066 class rose from roughly 255 to 548 m/s3). Keep only small numerical +# headroom: a rewrite may trade at most five percent of either peak, and the +# absolute allowance is deliberately tiny for already-quiet paths. +DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_RATIO = 1.05 +DEFAULT_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2 = 1.0 +DEFAULT_ACCELERATION_GUARD_FLOOR_MPS2 = 0.5 +DEFAULT_MAXIMUM_JERK_REGRESSION_RATIO = 1.05 +DEFAULT_MAXIMUM_JERK_REGRESSION_DELTA_MPS3 = 15.0 +DEFAULT_JERK_GUARD_FLOOR_MPS3 = 10.0 # A second, deliberately narrow gate catches a concentrated turn that evades # the 40-degree rule yet still carries an edit-visible sideways impulse. The # thresholds were calibrated against 2,479 authored turn events: the combined # gate adds only 10 events (0.4%) beyond the existing abrupt-turn policy while # covering the reviewed Shot_066 profile (high stop ratio plus ~22 m/s^2 and -# ~200 m/s^3 local lateral dynamics). +# ~200 m/s^3 local lateral dynamics). The zero-net-direction extension was +# separately scanned across 3,054 authored shots: it admitted one cut-boundary +# discontinuity, which the authored-dynamics guard correctly rejected. DEFAULT_IMPULSIVE_TURN_ANGLE_DEGREES = 15.0 DEFAULT_IMPULSIVE_STOP_SPEED_RATIO = 0.78 DEFAULT_IMPULSIVE_CORNER_CONCENTRATION = 0.40 DEFAULT_IMPULSIVE_LATERAL_ACCELERATION_MPS2 = 22.0 DEFAULT_IMPULSIVE_LATERAL_JERK_MPS3 = 200.0 DEFAULT_IMPULSIVE_TARGET_SPEED_RATIO = 0.72 -DEFAULT_IMPULSIVE_REVERSAL_WINDOW_SECONDS = 0.50 +DEFAULT_IMPULSIVE_REVERSAL_WINDOW_SECONDS = 0.55 # A generated curve can satisfy the broad monotonic-speed definition of # ``natural_easing`` while its turn is still concentrated enough to read as a # lateral kick. Keep this as a separate, narrower contradiction gate rather @@ -492,10 +500,16 @@ def analyze_translational_reversals( lateral_jerk_peaks[result_index] = float( np.max(np.linalg.norm(lateral, axis=1)) ) + # A one- or two-frame sideways kick can return to the original travel + # direction, leaving the persistent flank angle close to zero. Use the + # absolute adjacent-frame turn as a second angle witness. The existing + # coherence, travel, concentration, acceleration and jerk gates keep a + # deliberate distributed dolly/truck/arc from being classified as an + # impulse; continuous orbit motion remains explicitly excluded. impulsive_valid = ( (flank_speeds >= minimum_flank_speed_mps) & ( - angles + np.maximum(angles, peak_corner_angles) >= DEFAULT_IMPULSIVE_TURN_ANGLE_DEGREES - DEFAULT_ANGLE_COMPARISON_TOLERANCE_DEGREES ) @@ -793,6 +807,26 @@ def _dynamics_regression_exceeds_guard( ) +def _local_translation_dynamics_metrics( + positions_meters: np.ndarray, + left: int, + right: int, + sample_rate: float, +) -> dict[str, float]: + """Measure a rewrite window including every finite-difference seam. + + A shot-wide maximum can hide a new local kick when some unrelated frame is + already rougher. Three samples on either side cover the velocity, + acceleration and jerk stencils, so this local measurement makes the guard + sensitive to the exact neighbourhood changed by ``_ease_through_stop``. + """ + + positions = _validated_positions(positions_meters) + start = max(0, int(left) - 3) + stop = min(len(positions), int(right) + 4) + return translation_dynamics_metrics(positions[start:stop], sample_rate) + + def _event_metadata(events: list[TranslationReversal]) -> dict[str, object]: abrupt = [event for event in events if event.abrupt] impulsive = [event for event in events if event.impulsive] @@ -859,6 +893,12 @@ def regularize_translational_reversals( applied_angles: list[float] = [] applied_target_speed_ratios: list[float] = [] applied_impulsive_frames: list[int] = [] + applied_window_radii_frames: list[int] = [] + attempted_regularized_frames: list[int] = [] + dynamics_guard_rejected_frames: list[int] = [] + adaptive_radius_frames: list[int] = [] + dynamics_before = translation_dynamics_metrics(positions, sample_rate) + attempted_dynamics_after = dict(dynamics_before) pass_count = 0 pending_events = abrupt_before if apply_policy else [] @@ -884,11 +924,12 @@ def regularize_translational_reversals( # The high-impulse fallback is intentionally rare, so give its # braking envelope a slightly longer C2 runway instead of forcing # a stronger speed change into the normal 0.35-second window. - neighbor_limit = ( + preferred_radius = ( impulsive_requested_radius if event.impulsive else requested_radius ) + neighbor_limit = min(event.frame, len(output) - 1 - event.frame) if event_index > 0: neighbor_limit = min( neighbor_limit, @@ -899,23 +940,104 @@ def regularize_translational_reversals( neighbor_limit, (event_frames[event_index + 1] - event.frame) // 2, ) - radius = min( - neighbor_limit, - event.frame, - len(output) - 1 - event.frame, - ) - if radius < 4: + preferred_radius = min(preferred_radius, neighbor_limit) + if preferred_radius < 4: continue - output = _ease_through_stop( - output, - event.frame, - radius, - target_speed_ratio=event.target_speed_ratio, - turn_angle_degrees=event.angle_degrees, + + attempted_regularized_frames.append(event.frame) + # First retain the established window. If its C2 rewrite is still + # locally rougher than the authored input, spend at most another + # quarter second on the runway before giving up. This adaptive + # path fixes boundary-limited cases such as Shot_066 without + # weakening the dynamics contract or flattening every motion. + extension_step = max(2, int(round(0.05 * sample_rate))) + maximum_adaptive_radius = min( + neighbor_limit, + preferred_radius + max(4, int(round(0.25 * sample_rate))), ) + candidate_radii = list( + range( + preferred_radius, + maximum_adaptive_radius + 1, + extension_step, + ) + ) + if candidate_radii[-1] != maximum_adaptive_radius: + candidate_radii.append(maximum_adaptive_radius) + + # Keep the authored comparison region fixed while trying wider + # runways. If this ROI grew with ``radius``, an unrelated rough + # frame newly entering (for example at r=27 after r=21 failed) + # could raise the baseline maximum and hide a fresh center kick. + fixed_local_before = _local_translation_dynamics_metrics( + output, + event.frame - preferred_radius, + event.frame + preferred_radius, + sample_rate, + ) + accepted_output: np.ndarray | None = None + accepted_radius = preferred_radius + for radius in candidate_radii: + candidate = _ease_through_stop( + output, + event.frame, + radius, + target_speed_ratio=event.target_speed_ratio, + turn_angle_degrees=event.angle_degrees, + ) + local_before = _local_translation_dynamics_metrics( + output, + event.frame - radius, + event.frame + radius, + sample_rate, + ) + local_after = _local_translation_dynamics_metrics( + candidate, + event.frame - radius, + event.frame + radius, + sample_rate, + ) + fixed_local_after = _local_translation_dynamics_metrics( + candidate, + event.frame - preferred_radius, + event.frame + preferred_radius, + sample_rate, + ) + candidate_dynamics = translation_dynamics_metrics( + candidate, + sample_rate, + ) + for key in attempted_dynamics_after: + attempted_dynamics_after[key] = max( + attempted_dynamics_after[key], + candidate_dynamics[key], + ) + if _dynamics_regression_exceeds_guard(local_before, local_after): + continue + if _dynamics_regression_exceeds_guard( + fixed_local_before, + fixed_local_after, + ): + continue + if _dynamics_regression_exceeds_guard( + dynamics_before, + candidate_dynamics, + ): + continue + accepted_output = candidate + accepted_radius = radius + break + + if accepted_output is None: + dynamics_guard_rejected_frames.append(event.frame) + continue + output = accepted_output + if accepted_radius != preferred_radius: + adaptive_radius_frames.append(event.frame) applied_frames.append(event.frame) applied_angles.append(event.angle_degrees) applied_target_speed_ratios.append(event.target_speed_ratio) + applied_window_radii_frames.append(accepted_radius) if event.impulsive: applied_impulsive_frames.append(event.frame) pass_count = int(bool(applied_frames)) @@ -934,25 +1056,38 @@ def regularize_translational_reversals( # 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 ( + final_attempted_dynamics = translation_dynamics_metrics(output, sample_rate) + for key in attempted_dynamics_after: + attempted_dynamics_after[key] = max( + attempted_dynamics_after[key], + final_attempted_dynamics[key], + ) + final_dynamics_guard_triggered = bool(applied_frames) and ( _dynamics_regression_exceeds_guard( dynamics_before, - attempted_dynamics_after, + final_attempted_dynamics, ) ) - if dynamics_guard_triggered: + dynamics_guard_triggered = bool( + dynamics_guard_rejected_frames or final_dynamics_guard_triggered + ) + if final_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. + dynamics_guard_rejected_frames.extend( + frame + for frame in applied_frames + if frame not in dynamics_guard_rejected_frames + ) output = positions.copy() applied_frames.clear() applied_angles.clear() applied_target_speed_ratios.clear() applied_impulsive_frames.clear() + applied_window_radii_frames.clear() + adaptive_radius_frames.clear() pass_count = 0 endpoint_adjustment_clamped = False @@ -981,6 +1116,10 @@ def regularize_translational_reversals( "translationKinematicAttemptedReversalFrames": ( attempted_regularized_frames ), + "translationKinematicDynamicsGuardRejectedFrames": ( + dynamics_guard_rejected_frames + ), + "translationKinematicAdaptiveRadiusFrames": adaptive_radius_frames, "translationDirectionReversalCountBefore": before_metadata["count"], "translationAbruptReversalCountBefore": before_metadata["abruptCount"], "translationDirectionReversalFramesBefore": before_metadata["frames"], @@ -1012,6 +1151,9 @@ def regularize_translational_reversals( "translationRegularizedReversalFrames": applied_frames, "translationRegularizedTurnAnglesDegrees": applied_angles, "translationRegularizedTargetSpeedRatios": (applied_target_speed_ratios), + "translationRegularizedWindowRadiiFrames": ( + applied_window_radii_frames + ), "translationImpulsiveTurnCountBefore": before_metadata[ "impulsiveCount" ], diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/cw_camera_runtime.py b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/cw_camera_runtime.py index b4bc1ee..128b6eb 100644 --- a/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/cw_camera_runtime.py +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/cwai_sources/repository/MachineLearning/CameraDirector/cw_camera_runtime.py @@ -19,7 +19,7 @@ from typing import Any, Iterable BUILD_IDENTITY_SCHEMA_VERSION = "cw-camera-worker-build-identity-v1" BUILD_IDENTITY_FILE = "cw_camera_worker_build_identity.json" -WORKER_VERSION = "0.1.5" +WORKER_VERSION = "0.1.6" def is_frozen_runtime() -> bool: 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 f2b96b2..cfbaba8 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 @@ -87,7 +87,7 @@ HYBRID_METADATA_SCHEMA_VERSION = "3.6" HYBRID_SHOTS_SCHEMA_VERSION = "1.2" SELECTION_SAFETY_CONTRACT_VERSION = ( "hybrid-selection-safety-stable-facing-residual-dynamics-" - "post-clamp-kinematics-v2" + "authored-cost-exact-angular-tracking-post-clamp-kinematics-v4" ) ADJACENT_TRANSITION_EDGE_LIST_SCHEMA_VERSION = ( "authoritative-adjacent-transition-edges-v1" @@ -113,7 +113,7 @@ GENERATED_OUTPUT_PAYLOAD_NAMES = ( OPTIONAL_GENERATED_OUTPUT_NAMES = ("cut_plan_trace.json",) PRESERVED_OUTPUT_INPUT_NAMES = ("selected_shot_directives.json",) SEED_VARIATION_POLICY_VERSION = ( - "safe-distinct-motion-semantic-safety-seed-cycle-v5" + "safe-distinct-motion-semantic-authored-dynamics-seed-cycle-v6" ) SEED_VARIATION_SEQUENCE_CONTEXT_POLICY = ( "seed-independent-pre-transition-prefix-v2" @@ -131,8 +131,8 @@ PREPARATION_LOGIC_CONTRACT_VERSION = ( "hybrid-preparation-logic-v3-complete-rotation-6d" ) CANDIDATE_LOGIC_CONTRACT_VERSION = ( - "hybrid-candidate-logic-v28-post-clamp-kinematic-fail-closed-" - "verified-cut-reference-stable-placement-facing-selection-safety-contract" + "hybrid-candidate-logic-v30-exact-float32-tracking-causal-entry-" + "minimum-jerk-authored-dynamics-cost-c2-semantic-dead-zone" ) CUT_REFERENCE_SELECTION_POLICY_VERSION = ( "production-exact-audio-cut-only-generation-input-v1" @@ -141,16 +141,16 @@ CANDIDATE_CACHE_STAGE_VERSIONS = { "shotSegmentation": "data-driven-candidates-ranker-global-dag-v1", "shotPlanning": "hierarchical-or-legacy-reconciliation-replay-v3-static-budget", "candidateRanking": ( - "template-rank-v19-c2-position-residual-screen-dynamics-causal-aim-" + "template-rank-v20-c2-position-residual-screen-dynamics-cost-aware-aim-" "adjacent-transition-v3-no-forced-side-alternation" ), "trajectoryRetargeting": ( - "retarget-v26-speed-adaptive-final-radial-post-kinematic-audit-" - "residual-dynamics-semantic-causal-jerk-bounded-baked-aim" + "retarget-v28-speed-adaptive-final-radial-post-kinematic-audit-" + "residual-dynamics-semantic-exact-tracking-causal-entry-minimum-jerk" ), "compositionSafety": ( - "person-safety-v17-full-song-root-calibrated-choreography-facing-" - "fail-closed-residual-dynamics-post-clamp-kinematics" + "person-safety-v18-full-song-root-calibrated-choreography-facing-" + "authored-cost-residual-dynamics-post-clamp-kinematics" ), "variationPool": ( SEED_VARIATION_POLICY_VERSION @@ -159,20 +159,32 @@ CANDIDATE_CACHE_STAGE_VERSIONS = { "sequenceContinuation": SEED_VARIATION_SEQUENCE_CONTEXT_POLICY, } TRAJECTORY_RETARGETING_VERSION = ( - "c2-position-speed-adaptive-final-radial-post-kinematic-audit-v14" + "c2-position-speed-adaptive-final-radial-post-kinematic-audit-v15" ) TEMPLATE_MOTION_SMOOTHING_VERSION = ( "savgol61-nearest-edge-step-guard-v1" ) MOTION_SELECTION_VERSION = ( - "non-static-retention-residual-screen-tiebreak-yamo-kinematics-v11" + "non-static-retention-residual-screen-authored-cost-yamo-kinematics-v12" ) FINAL_POSITION_SMOOTHING_WINDOW_FRAMES = 15 FINAL_POSITION_SMOOTHING_VERSION = ( "nearest-edge-low-frequency-net-travel-dynamics-guard-v3" ) +FINAL_RADIAL_DYNAMICS_GUARD_POLICY_VERSION = ( + "final-radial-world-dynamics-regression-guard-v1" +) +FINAL_RADIAL_MAXIMUM_DYNAMICS_REGRESSION_RATIO = 1.05 +FINAL_RADIAL_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2 = 0.50 +FINAL_RADIAL_MAXIMUM_JERK_REGRESSION_DELTA_MPS3 = 10.0 TRAJECTORY_DYNAMICS_SELECTION_WEIGHT = 0.18 TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY = 0.90 +CANDIDATE_DYNAMICS_SELECTION_POLICY_VERSION = ( + "authored-cost-preferred-uncapped-failure-penalty-v1" +) +CANDIDATE_DYNAMICS_MAXIMUM_ACCEPTABLE_COST = 6.0 +CANDIDATE_DYNAMICS_COST_FAILURE_BASE_PENALTY = 3.0 +CANDIDATE_DYNAMICS_COST_FAILURE_EXCESS_WEIGHT = 0.75 CANDIDATE_DYNAMICS_HARD_GATE_POLICY_VERSION = ( "candidate-dynamics-hard-gate-v3-stabilized-screen-residual" ) @@ -205,7 +217,10 @@ ROLLING_STATIC_BUDGET_MAX_STATIC_SHOTS = 1 ROLLING_STATIC_BUDGET_POLICY_VERSION = ( "seed-independent-static-slot-reservation-v1" ) -AIM_POLICY_VERSION = "semantic-focus-c2-causal-jerk-bounded-baked-aim-v4" +AIM_POLICY_VERSION = ( + "semantic-focus-c2-hysteretic-dead-zone-causal-boundary-exact-tracking-" + "minimum-jerk-baked-aim-v6" +) AIM_SAMPLE_RATE = 60.0 AIM_TARGET_SMOOTHING_SECONDS = 0.60 AIM_TARGET_DEAD_ZONE_METERS = 0.055 @@ -216,11 +231,18 @@ AIM_MAXIMUM_ANGULAR_SPEED_DPS = 90.0 AIM_MAXIMUM_ANGULAR_ACCELERATION_DPS2 = 360.0 # The authored corpus, after restricting samples to the existing 90 dps / 360 # dps2 contract, has an angular-jerk P99 of about 1,950 dps3 and a P99.9 of -# about 6,610 dps3. Recent generated shots reached 28,000--43,000 dps3 even -# while respecting the speed and acceleration limits. 7,200 dps3 therefore -# removes one/two-frame acceleration flips without flattening the authored -# long-tail camera language. +# about 6,610 dps3. The larger value remains a catastrophic ceiling, not a +# quality target: normal generation searches all causal response candidates +# for an authored-range minimum-jerk path before it may use that long tail. AIM_MAXIMUM_ANGULAR_JERK_DPS3 = 7200.0 +AIM_AUTHORED_ANGULAR_JERK_TARGET_DPS3 = 2400.0 +AIM_AUTHORED_ANGULAR_JERK_P95_TARGET_DPS3 = 1950.0 +AIM_MINIMUM_JERK_TRACKING_P90_BUDGET_DEGREES = 2.5 +AIM_MINIMUM_JERK_TRACKING_MAXIMUM_BUDGET_DEGREES = 5.0 +AIM_MINIMUM_JERK_TRACKING_FINAL_BUDGET_DEGREES = 2.0 +AIM_MINIMUM_JERK_SELECTION_POLICY_VERSION = ( + "causal-entry-authored-target-exact-float32-tracking-budget-objective-v2" +) AIM_ANGULAR_JERK_P95_FREE_RATIO = 0.25 AIM_ANGULAR_JERK_PEAK_FREE_RATIO = 0.75 AIM_ANGULAR_JERK_SELECTION_WEIGHT = 2.0 @@ -3490,6 +3512,76 @@ def smooth_camera_world_position(values: np.ndarray) -> np.ndarray: return polished if retention_safe and acceleration_safe and jerk_safe else source +def final_radial_dynamics_regression_audit( + before_world_position: np.ndarray, + after_world_position: np.ndarray, + sample_rate: float = HYBRID_SAMPLE_RATE, +) -> dict[str, object]: + """Fail closed when the final distance projection roughens the camera. + + The radial projection is required for person-distance safety, so an unsafe + result cannot simply be replaced with its potentially out-of-bounds input. + Instead this audit lets candidate selection try another authored camera. + Both P99 and the rendered-frame maximum are checked so a single new jolt is + not hidden by an otherwise smooth shot. + """ + + before = trajectory_quality.translation_dynamics_metrics( + before_world_position, + sample_rate=sample_rate, + ) + after = trajectory_quality.translation_dynamics_metrics( + after_world_position, + sample_rate=sample_rate, + ) + checks: dict[str, bool] = {} + thresholds: dict[str, float] = {} + observed_before: dict[str, float] = {} + observed_after: dict[str, float] = {} + for group, delta, label in ( + ( + "accelerationMetersPerSecondSquared", + FINAL_RADIAL_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2, + "acceleration", + ), + ( + "jerkMetersPerSecondCubed", + FINAL_RADIAL_MAXIMUM_JERK_REGRESSION_DELTA_MPS3, + "jerk", + ), + ): + for statistic in ("p99", "maximum"): + before_value = float(before[group][statistic]) + after_value = float(after[group][statistic]) + threshold = ( + before_value + * FINAL_RADIAL_MAXIMUM_DYNAMICS_REGRESSION_RATIO + + delta + ) + key = label + statistic.capitalize() + observed_before[key] = before_value + observed_after[key] = after_value + thresholds[key] = threshold + checks[key] = bool(after_value <= threshold + 1e-9) + return { + "policyVersion": FINAL_RADIAL_DYNAMICS_GUARD_POLICY_VERSION, + "maximumRegressionRatio": ( + FINAL_RADIAL_MAXIMUM_DYNAMICS_REGRESSION_RATIO + ), + "maximumAccelerationRegressionDeltaMetersPerSecondSquared": ( + FINAL_RADIAL_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2 + ), + "maximumJerkRegressionDeltaMetersPerSecondCubed": ( + FINAL_RADIAL_MAXIMUM_JERK_REGRESSION_DELTA_MPS3 + ), + "before": observed_before, + "after": observed_after, + "thresholds": thresholds, + "checks": checks, + "passed": bool(all(checks.values())), + } + + def position_path_length(values: np.ndarray) -> float: points = np.asarray(values, dtype=np.float64) if len(points) < 2: @@ -3753,6 +3845,47 @@ def source_aspect_quality( ) +def _c2_dead_zone_response_distance( + distance_meters: float, + dead_zone_meters: float, + transition_meters: float, +) -> float: + """Map a hysteretic tracking error through a C2 dead-zone boundary. + + The focus tracker measures error from its previous output, so the dead + zone already has hysteresis: motion must accumulate outside the retained + output before the target moves. The old ``max(error - dead_zone, 0)`` + response changed slope instantly at that boundary. This quintic joins a + stationary response to the original unit-slope response with continuous + value, velocity and acceleration. + """ + + distance = float(distance_meters) + dead_zone = float(dead_zone_meters) + transition = float(transition_meters) + if ( + not math.isfinite(distance) + or not math.isfinite(dead_zone) + or not math.isfinite(transition) + or distance < 0.0 + or dead_zone < 0.0 + or transition < 0.0 + ): + raise ValueError("Dead-zone distances must be finite and non-negative.") + excess = distance - dead_zone + if excess <= 0.0: + return 0.0 + if transition <= 1e-12 or excess >= transition: + return excess + normalized = excess / transition + response = transition * ( + 6.0 * normalized**3 + - 8.0 * normalized**4 + + 3.0 * normalized**5 + ) + return float(response) + + def stabilize_semantic_focus_path( focus_world_positions: np.ndarray, smoothing_seconds: float = AIM_TARGET_SMOOTHING_SECONDS, @@ -3776,17 +3909,26 @@ def stabilize_semantic_focus_path( mode="nearest", truncate=3.0, ).astype(np.float32) + # One dead-zone radius is also a compact C2 engagement band. Beyond this + # band the mapping is exactly the previous unit-slope response, so large + # intentional performer travel is neither attenuated nor delayed further. + dead_zone_transition_meters = float(dead_zone_meters) stabilized = np.empty_like(low_frequency) stabilized[0] = low_frequency[0] for index in range(1, len(stabilized)): delta = low_frequency[index] - stabilized[index - 1] distance = float(np.linalg.norm(delta)) - if distance <= dead_zone_meters: + response_distance = _c2_dead_zone_response_distance( + distance, + dead_zone_meters, + dead_zone_transition_meters, + ) + if response_distance <= 0.0 or distance <= 1e-12: stabilized[index] = stabilized[index - 1] else: stabilized[index] = ( stabilized[index - 1] - + delta * ((distance - dead_zone_meters) / distance) + + delta * (response_distance / distance) ) if len(stabilized) > 4: stabilized = gaussian_filter1d( @@ -3810,6 +3952,8 @@ def stabilize_semantic_focus_path( ), "aimTargetSmoothingSeconds": float(smoothing_seconds), "aimTargetDeadZoneMeters": float(dead_zone_meters), + "aimTargetDeadZoneTransitionMeters": dead_zone_transition_meters, + "aimTargetDeadZoneResponse": "hysteretic_quintic_c2", } @@ -4269,6 +4413,140 @@ def _angular_velocity_dynamics_vectors( return acceleration, jerk +def _angular_velocity_tracking_deviation_metrics( + candidate_velocity: np.ndarray, + reference_velocity: np.ndarray, + sample_rate: float, +) -> dict[str, float]: + """Estimate response lag without integrating every smoothing candidate. + + Both paths are body-local per-frame rotation vectors. Their cumulative + difference is a conservative small-angle proxy used only to keep the + minimum-jerk search close to the acceleration-bounded reference path. The + selected quaternion path is integrated and audited exactly afterwards. + """ + + candidate = np.asarray(candidate_velocity, dtype=np.float64) + reference = np.asarray(reference_velocity, dtype=np.float64) + if ( + candidate.ndim != 2 + or candidate.shape[1] != 3 + or candidate.shape != reference.shape + ): + raise ValueError( + "candidate_velocity and reference_velocity must have matching " + "[sample, 3] shapes." + ) + if not math.isfinite(sample_rate) or sample_rate <= 0.0: + raise ValueError("sample_rate must be finite and positive.") + if not np.isfinite(candidate).all() or not np.isfinite(reference).all(): + raise ValueError("angular velocity paths must contain only finite values.") + if len(candidate) == 0: + deviation = np.asarray([0.0], dtype=np.float64) + else: + cumulative_difference = np.cumsum( + (candidate - reference) / float(sample_rate), + axis=0, + ) + deviation = np.degrees( + np.linalg.norm(cumulative_difference, axis=1) + ) + return { + "p90": float(np.quantile(deviation, 0.90)), + "maximum": float(np.max(deviation)), + "final": float(deviation[-1]), + } + + +def _angular_tracking_deviation_within_budget( + p90_degrees: object, + maximum_degrees: object, + final_degrees: object, +) -> bool: + """Validate all exact tracking values and apply the authored budgets.""" + + raw_values = (p90_degrees, maximum_degrees, final_degrees) + if any( + not isinstance(value, (int, float)) or isinstance(value, bool) + for value in raw_values + ): + return False + values = tuple(float(value) for value in raw_values) + if any(not math.isfinite(value) or value < 0.0 for value in values): + return False + p90, maximum, final = values + return bool( + p90 <= AIM_MINIMUM_JERK_TRACKING_P90_BUDGET_DEGREES + 1e-12 + and maximum + <= AIM_MINIMUM_JERK_TRACKING_MAXIMUM_BUDGET_DEGREES + 1e-12 + and final <= AIM_MINIMUM_JERK_TRACKING_FINAL_BUDGET_DEGREES + 1e-12 + ) + + +def _minimum_jerk_candidate_objective( + maximum_jerk_dps3: float, + p95_jerk_dps3: float, + tracking_deviation: dict[str, float], + authored_target_dps3: float, +) -> float: + """Balance authored-range jerk against a tightly bounded tracking lag.""" + + return float( + p95_jerk_dps3 / AIM_AUTHORED_ANGULAR_JERK_P95_TARGET_DPS3 + + 0.20 * maximum_jerk_dps3 / authored_target_dps3 + + 0.50 + * tracking_deviation["p90"] + / AIM_MINIMUM_JERK_TRACKING_P90_BUDGET_DEGREES + + 0.15 + * tracking_deviation["maximum"] + / AIM_MINIMUM_JERK_TRACKING_MAXIMUM_BUDGET_DEGREES + + 0.20 + * tracking_deviation["final"] + / AIM_MINIMUM_JERK_TRACKING_FINAL_BUDGET_DEGREES + ) + + +def _quaternion_path_error_statistics( + candidate_rotations: np.ndarray, + reference_rotations: np.ndarray, +) -> dict[str, float]: + """Return exact shortest-arc path error in degrees.""" + + candidate = np.asarray(candidate_rotations, dtype=np.float64).copy() + reference = np.asarray(reference_rotations, dtype=np.float64).copy() + if ( + candidate.ndim != 2 + or candidate.shape[1] != 4 + or candidate.shape != reference.shape + or len(candidate) == 0 + ): + raise ValueError( + "candidate_rotations and reference_rotations must have matching " + "non-empty [frame, 4] shapes." + ) + candidate /= np.maximum( + np.linalg.norm(candidate, axis=1, keepdims=True), + 1e-12, + ) + reference /= np.maximum( + np.linalg.norm(reference, axis=1, keepdims=True), + 1e-12, + ) + relative = train.quat_multiply( + train.quat_conjugate(reference), + candidate, + ) + relative[relative[:, 3] < 0.0] *= -1.0 + errors = np.degrees( + np.linalg.norm(_quaternion_to_rotvec(relative), axis=1) + ) + return { + "p90": float(np.quantile(errors, 0.90)), + "maximum": float(np.max(errors)), + "final": float(errors[-1]), + } + + def _causal_smooth_angular_velocity( angular_velocity: np.ndarray, alpha: float, @@ -4427,16 +4705,20 @@ def limit_quaternion_kinematics( AIM_MAXIMUM_ANGULAR_ACCELERATION_DPS2 ), maximum_jerk_dps3: float = AIM_MAXIMUM_ANGULAR_JERK_DPS3, -) -> tuple[np.ndarray, dict[str, float]]: + authored_quality_target_dps3: float = ( + AIM_AUTHORED_ANGULAR_JERK_TARGET_DPS3 + ), +) -> tuple[np.ndarray, dict[str, object]]: """Bake stable quaternion keys with within-shot C2 dynamics bounds. The first stage is an acceleration-bounded, moving-target tracker. Its robust feed-forward preserves legal constant pans from the first key while - isolated pose jumps are handled as position errors. The second stage uses - a causal positive-kernel velocity filter until jerk is bounded. It cannot - move before an internal target change, avoids the previous bang-bang - controller's static-target limit cycle, and never invents an imaginary - zero-velocity key at a hard cut. + isolated pose jumps are handled as position errors. The second stage + evaluates the complete causal positive-kernel response family and chooses + the minimum authored-range jerk objective within a strict tracking budget. + It cannot move before an internal target change, avoids the previous + bang-bang controller's static-target limit cycle, and never invents an + imaginary zero-velocity key at a hard cut. """ desired = np.asarray(desired_rotations, dtype=np.float64).copy() @@ -4449,6 +4731,7 @@ def limit_quaternion_kinematics( ("maximum_speed_dps", maximum_speed_dps), ("maximum_acceleration_dps2", maximum_acceleration_dps2), ("maximum_jerk_dps3", maximum_jerk_dps3), + ("authored_quality_target_dps3", authored_quality_target_dps3), ): if not math.isfinite(value) or value <= 0.0: raise ValueError(f"{label} must be finite and positive.") @@ -4458,6 +4741,10 @@ def limit_quaternion_kinematics( speed_limit = math.radians(maximum_speed_dps) acceleration_limit = math.radians(maximum_acceleration_dps2) jerk_limit = math.radians(maximum_jerk_dps3) + authored_quality_target_dps3 = min( + float(authored_quality_target_dps3), + float(maximum_jerk_dps3), + ) if len(desired) == 1: final_output = desired.astype(np.float32) dynamics = quaternion_angular_dynamics_metrics(final_output, sample_rate) @@ -4470,10 +4757,29 @@ def limit_quaternion_kinematics( "aimAngularJerkLimitDegreesPerSecondCubed": float( maximum_jerk_dps3 ), + "aimAngularJerkAuthoredTargetDegreesPerSecondCubed": float( + authored_quality_target_dps3 + ), + "aimAngularJerkAuthoredTargetPassed": True, "aimAngularJerkNearLimitFrameRatio": 0.0, + "aimAngularJerkNearAuthoredTargetFrameRatio": 0.0, "aimAngularKinematicLimitScale": 1.0, "aimAngularVelocitySmoothingSigmaFrames": 0.0, "aimAngularVelocityCausalSmoothingAlpha": 1.0, + "aimAngularMinimumJerkSelectionPolicy": ( + AIM_MINIMUM_JERK_SELECTION_POLICY_VERSION + ), + "aimAngularSmoothingCandidateCount": 1, + "aimAngularSmoothingHardSafeCandidateCount": 1, + "aimAngularMinimumJerkTrackingBudgetPassed": True, + "aimAngularMinimumJerkProxyTrackingBudgetPassed": True, + "aimAngularMinimumJerkSelectionObjective": 0.0, + "aimAngularReferenceDeviationDegreesP90": 0.0, + "aimAngularReferenceDeviationDegreesMax": 0.0, + "aimAngularReferenceDeviationDegreesFinal": 0.0, + "aimAngularReferenceDeviationProxyDegreesP90": 0.0, + "aimAngularReferenceDeviationProxyDegreesMax": 0.0, + "aimAngularReferenceDeviationProxyDegreesFinal": 0.0, } ) dynamics["aimRotationDynamicsSelectionPenalty"] = ( @@ -4496,10 +4802,18 @@ def limit_quaternion_kinematics( dtype=np.float64, ) - # Entry velocity belongs to the shot itself. Starting from its persistent - # target velocity prevents the artificial six-frame ramp that previously - # lagged every legal 45/80 dps pan after a hard cut. - previous_velocity = feed_forward[0].copy() + # Never initialize motion from a future-informed median when the raw first + # interval is a hold: that caused frame-1 pre-echo for pans beginning at + # frames 1/2/3. When the first interval itself moves, the robust value is + # still useful to distinguish a legal constant pan from an instantaneous + # pose step, so the established step-settling behavior is preserved. + entry_velocity = ( + np.zeros(3, dtype=np.float64) + if float(np.linalg.norm(desired_velocity[0])) <= 1e-10 + else feed_forward[0].copy() + ) + entry_velocity = _bounded_vector(entry_velocity, speed_limit) + previous_velocity = entry_velocity.copy() base_output = np.empty_like(desired) base_output[0] = desired[0] base_velocities: list[np.ndarray] = [] @@ -4577,6 +4891,9 @@ def limit_quaternion_kinematics( speed_contract = speed_limit * speed_headroom acceleration_contract = acceleration_limit * acceleration_headroom jerk_contract = jerk_limit * jerk_headroom + authored_jerk_contract = ( + math.radians(authored_quality_target_dps3) * jerk_headroom + ) smoothing_candidates = ( 1.0, 0.8, @@ -4594,13 +4911,12 @@ def limit_quaternion_kinematics( 0.025, 0.01, ) - selected_velocity: np.ndarray | None = None - selected_alpha = 1.0 + smoothing_audits: list[dict[str, object]] = [] for smoothing_alpha in smoothing_candidates: candidate_velocity = _causal_smooth_angular_velocity( velocity_array, smoothing_alpha, - feed_forward[0], + entry_velocity, ) maximum_speed = float( np.max(np.linalg.norm(candidate_velocity, axis=1), initial=0.0) @@ -4619,13 +4935,100 @@ def limit_quaternion_kinematics( maximum_jerk = float( np.max(np.linalg.norm(candidate_jerk, axis=1), initial=0.0) ) - if ( + jerk_magnitudes_dps3 = np.degrees( + np.linalg.norm(candidate_jerk, axis=1) + ) + maximum_jerk_dps3_candidate = math.degrees(maximum_jerk) + p95_jerk_dps3_candidate = ( + float(np.quantile(jerk_magnitudes_dps3, 0.95)) + if len(jerk_magnitudes_dps3) + else 0.0 + ) + tracking_deviation = _angular_velocity_tracking_deviation_metrics( + candidate_velocity, + velocity_array, + sample_rate, + ) + hard_safe = bool( maximum_acceleration <= acceleration_contract + 1e-12 and maximum_jerk <= jerk_contract + 1e-12 - ): - selected_velocity = candidate_velocity - selected_alpha = float(smoothing_alpha) - break + ) + tracking_budget_passed = _angular_tracking_deviation_within_budget( + tracking_deviation["p90"], + tracking_deviation["maximum"], + tracking_deviation["final"], + ) + authored_target_passed = bool( + maximum_jerk <= authored_jerk_contract + 1e-12 + ) + smoothing_audits.append( + { + "velocity": candidate_velocity, + "alpha": float(smoothing_alpha), + "hardSafe": hard_safe, + "trackingBudgetPassed": tracking_budget_passed, + "authoredTargetPassed": authored_target_passed, + "objective": _minimum_jerk_candidate_objective( + maximum_jerk_dps3_candidate, + p95_jerk_dps3_candidate, + tracking_deviation, + authored_quality_target_dps3, + ), + "trackingDeviation": tracking_deviation, + } + ) + + hard_safe_audits = [ + audit for audit in smoothing_audits if audit["hardSafe"] is True + ] + tracking_safe_audits = [ + audit + for audit in hard_safe_audits + if audit["trackingBudgetPassed"] is True + ] + authored_quality_audits = [ + audit + for audit in tracking_safe_audits + if audit["authoredTargetPassed"] is True + ] + selection_pool = ( + authored_quality_audits + or tracking_safe_audits + or hard_safe_audits + ) + selected_velocity: np.ndarray | None = None + selected_alpha = 1.0 + selected_tracking_budget_passed = False + selected_authored_target_passed = False + selected_objective = float("inf") + selected_tracking_deviation = { + "p90": 0.0, + "maximum": 0.0, + "final": 0.0, + } + if selection_pool: + selected_audit = min( + selection_pool, + key=lambda audit: ( + float(audit["objective"]), + -float(audit["alpha"]), + ), + ) + selected_velocity = np.asarray( + selected_audit["velocity"], + dtype=np.float64, + ).copy() + selected_alpha = float(selected_audit["alpha"]) + selected_tracking_budget_passed = bool( + selected_audit["trackingBudgetPassed"] + ) + selected_authored_target_passed = bool( + selected_audit["authoredTargetPassed"] + ) + selected_objective = float(selected_audit["objective"]) + selected_tracking_deviation = dict( + selected_audit["trackingDeviation"] + ) kinematic_limit_scale = 1.0 if selected_velocity is None: @@ -4655,12 +5058,60 @@ def limit_quaternion_kinematics( ) selected_velocity = velocity_array * kinematic_limit_scale selected_alpha = -1.0 + selected_tracking_deviation = ( + _angular_velocity_tracking_deviation_metrics( + selected_velocity, + velocity_array, + sample_rate, + ) + ) + selected_tracking_budget_passed = ( + _angular_tracking_deviation_within_budget( + selected_tracking_deviation["p90"], + selected_tracking_deviation["maximum"], + selected_tracking_deviation["final"], + ) + ) + fallback_acceleration, fallback_jerk = ( + _angular_velocity_dynamics_vectors( + selected_velocity, + sample_rate, + ) + ) + fallback_jerk_magnitudes = np.degrees( + np.linalg.norm(fallback_jerk, axis=1) + ) + fallback_maximum_jerk_dps3 = ( + float(np.max(fallback_jerk_magnitudes)) + if len(fallback_jerk_magnitudes) + else 0.0 + ) + fallback_p95_jerk_dps3 = ( + float(np.quantile(fallback_jerk_magnitudes, 0.95)) + if len(fallback_jerk_magnitudes) + else 0.0 + ) + selected_authored_target_passed = bool( + fallback_maximum_jerk_dps3 + <= authored_quality_target_dps3 * jerk_headroom + 1e-12 + ) + selected_objective = _minimum_jerk_candidate_objective( + fallback_maximum_jerk_dps3, + fallback_p95_jerk_dps3, + selected_tracking_deviation, + authored_quality_target_dps3, + ) # The public payload is float32. Re-audit that actual representation and, # only for a numerical contract overshoot, uniformly reduce the already # causal path. Uniform scaling cannot create anticipation or a reversal. final_output: np.ndarray | None = None - dynamics: dict[str, float] | None = None + dynamics: dict[str, object] | None = None + serialized_jerk_limit_dps3 = ( + authored_quality_target_dps3 + if selected_authored_target_passed + else maximum_jerk_dps3 + ) for _ in range(4): output = _integrate_angular_velocity( desired[0], @@ -4680,7 +5131,7 @@ def limit_quaternion_kinematics( ], 1e-12, ), - maximum_jerk_dps3 * jerk_headroom + serialized_jerk_limit_dps3 * jerk_headroom / max( dynamics["aimAngularJerkDegreesPerSecondCubedMax"], 1e-12, @@ -4690,6 +5141,16 @@ def limit_quaternion_kinematics( break selected_velocity *= serialized_scale kinematic_limit_scale *= serialized_scale + # If the last permitted iteration applied a numerical headroom scale, the + # loop-local output still represents the preceding velocity. Materialize + # and audit the actual selected velocity once more before publishing. + output = _integrate_angular_velocity( + desired[0], + selected_velocity, + sample_rate, + ) + final_output = output.astype(np.float32) + dynamics = quaternion_angular_dynamics_metrics(final_output, sample_rate) assert final_output is not None assert dynamics is not None _, _, jerk_vectors = _quaternion_angular_dynamics_vectors( @@ -4701,6 +5162,21 @@ def limit_quaternion_kinematics( if len(jerk_vectors) else np.asarray([0.0], dtype=np.float64) ) + exact_reference_deviation = _quaternion_path_error_statistics( + final_output, + base_output, + ) + final_tracking_budget_passed = ( + _angular_tracking_deviation_within_budget( + exact_reference_deviation["p90"], + exact_reference_deviation["maximum"], + exact_reference_deviation["final"], + ) + ) + final_authored_target_passed = bool( + dynamics["aimAngularJerkDegreesPerSecondCubedMax"] + <= authored_quality_target_dps3 + 1e-6 + ) dynamics.update( { "aimAngularSpeedLimitDegreesPerSecond": float(maximum_speed_dps), @@ -4710,14 +5186,60 @@ def limit_quaternion_kinematics( "aimAngularJerkLimitDegreesPerSecondCubed": float( maximum_jerk_dps3 ), + "aimAngularJerkAuthoredTargetDegreesPerSecondCubed": float( + authored_quality_target_dps3 + ), + "aimAngularJerkAuthoredTargetPassed": ( + final_authored_target_passed + ), "aimAngularJerkNearLimitFrameRatio": float( np.mean(jerk_magnitude_dps3 >= maximum_jerk_dps3 * 0.9) ), + "aimAngularJerkNearAuthoredTargetFrameRatio": float( + np.mean( + jerk_magnitude_dps3 + >= authored_quality_target_dps3 * 0.9 + ) + ), "aimAngularKinematicLimitScale": float(kinematic_limit_scale), # Retained for metadata compatibility; the controller no longer # uses a non-causal Gaussian kernel. "aimAngularVelocitySmoothingSigmaFrames": 0.0, "aimAngularVelocityCausalSmoothingAlpha": float(selected_alpha), + "aimAngularMinimumJerkSelectionPolicy": ( + AIM_MINIMUM_JERK_SELECTION_POLICY_VERSION + ), + "aimAngularSmoothingCandidateCount": len(smoothing_audits), + "aimAngularSmoothingHardSafeCandidateCount": len( + hard_safe_audits + ), + "aimAngularMinimumJerkTrackingBudgetPassed": bool( + final_tracking_budget_passed + ), + "aimAngularMinimumJerkProxyTrackingBudgetPassed": bool( + selected_tracking_budget_passed + ), + "aimAngularMinimumJerkSelectionObjective": float( + selected_objective + ), + "aimAngularReferenceDeviationProxyDegreesP90": float( + selected_tracking_deviation["p90"] + ), + "aimAngularReferenceDeviationProxyDegreesMax": float( + selected_tracking_deviation["maximum"] + ), + "aimAngularReferenceDeviationProxyDegreesFinal": float( + selected_tracking_deviation["final"] + ), + "aimAngularReferenceDeviationDegreesP90": float( + exact_reference_deviation["p90"] + ), + "aimAngularReferenceDeviationDegreesMax": float( + exact_reference_deviation["maximum"] + ), + "aimAngularReferenceDeviationDegreesFinal": float( + exact_reference_deviation["final"] + ), } ) dynamics["aimRotationDynamicsSelectionPenalty"] = ( @@ -5387,6 +5909,10 @@ def retarget_template_to_shot( # positional transform. We do not run another center-based step scaler # afterward because that would invalidate the bound again; the actual # final step is audited below and unsafe candidates are rejected. + camera_world_position_before_final_radial_clamp = np.asarray( + camera_world_position, + dtype=np.float64, + ).copy() final_relative_position_meters = ( camera_world_position - stage_anchor_path ) @@ -5570,7 +6096,13 @@ def retarget_template_to_shot( # the pre-clamp curve, while every After field is the final raw output # audit. Compact stage summaries retain the causal explanation without # duplicating the full metric object for every candidate. - translation_kinematic_metrics = post_clamp_translation_kinematic_metrics + # Build the sequence-level view on a shallow copy. The original post + # stage dictionary remains the authoritative per-stage snapshot used + # below; mutating it with combined pre/post fields would make the second + # stage falsely report unioned diagnostics. + translation_kinematic_metrics = dict( + post_clamp_translation_kinematic_metrics + ) for metric_name, metric_value in ( pre_clamp_translation_kinematic_metrics.items() ): @@ -5601,6 +6133,34 @@ def retarget_template_to_shot( "translationKinematicDynamicsGuardTriggered" ] ) + translation_kinematic_metrics[ + "translationKinematicDynamicsGuardRejectedFrames" + ] = list( + dict.fromkeys( + [ + *pre_clamp_translation_kinematic_metrics[ + "translationKinematicDynamicsGuardRejectedFrames" + ], + *post_clamp_translation_kinematic_metrics[ + "translationKinematicDynamicsGuardRejectedFrames" + ], + ] + ) + ) + translation_kinematic_metrics[ + "translationKinematicAdaptiveRadiusFrames" + ] = list( + dict.fromkeys( + [ + *pre_clamp_translation_kinematic_metrics[ + "translationKinematicAdaptiveRadiusFrames" + ], + *post_clamp_translation_kinematic_metrics[ + "translationKinematicAdaptiveRadiusFrames" + ], + ] + ) + ) combined_attempted_frames = list( dict.fromkeys( [ @@ -5669,6 +6229,48 @@ def retarget_template_to_shot( "translationRegularizedTargetSpeedRatios" ], ] + # These radii are positional peers of the concatenated turn angles and + # target-speed ratios. Do not de-duplicate them: the same frame may be + # independently repaired before and after radial projection, and dropping + # one radius would silently break the per-attempt alignment. + translation_kinematic_metrics[ + "translationRegularizedWindowRadiiFrames" + ] = [ + *pre_clamp_translation_kinematic_metrics[ + "translationRegularizedWindowRadiiFrames" + ], + *post_clamp_translation_kinematic_metrics[ + "translationRegularizedWindowRadiiFrames" + ], + ] + translation_kinematic_metrics[ + "translationAttemptedAccelerationMetersPerSecondSquaredMax" + ] = max( + float( + pre_clamp_translation_kinematic_metrics[ + "translationAttemptedAccelerationMetersPerSecondSquaredMax" + ] + ), + float( + post_clamp_translation_kinematic_metrics[ + "translationAttemptedAccelerationMetersPerSecondSquaredMax" + ] + ), + ) + translation_kinematic_metrics[ + "translationAttemptedJerkMetersPerSecondCubedMax" + ] = max( + float( + pre_clamp_translation_kinematic_metrics[ + "translationAttemptedJerkMetersPerSecondCubedMax" + ] + ), + float( + post_clamp_translation_kinematic_metrics[ + "translationAttemptedJerkMetersPerSecondCubedMax" + ] + ), + ) translation_kinematic_metrics[ "translationImpulsiveTurnRegularizedFrames" ] = list( @@ -5687,6 +6289,26 @@ def retarget_template_to_shot( { "stage": "before_final_radial_clamp", "policyApplied": pre_applied, + "guardTriggered": bool( + pre_clamp_translation_kinematic_metrics[ + "translationKinematicDynamicsGuardTriggered" + ] + ), + "rejectedFrames": list( + pre_clamp_translation_kinematic_metrics[ + "translationKinematicDynamicsGuardRejectedFrames" + ] + ), + "adaptiveRadiusFrames": list( + pre_clamp_translation_kinematic_metrics[ + "translationKinematicAdaptiveRadiusFrames" + ] + ), + "windowRadiiFrames": list( + pre_clamp_translation_kinematic_metrics[ + "translationRegularizedWindowRadiiFrames" + ] + ), "impulsiveTurnCountBefore": int( pre_clamp_translation_kinematic_metrics[ "translationImpulsiveTurnCountBefore" @@ -5707,6 +6329,26 @@ def retarget_template_to_shot( "stage": "after_final_radial_clamp", "policyApplied": accepted_post_applied, "safetyRejected": post_clamp_kinematic_safety_rejected, + "guardTriggered": bool( + post_clamp_translation_kinematic_metrics[ + "translationKinematicDynamicsGuardTriggered" + ] + ), + "rejectedFrames": list( + post_clamp_translation_kinematic_metrics[ + "translationKinematicDynamicsGuardRejectedFrames" + ] + ), + "adaptiveRadiusFrames": list( + post_clamp_translation_kinematic_metrics[ + "translationKinematicAdaptiveRadiusFrames" + ] + ), + "windowRadiiFrames": list( + post_clamp_translation_kinematic_metrics[ + "translationRegularizedWindowRadiiFrames" + ] + ), "regularizedFrames": post_stage_regularized_frames, "directionReversalCountAfter": ( post_stage_direction_reversal_count_after @@ -5725,6 +6367,14 @@ def retarget_template_to_shot( trajectory_step_after_kinematic_meters = ( trajectory_step_after_final_distance_clamp_meters ) + final_radial_dynamics_audit = final_radial_dynamics_regression_audit( + camera_world_position_before_final_radial_clamp, + camera_world_position, + sample_rate=HYBRID_SAMPLE_RATE, + ) + final_radial_dynamics_regression_passed = bool( + final_radial_dynamics_audit["passed"] + ) final_post_clamp_kinematic_passed = bool( int( post_clamp_translation_kinematic_metrics[ @@ -5732,6 +6382,7 @@ def retarget_template_to_shot( ] ) == 0 + and final_radial_dynamics_regression_passed and int( post_clamp_translation_kinematic_metrics[ "translationImpulsiveTurnCountAfter" @@ -5762,6 +6413,12 @@ def retarget_template_to_shot( "postClampKinematicSafetyRejected": ( post_clamp_kinematic_safety_rejected ), + "finalRadialDynamicsRegressionAudit": ( + final_radial_dynamics_audit + ), + "finalRadialDynamicsRegressionPassed": ( + final_radial_dynamics_regression_passed + ), "finalPostClampKinematicPassed": final_post_clamp_kinematic_passed, "finalDistanceAuditAfterAllPositionTransforms": True, "finalDistanceBoundsPassed": final_distance_bounds_passed, @@ -6210,8 +6867,13 @@ def retarget_template_to_shot( trajectory_quality.STABILIZED_SCREEN_RESIDUAL_INPUT ), ) + candidate_dynamics_cost_passed = bool(candidate_dynamics["passed"]) candidate_dynamics_penalty = candidate_dynamics_selection_penalty( - float(candidate_dynamics["cost"]) + float(candidate_dynamics["cost"]), + cost_passed=candidate_dynamics_cost_passed, + maximum_acceptable_cost=float( + candidate_dynamics["maximumAcceptableCost"] + ), ) translation_dynamics = candidate_dynamics["translationMetrics"] screen_dynamics = candidate_dynamics["screenSpaceMetrics"] @@ -6225,7 +6887,10 @@ def retarget_template_to_shot( candidate_dynamics_catastrophic_passed = bool( candidate_dynamics_hard_gate_result["passed"] ) - candidate_dynamics["costPassed"] = bool(candidate_dynamics["passed"]) + candidate_dynamics["costPassed"] = candidate_dynamics_cost_passed + candidate_dynamics["selectionPolicyVersion"] = ( + CANDIDATE_DYNAMICS_SELECTION_POLICY_VERSION + ) candidate_dynamics["hardGate"] = candidate_dynamics_hard_gate_result # Generated payloads expose one authoritative `passed` field. The soft # cost remains useful for ranking (`costPassed`) but never overrides a @@ -6583,19 +7248,135 @@ def stabilized_screen_residual( return residual -def candidate_dynamics_selection_penalty(cost: object) -> float: - """Bound soft dynamics to a tie-break without weakening hard safety.""" +def candidate_dynamics_selection_penalty( + cost: object, + *, + cost_passed: object | None = None, + maximum_acceptable_cost: object = ( + CANDIDATE_DYNAMICS_MAXIMUM_ACCEPTABLE_COST + ), +) -> float: + """Prefer authored dynamics and strongly differentiate cost failures. + + Passing costs remain the established bounded tie-break. A failed authored + cost receives an explicit discontinuous penalty plus an uncapped excess + term, so several very rough candidates can no longer all collapse to the + same 0.9 score while still remaining available as a last-resort fallback. + """ if not isinstance(cost, (int, float)) or isinstance(cost, bool): raise ValueError("candidate dynamics cost must be a finite number") value = float(cost) if not math.isfinite(value) or value < 0.0: raise ValueError("candidate dynamics cost must be finite and non-negative") - return float( - min( - value * TRAJECTORY_DYNAMICS_SELECTION_WEIGHT, - TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY, + if ( + not isinstance(maximum_acceptable_cost, (int, float)) + or isinstance(maximum_acceptable_cost, bool) + ): + raise ValueError("maximum acceptable dynamics cost must be finite") + maximum = float(maximum_acceptable_cost) + if not math.isfinite(maximum) or maximum < 0.0: + raise ValueError( + "maximum acceptable dynamics cost must be finite and non-negative" ) + inferred_passed = value <= maximum + if cost_passed is None: + passed = inferred_passed + elif type(cost_passed) is bool: + passed = bool(cost_passed) + if passed != inferred_passed: + raise ValueError( + "candidate dynamics costPassed disagrees with its cost threshold" + ) + else: + raise ValueError("candidate dynamics costPassed must be boolean") + bounded_tiebreak = min( + value * TRAJECTORY_DYNAMICS_SELECTION_WEIGHT, + TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY, + ) + if passed: + return float(bounded_tiebreak) + return float( + TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY + + CANDIDATE_DYNAMICS_COST_FAILURE_BASE_PENALTY + + max(0.0, value - maximum) + * CANDIDATE_DYNAMICS_COST_FAILURE_EXCESS_WEIGHT + ) + + +def candidate_passes_authored_dynamics_cost(candidate: dict) -> bool: + """Return the explicit fail-closed authored dynamics-cost result.""" + + dynamics = candidate.get("candidateDynamics") + if not isinstance(dynamics, dict): + return False + cost_passed = dynamics.get("costPassed") + cost = dynamics.get("cost") + maximum = dynamics.get("maximumAcceptableCost") + if ( + type(cost_passed) is not bool + or not isinstance(cost, (int, float)) + or isinstance(cost, bool) + or not isinstance(maximum, (int, float)) + or isinstance(maximum, bool) + ): + return False + cost_value = float(cost) + maximum_value = float(maximum) + if ( + not math.isfinite(cost_value) + or cost_value < 0.0 + or not math.isfinite(maximum_value) + or maximum_value < 0.0 + ): + return False + return bool(cost_passed and cost_value <= maximum_value + 1e-12) + + +def candidate_passes_authored_angular_quality(candidate: dict) -> bool: + """Fail closed on boolean or numeric authored-angle inconsistencies.""" + + composition = candidate.get("composition") + if not isinstance(composition, dict): + return False + target_passed = composition.get("aimAngularJerkAuthoredTargetPassed") + tracking_passed = composition.get( + "aimAngularMinimumJerkTrackingBudgetPassed" + ) + maximum_jerk = composition.get( + "aimAngularJerkDegreesPerSecondCubedMax" + ) + authored_target = composition.get( + "aimAngularJerkAuthoredTargetDegreesPerSecondCubed" + ) + if ( + not isinstance(maximum_jerk, (int, float)) + or isinstance(maximum_jerk, bool) + or not isinstance(authored_target, (int, float)) + or isinstance(authored_target, bool) + ): + return False + maximum_jerk_value = float(maximum_jerk) + authored_target_value = float(authored_target) + jerk_numeric_passed = bool( + math.isfinite(maximum_jerk_value) + and maximum_jerk_value >= 0.0 + and math.isfinite(authored_target_value) + and authored_target_value > 0.0 + and maximum_jerk_value <= authored_target_value + 1e-6 + ) + tracking_numeric_passed = _angular_tracking_deviation_within_budget( + composition.get("aimAngularReferenceDeviationDegreesP90"), + composition.get("aimAngularReferenceDeviationDegreesMax"), + composition.get("aimAngularReferenceDeviationDegreesFinal"), + ) + return bool( + type(target_passed) is bool + and target_passed + and jerk_numeric_passed + and type(tracking_passed) is bool + and tracking_passed + and tracking_numeric_passed ) @@ -6727,6 +7508,9 @@ def candidate_passes_safety_and_aim(candidate: dict) -> bool: dynamics_passed = ( dynamics.get("passed") if isinstance(dynamics, dict) else None ) + dynamics_cost_passed = ( + dynamics.get("costPassed") if isinstance(dynamics, dict) else None + ) catastrophic_passed = candidate.get( "candidateDynamicsCatastrophicPassed" ) @@ -6744,6 +7528,11 @@ def candidate_passes_safety_and_aim(candidate: dict) -> bool: and front_metrics_are_finite and type(dynamics_passed) is bool and dynamics_passed + # Cost failure is a best-effort quality state rather than a + # catastrophic safety failure, but the current explicit audit must be + # present so pool-level authored-cost preference cannot be bypassed by + # an old cache payload. + and type(dynamics_cost_passed) is bool and type(catastrophic_passed) is bool and catastrophic_passed and dynamics_passed == catastrophic_passed @@ -6762,11 +7551,39 @@ def quality_eligible_safe_variation_candidates( if maximum_score_delta < 0.0: raise ValueError("maximum_score_delta must be non-negative.") + safe_candidates = [ + candidate + for candidate in candidates + if candidate_passes_safety_and_aim(candidate) + ] + authored_cost_candidates = [ + candidate + for candidate in safe_candidates + if candidate_passes_authored_dynamics_cost(candidate) + ] + authored_angular_candidates = [ + candidate + for candidate in safe_candidates + if candidate_passes_authored_angular_quality(candidate) + ] + fully_authored_candidates = [ + candidate + for candidate in authored_cost_candidates + if candidate_passes_authored_angular_quality(candidate) + ] + # Prefer authored-cost candidates absolutely whenever one exists. Failed + # costs remain only as an explicit last-resort fallback so generation does + # not become unavailable for a difficult shot. + preferred_candidates = ( + fully_authored_candidates + or authored_cost_candidates + or authored_angular_candidates + or safe_candidates + ) safe_candidates = sorted( ( candidate - for candidate in candidates - if candidate_passes_safety_and_aim(candidate) + for candidate in preferred_candidates ), key=_camera_variation_sort_key, ) @@ -6807,7 +7624,15 @@ def build_distinct_safe_variation_pool( def seed_variation_needs_more_candidates(candidates: list[dict]) -> bool: """Whether adaptive evaluation still lacks two distinct safe choices.""" - return len(build_distinct_safe_variation_pool(candidates)) < 2 + fully_authored_candidates = [ + candidate + for candidate in candidates + if candidate_passes_authored_dynamics_cost(candidate) + and candidate_passes_authored_angular_quality(candidate) + ] + return ( + len(build_distinct_safe_variation_pool(fully_authored_candidates)) < 2 + ) def transition_passing_distinct_variation_pool( @@ -6986,8 +7811,18 @@ def selectable_safe_candidate_results( for result in candidate_results if candidate_passes_safety_and_aim(result) ] + authored_cost_results = [ + result + for result in safe_results + if candidate_passes_authored_dynamics_cost(result) + ] + fully_authored_results = [ + result + for result in authored_cost_results + if candidate_passes_authored_angular_quality(result) + ] policy_results = visible_non_static_results( - safe_results, + fully_authored_results or authored_cost_results or safe_results, moving_opening_required or rolling_static_budget_required or planned_non_static_required, @@ -7016,6 +7851,7 @@ def policy_safe_candidate_results( result for result in candidate_results if candidate_passes_safety_and_aim(result) + and candidate_passes_authored_dynamics_cost(result) ], moving_opening_required or rolling_static_budget_required @@ -11395,6 +12231,22 @@ def main() -> None: "radialDistanceClamp" ], "candidateDynamics": selected_result["candidateDynamics"], + "candidateDynamicsCostPassed": bool( + candidate_passes_authored_dynamics_cost(selected_result) + ), + "candidateDynamicsCostFallbackUsed": bool( + not candidate_passes_authored_dynamics_cost( + selected_result + ) + ), + "authoredAngularQualityPassed": bool( + candidate_passes_authored_angular_quality(selected_result) + ), + "authoredAngularQualityFallbackUsed": bool( + not candidate_passes_authored_angular_quality( + selected_result + ) + ), "candidateDynamicsCatastrophicPassed": bool( selected_result["candidateDynamicsCatastrophicPassed"] ), @@ -11922,6 +12774,16 @@ def main() -> None: dict, ) ] + candidate_dynamics_cost_fallback_shots = [ + shot + for shot in shots + if shot.get("candidateDynamicsCostFallbackUsed") is True + ] + authored_angular_quality_fallback_shots = [ + shot + for shot in shots + if shot.get("authoredAngularQualityFallbackUsed") is True + ] trajectory_resampling_audited_shots = [ shot for shot in shots @@ -12214,6 +13076,31 @@ def main() -> None: "maximumAngularJerkDegreesPerSecondCubed": ( AIM_MAXIMUM_ANGULAR_JERK_DPS3 ), + "authoredAngularJerkTargetDegreesPerSecondCubed": ( + AIM_AUTHORED_ANGULAR_JERK_TARGET_DPS3 + ), + "authoredAngularJerkP95TargetDegreesPerSecondCubed": ( + AIM_AUTHORED_ANGULAR_JERK_P95_TARGET_DPS3 + ), + "minimumJerkSelectionPolicy": ( + AIM_MINIMUM_JERK_SELECTION_POLICY_VERSION + ), + "minimumJerkTrackingAuditRepresentation": ( + "published_float32_quaternion_path" + ), + "minimumJerkCandidateGateNumericCrossCheck": True, + "causalEntryBoundaryPolicy": ( + "raw_zero_first_interval_forces_zero_entry_velocity" + ), + "minimumJerkTrackingP90BudgetDegrees": ( + AIM_MINIMUM_JERK_TRACKING_P90_BUDGET_DEGREES + ), + "minimumJerkTrackingMaximumBudgetDegrees": ( + AIM_MINIMUM_JERK_TRACKING_MAXIMUM_BUDGET_DEGREES + ), + "minimumJerkTrackingFinalBudgetDegrees": ( + AIM_MINIMUM_JERK_TRACKING_FINAL_BUDGET_DEGREES + ), "angularJerkP95PenaltyFreeRatio": ( AIM_ANGULAR_JERK_P95_FREE_RATIO ), @@ -12254,10 +13141,35 @@ def main() -> None: "softCostPolicy": asdict( trajectory_quality.CandidateDynamicsPolicy() ), - "softSelectionRole": "bounded_tiebreak", + "softSelectionRole": ( + "bounded_tiebreak_when_cost_passes; authored-cost-preferred " + "uncapped failure penalty otherwise" + ), + "selectionPolicyVersion": ( + CANDIDATE_DYNAMICS_SELECTION_POLICY_VERSION + ), + "costFailureBasePenalty": ( + CANDIDATE_DYNAMICS_COST_FAILURE_BASE_PENALTY + ), + "costFailureExcessWeight": ( + CANDIDATE_DYNAMICS_COST_FAILURE_EXCESS_WEIGHT + ), "finalPositionSmoothingVersion": ( FINAL_POSITION_SMOOTHING_VERSION ), + "finalRadialDynamicsGuard": { + "version": FINAL_RADIAL_DYNAMICS_GUARD_POLICY_VERSION, + "maximumRegressionRatio": ( + FINAL_RADIAL_MAXIMUM_DYNAMICS_REGRESSION_RATIO + ), + "maximumAccelerationRegressionDeltaMetersPerSecondSquared": ( + FINAL_RADIAL_MAXIMUM_ACCELERATION_REGRESSION_DELTA_MPS2 + ), + "maximumJerkRegressionDeltaMetersPerSecondCubed": ( + FINAL_RADIAL_MAXIMUM_JERK_REGRESSION_DELTA_MPS3 + ), + "failureBehavior": "candidate_fail_closed", + }, "selectionWeight": TRAJECTORY_DYNAMICS_SELECTION_WEIGHT, "maximumSelectionPenalty": ( TRAJECTORY_DYNAMICS_MAXIMUM_SELECTION_PENALTY @@ -12570,6 +13482,20 @@ def main() -> None: bool(shot.get("candidateDynamicsCatastrophicPassed", False)) for shot in dynamics_audited_shots ), + "candidateDynamicsCostFallbackShotCount": len( + candidate_dynamics_cost_fallback_shots + ), + "candidateDynamicsCostFallbackShotIndices": [ + int(shot["index"]) + for shot in candidate_dynamics_cost_fallback_shots + ], + "authoredAngularQualityFallbackShotCount": len( + authored_angular_quality_fallback_shots + ), + "authoredAngularQualityFallbackShotIndices": [ + int(shot["index"]) + for shot in authored_angular_quality_fallback_shots + ], "candidateDynamicsCostMinMedianP90Max": value_distribution( [ float(shot["candidateDynamics"]["cost"]) diff --git a/CameraAI~/Tools~/CWCameraWorker/_internal/wheel-0.45.1.dist-info/RECORD b/CameraAI~/Tools~/CWCameraWorker/_internal/wheel-0.45.1.dist-info/RECORD index b0c81fd..0fff45b 100644 --- a/CameraAI~/Tools~/CWCameraWorker/_internal/wheel-0.45.1.dist-info/RECORD +++ b/CameraAI~/Tools~/CWCameraWorker/_internal/wheel-0.45.1.dist-info/RECORD @@ -1,4 +1,4 @@ -../../Scripts/wheel.exe,sha256=A69vg7y22iGuDMaqOWKEJzU_3jcjugdke2okLg6B5Cg,108448 +../../Scripts/wheel.exe,sha256=A40JvpbJRmAo327Gh4g5jNGRWEobaBMdktVtpIqzJAk,108448 wheel-0.45.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 wheel-0.45.1.dist-info/LICENSE.txt,sha256=MMI2GGeRCPPo6h0qZYx8pBe9_IkcmO8aifpP8MmChlQ,1107 wheel-0.45.1.dist-info/METADATA,sha256=mKz84H7m7jsxJyzeIcTVORiTb0NPMV39KvOIYhGgmjA,2313 diff --git a/CameraAI~/Tools~/CWCameraWorker/cw_camera_worker_distribution_manifest.json b/CameraAI~/Tools~/CWCameraWorker/cw_camera_worker_distribution_manifest.json index 9b6b092..6d6eed7 100644 --- a/CameraAI~/Tools~/CWCameraWorker/cw_camera_worker_distribution_manifest.json +++ b/CameraAI~/Tools~/CWCameraWorker/cw_camera_worker_distribution_manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": "cw-camera-worker-distribution-build-v1", - "createdUtc": "2026-08-08T20:03:52.1316389Z", - "workerVersion": "0.1.5", + "createdUtc": "2026-08-09T07:15:49.8790622Z", + "workerVersion": "0.1.6", "protocolVersion": "1", "buildIdentitySchemaVersion": "cw-camera-worker-build-identity-v1", "buildEnvironment": { @@ -65,9 +65,9 @@ ], "payload": { "fileCount": 646, - "bytes": 267603627, - "executableSha256": "055fb16b60454b0d131ed2201eccad8d11977bd99ef011468d44638b33e4a1ef", - "buildIdentitySha256": "84428e90a523ec40aa54d45c7b795e586979fce6f4a0e139e0bd83fc3c0ce906", + "bytes": 267657984, + "executableSha256": "09901a07746f98e94d3a4307df83b37044732e15b5001cdc9c7a67133d520847", + "buildIdentitySha256": "34120be099cf1355cafb9af96cdb355ca9cd1b69651f20a050a1e30db1f3948e", "sanityRange": { "minimumFileCount": 500, "maximumFileCount": 1000, diff --git a/CameraAI~/package.json b/CameraAI~/package.json index c7c4dde..fed7053 100644 --- a/CameraAI~/package.json +++ b/CameraAI~/package.json @@ -1,6 +1,6 @@ { "name": "com.mingle.cw-ai", - "version": "0.4.9", + "version": "0.4.10", "displayName": "Mingle Camera Work AI", "description": "Self-contained high-quality Unity Timeline camera generation with an embedded prepared reference library, per-shot editable clips, and A/B review tools.", "unity": "6000.0", diff --git a/README.md b/README.md index f787de7..208191a 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.15 +https://kindnick-git.duckdns.org/mingle/streamingle-unity-utilities.git?path=/CameraAI~#v0.1.16 ``` The Camera AI package includes the complete Windows x64