import { useLayoutEffect, useRef, useState } from 'react' import { RefreshCw, Trash2 } from 'lucide-react' import { applyTimecode, nudgeKeyframe } from '../lib/keyframe-controls' import { formatKeyframeStrength, nudgeKeyframeStrength, } from '../lib/keyframe-strength' import { findNearestFreeFrameIndex, formatKeyframeTimecode, frameFromPointer, sameDraggedFrame, withDraggedFrame, type DraggedFrame, } from '../lib/keyframe-timeline' import type { KeyframeItem } from '../lib/multi-keyframe' import { pathToFileUrl } from '../lib/file-url' import { KeyframeStrengthRail } from './KeyframeStrengthRail' interface KeyframeTimelineProps { keyframes: readonly KeyframeItem[] fps: number lastFrame: number playheadFrame: number onPlayheadChange: (frameIndex: number) => void onDragFrameChange?: (drag: DraggedFrame | null) => void onFrameChange: (id: string, frameIndex: number) => void onStrengthChange: (id: string, strength: number) => void onReplaceRequest: (id: string) => void onDelete: (id: string) => void onImagesDrop: (dataTransfer: DataTransfer, replaceId: string | null) => void } interface DragState { id: string frameIndex: number pointerId: number } export function KeyframeTimeline({ keyframes, fps, lastFrame, playheadFrame, onPlayheadChange, onDragFrameChange, onFrameChange, onStrengthChange, onReplaceRequest, onDelete, onImagesDrop, }: KeyframeTimelineProps) { const trackRef = useRef(null) const dragRef = useRef(null) const onDragFrameChangeRef = useRef(onDragFrameChange) onDragFrameChangeRef.current = onDragFrameChange const [drag, setDrag] = useState(null) const [timecodeDraft, setTimecodeDraft] = useState<{ id: string value: string } | null>(null) useLayoutEffect(() => { return () => onDragFrameChangeRef.current?.(null) }, []) const updateDrag = (next: DragState | null) => { const prev = dragRef.current dragRef.current = next const overlay = next ? { id: next.id, frameIndex: next.frameIndex } : null const prevOverlay = prev ? { id: prev.id, frameIndex: prev.frameIndex } : null if (sameDraggedFrame(prevOverlay, overlay)) return setDrag(next) onDragFrameChangeRef.current?.(overlay) } const displayed = withDraggedFrame( keyframes, drag ? { id: drag.id, frameIndex: drag.frameIndex } : null, ) const frameAtPointer = (clientX: number) => { const rect = trackRef.current?.getBoundingClientRect() return rect ? frameFromPointer(clientX, rect, lastFrame) : 0 } const handlePointerMove = (event: React.PointerEvent) => { const activeDrag = dragRef.current if (activeDrag && activeDrag.pointerId === event.pointerId) { const frameIndex = frameAtPointer(event.clientX) updateDrag({ ...activeDrag, frameIndex }) onPlayheadChange(frameIndex) return } if (event.currentTarget.hasPointerCapture(event.pointerId)) { onPlayheadChange(frameAtPointer(event.clientX)) } } const finishDrag = (event: React.PointerEvent) => { const activeDrag = dragRef.current if (!activeDrag || activeDrag.pointerId !== event.pointerId) return const otherKeyframes = keyframes.filter((keyframe) => keyframe.id !== activeDrag.id) const frameIndex = findNearestFreeFrameIndex(otherKeyframes, frameAtPointer(event.clientX), lastFrame) if (frameIndex !== null) onFrameChange(activeDrag.id, frameIndex) updateDrag(null) } const positionPercent = (frameIndex: number) => lastFrame > 0 ? (frameIndex / lastFrame) * 100 : 0 const commitTimecode = (id: string, value: string) => { const frameIndex = applyTimecode(keyframes, id, value, fps, lastFrame) if (frameIndex !== null) onFrameChange(id, frameIndex) setTimecodeDraft(null) } return (
updateDrag(null)} onPointerDown={(event) => { if (event.target !== event.currentTarget) return event.currentTarget.setPointerCapture(event.pointerId) onPlayheadChange(frameAtPointer(event.clientX)) }} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault() onImagesDrop(event.dataTransfer, null) }} >
{displayed.map((keyframe) => { const markerFrame = keyframe.frameIndex const source = keyframes.find((item) => item.id === keyframe.id) ?? keyframe const timecode = formatKeyframeTimecode(markerFrame, fps) return (
event.preventDefault()} onDrop={(event) => { event.preventDefault() event.stopPropagation() onImagesDrop(event.dataTransfer, keyframe.id) }} > onStrengthChange(keyframe.id, strength)} />
{ setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value }) }} onChange={(event) => { setTimecodeDraft({ id: keyframe.id, value: event.currentTarget.value }) }} onBlur={(event) => commitTimecode(keyframe.id, event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation() if (event.key === 'Enter') event.currentTarget.blur() if (event.key === 'Escape') { event.currentTarget.value = timecode event.currentTarget.blur() } }} />
) })}
0 {lastFrame}
) }