fix(director): land on the requested shot

A shot action seeks to the target shot's startProgress, which
sceneTimingForShot computes as start / total. sceneSeekState multiplied
that back into seconds and compared it with each shot's end, but
(start / total) * total can round one ulp below start. The comparison
then put the seek at the very end of the previous shot. With five equal
3.9 s shots, "go to" shots 2, 3 and 5 landed on shots 1, 2 and 4.

Compare the requested progress against each shot's end / total instead,
the same division the caller used, so a shot's own start always selects
that shot. Exact boundaries still select the next shot, and anything
below a start still selects the previous one.
This commit is contained in:
Aditya committed 2026-10-16 16:48:37 +05:30
1 parent d4492d8f8c
commit bd48533b03
4 files changed
+54 -4

No files matched your search

+12 -4
View File
@@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
// Compare in progress units: a shot's startProgress (start / total)
// times total can round below its start and select the previous shot.
if (
normalized < endElapsedSec / totalSec ||
index === scene.shots.length - 1
) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
// Keep the elapsed time inside the selected shot, so it agrees with
// shotIndex when targetSec rounded below the shot's start.
const elapsedSec = Math.max(targetSec, startElapsedSec);
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = holdForShot(scene, shot);
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
const shotElapsedSec = Math.max(
0,
Math.min(shotDurationSec, targetSec - startElapsedSec),
Math.min(shotDurationSec, elapsedSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
@@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
);
const move = resolveCameraMove(scene, shot);
return {
sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
sceneElapsedSec: targetSec,
sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0,
sceneElapsedSec: elapsedSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
+36
View File
@@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', ()
);
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
});
test('seeking to a shot start lands in that shot for every shot count and duration', () => {
const misses = [];
for (let count = 2; count <= 12; count += 1) {
for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) {
for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) {
const shots = Array.from({ length: count }, (_, index) => ({
id: `s${index}`,
// Every third shot is longer, so boundaries are not all equal steps.
durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0),
holdSec: holdTenths / 10,
camera: camera(20, 0),
}));
const mixed = { shots };
for (const [index, shot] of shots.entries()) {
const { startProgress, startElapsedSec } = sceneTimingForShot(
mixed,
shot,
duration,
);
const seek = sceneSeekState(mixed, startProgress, duration, hold);
// One or two ulps earlier still belongs to the previous shot.
const before = startProgress * (1 - Number.EPSILON);
const previous = sceneSeekState(mixed, before, duration, hold);
if (
seek.shotIndex !== index ||
seek.sceneElapsedSec < startElapsedSec ||
previous.shotIndex !== Math.max(0, index - 1)
)
misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`);
}
}
}
}
assert.deepEqual(misses.slice(0, 5), []);
});