fix(camera-ai): eliminate generated camera trajectory jolts

This commit is contained in:
KINDNICK 2026-08-09 02:23:06 +09:00
parent 1fd93454eb
commit 9a7e0a3fd0
10 changed files with 3790 additions and 151 deletions

View File

@ -1,5 +1,37 @@
# Changelog
## 0.4.8 - 2026-08-09
- Replaced the unstable frame-local aim controller with a causal, feed-forward
angular-velocity path. Static targets now settle without a left/right limit
cycle, legal 45/80-degree-per-second pans keep their entry velocity, and
abrupt changes are bounded to 90 deg/s, 360 deg/s^2, and 7,200 deg/s^3.
- Added C2 position resampling, speed-adaptive C2 distance transitions, and a
final distance/step audit after every trajectory transform. The final
candidate gate now fails closed on missing data and checks translation jerk,
screen-space p99 and one-frame impulses, and the actual post-clamp step.
- Calibrated the new dynamics policy against 1,381 runtime-eligible authored
shots. The 600 m/s^3 translation maximum applies only to final generated
candidates; expressive authored wide/dolly templates remain available for
retargeting and soft ranking.
- Replaced Unity's piecewise-linear reduced position curves with velocity-aware
C1 Hermite curves. Every reduced shot is audited at source times and
midpoints for position error, local speed/acceleration/jerk regressions, and
meaningful-motion retention; unsafe reductions automatically fall back to
exact position keys for that shot only.
- Rebuilt the self-contained Windows worker as 0.1.4 with the trajectory module
and build-identity snapshot included. The frozen worker doctor and 196-test
build suite pass without requiring Python on the artist machine.
- On the 12,269-frame Arisa regression input, all 56 shots passed visibility,
facing, aim, distance, step, screen-dynamics, and cut-transition checks.
Worst translation jerk improved from 641.61 to 247.81 m/s^3 and worst
angular jerk from 30,294.84 to 7,146.35 deg/s^3; uncached generation improved
from about 124 seconds to 107 seconds.
- Unity compiled with zero Camera AI errors and the package EditMode suite
passed 121/121. The Balanced importer retained simplified C1 curves for 41
shots and safely selected exact position keys for the 15 shots whose reduced
curves exceeded the local motion or 5 mm error contract.
## 0.4.7 - 2026-08-09
- Fixed the incomplete 6D-to-quaternion conversion that replaced every

File diff suppressed because it is too large Load Diff

View File

