From bd48533b03aba73d5eca76717d2c35e1785d4e1b Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:43:24 +0530 Subject: [PATCH] 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. --- CHANGELOG.md | 4 ++++ docs/CURRENT-STATE.md | 2 ++ src/director/timeline.js | 16 +++++++++++---- src/director/timeline.test.mjs | 36 ++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..da7866816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..29f82501d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -427,6 +427,8 @@ reference. Legacy projects retain their ordinary flights, content and edits. Seeking between ordinary camera poses uses the shortest longitude arc across the dateline while preserving both authored endpoint longitudes, and turns heading and roll the short way for any authored angles. +A seek to a shot's start, as a shot action makes, selects that shot rather than +the end of the previous one. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input diff --git a/src/director/timeline.js b/src/director/timeline.js index f7cffcece..5bcb42db6 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -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, diff --git a/src/director/timeline.test.mjs b/src/director/timeline.test.mjs index 1950eeef0..69a370c98 100644 --- a/src/director/timeline.test.mjs +++ b/src/director/timeline.test.mjs @@ -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), []); +});