fix(camera-ai): smooth generated camera curves

This commit is contained in:
KINDNICK 2026-08-09 16:33:02 +09:00
parent 5a2e95485e
commit b78c6319b6
18 changed files with 4078 additions and 418 deletions

View File

@ -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

View File

@ -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

View File

@ -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<string>();
// 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(
normalizedProjectRoot,
"Packages",
"com.mingle.cw-ai",
"RuntimeData~"));
candidates.AddRange(new[]
{
normalizedProjectRoot,
Path.Combine(
Directory.GetParent(normalizedProjectRoot)?.FullName ??
normalizedProjectRoot,
"CW-AI"));
candidates.Add(Environment.CurrentDirectory);
"CW-AI"),
Environment.CurrentDirectory
});
foreach (var candidate in candidates)
{
@ -4224,34 +4315,47 @@ namespace Streamingle.Editor
"CW-AI");
}
private static IEnumerable<string> 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<string> 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)
{
yield return Path.Combine(match, "RuntimeData~");
var fullPath = TryGetFullPath(candidate);
if (string.IsNullOrWhiteSpace(fullPath))
{
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(
var previousRoot = _cwAiRoot;
var repairedLibraryRoot = !string.Equals(
currentRoot.TrimEnd('\\', '/'),
resolvedRoot.TrimEnd('\\', '/'),
StringComparison.OrdinalIgnoreCase))
StringComparison.OrdinalIgnoreCase);
if (repairedLibraryRoot)
{
return false;
}
var previousRoot = _cwAiRoot;
_cwAiRoot = resolvedRoot;
if (string.IsNullOrWhiteSpace(_cliExecutable) ||
!File.Exists(_cliExecutable))
{
_cliExecutable = ResolveCliExecutable(true);
}
AppendLog(
"[library-root-auto-repair] " +
$"{previousRoot} -> {_cwAiRoot}");
return true;
}
var previousExecutable = _cliExecutable;
var resolvedExecutable = ResolveCliExecutable();
if (!string.Equals(
TryGetFullPath(previousExecutable),
TryGetFullPath(resolvedExecutable),
StringComparison.OrdinalIgnoreCase))
{
_cliExecutable = resolvedExecutable;
AppendLog(
"[worker-path-auto-repair] " +
$"{previousExecutable} -> {_cliExecutable}");
}
return repairedLibraryRoot;
}
private static string ExistingDirectoryOrFallback(

File diff suppressed because it is too large Load Diff

View File

@ -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 패키지의 대용량 바이너리를 받으려면

View File

@ -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.

View File

@ -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<InvalidOperationException>(() =>
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<float> times,
IReadOnlyList<float> 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<double> times,
IReadOnlyList<Quaternion> 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<double> 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<double> 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];

View File

@ -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()
{

Binary file not shown.

View File

@ -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",

View File

@ -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(
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"
],

View File

@ -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:

View File

@ -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

View File

@ -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,

View File

@ -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",

View File

@ -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