@ -155,6 +155,293 @@ namespace Streamingle.Editor
Is.LessThan(exact.DutchIndices.Length));
}
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Balanced)]
[TestCase(AICameraTimelinePreviewImporter.CurveSimplificationPreset.Editable)]
public void ReducedPositionCurvesUseAuditedC1HermiteWithoutOvershoot(
AICameraTimelinePreviewImporter.CurveSimplificationPreset preset)
{
var samples = CreateQuadraticMotionSamples(241);
var simplified = Simplify(samples, preset);
var curves = AICameraTimelinePreviewImporter.BuildPositionCurves(
simplified,
preset);
Assert.That(curves.UsedExactFallback, Is.False, curves.FallbackReason);
Assert.That(simplified.PositionUsedExactFallback, Is.False);
Assert.That(
curves.XCurve.length,
Is.LessThan(samples.Times.Length));
AssertCurveIsC1(curves.XCurve);
AssertCurveIsC1(curves.YCurve);
AssertCurveIsC1(curves.ZCurve);
AssertCurveDoesNotOvershootSelectedSegments(
curves.XCurve,
simplified.Times,
simplified.PositionIndices);
AssertCurveDoesNotOvershootSelectedSegments(
curves.YCurve,
simplified.Times,
simplified.PositionIndices);
AssertCurveDoesNotOvershootSelectedSegments(
curves.ZCurve,
simplified.Times,
simplified.PositionIndices);
var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
simplified.Times,
simplified.Positions,
curves.XCurve,
curves.YCurve,
curves.ZCurve,
AICameraTimelinePreviewImporter
.GetCurveSimplificationSettings(preset)
.PositionErrorMeters);
Assert.That(audit.Passed, Is.True, audit.FailureReason);
var rawMaximumJerk = MeasureMaximumJerk(
simplified.Times,
simplified.Positions);
var evaluatedMaximumJerk = MeasureMaximumJerk(
simplified.Times,
EvaluatePositionCurves(simplified.Times, curves));
var allowedMaximumJerk = Mathf.Max(
250f,
Mathf.Max(rawMaximumJerk * 3f, rawMaximumJerk + 150f));
Assert.That(
evaluatedMaximumJerk,
Is.LessThanOrEqualTo(allowedMaximumJerk));
}
[Test]
public void ReducedPositionCurveAuditFallsBackToExactPositionOnly()
{
const int count = 61;
var times = new double[count];
var positions = new Vector3[count];
for (var index = 0; index < count; index++)
{
var time = index / 60.0;
times[index] = time;
positions[index] = new Vector3(
Mathf.Sin((float)time * Mathf.PI * 2f),
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.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));
for (var index = 0; index < count; index++)
{
Assert.That(
curves.XCurve.Evaluate((float)times[index]),
Is.EqualTo(positions[index].x).Within(0.000001f));
}
}
[Test]
public void MotionAuditRejectsPathologicalMidpointVelocityRegression()
{
const int count = 61;
var times = new double[count];
var positions = new Vector3[count];
for (var index = 0; index < count; index++)
{
var time = index / 60.0;
times[index] = time;
positions[index] = new Vector3((float)time, 0f, 0f);
}
var pathologicalX = new AnimationCurve(
new Keyframe(0f, 0f, 1f, 80f),
new Keyframe(1f, 1f, -80f, 1f));
var constant = AnimationCurve.Linear(0f, 0f, 1f, 0f);
var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
times,
positions,
pathologicalX,
constant,
constant,
100f);
Assert.That(audit.Passed, Is.False);
Assert.That(audit.FailureReason, Does.Contain("regressed"));
}
[Test]
public void AuditGridUsesExactSourceTimesAndDropsFloatingTailDuplicates()
{
var frame = 1.0 / 60.0;
var times = new[]
{
0.0,
frame,
frame * 2.0,
frame * 3.0,
frame * 3.0 + 0.00000000005
};
var auditTimes = AICameraTimelinePreviewImporter
.BuildPositionCurveAuditTimes(times);
Assert.That(auditTimes.Count, Is.EqualTo(7));
Assert.That(auditTimes[0], Is.EqualTo(times[0]));
Assert.That(auditTimes[2], Is.EqualTo(times[1]));
Assert.That(auditTimes[4], Is.EqualTo(times[2]));
Assert.That(auditTimes[6], Is.EqualTo(times[3]));
for (var index = 1; index < auditTimes.Count; index++)
{
Assert.That(
auditTimes[index] - auditTimes[index - 1],
Is.GreaterThan(frame * 0.49),
$"Audit time {index} introduced a floating-point tail.");
}
}
[Test]
public void AlignedMotionAuditFindsLocalJerkHiddenByEarlierGlobalMaximum()
{
const int count = 40;
var times = new double[count];
var reference = new Vector3[count];
var candidate = new Vector3[count];
for (var index = 0; index < count; index++)
{
times[index] = index / 60.0;
}
reference[3] = new Vector3(0.1f, 0f, 0f);
Array.Copy(reference, candidate, count);
candidate[30] = new Vector3(0.001f, 0f, 0f);
Assert.That(
MeasureMaximumJerk(times, candidate),
Is.EqualTo(MeasureMaximumJerk(times, reference))
.Within(0.01f),
"The earlier source jolt must mask the new jolt in a global-max audit.");
Assert.That(
AICameraTimelinePreviewImporter.TryDescribeAlignedMotionRegression(
times,
reference,
candidate,
out var failureReason),
Is.True);
Assert.That(failureReason, Does.Contain("windowed jerk"));
}
[Test]
public void MotionRetentionAuditRejectsErasedSubtleLowFrequencyMove()
{
const int count = 241;
var times = new double[count];
var positions = new Vector3[count];
for (var index = 0; index < count; index++)
{
var time = index / 60.0;
times[index] = time;
positions[index] = new Vector3(
0.006f * Mathf.Sin((float)(time * Math.PI * 0.5)),
0f,
0f);
}
var erased = AnimationCurve.Linear(
0f,
0f,
(float)times[count - 1],
0f);
var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
times,
positions,
erased,
erased,
erased,
0.02f);
Assert.That(audit.Passed, Is.False);
Assert.That(
audit.FailureReason,
Does.Contain("meaningful motion retention"));
}
[Test]
public void MotionRetentionAuditIgnoresSubDeadbandPositionNoise()
{
const int count = 121;
var times = new double[count];
var positions = new Vector3[count];
for (var index = 0; index < count; index++)
{
times[index] = index / 60.0;
positions[index] = new Vector3(
index % 2 == 0 ? 0.0001f : -0.0001f,
0f,
0f);
}
var denoised = AnimationCurve.Linear(
0f,
0f,
(float)times[count - 1],
0f);
var audit = AICameraTimelinePreviewImporter.AuditPositionCurves(
times,
positions,
denoised,
denoised,
denoised,
0.02f);
Assert.That(audit.Passed, Is.True, audit.FailureReason);
}
[Test]
public void ExactPositionCurvesRetainEveryLinearSampleWithoutFallback()
{
var samples = CreateSamples(91);
var simplified = Simplify(
samples,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
var curves = AICameraTimelinePreviewImporter.BuildPositionCurves(
simplified,
AICameraTimelinePreviewImporter.CurveSimplificationPreset.Exact);
Assert.That(curves.UsedExactFallback, Is.False);
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));
for (var index = 0; index < samples.Times.Length; index++)
{
var time = (float)samples.Times[index];
Assert.That(
curves.XCurve.Evaluate(time),
Is.EqualTo(samples.Positions[index].x).Within(0.000001f));
Assert.That(
curves.YCurve.Evaluate(time),
Is.EqualTo(samples.Positions[index].y).Within(0.000001f));
Assert.That(
curves.ZCurve.Evaluate(time),
Is.EqualTo(samples.Positions[index].z).Within(0.000001f));
}
}
[Test]
public void EditablePresetPreservesStopThroughDirectionChangeKeys()
{
@ -448,6 +735,160 @@ namespace Streamingle.Editor
preset);
}
private static CameraSamples CreateQuadraticMotionSamples(int count)
{
var times = new double[count];
var positions = new Vector3[count];
var rotations = new Quaternion[count];
var fieldOfView = new float[count];
var dutch = new float[count];
for (var index = 0; index < count; index++)
{
var time = index / 60.0;
var timeFloat = (float)time;
times[index] = time;
positions[index] = new Vector3(
0.4f * timeFloat + 0.15f * timeFloat * timeFloat,
1.6f + 0.02f * timeFloat * timeFloat,
-4f + 0.1f * timeFloat);
rotations[index] = Quaternion.identity;
fieldOfView[index] = 40f;
dutch[index] = 0f;
}
return new CameraSamples(
times,
positions,
rotations,
fieldOfView,
dutch);
}
private static AICameraTimelinePreviewImporter.SimplifiedCameraCurves
CreatePositionOnlySimplifiedCurves(
double[] times,
Vector3[] positions,
int[] positionIndices)
{
var rotations = Enumerable.Repeat(
Quaternion.identity,
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,
positionIndices,
allIndices,
allIndices,
allIndices);
}
private static void AssertCurveIsC1(AnimationCurve curve)
{
var keys = curve.keys;
for (var index = 0; index < keys.Length; index++)
{
Assert.That(
keys[index].inTangent,
Is.EqualTo(keys[index].outTangent).Within(0.000001f),
$"Curve key {index} has a velocity discontinuity.");
}
}
private static void AssertCurveDoesNotOvershootSelectedSegments(
AnimationCurve curve,
IReadOnlyList<double> times,
IReadOnlyList<int> indices)
{
const int samplesPerSegment = 16;
for (var segment = 1; segment < indices.Count; segment++)
{
var start = (float)times[indices[segment - 1]];
var end = (float)times[indices[segment]];
var startValue = curve.Evaluate(start);
var endValue = curve.Evaluate(end);
var minimum = Mathf.Min(startValue, endValue) - 0.000001f;
var maximum = Mathf.Max(startValue, endValue) + 0.000001f;
for (var sample = 0; sample <= samplesPerSegment; sample++)
{
var time = Mathf.Lerp(
start,
end,
sample / (float)samplesPerSegment);
Assert.That(
curve.Evaluate(time),
Is.InRange(minimum, maximum),
$"Curve overshot selected segment {segment - 1}.");
}
}
}
private static Vector3[] EvaluatePositionCurves(
IReadOnlyList<double> times,
AICameraTimelinePreviewImporter.PositionCurveBuildResult curves)
{
var result = new Vector3[times.Count];
for (var index = 0; index < times.Count; index++)
{
var time = (float)times[index];
result[index] = new Vector3(
curves.XCurve.Evaluate(time),
curves.YCurve.Evaluate(time),
curves.ZCurve.Evaluate(time));
}
return result;
}
private static float MeasureMaximumJerk(
IReadOnlyList<double> times,
IReadOnlyList<Vector3> positions)
{
if (times.Count < 4)
{
return 0f;
}
var velocities = new Vector3[times.Count - 1];
var velocityTimes = new double[velocities.Length];
for (var index = 0; index < velocities.Length; index++)
{
velocities[index] =
(positions[index + 1] - positions[index]) /
(float)(times[index + 1] - times[index]);
velocityTimes[index] = (times[index + 1] + times[index]) * 0.5;
}
var accelerations = new Vector3[velocities.Length - 1];
var accelerationTimes = new double[accelerations.Length];
for (var index = 0; index < accelerations.Length; index++)
{
accelerations[index] =
(velocities[index + 1] - velocities[index]) /
(float)(velocityTimes[index + 1] - velocityTimes[index]);
accelerationTimes[index] =
(velocityTimes[index + 1] + velocityTimes[index]) * 0.5;
}
var maximumJerk = 0f;
for (var index = 0; index < accelerations.Length - 1; index++)
{
var jerk =
(accelerations[index + 1] - accelerations[index]) /
(float)(accelerationTimes[index + 1] -
accelerationTimes[index]);
maximumJerk = Mathf.Max(maximumJerk, jerk.magnitude);
}
return maximumJerk;
}
private static CameraSamples CreateSamples(int count)
{
var times = new double[count];

Binary file not shown.

View File

@ -1,7 +1,7 @@
{
"schemaVersion": "cw-camera-worker-build-identity-v1",
"workerVersion": "0.1.3",
"createdUtc": "2026-08-08T15:42:34.493408+00:00",
"workerVersion": "0.1.4",
"createdUtc": "2026-08-08T17:11:19.216870+00:00",
"python": "3.12.13",
"sourceRootRelative": "cwai_sources/repository",
"sourceSha256": {
@ -10,20 +10,21 @@
"MachineLearning/CameraDirector/camera_kinematics.py": "c7b85616ef44a0f28f11702459d67d78aad7cf26ed00c2548ecb78b06fc27025",
"MachineLearning/CameraDirector/camera_runtime_data.py": "29fd30a1c97d384f7bb22d024a4f88178f6245858925c302b059a7a1afdeaac1",
"MachineLearning/CameraDirector/cw_camera_cli.py": "943870a4cdec7e4b830b9c945d690e700902cf2a37d1e49987bba85bf0b0ea87",
"MachineLearning/CameraDirector/cw_camera_runtime.py": "337157e0ddaf2b3a331c7e47c73b50f01cb8c7cdfedf2cfa620adc9d79338b54",
"MachineLearning/CameraDirector/cw_camera_runtime.py": "94d9f03488978cf34cadbc9495542983c15c8e5050be6d7b9b0317845ca25242",
"MachineLearning/CameraDirector/data_driven_cut_planner.py": "b6d48d03f8725aa8327abe81afa483c43edc368e7dee9f9e7228e21982f71ad2",
"MachineLearning/CameraDirector/generate_hybrid.py": "68e36c9a8abc7771527a7f383ba5dc501ba437c993610103bb56f9b18e1abbc1",
"MachineLearning/CameraDirector/generate_hybrid.py": "fbb7bd07fb32f81efd259b4bb56e30404df59eb98fe05c9c92429a214f666659",
"MachineLearning/CameraDirector/hybrid_candidate_cache.py": "a0a5b6a8f612f18cb2875394f17ed89950e1e62e38c1226457d9e35848f6e380",
"MachineLearning/CameraDirector/hybrid_cut_reference.py": "53521d9e31d315c011892961887baf0cde6451020bf39c9d080a5072d4707fcb",
"MachineLearning/CameraDirector/hybrid_preparation_cache.py": "1b0df53124109dee2d744777979a0553999cb276cb6f538a17ddce6516e3da8b",
"MachineLearning/CameraDirector/hybrid_single_shot_cache.py": "f540e603b0ccffb86ee003d441c3889c245a208db8fd8b2b966fdacfeea30950",
"MachineLearning/CameraDirector/planner.py": "8575788c0ff1e984f8238354dcd6de2dab9f62afeb71fa4b77ebfc7c66400372",
"MachineLearning/CameraDirector/shot_features.py": "fd912b00320ea53ca682c010de6ed1102edcd021ceb4822a5785033a500786fe",
"MachineLearning/CameraDirector/train.py": "40e9e9a9ab824d596b14f987b2977f187695c3d5a2a2bde9da67e95d38d78836"
"MachineLearning/CameraDirector/train.py": "40e9e9a9ab824d596b14f987b2977f187695c3d5a2a2bde9da67e95d38d78836",
"MachineLearning/CameraDirector/trajectory_quality.py": "8ede816e1d155e9d35fd6d18b3aaa145cd4957e205d898e11c1691222ef420a2"
},
"preparationLogicIdentifier": "acba2a46d9d979b013b7154bada6b6a8a7adfd7acd1721876ce6699ed4e03e84",
"candidateLogicIdentifier": "9cca18ec839f70fffad3e38ab05fbb9209c51d17081ec02dad1b2af1f93df223",
"generationCodeIdentifier": "2d225f7a926869593d8572e369e504fa6440565f84329ff797f66f3912abf7b0",
"candidateLogicIdentifier": "a9503f8764910d763fe0135f6b1425b0cba289dbd1dd149609d2c00e508470c1",
"generationCodeIdentifier": "05e85df5ac7eb614f68e340502cb03a9c5a073f91eba5d16d642340728d51d67",
"generationCodeFiles": [
"adjacent_transition.py",
"camera_kinematics.py",
@ -32,6 +33,7 @@
"generate_hybrid.py",
"hybrid_cut_reference.py",
"planner.py",
"shot_features.py"
"shot_features.py",
"trajectory_quality.py"
]
}

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.3"
WORKER_VERSION = "0.1.4"
def is_frozen_runtime() -> bool:

View File

@ -1,6 +1,6 @@
{
"name": "com.mingle.cw-ai",
"version": "0.4.7",
"version": "0.4.8",
"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",