fix(export): remap camera moves onto export time - #50
Open
Joilence wants to merge 1 commit into
Open
Conversation
Camera move times are recorded at wall clock, then handed to zoompan as `between(in_time, ...)` windows. The speed ramp and freezes both rewrite the timeline upstream of that filter, so in_time is the export timestamp while the window still describes the recording. Placements already made that trip through applySpeedRampToTimeline and adjustPlacementsForFreezes. Camera moves made neither, so every zoom fired late by the total gap time removed ahead of it, stepping further out at each compressed gap and leaving the last move in a video carrying the whole accumulated error. Both ends are remapped, not just the start. A move spanning a compressed gap occupies less time on the output timeline than it did on the recording, so shifting without shrinking would leave the zoom running past the content it belongs to. The scale factor is exactly 1 for a move inside one scene, which is the common case. Wired through all four export paths, pipeline, CLI, preview and viewport variants, per the requirement in CLAUDE.md that every export feature reach all of them or the outputs diverge silently. They share one exportTimelineRemap rather than four copies that can drift apart. remapTimeMs now continues past its last segment at that segment's speed instead of saturating there. Clamping is right for a placement, which cannot outlive the timeline, and wrong for measuring a rate: a closing zoom whose zoom-out tail overhangs the recording end had the whole overhang charged against the few ms of timeline left, rendering a 400ms ease in two frames. No placement reaches that branch, so the ramped timing of everything else is unchanged. Also guards a fade rounding to zero. buildCameraMoveFilter divides by the fade duration to build its progress ramp, so a 0 yields `(in_time-S)/0.0000`, which ffmpeg accepts without complaint and renders as a move that silently does nothing. remapCameraMoves and exportTimelineRemap are exported from the package root so a consumer assembling its own export can make the same trip. Known approximation: the span ratio is uniform across a move's zoom-in, hold and zoom-out, so a move whose hold alone spans a compressed gap has its fades compressed too, and a chained move is measured over a zoom-out the filter never renders. Both are bounded and much closer than no remap.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Camera move times are recorded at wall clock, then handed to
zoompanasbetween(in_time, ...)windows. The speed ramp rewrites the timeline upstream of that filter: the ramp'strim/setpts/concatruns first, and the camera filter reads its output. Soin_timeis the export timestamp while the window still describes the recording.Placements already make that trip, through
applySpeedRampToTimelineandadjustPlacementsForFreezes. Camera moves made neither:pipeline.ts:420shifts them for head trim and nothing else. Every zoom fires late by the gap time removed ahead of it, so the last move in a video carries the whole accumulated error. Nothing errors; the filter renders, it just points at the wrong moment.D-camera-move-side-by-side-orig.mp4
What
remapCameraMovesmaps a move onto the export clock;exportTimelineRemapcomposes the two rewrites it has to survive, the ramp and any freezes. Wired through all four export paths per theCLAUDE.mdrequirement that every export feature reach all of them, sharing one mapping rather than four copies that can drift.remapTimeMsnow continues past its last segment at that segment's speed instead of saturating. Clamping suits a placement, which cannot outlive the timeline; it is wrong for measuring a rate, because a move's zoom-out tail can overhang the recording end and the saturated endpoint charged that overhang against the few ms left. A closing zoom in an uncompressed final scene rendered its 400ms ease in 67ms. No placement reaches the new branch.buildCameraMoveFilterdivides by it, and(in_time-S)/0.0000is a move ffmpeg renders as doing nothing.Known approximation: the span ratio is uniform across zoom-in, hold and zoom-out, so a move whose hold alone spans a compressed gap has its fades compressed too. Remapping also shrinks the gaps
detectChainedPairsmeasures, so moves that bounce onmaincan now chain into a pan. Both are bounded and much closer than no remap.Test