{
e.preventDefault()
e.stopPropagation()
const startY = e.clientY
const startVH = videoTrackHeight
const startAH = audioTrackHeight
const onMove = (ev: MouseEvent) => {
const delta = ev.clientY - startY
const newVH = Math.max(32, Math.min(200, startVH + delta))
const newAH = Math.max(32, Math.min(200, startAH - delta))
setVideoTrackHeight(newVH)
setAudioTrackHeight(newAH)
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}}
/>
)}
{
e.stopPropagation()
if (track.type === 'subtitle') {
e.preventDefault()
return
}
handleTrackDrop(e, realIndex)
}}
onDragOver={(e) => e.preventDefault()}
onDoubleClick={() => {
if (track.type === 'subtitle' && !track.locked) {
addSubtitleClip(realIndex)
}
}}
/>
))}
{/* Lasso selection rectangle */}
{lassoRect && lassoOriginRef.current && (() => {
const origin = lassoOriginRef.current!
const container = trackContainerRef.current
if (!container) return null
const scrollLeft = container.scrollLeft
const scrollTop = container.scrollTop
const x1 = Math.min(lassoRect.startX, lassoRect.currentX) - origin.containerLeft + scrollLeft
const x2 = Math.max(lassoRect.startX, lassoRect.currentX) - origin.containerLeft + scrollLeft
const y1 = Math.min(lassoRect.startY, lassoRect.currentY) - origin.containerTop + scrollTop
const y2 = Math.max(lassoRect.startY, lassoRect.currentY) - origin.containerTop + scrollTop
return (
)
})()}
{clips.map(clip => {
const liveAsset = clip.assetId ? assets.find(a => a.id === clip.assetId) : null
const clipColor = getColorLabel(clip.colorLabel || liveAsset?.colorLabel || clip.asset?.colorLabel)
return (
handleClipMouseDown(e, clip)}
onDoubleClick={() => {
setSelectedClipIds(expandWithLinkedClips(new Set([clip.id])))
setShowPropertiesPanel(true)
}}
onMouseMove={(e) => {
if (activeTool === 'blade') {
const rect = e.currentTarget.getBoundingClientRect()
const ox = e.clientX - rect.left
const hoverTime = clip.startTime + (ox / rect.width) * clip.duration
setBladeHoverInfo({ clipId: clip.id, offsetX: ox, time: hoverTime })
}
}}
onMouseLeave={() => {
if (activeTool === 'blade' && bladeHoverInfo?.clipId === clip.id) {
setBladeHoverInfo(null)
}
}}
onContextMenu={(e) => handleClipContextMenu(e, clip)}
/* EFFECTS HIDDEN - drag-drop for effects hidden because effects are not applied during export */
>
{/* Blade cut indicator line */}
{activeTool === 'blade' && bladeHoverInfo && (() => {
// Show indicator on the hovered clip, or on all clips at that time when Shift is held
const isHoveredClip = bladeHoverInfo.clipId === clip.id
const isShiftTarget = bladeShiftHeld && !isHoveredClip &&
bladeHoverInfo.time > clip.startTime + 0.05 &&
bladeHoverInfo.time < clip.startTime + clip.duration - 0.05
if (!isHoveredClip && !isShiftTarget) return null
const indicatorPx = isHoveredClip
? bladeHoverInfo.offsetX
: (bladeHoverInfo.time - clip.startTime) * pixelsPerSecond
return (
)
})()}
{clip.type === 'adjustment' ? (
) : clip.type === 'text' ? (
) : clip.type === 'audio' ? (
<>
>
) : clip.asset && (() => {
const liveAsset = getLiveAsset(clip)
if (!liveAsset) return null
let thumbPath: string | undefined = liveAsset.smallThumbnailPath
const takeIdx = clip.takeIndex ?? liveAsset.activeTakeIndex
if (liveAsset.takes && liveAsset.takes.length > 0 && takeIdx !== undefined) {
const idx = Math.max(0, Math.min(takeIdx, liveAsset.takes.length - 1))
thumbPath = liveAsset.takes[idx].smallThumbnailPath
}
return thumbPath ? (
})
) : (
)
})()}
{clip.type === 'adjustment' ? 'Adjustment Layer' : clip.type === 'text' ? (clip.textStyle?.text?.slice(0, 30) || 'Text') : clip.asset?.prompt?.slice(0, 30) || clip.importedName || 'Clip'}
{clip.duration.toFixed(1)}s
{(() => {
const resInfo = getClipResolution(clip)
if (!resInfo) return null
return {resInfo.displayName}
})()}
{clip.speed !== 1 && {clip.speed}x}
{clip.reversed && REV}
{clip.muted && M}
{(clip.flipH || clip.flipV) && FLIP}
{clip.colorCorrection && Object.values(clip.colorCorrection).some(v => v !== 0) && CC}
{clip.letterbox?.enabled && LB}
{clip.linkedClipIds?.length && }
{/* Take navigation + Regenerate (only for clips with gen params) */}
{(() => {
const liveAsset = getLiveAsset(clip)
if (!liveAsset || clip.duration * pixelsPerSecond <= 60 || clip.type === 'adjustment' || clip.type === 'audio') return null
return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
{/* Take navigation: prev/next */}
{liveAsset.takes && liveAsset.takes.length > 1 && (
<>
{(clip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes.length - 1)) + 1}/{liveAsset.takes.length}
>
)}
{canUseRetake && clip.type === 'video' && (
)}
)
})()}
{/* Regenerating overlay on the clip */}
{clip.isRegenerating && (
{regenProgress > 0 ? `${regenProgress}%` : 'Regenerating...'}
{canCancelInFlight && (
)}
)}
{/* Transition in indicator */}
{clip.transitionIn?.type !== 'none' && clip.transitionIn?.duration > 0 && (
)}
{/* Transition out indicator */}
{clip.transitionOut?.type !== 'none' && clip.transitionOut?.duration > 0 && (
)}
{/* Resolution color-code bar at the bottom of the clip */}
{(() => {
const resInfo = getClipResolution(clip)
if (!resInfo) return null
return (
)
})()}
handleResizeStart(e, clip, 'left')}
>
handleResizeStart(e, clip, 'right')}
>
)})}
{/* Gap indicators between clips */}
{timelineGaps.map((gap, i) => {
const leftPx = gap.startTime * pixelsPerSecond
const widthPx = (gap.endTime - gap.startTime) * pixelsPerSecond
const topPx = trackTopPx(gap.trackIndex, 4)
const isSelected = selectedGap &&
selectedGap.trackIndex === gap.trackIndex &&
Math.abs(selectedGap.startTime - gap.startTime) < 0.01 &&
Math.abs(selectedGap.endTime - gap.endTime) < 0.01
const isGeneratingHere = generatingGap &&
generatingGap.trackIndex === gap.trackIndex &&
Math.abs(generatingGap.startTime - gap.startTime) < 0.01 &&
Math.abs(generatingGap.endTime - gap.endTime) < 0.01
if (widthPx < 4) return null
return (
{
if (e.button !== 0 || activeTool !== 'select' || isGeneratingHere) return
startSelectionLasso(e.clientX, e.clientY, e.shiftKey)
suppressGapClickRef.current = false
const startX = e.clientX
const startY = e.clientY
const onMove = (event: MouseEvent) => {
if (
Math.abs(event.clientX - startX) >= 4 ||
Math.abs(event.clientY - startY) >= 4
) {
suppressGapClickRef.current = true
}
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}}
onClick={(e) => {
e.stopPropagation()
if (suppressGapClickRef.current) {
suppressGapClickRef.current = false
return
}
if (isGeneratingHere) return
selectGap(gap, e.currentTarget as HTMLElement)
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
if (isGeneratingHere) return
selectGap(gap, e.currentTarget as HTMLElement)
}}
>
{isGeneratingHere ? (
{widthPx > 50 && (
{gapGenerationApi.progress > 0 ? `${gapGenerationApi.progress}%` : 'Generating...'}
)}
{widthPx > 30 && (
)}
{gapGenerationApi.canCancel && (
)}
) : (
{(gap.endTime - gap.startTime).toFixed(1)}s
)}
)
})}
{/* Subtitle clips on subtitle tracks */}
{subtitles.map(sub => {
const track = tracks[sub.trackIndex]
if (!track || track.type !== 'subtitle') return null
const leftPx = sub.startTime * pixelsPerSecond
const widthPx = Math.max(20, (sub.endTime - sub.startTime) * pixelsPerSecond)
const topPx = trackTopPx(sub.trackIndex, 4)
const isSelected = selectedSubtitleId === sub.id
const isEditing = editingSubtitleId === sub.id
return (
{
e.stopPropagation()
setSelectedSubtitleId(sub.id)
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingSubtitleId(sub.id)
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setSelectedSubtitleId(sub.id)
}}
onMouseDown={(e) => {
if (track.locked || e.button !== 0) return
e.stopPropagation()
const startX = e.clientX
const origStart = sub.startTime
const origEnd = sub.endTime
const dur = origEnd - origStart
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX
const dt = dx / pixelsPerSecond
const newStart = Math.max(0, origStart + dt)
updateSubtitle(sub.id, { startTime: newStart, endTime: newStart + dur })
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}}
>
{/* Subtitle text */}
{isEditing ? (
{
updateSubtitle(sub.id, { text: e.target.value })
setEditingSubtitleId(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateSubtitle(sub.id, { text: (e.target as HTMLInputElement).value })
setEditingSubtitleId(null)
}
if (e.key === 'Escape') setEditingSubtitleId(null)
e.stopPropagation()
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
{sub.text}
)}
{/* Left resize handle */}
{
e.stopPropagation()
const startX = e.clientX
const origStart = sub.startTime
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX
const dt = dx / pixelsPerSecond
const newStart = Math.max(0, Math.min(sub.endTime - 0.2, origStart + dt))
updateSubtitle(sub.id, { startTime: newStart })
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}}
/>
{/* Right resize handle */}
{
e.stopPropagation()
const startX = e.clientX
const origEnd = sub.endTime
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX
const dt = dx / pixelsPerSecond
const newEnd = Math.max(sub.startTime + 0.2, origEnd + dt)
updateSubtitle(sub.id, { endTime: newEnd })
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}}
/>
)
})}
{/* Cut point indicators for cross-dissolve */}
{cutPoints.map((cp) => {
const leftPx = cp.time * pixelsPerSecond
const topPx = trackTopPx(cp.trackIndex, 4)
const isHovered = hoveredCutPoint?.leftClipId === cp.leftClip.id && hoveredCutPoint?.rightClipId === cp.rightClip.id
const dissolveDur = cp.hasDissolve ? (cp.leftClip.transitionOut?.duration || DEFAULT_DISSOLVE_DURATION) : 0
const dissolveWidthPx = dissolveDur * pixelsPerSecond
return (
setHoveredCutPoint({
leftClipId: cp.leftClip.id,
rightClipId: cp.rightClip.id,
time: cp.time,
trackIndex: cp.trackIndex,
})}
onMouseLeave={() => setHoveredCutPoint(null)}
>
{/* Visible indicator line */}
{cp.hasDissolve ? (
<>
{/* Dissolve region visual (gradient bar on the clip area) */}
{/* Dissolve duration label */}
{dissolveDur.toFixed(1)}s
{/* Left drag handle */}
{
e.stopPropagation()
e.preventDefault()
const startX = e.clientX
const startDur = dissolveDur
const handleMove = (ev: MouseEvent) => {
const delta = (startX - ev.clientX) / pixelsPerSecond
const newDur = Math.max(0.1, Math.min(cp.leftClip.duration * 0.9, startDur + delta))
setClips(prev => prev.map(c => {
if (c.id === cp.leftClip.id) return { ...c, transitionOut: { ...c.transitionOut, duration: +newDur.toFixed(2) } }
if (c.id === cp.rightClip.id) return { ...c, transitionIn: { ...c.transitionIn, duration: +newDur.toFixed(2) } }
return c
}))
}
const handleUp = () => {
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleUp)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}}
>
{/* Right drag handle */}
{
e.stopPropagation()
e.preventDefault()
const startX = e.clientX
const startDur = dissolveDur
const handleMove = (ev: MouseEvent) => {
const delta = (ev.clientX - startX) / pixelsPerSecond
const newDur = Math.max(0.1, Math.min(cp.rightClip.duration * 0.9, startDur + delta))
setClips(prev => prev.map(c => {
if (c.id === cp.leftClip.id) return { ...c, transitionOut: { ...c.transitionOut, duration: +newDur.toFixed(2) } }
if (c.id === cp.rightClip.id) return { ...c, transitionIn: { ...c.transitionIn, duration: +newDur.toFixed(2) } }
return c
}))
}
const handleUp = () => {
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleUp)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}}
>
{/* Remove button (shown on hover, positioned inside the zone) */}
{isHovered && (
e.stopPropagation()}
>
)}
>
) : (
<>
{/* No dissolve: show add button on hover (positioned inside the zone) */}
{isHovered && (
e.stopPropagation()}
>
)}
>
)}
)
})}
{/* close relative inner */}
{/* close trackContainerRef */}