Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ImagePlus, Plus } from 'lucide-react'
|
||||
import { imagePathsFromDataTransfer, imagePathsFromFiles } from '../lib/keyframe-drop'
|
||||
import { lastFrameFromDuration, type DraggedFrame } from '../lib/keyframe-timeline'
|
||||
import { applyKeyframeImagePaths, type KeyframeItem } from '../lib/multi-keyframe'
|
||||
import { KeyframeTimeline } from './KeyframeTimeline'
|
||||
|
||||
interface MultiKeyframePanelProps {
|
||||
keyframes: readonly KeyframeItem[]
|
||||
duration: number | null
|
||||
fps: number
|
||||
maxCount: number
|
||||
playheadFrame: number
|
||||
onPlayheadChange: (frameIndex: number) => void
|
||||
onChange: (keyframes: KeyframeItem[]) => void
|
||||
onDragFrameChange?: (drag: DraggedFrame | null) => void
|
||||
}
|
||||
|
||||
export function MultiKeyframePanel({
|
||||
keyframes,
|
||||
duration,
|
||||
fps,
|
||||
maxCount,
|
||||
playheadFrame,
|
||||
onPlayheadChange,
|
||||
onChange,
|
||||
onDragFrameChange,
|
||||
}: MultiKeyframePanelProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const replaceIdRef = useRef<string | null>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const lastFrame = duration == null ? null : lastFrameFromDuration(duration, fps)
|
||||
const addEnabled = lastFrame !== null && keyframes.length < maxCount
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastFrame === null) onDragFrameChange?.(null)
|
||||
}, [lastFrame, onDragFrameChange])
|
||||
|
||||
const applyPaths = (paths: string[], replaceId: string | null = null) => {
|
||||
onChange(applyKeyframeImagePaths({
|
||||
keyframes,
|
||||
paths,
|
||||
replaceId,
|
||||
lastFrame,
|
||||
preferredFrame: playheadFrame,
|
||||
maxCount,
|
||||
}))
|
||||
}
|
||||
|
||||
const openFilePicker = (replaceId: string | null) => {
|
||||
replaceIdRef.current = replaceId
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-zinc-800/60 px-2 pb-2 pt-2">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-zinc-300">
|
||||
<ImagePlus className="h-3.5 w-3.5 text-zinc-500" />
|
||||
<span>Keyframes</span>
|
||||
<span className="text-zinc-600">{keyframes.length}/{maxCount}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!addEnabled}
|
||||
title={keyframes.length >= maxCount
|
||||
? `You can place up to ${maxCount} keyframes`
|
||||
: lastFrame === null
|
||||
? 'Choose a duration to place keyframes'
|
||||
: 'Add keyframes at the playhead'}
|
||||
className="flex h-6 items-center gap-1 rounded-md border border-dashed border-zinc-700 px-2 text-[10px] text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => openFilePicker(null)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault()
|
||||
if (addEnabled) setIsDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
setIsDragOver(false)
|
||||
applyPaths(imagePathsFromDataTransfer(event.dataTransfer))
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className={isDragOver ? 'text-blue-300' : undefined}>Add keyframe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastFrame === null ? (
|
||||
<div className="flex h-12 items-center justify-center rounded-lg border border-dashed border-zinc-700 text-[10px] text-zinc-500">
|
||||
Choose a duration to place keyframes
|
||||
</div>
|
||||
) : (
|
||||
<KeyframeTimeline
|
||||
keyframes={keyframes}
|
||||
fps={fps}
|
||||
lastFrame={lastFrame}
|
||||
playheadFrame={Math.min(playheadFrame, lastFrame)}
|
||||
onPlayheadChange={onPlayheadChange}
|
||||
onDragFrameChange={onDragFrameChange}
|
||||
onFrameChange={(id, frameIndex) => {
|
||||
onChange(keyframes.map((keyframe) => (
|
||||
keyframe.id === id ? { ...keyframe, frameIndex } : keyframe
|
||||
)))
|
||||
onPlayheadChange(frameIndex)
|
||||
}}
|
||||
onStrengthChange={(id, strength) => {
|
||||
onChange(keyframes.map((keyframe) => (
|
||||
keyframe.id === id ? { ...keyframe, strength } : keyframe
|
||||
)))
|
||||
}}
|
||||
onReplaceRequest={(id) => openFilePicker(id)}
|
||||
onDelete={(id) => onChange(keyframes.filter((keyframe) => keyframe.id !== id))}
|
||||
onImagesDrop={(dataTransfer, replaceId) => {
|
||||
applyPaths(imagePathsFromDataTransfer(dataTransfer), replaceId)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
applyPaths(imagePathsFromFiles(event.target.files ?? []), replaceIdRef.current)
|
||||
replaceIdRef.current = null
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user