fix(camera-ai): smooth generated camera curves
This commit is contained in:
parent
5a2e95485e
commit
b78c6319b6
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
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<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)
|
||||
{
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 패키지의 대용량 바이너리를 받으려면
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
BIN
CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe
(Stored with Git LFS)
BIN
CameraAI~/Tools~/CWCameraWorker/CWCameraWorker.exe
(Stored with Git LFS)
Binary file not shown.
Binary file not shown.
@ -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",
|
||||
|
||||
@ -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"
|
||||
],
|
||||
|
||||
@ -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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user