Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions specs/issue-timeline-playhead-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Timeline playhead ↔ thumbnail alignment + scroll behavior

GitHub issue: #57

## Problem

1. **Misalignment**: Media blocks in `buildTimelineLayout` are placed with cumulative packing (`leftPx += width + gap`), but the playhead uses `currentFrame * pixelsPerFrame` (time axis). Changing slide duration widens/narrows packed blocks without moving them on the time axis, so the red line no longer lines up with thumbnails.
2. **Unwanted scroll**: `TimelinePanel` auto-scrolls to the playhead whenever it is outside the viewport, even while paused. Scrolling away to click a distant thumbnail snaps back to the old playhead position before the seek completes.

## Fix

### `sequence-planner/timelineLayout.ts`

- Position **included** media blocks at `entry.startFrame * pixelsPerFrame` (same coordinate system as playhead and audio lane).
- **Excluded** slides (no plan entry) keep compact packed placement after the previous block in storyboard order so reorder UX is unchanged.
- Update unit tests for start-frame positioning and transition overlap cases.

### `editor-shell/App.tsx`

- When a slide select triggers a seek, synchronously update `currentFrame` / `currentSlideId` (don't wait for player RAF poll).
- Track `isPlaying` via Remotion player `play` / `pause` events.

### `editor-shell/TimelinePanel.tsx`

- Auto-scroll to playhead **only while playing**.
- When paused and the user selects a single slide, scroll that block into view (centre if off-screen).

## Testing

- Extend `timelineLayout.test.ts` for start-frame block positions.
- `pnpm test`, `pnpm lint`, `pnpm build`.
5 changes: 5 additions & 0 deletions src/editor-shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function App() {
const [currentFrame, setCurrentFrame] = useState(0)
const [currentSlideId, setCurrentSlideId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [sidebarOpenSections, setSidebarOpenSections] = useState(['settings', 'soundtrack'])
const clearSelectionRef = useRef<(() => void) | null>(null)

Expand Down Expand Up @@ -205,6 +206,8 @@ export function App() {
const startFrame = startFrameForSlideId(renderPlan, id)
if (startFrame !== null) {
playerRef.current?.seekTo(startFrame)
setCurrentFrame(startFrame)
setCurrentSlideId(id)
}
}
}, [renderPlan, selectSlide])
Expand Down Expand Up @@ -272,6 +275,7 @@ export function App() {
compositionHeight={canvas.height}
compositionWidth={canvas.width}
onFrameChange={handleFrameChange}
onPlayingChange={setIsPlaying}
playerRef={playerRef}
renderPlan={renderPlan}
totalFrames={totalFrames}
Expand All @@ -283,6 +287,7 @@ export function App() {
audioTracks={audioTracks}
currentFrame={currentFrame}
currentSlideId={currentSlideId}
isPlaying={isPlaying}
loudnessCache={loudnessCache}
onClearSelection={clearSelection}
onMoveToBeginning={handleMoveToBeginning}
Expand Down
33 changes: 32 additions & 1 deletion src/editor-shell/PlayerPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,50 @@ type Props = {
compositionHeight: number
compositionWidth: number
onFrameChange: (frame: number) => void
onPlayingChange?: (isPlaying: boolean) => void
playerRef?: React.RefObject<PlayerRef | null>
renderPlan: RenderPlan
totalFrames: number
}

export function PlayerPane({ compositionHeight, compositionWidth, onFrameChange, playerRef, renderPlan, totalFrames }: Props) {
export function PlayerPane({
compositionHeight,
compositionWidth,
onFrameChange,
onPlayingChange,
playerRef,
renderPlan,
totalFrames,
}: Props) {
const embeddedHostRef = useRef<HTMLDivElement>(null)
const presentationHostRef = useRef<HTMLDivElement>(null)
const fallbackPlayerRef = useRef<PlayerRef>(null)
const resolvedPlayerRef = playerRef ?? fallbackPlayerRef
const [isPresenting, setIsPresenting] = useState(false)
const [presentationFrame, setPresentationFrame] = useState(0)

useEffect(() => {
const player = resolvedPlayerRef.current
if (!player || !onPlayingChange) return

function handlePlay() {
onPlayingChange?.(true)
}

function handlePause() {
onPlayingChange?.(false)
}

player.addEventListener('play', handlePlay)
player.addEventListener('pause', handlePause)
onPlayingChange?.(player.isPlaying())

return () => {
player.removeEventListener('play', handlePlay)
player.removeEventListener('pause', handlePause)
}
}, [onPlayingChange, isPresenting, renderPlan, resolvedPlayerRef, totalFrames])

useEffect(() => {
let animationFrameId = 0
let lastReportedFrame = -1
Expand Down
2 changes: 1 addition & 1 deletion src/editor-shell/TimelineMediaBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function TimelineMediaBlock({
<li
className={cn(
'absolute top-0 flex h-full shrink-0 cursor-grab flex-col gap-1 rounded-md border bg-background p-1 transition-colors hover:border-muted-foreground/60 active:cursor-grabbing',
slide.excluded && 'opacity-60',
slide.excluded && 'z-10 opacity-60',
currentSlideId === slide.id && 'border-transparent ring-2 ring-emerald-500',
isSelected && 'border-transparent ring-2 ring-primary',
)}
Expand Down
45 changes: 39 additions & 6 deletions src/editor-shell/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { LoudnessCache } from '../audio-analysis/types'
import {
buildTimelineLayout,
type RenderPlan,
type TimelineMediaBlock as TimelineMediaBlockLayout,
} from '../sequence-planner'
import type { AudioTrack } from '../project-store'
import { TimelineAudioClip } from './TimelineAudioClip'
Expand All @@ -14,11 +15,18 @@ import type { TimelineDragState } from './timelineDrag'
import { useTimelineZoom } from './useTimelineZoom'
import { useWaveformPeaks } from './useWaveformPeaks'

const TIMELINE_SCROLL_MARGIN_PX = 48

function blockCenterPx(block: TimelineMediaBlockLayout): number {
return block.leftPx + block.widthPx / 2
}

type Props = {
audioClips: AudioClip[]
audioTracks: AudioTrack[]
currentFrame: number
currentSlideId: string | null
isPlaying: boolean
loudnessCache: LoudnessCache | undefined
onClearSelection: () => void
onMoveToBeginning: (indices: number[]) => void
Expand All @@ -38,6 +46,7 @@ export function TimelinePanel({
audioTracks,
currentFrame,
currentSlideId,
isPlaying,
loudnessCache,
onClearSelection,
onMoveToBeginning,
Expand All @@ -53,6 +62,7 @@ export function TimelinePanel({
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const mediaDragRef = useRef<TimelineDragState | null>(null)
const lastScrolledSelectionRef = useRef<string | null>(null)
const { waveformCache } = useWaveformPeaks({ audioClips, audioTracks })
const {
pixelsPerFrame,
Expand Down Expand Up @@ -90,19 +100,42 @@ export function TimelinePanel({
onSeek(clampedFrame)
}, [onClearSelection, onSeek, pixelsPerFrame, renderPlan.totalFrames])

useEffect(() => {
const scrollToCenterPx = useCallback((targetPx: number) => {
const scrollElement = scrollRef.current
if (!scrollElement) return

const playheadX = playheadLeftPx
const margin = TIMELINE_SCROLL_MARGIN_PX
const viewStart = scrollElement.scrollLeft
const viewEnd = viewStart + scrollElement.clientWidth
const margin = 48

if (playheadX < viewStart + margin || playheadX > viewEnd - margin) {
scrollElement.scrollLeft = Math.max(0, playheadX - scrollElement.clientWidth / 2)
if (targetPx < viewStart + margin || targetPx > viewEnd - margin) {
scrollElement.scrollLeft = Math.max(0, targetPx - scrollElement.clientWidth / 2)
}
}, [])

useEffect(() => {
if (!isPlaying) return
scrollToCenterPx(playheadLeftPx)
}, [isPlaying, playheadLeftPx, scrollToCenterPx])

const selectedSlideId = selectedSlideIds.size === 1 ? [...selectedSlideIds][0] : null

useEffect(() => {
if (!selectedSlideId) {
lastScrolledSelectionRef.current = null
}
}, [playheadLeftPx])
}, [selectedSlideId])

useEffect(() => {
if (isPlaying || !selectedSlideId) return
if (lastScrolledSelectionRef.current === selectedSlideId) return

const block = layout.mediaBlocks.find((entry) => entry.slideId === selectedSlideId)
if (!block) return

scrollToCenterPx(blockCenterPx(block))
lastScrolledSelectionRef.current = selectedSlideId
}, [isPlaying, layout.mediaBlocks, scrollToCenterPx, selectedSlideId])

return (
<div className="flex h-full min-h-0 flex-col bg-card">
Expand Down
45 changes: 45 additions & 0 deletions src/sequence-planner/timelineLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,56 @@ describe('buildTimelineLayout', () => {
[],
)

expect(layout.mediaBlocks[0].leftPx).toBe(0)
expect(layout.mediaBlocks[0].widthPx).toBe(60 * DEFAULT_PIXELS_PER_FRAME)
expect(layout.mediaBlocks[1].leftPx).toBe(45 * DEFAULT_PIXELS_PER_FRAME)
expect(layout.mediaBlocks[1].widthPx).toBe(120 * DEFAULT_PIXELS_PER_FRAME)
expect(layout.totalWidthPx).toBeGreaterThanOrEqual(165 * DEFAULT_PIXELS_PER_FRAME)
})

it('aligns included slides to render plan start frames', () => {
const renderPlan: RenderPlan = {
entries: [
imageEntry('a', 0, 90),
imageEntry('b', 75, 90),
],
totalFrames: 165,
}

const layout = buildTimelineLayout(
[
renderPlan.entries[0].slide,
renderPlan.entries[1].slide,
],
renderPlan,
[],
)

expect(layout.mediaBlocks[0].leftPx).toBe(0)
expect(layout.mediaBlocks[1].leftPx).toBe(75 * DEFAULT_PIXELS_PER_FRAME)
})

it('packs excluded slides after the previous block in storyboard order', () => {
const included = imageEntry('a', 0, 60)
const excludedSlide = {
...imageEntry('b', 0, 30).slide,
excluded: true,
}
const renderPlan: RenderPlan = {
entries: [included],
totalFrames: 60,
}

const layout = buildTimelineLayout(
[included.slide, excludedSlide],
renderPlan,
[],
)

expect(layout.mediaBlocks[0].leftPx).toBe(0)
expect(layout.mediaBlocks[1].leftPx).toBe(60 * DEFAULT_PIXELS_PER_FRAME + 4)
})

it('enforces a minimum block width for very short slides', () => {
const renderPlan: RenderPlan = {
entries: [imageEntry('a', 0, 5)],
Expand Down
35 changes: 21 additions & 14 deletions src/sequence-planner/timelineLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,32 +54,39 @@ function blockWidthPx(
return Math.max(minBlockWidthPx, durationInFrames * pixelsPerFrame)
}

function durationInFramesForSlide(slide: Slide, renderPlan: RenderPlan): number {
const entry = firstPassEntries(renderPlan).find((planEntry) => planEntry.slide.id === slide.id)
if (entry) return entry.durationInFrames
if (isTitleSlide(slide)) return slide.durationInFrames
return slide.durationInFrames
}

function buildMediaBlocks(
slides: Slide[],
renderPlan: RenderPlan,
pixelsPerFrame: number,
minBlockWidthPx: number,
): TimelineMediaBlock[] {
let leftPx = 0
const entryBySlideId = new Map(
firstPassEntries(renderPlan).map((entry) => [entry.slide.id, entry]),
)
let packedLeftPx = 0
const blocks: TimelineMediaBlock[] = []

for (const slide of slides) {
const durationInFrames = durationInFramesForSlide(slide, renderPlan)
const entry = slide.excluded ? undefined : entryBySlideId.get(slide.id)
const durationInFrames = entry?.durationInFrames
?? (isTitleSlide(slide) ? slide.durationInFrames : slide.durationInFrames)
const widthPx = blockWidthPx(durationInFrames, pixelsPerFrame, minBlockWidthPx)
const leftPx = entry
? entry.startFrame * pixelsPerFrame
: packedLeftPx

blocks.push({
durationInFrames,
leftPx,
slideId: slide.id,
widthPx,
})
leftPx += widthPx + TIMELINE_BLOCK_GAP_PX

if (entry) {
packedLeftPx = Math.max(packedLeftPx, leftPx + widthPx + TIMELINE_BLOCK_GAP_PX)
} else {
packedLeftPx += widthPx + TIMELINE_BLOCK_GAP_PX
}
}

return blocks
Expand All @@ -93,10 +100,10 @@ export function buildTimelineLayout(
minBlockWidthPx = DEFAULT_MIN_BLOCK_WIDTH_PX,
): TimelineLayout {
const mediaBlocks = buildMediaBlocks(slides, renderPlan, pixelsPerFrame, minBlockWidthPx)
const mediaContentWidthPx = mediaBlocks.length > 0
? mediaBlocks[mediaBlocks.length - 1].leftPx
+ mediaBlocks[mediaBlocks.length - 1].widthPx
: 0
const mediaContentWidthPx = mediaBlocks.reduce(
(maxEnd, block) => Math.max(maxEnd, block.leftPx + block.widthPx),
0,
)
const audioEndPx = (renderPlan.audioSegments ?? []).reduce(
(maxEnd, segment) => Math.max(
maxEnd,
Expand Down
Loading