Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386 committed 2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 files changed
+63054

No files matched your search

+243
View File
@@ -0,0 +1,243 @@
/**
* 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)
}