244 lines
8.0 KiB
TypeScript
244 lines
8.0 KiB
TypeScript
/**
|
||
* Frame-grid math for multi-keyframe editing.
|
||
*
|
||
* Positions are integer frame indices in `0..lastFrame`. Two keyframes may
|
||
* never share an index — every placement routes through pickFreeFrameIndex or
|
||
* retimeKeyframes so collisions cannot silently drop images.
|
||
*/
|
||
|
||
type Positioned = { frameIndex: number }
|
||
|
||
/**
|
||
* Last legal keyframe index for a clip. Must match backend
|
||
* ``frame_math.compute_num_frames(duration, fps) - 1`` — duration×fps rounded
|
||
* to the pipeline's (n-1)%8==0 grid — or 25/50 fps stills can sit past the
|
||
* generated clip and 422.
|
||
*/
|
||
export function lastFrameFromDuration(durationSeconds: number, fps: number): number {
|
||
const frameCount = Math.max(9, Math.floor((durationSeconds * fps) / 8) * 8 + 1)
|
||
return frameCount - 1
|
||
}
|
||
|
||
export function clampFrameIndex(frameIndex: number, lastFrame: number): number {
|
||
return Math.min(Math.max(0, Math.round(frameIndex)), Math.max(0, lastFrame))
|
||
}
|
||
|
||
/**
|
||
* The keyframe on the playhead, else the last one before it — a gap shows the
|
||
* image still standing rather than going blank. Unordered input is sorted.
|
||
*/
|
||
export function keyframeAtOrBefore<T extends { frameIndex: number }>(
|
||
keyframes: readonly T[],
|
||
frameIndex: number,
|
||
): T | undefined {
|
||
const ordered = [...keyframes].sort((a, b) => a.frameIndex - b.frameIndex)
|
||
let match: T | undefined
|
||
for (const keyframe of ordered) {
|
||
if (keyframe.frameIndex > frameIndex) break
|
||
match = keyframe
|
||
}
|
||
return match ?? ordered[0]
|
||
}
|
||
|
||
export type DraggedFrame = {
|
||
id: string
|
||
frameIndex: number
|
||
}
|
||
|
||
export function sameDraggedFrame(
|
||
left: DraggedFrame | null | undefined,
|
||
right: DraggedFrame | null | undefined,
|
||
): boolean {
|
||
return left?.id === right?.id && left?.frameIndex === right?.frameIndex
|
||
}
|
||
|
||
export function withDraggedFrame<T extends { id: string; frameIndex: number }>(
|
||
keyframes: readonly T[],
|
||
drag: DraggedFrame | null,
|
||
): readonly T[] {
|
||
if (!drag) return keyframes
|
||
return keyframes.map((keyframe) => (
|
||
keyframe.id === drag.id && keyframe.frameIndex === drag.frameIndex
|
||
? keyframe
|
||
: keyframe.id === drag.id
|
||
? { ...keyframe, frameIndex: drag.frameIndex }
|
||
: keyframe
|
||
))
|
||
}
|
||
|
||
export function previewKeyframeForPlayhead<T extends { id: string; frameIndex: number }>(
|
||
keyframes: readonly T[],
|
||
playheadFrame: number,
|
||
drag: DraggedFrame | null = null,
|
||
): T | undefined {
|
||
return keyframeAtOrBefore(withDraggedFrame(keyframes, drag), playheadFrame)
|
||
}
|
||
|
||
/** `MM:SS.FF`, where `FF` is the frame within its second (`0..fps - 1`). */
|
||
export function formatKeyframeTimecode(frameIndex: number, fps: number): string {
|
||
const total = Math.max(0, Math.round(frameIndex))
|
||
const frames = total % fps
|
||
const totalSeconds = Math.floor(total / fps)
|
||
const minutes = Math.floor(totalSeconds / 60)
|
||
const seconds = totalSeconds % 60
|
||
const pad = (value: number) => String(value).padStart(2, '0')
|
||
return `${pad(minutes)}:${pad(seconds)}.${pad(frames)}`
|
||
}
|
||
|
||
/**
|
||
* Parses `MM:SS.FF` (frames optional) into a frame index. Returns null for
|
||
* anything unparseable or out of grid.
|
||
*/
|
||
export function parseKeyframeTimecode(value: string, fps: number): number | null {
|
||
const match = /^(\d{1,2}):(\d{1,2})(?:\.(\d{1,2}))?$/.exec(value.trim())
|
||
if (!match) return null
|
||
|
||
const minutes = Number(match[1])
|
||
const seconds = Number(match[2])
|
||
const frames = Number(match[3] ?? '0')
|
||
if (seconds >= 60 || frames >= fps) return null
|
||
|
||
return (minutes * 60 + seconds) * fps + frames
|
||
}
|
||
|
||
export function frameFromPointer(
|
||
clientX: number,
|
||
trackRect: { left: number; width: number },
|
||
lastFrame: number,
|
||
): number {
|
||
if (lastFrame <= 0 || trackRect.width <= 0) return 0
|
||
const ratio = (clientX - trackRect.left) / trackRect.width
|
||
return clampFrameIndex(Math.round(ratio * lastFrame), lastFrame)
|
||
}
|
||
|
||
function inRangeTakenFrameSet(keyframes: readonly Positioned[], lastFrame: number): Set<number> {
|
||
return new Set(
|
||
keyframes
|
||
.map(({ frameIndex }) => frameIndex)
|
||
.filter((frameIndex) => frameIndex >= 0 && frameIndex <= lastFrame),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Where to drop a new keyframe: the preferred frame when free, otherwise the
|
||
* middle of the widest free stretch so burst adds spread across the timeline.
|
||
*/
|
||
export function pickFreeFrameIndex(
|
||
keyframes: readonly Positioned[],
|
||
lastFrame: number,
|
||
preferredFrame: number,
|
||
): number | null {
|
||
const durationFrames = lastFrame + 1
|
||
if (durationFrames <= 0) return null
|
||
|
||
const takenSet = inRangeTakenFrameSet(keyframes, lastFrame)
|
||
if (takenSet.size >= durationFrames) return null
|
||
|
||
const clampedPreferred = clampFrameIndex(preferredFrame, lastFrame)
|
||
if (!takenSet.has(clampedPreferred)) return clampedPreferred
|
||
|
||
let widest: { start: number; length: number } | undefined
|
||
let runStart: number | undefined
|
||
for (let frame = 0; frame <= durationFrames; frame++) {
|
||
const isFree = frame < durationFrames && !takenSet.has(frame)
|
||
if (isFree) {
|
||
runStart ??= frame
|
||
continue
|
||
}
|
||
if (runStart === undefined) continue
|
||
const length = frame - runStart
|
||
if (!widest || length > widest.length) widest = { start: runStart, length }
|
||
runStart = undefined
|
||
}
|
||
|
||
if (!widest) return null
|
||
return widest.start + Math.floor((widest.length - 1) / 2)
|
||
}
|
||
|
||
/**
|
||
* Where a dragged or retyped keyframe lands: the target frame, or the closest
|
||
* free one either side. Never jumps to an unrelated gap on the timeline.
|
||
*/
|
||
export function findNearestFreeFrameIndex(
|
||
keyframes: readonly Positioned[],
|
||
targetFrame: number,
|
||
lastFrame: number,
|
||
): number | null {
|
||
const durationFrames = lastFrame + 1
|
||
if (durationFrames <= 0) return null
|
||
|
||
const takenSet = inRangeTakenFrameSet(keyframes, lastFrame)
|
||
const target = clampFrameIndex(targetFrame, lastFrame)
|
||
for (let offset = 0; offset < durationFrames; offset++) {
|
||
const before = target - offset
|
||
const after = target + offset
|
||
if (after <= lastFrame && !takenSet.has(after)) return after
|
||
if (before >= 0 && !takenSet.has(before)) return before
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Re-grids keyframes after a frame-rate or duration change. Scales each
|
||
* keyframe by oldLastFrame → newLastFrame so a marker at 50% of a 5s clip
|
||
* stays at 50% of a 10s clip (and the reverse). Collisions still spread to
|
||
* unique frames; extras past the slot count are dropped.
|
||
*/
|
||
export function retimeKeyframes<T extends { frameIndex: number }>(
|
||
keyframes: readonly T[],
|
||
oldLastFrame: number,
|
||
newLastFrame: number,
|
||
): T[] {
|
||
const scale = oldLastFrame <= 0 ? 0 : newLastFrame / oldLastFrame
|
||
|
||
const targets = keyframes
|
||
.map((keyframe, order) => ({
|
||
keyframe,
|
||
order,
|
||
target: clampFrameIndex(keyframe.frameIndex * scale, newLastFrame),
|
||
}))
|
||
.sort((a, b) => a.target - b.target || a.order - b.order)
|
||
|
||
const positions = targets.map(({ target }) => target)
|
||
for (let i = 1; i < positions.length; i++) {
|
||
positions[i] = Math.max(positions[i], positions[i - 1] + 1)
|
||
}
|
||
let ceiling = newLastFrame
|
||
for (let i = positions.length - 1; i >= 0; i--) {
|
||
positions[i] = Math.max(0, Math.min(positions[i], ceiling))
|
||
ceiling = positions[i] - 1
|
||
}
|
||
|
||
const used = new Set<number>()
|
||
const retimed: T[] = []
|
||
for (let i = 0; i < targets.length; i++) {
|
||
const frameIndex = positions[i]
|
||
if (used.has(frameIndex)) continue
|
||
used.add(frameIndex)
|
||
retimed.push({ ...targets[i].keyframe, frameIndex })
|
||
}
|
||
return retimed
|
||
}
|
||
|
||
type TimelineSettings = {
|
||
duration: number | null
|
||
fps: number
|
||
}
|
||
|
||
export function retimeKeyframesForSettings<T extends { frameIndex: number }>(
|
||
keyframes: readonly T[],
|
||
previous: TimelineSettings,
|
||
next: TimelineSettings,
|
||
): T[] {
|
||
const fallbackDuration = previous.duration ?? next.duration
|
||
const fpsRatio = next.fps / previous.fps
|
||
const oldLastFrame = fallbackDuration === null
|
||
? Math.max(0, ...keyframes.map(({ frameIndex }) => frameIndex))
|
||
: lastFrameFromDuration(previous.duration ?? fallbackDuration, previous.fps)
|
||
const newLastFrame = fallbackDuration === null
|
||
? Math.max(0, ...keyframes.map(({ frameIndex }) => Math.round(frameIndex * fpsRatio)))
|
||
: lastFrameFromDuration(next.duration ?? fallbackDuration, next.fps)
|
||
|
||
return retimeKeyframes(keyframes, oldLastFrame, newLastFrame)
|
||
}
|