diff --git a/changelog.d/1171-director-goto-shot.md b/changelog.d/1171-director-goto-shot.md new file mode 100644 index 000000000..4c08351ef --- /dev/null +++ b/changelog.d/1171-director-goto-shot.md @@ -0,0 +1,3 @@ +- 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 (Aditya, @Rohilalala, #1171). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d63467981..0932f9127 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -430,6 +430,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), []); +});