3333 lines
158 KiB
TypeScript
3333 lines
158 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { shallow } from 'zustand/vanilla/shallow'
|
|
import {
|
|
Plus, Trash2,
|
|
ZoomIn, ZoomOut, Maximize2,
|
|
Volume2, VolumeX, Copy,
|
|
Layers,
|
|
Gauge, Upload,
|
|
Magnet, Lock, Unlock, GripVertical, Pencil, Film,
|
|
Palette,
|
|
Eye, EyeOff, ChevronRight, ChevronLeft,
|
|
X, MessageSquare, FileUp, FileDown,
|
|
Sparkles, Type,
|
|
Music, RefreshCw, Loader2, Link2,
|
|
CircleDot, Circle, PanelRight,
|
|
} from 'lucide-react'
|
|
import { Button } from '../../components/ui/button'
|
|
import { Tooltip } from '../../components/ui/tooltip'
|
|
import { ClipWaveform } from '../../components/AudioWaveform'
|
|
import type { GenerationSettings } from '../../components/SettingsPanel'
|
|
import { useAppSettings } from '../../contexts/AppSettingsContext'
|
|
import { useVideoGenerationModelSpecs } from '../../hooks/use-video-generation-model-specs'
|
|
import { RETAKE_EXTEND_MODELS } from '../../hooks/use-retake'
|
|
import type { GenerationError } from '../../lib/generation-errors'
|
|
import { addVisualAssetToProject } from '../../lib/asset-copy'
|
|
import { GapGenerationModal } from './GapGenerationModal'
|
|
import { ClipContextMenu, type ClipContextMenuState } from './ClipContextMenu'
|
|
import type { TimelineClip, Track, SubtitleClip, Asset, TextOverlayStyle } from '../../types/project-model'
|
|
import { ApiClient } from '../../lib/api-client'
|
|
import { pathToFileUrl } from '../../lib/file-url'
|
|
import {
|
|
areVideoGenerationSettingsEquivalent,
|
|
getApiOfferingCapabilities,
|
|
getLocalOfferingCapabilities,
|
|
getVideoGenerationModelSpecs,
|
|
resolveVideoGenerationOptions,
|
|
sanitizeVideoGenerationSettings,
|
|
} from '../../lib/video-generation-model-specs'
|
|
import {
|
|
PRIMARY_TOOLS, TRIM_TOOLS,
|
|
CUT_POINT_TOLERANCE, DEFAULT_DISSOLVE_DURATION,
|
|
type ToolType,
|
|
getShortcutLabel, tooltipLabel,
|
|
formatTime, parseTime, getColorLabel,
|
|
} from './video-editor-utils'
|
|
import {
|
|
applyStateAction,
|
|
} from './editor-actions'
|
|
import {
|
|
selectActiveTimeline,
|
|
selectActiveTimelineInPoint,
|
|
selectActiveTimelineOutPoint,
|
|
selectAssets,
|
|
selectCanUseClipboard,
|
|
selectClipMaxDurationFromAssets,
|
|
selectClipPathFromAssets,
|
|
selectClipResolutionFromAssets,
|
|
selectClips,
|
|
selectCurrentTime,
|
|
selectEditingSubtitleId,
|
|
selectIsPlaying,
|
|
selectLastTrimTool,
|
|
selectLiveAssetForClipFromAssets,
|
|
selectOpenTimelineIds,
|
|
selectPixelsPerSecond,
|
|
selectSelectedClipForProperties,
|
|
selectSelectedClipIds,
|
|
selectSelectedSubtitleId,
|
|
selectShowPropertiesPanel,
|
|
selectSnapEnabled,
|
|
selectSubtitleTrackStyleIdx,
|
|
selectSubtitles,
|
|
selectTimelineRenameState,
|
|
selectTimelines,
|
|
selectTotalDuration,
|
|
selectTracks,
|
|
selectZoom,
|
|
selectActiveTool,
|
|
} from './editor-selectors'
|
|
import { useTimelineDrag } from './useTimelineDrag'
|
|
import { useEditorActions, useEditorStore } from './editor-store'
|
|
|
|
// Custom scissors cursor SVG for the blade tool (white with dark outline for contrast)
|
|
const SCISSORS_CURSOR_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='6' cy='6' r='3'/><path d='M8.12 8.12 12 12'/><path d='M20 4 8.12 15.88'/><circle cx='6' cy='18' r='3'/><path d='M14.8 14.8 20 20'/></svg>`
|
|
const SCISSORS_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(SCISSORS_CURSOR_SVG)}") 12 12, crosshair`
|
|
|
|
// Track Select Forward cursors — stacked chevrons for all tracks, single for one track
|
|
const TRACK_FWD_ALL_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='28' viewBox='0 0 24 28' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M8 1l5 5-5 5'/><path d='M8 16l5 5-5 5'/></svg>`
|
|
const TRACK_FWD_ALL_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(TRACK_FWD_ALL_SVG)}") 12 12, e-resize`
|
|
const TRACK_FWD_ONE_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M8 6l6 6-6 6'/></svg>`
|
|
const TRACK_FWD_ONE_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(TRACK_FWD_ONE_SVG)}") 12 12, e-resize`
|
|
|
|
type TimelineMenuState = { timelineId: string; x: number; y: number } | null
|
|
type GapSelection = { trackIndex: number; startTime: number; endTime: number } | null
|
|
type GapAnchor = { x: number; gapTop: number; gapBottom: number } | null
|
|
type GapGenerateMode = 'text-to-video' | 'image-to-video' | 'text-to-image'
|
|
|
|
interface GapGenerationApi {
|
|
generate: (prompt: string, imagePath: string | null, settings: GenerationSettings) => Promise<void>
|
|
generateImage: (prompt: string, settings: GenerationSettings) => Promise<void>
|
|
videoPath: string | null
|
|
imagePath: string | null
|
|
isGenerating: boolean
|
|
progress: number
|
|
statusMessage: string
|
|
cancel: () => void
|
|
reset: () => void
|
|
error: GenerationError | null
|
|
canCancel: boolean
|
|
}
|
|
|
|
export interface VideoEditorTimelineEditingPanelProps {
|
|
currentProjectId: string | null
|
|
playbackTimeRef: React.MutableRefObject<number>
|
|
centerOnPlayheadRef: React.MutableRefObject<boolean>
|
|
getMinZoom: () => number
|
|
canUseIcLora: boolean
|
|
handleICLoraClip: (clip: TimelineClip) => void
|
|
kbLayout: any
|
|
handleExportTimelineXml: () => void
|
|
subtitleFileInputRef: React.RefObject<HTMLInputElement>
|
|
handleImportSrt: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
handleExportSrt: () => void
|
|
gapGenerationApi: GapGenerationApi
|
|
selectedGapRefBridge: React.MutableRefObject<GapSelection>
|
|
gapGenerateModeRefBridge: React.MutableRefObject<GapGenerateMode | null>
|
|
clearSelectedGapRefBridge: React.MutableRefObject<() => void>
|
|
closeSelectedGapRefBridge: React.MutableRefObject<() => void>
|
|
timelineRefBridge: React.MutableRefObject<HTMLDivElement | null>
|
|
trackContainerRefBridge: React.MutableRefObject<HTMLDivElement | null>
|
|
trackHeadersRefBridge: React.MutableRefObject<HTMLDivElement | null>
|
|
rulerScrollRefBridge: React.MutableRefObject<HTMLDivElement | null>
|
|
markerDragOriginRef: React.MutableRefObject<'timeline' | 'scrubbar' | null>
|
|
setDraggingMarker: (v: 'timelineIn' | 'timelineOut' | null) => void
|
|
bladeShiftHeld: boolean
|
|
onTimelinePanelContextMenu?: (e: React.MouseEvent, timelineId: string) => void
|
|
fitToViewRef: React.MutableRefObject<() => void>
|
|
onRevealAsset: (assetId: string) => void
|
|
onCreateVideoFromImage: (clip: TimelineClip) => void
|
|
onCaptureFrameForVideo: (clip: TimelineClip) => void
|
|
onCreateVideoFromAudio: (clip: TimelineClip) => void
|
|
handleRegenerate: (assetId: string, clipId: string) => void
|
|
handleRetakeClip: (clip: TimelineClip) => void
|
|
handleCancelRegeneration: () => void
|
|
canCancelInFlight: boolean
|
|
isRegenerating: boolean
|
|
regenProgress: number
|
|
}
|
|
|
|
export function VideoEditorTimelineEditingPanel(props: VideoEditorTimelineEditingPanelProps) {
|
|
const {
|
|
currentProjectId,
|
|
playbackTimeRef,
|
|
centerOnPlayheadRef,
|
|
getMinZoom,
|
|
canUseIcLora,
|
|
handleICLoraClip,
|
|
kbLayout,
|
|
handleExportTimelineXml,
|
|
subtitleFileInputRef,
|
|
handleImportSrt,
|
|
handleExportSrt,
|
|
gapGenerationApi,
|
|
selectedGapRefBridge,
|
|
gapGenerateModeRefBridge,
|
|
clearSelectedGapRefBridge,
|
|
closeSelectedGapRefBridge,
|
|
timelineRefBridge,
|
|
trackContainerRefBridge,
|
|
trackHeadersRefBridge,
|
|
rulerScrollRefBridge,
|
|
markerDragOriginRef,
|
|
setDraggingMarker,
|
|
bladeShiftHeld,
|
|
onTimelinePanelContextMenu,
|
|
fitToViewRef,
|
|
onRevealAsset,
|
|
onCreateVideoFromImage,
|
|
onCaptureFrameForVideo,
|
|
onCreateVideoFromAudio,
|
|
handleRegenerate,
|
|
handleRetakeClip,
|
|
handleCancelRegeneration,
|
|
canCancelInFlight,
|
|
isRegenerating,
|
|
regenProgress,
|
|
} = props
|
|
const actions = useEditorActions()
|
|
const { shouldVideoGenerateWithLtxApi } = useAppSettings()
|
|
const {
|
|
modelSpecs: videoGenerationModelSpecsResponse,
|
|
isLoading: isLoadingVideoGenerationModelSpecs,
|
|
errorMessage: videoGenerationModelSpecsErrorMessage,
|
|
} = useVideoGenerationModelSpecs()
|
|
const localCaps = getLocalOfferingCapabilities(videoGenerationModelSpecsResponse)
|
|
// Retake/Extend always request RETAKE_EXTEND_MODELS (currently "pro" / 2.3 Pro),
|
|
// not the timeline clip's t2v/i2v generation pipeline.
|
|
const apiCaps = getApiOfferingCapabilities(
|
|
videoGenerationModelSpecsResponse,
|
|
RETAKE_EXTEND_MODELS[0],
|
|
)
|
|
const canUseRetake = shouldVideoGenerateWithLtxApi
|
|
? Boolean(apiCaps?.retake)
|
|
: Boolean(localCaps?.retake)
|
|
const requestRetakeClip = (clip: TimelineClip) => {
|
|
if (!canUseRetake) return
|
|
handleRetakeClip(clip)
|
|
}
|
|
|
|
const assets = useEditorStore(selectAssets)
|
|
const timelines = useEditorStore(selectTimelines)
|
|
const activeTimeline = useEditorStore(selectActiveTimeline)
|
|
const clips = useEditorStore(selectClips)
|
|
const tracks = useEditorStore(selectTracks)
|
|
const subtitles = useEditorStore(selectSubtitles)
|
|
const totalDuration = useEditorStore(selectTotalDuration)
|
|
const pixelsPerSecond = useEditorStore(selectPixelsPerSecond)
|
|
const currentTime = useEditorStore(selectCurrentTime)
|
|
const isPlaying = useEditorStore(selectIsPlaying)
|
|
const inPoint = useEditorStore(selectActiveTimelineInPoint)
|
|
const outPoint = useEditorStore(selectActiveTimelineOutPoint)
|
|
const zoom = useEditorStore(selectZoom)
|
|
const selectedClip = useEditorStore(selectSelectedClipForProperties)
|
|
const selectedClipIds = useEditorStore(selectSelectedClipIds)
|
|
const activeTool = useEditorStore(selectActiveTool)
|
|
const lastTrimTool = useEditorStore(selectLastTrimTool)
|
|
const snapEnabled = useEditorStore(selectSnapEnabled)
|
|
const showPropertiesPanel = useEditorStore(selectShowPropertiesPanel)
|
|
const openTimelineIds = useEditorStore(selectOpenTimelineIds)
|
|
const { renamingTimelineId, renameValue, renameSource } = useEditorStore(selectTimelineRenameState, shallow)
|
|
const subtitleTrackStyleIdx = useEditorStore(selectSubtitleTrackStyleIdx)
|
|
const selectedSubtitleId = useEditorStore(selectSelectedSubtitleId)
|
|
const editingSubtitleId = useEditorStore(selectEditingSubtitleId)
|
|
const hasClipboard = useEditorStore(selectCanUseClipboard)
|
|
|
|
const setClips = useCallback((value: React.SetStateAction<TimelineClip[]>) => {
|
|
actions.setTimelineClips(value)
|
|
}, [actions])
|
|
|
|
const setTracks = useCallback((value: React.SetStateAction<Track[]>) => {
|
|
actions.setTimelineTracks(value)
|
|
}, [actions])
|
|
|
|
const setSubtitles = useCallback((value: React.SetStateAction<SubtitleClip[]>) => {
|
|
actions.setTimelineSubtitles(value)
|
|
}, [actions])
|
|
|
|
const setCurrentTime = useCallback((time: number) => {
|
|
actions.setCurrentTime(time)
|
|
}, [actions])
|
|
|
|
const setIsPlaying = useCallback((playing: boolean) => {
|
|
if (playing) actions.play()
|
|
else actions.pause()
|
|
}, [actions])
|
|
|
|
const setZoom = useCallback((value: React.SetStateAction<number>) => {
|
|
actions.setZoom(applyStateAction(value, zoom))
|
|
}, [actions, zoom])
|
|
|
|
const setSelectedClipIds = useCallback((value: React.SetStateAction<Set<string>>) => {
|
|
actions.setSelectedClipIds(value)
|
|
}, [actions])
|
|
|
|
const setActiveTool = useCallback((tool: ToolType) => {
|
|
actions.setActiveTool(tool)
|
|
}, [actions])
|
|
|
|
const setLastTrimTool = useCallback((tool: ToolType) => {
|
|
actions.setLastTrimTool(tool)
|
|
}, [actions])
|
|
|
|
const setSnapEnabled = useCallback((enabled: boolean) => {
|
|
actions.setSnapEnabled(enabled)
|
|
}, [actions])
|
|
|
|
const setShowPropertiesPanel = useCallback((value: React.SetStateAction<boolean>) => {
|
|
actions.setShowPropertiesPanel(applyStateAction(value, showPropertiesPanel))
|
|
}, [actions, showPropertiesPanel])
|
|
|
|
const setRenameValue = useCallback((value: string) => {
|
|
actions.setTimelineRenameValue(value)
|
|
}, [actions])
|
|
|
|
const setRenamingTimelineId = useCallback((id: string | null) => {
|
|
if (id === null) {
|
|
actions.cancelTimelineRename()
|
|
}
|
|
}, [actions])
|
|
|
|
const handleAddTimeline = useCallback(() => {
|
|
actions.createTimeline()
|
|
}, [actions])
|
|
|
|
const handleSwitchTimeline = useCallback((id: string) => {
|
|
actions.switchActiveTimeline(id)
|
|
}, [actions])
|
|
|
|
const handleCloseTimelineTab = useCallback((id: string) => {
|
|
actions.closeTimelineTab(id)
|
|
}, [actions])
|
|
|
|
const handleStartRename = useCallback((id: string, name: string, source: 'tab' | 'panel' = 'tab') => {
|
|
actions.startTimelineRename(id, source)
|
|
actions.setTimelineRenameValue(name)
|
|
}, [actions])
|
|
|
|
const handleFinishRename = useCallback(() => {
|
|
actions.commitTimelineRename()
|
|
}, [actions])
|
|
|
|
const handleDeleteTimeline = useCallback((id: string) => {
|
|
actions.deleteTimeline(id)
|
|
}, [actions])
|
|
|
|
const handleDuplicateTimeline = useCallback((id: string) => {
|
|
actions.duplicateTimeline(id)
|
|
}, [actions])
|
|
|
|
const handleCopy = useCallback(() => {
|
|
actions.copySelection()
|
|
}, [actions])
|
|
|
|
const handleCut = useCallback(() => {
|
|
actions.cutSelection()
|
|
}, [actions])
|
|
|
|
const handlePaste = useCallback(() => {
|
|
actions.pasteSelection()
|
|
}, [actions])
|
|
|
|
const addSubtitleTrack = useCallback(() => {
|
|
actions.addSubtitleTrack()
|
|
}, [actions])
|
|
|
|
const createAdjustmentLayerAsset = useCallback(() => {
|
|
actions.createAdjustmentLayerAsset()
|
|
}, [actions])
|
|
|
|
const addTextClip = useCallback((style?: Partial<TextOverlayStyle>, startTime?: number, trackIdx?: number) => {
|
|
actions.addTextClip({ style, startTime, trackIndex: trackIdx })
|
|
}, [actions])
|
|
|
|
const duplicateClip = useCallback((clipId: string) => {
|
|
actions.duplicateClips([clipId])
|
|
}, [actions])
|
|
|
|
const splitClipAtPlayhead = useCallback((clipId: string, atTime?: number, batchClipIds?: string[]) => {
|
|
actions.splitClipsAtTime(batchClipIds ?? [clipId], atTime ?? currentTime)
|
|
}, [actions, currentTime])
|
|
|
|
const updateClip = useCallback((id: string, patch: Partial<TimelineClip>) => {
|
|
actions.updateClip(id, patch)
|
|
}, [actions])
|
|
|
|
const updateAsset = useCallback((_projectId: string, assetId: string, updates: Partial<Asset>) => {
|
|
actions.updateAsset(assetId, updates)
|
|
}, [actions])
|
|
|
|
const addClipToTimeline = useCallback((asset: Asset, trackIndex: number, startTime?: number) => {
|
|
actions.insertAssetsToTimeline({ assets: [asset], trackIndex, startTime })
|
|
}, [actions])
|
|
|
|
const resolveClipPath = useCallback((clip: TimelineClip | null) => {
|
|
return clip ? selectClipPathFromAssets(assets, clip) : ''
|
|
}, [assets])
|
|
|
|
const getMaxClipDuration = useCallback((clip: TimelineClip) => {
|
|
return selectClipMaxDurationFromAssets(assets, clip)
|
|
}, [assets])
|
|
|
|
const setSubtitleTrackStyleIdx = useCallback((idx: number | null) => {
|
|
actions.setSubtitleTrackStyleEditorTrack(idx ?? undefined)
|
|
}, [actions])
|
|
|
|
const setSelectedSubtitleId = useCallback((id: string | null) => {
|
|
if (id) actions.setSelectedSubtitle(id)
|
|
else actions.clearSelectedSubtitle()
|
|
}, [actions])
|
|
|
|
const setEditingSubtitleId = useCallback((value: React.SetStateAction<string | null>) => {
|
|
actions.setEditingSubtitleId(value)
|
|
}, [actions])
|
|
|
|
const updateSubtitle = useCallback((id: string, patch: Partial<SubtitleClip>) => {
|
|
actions.updateSubtitle(id, patch)
|
|
}, [actions])
|
|
|
|
const getClipPath = useCallback((clip: TimelineClip) => {
|
|
return selectClipPathFromAssets(assets, clip)
|
|
}, [assets])
|
|
|
|
const getClipResolution = useCallback((clip: TimelineClip) => {
|
|
return selectClipResolutionFromAssets(assets, clip)
|
|
}, [assets])
|
|
|
|
const getLiveAsset = useCallback((clip: TimelineClip) => {
|
|
return selectLiveAssetForClipFromAssets(assets, clip)
|
|
}, [assets])
|
|
|
|
const handleClipTakeChange = useCallback((clipId: string, direction: 'prev' | 'next') => {
|
|
actions.stepClipTake(clipId, direction)
|
|
}, [actions])
|
|
|
|
const handleDeleteTake = useCallback((clipId: string) => {
|
|
actions.deleteClipDisplayedTake(clipId)
|
|
}, [actions])
|
|
|
|
const [editingTimecode, setEditingTimecode] = useState(false)
|
|
const [timecodeInput, setTimecodeInput] = useState('')
|
|
const timecodeInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const [showTrimFlyout, setShowTrimFlyout] = useState(false)
|
|
const trimLongPressRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const trimFlyoutOpenedRef = useRef(false)
|
|
|
|
const [timelineContextMenu, setTimelineContextMenu] = useState<TimelineMenuState>(null)
|
|
const timelineContextMenuRef = useRef<HTMLDivElement>(null)
|
|
const [clipContextMenu, setClipContextMenu] = useState<ClipContextMenuState | null>(null)
|
|
const clipContextMenuRef = useRef<HTMLDivElement>(null)
|
|
const renameInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const [bladeHoverInfo, setBladeHoverInfo] = useState<{ clipId: string; offsetX: number; time: number } | null>(null)
|
|
const [hoveredCutPoint, setHoveredCutPoint] = useState<{
|
|
leftClipId: string; rightClipId: string; time: number; trackIndex: number
|
|
} | null>(null)
|
|
|
|
const [videoTrackHeight, setVideoTrackHeight] = useState(56)
|
|
const [audioTrackHeight, setAudioTrackHeight] = useState(56)
|
|
const [subtitleTrackHeight, setSubtitleTrackHeight] = useState(40)
|
|
const suppressGapClickRef = useRef(false)
|
|
const [selectedGapAnchor, setSelectedGapAnchor] = useState<GapAnchor>(null)
|
|
const [selectedGap, setSelectedGap] = useState<GapSelection>(null)
|
|
const [gapGenerateMode, setGapGenerateMode] = useState<GapGenerateMode | null>(null)
|
|
const [gapPrompt, setGapPrompt] = useState('')
|
|
const [gapSettings, setGapSettings] = useState<GenerationSettings>({
|
|
model: 'fast',
|
|
duration: 5,
|
|
videoResolution: '540p',
|
|
fps: 24,
|
|
audio: true,
|
|
cameraMotion: 'none',
|
|
imageResolution: '1080p',
|
|
imageAspectRatio: '16:9',
|
|
imageSteps: 30,
|
|
})
|
|
const [gapImageFile, setGapImageFile] = useState<File | null>(null)
|
|
const gapImageInputRef = useRef<HTMLInputElement>(null)
|
|
const [gapApplyAudioToTrack, setGapApplyAudioToTrack] = useState(true)
|
|
const [generatingGap, setGeneratingGap] = useState<{
|
|
trackIndex: number
|
|
startTime: number
|
|
endTime: number
|
|
mode: GapGenerateMode
|
|
prompt: string
|
|
settings: GenerationSettings
|
|
applyAudio: boolean
|
|
} | null>(null)
|
|
const [gapSuggesting, setGapSuggesting] = useState(false)
|
|
const [gapSuggestion, setGapSuggestion] = useState<string | null>(null)
|
|
const [gapSuggestionError, setGapSuggestionError] = useState(false)
|
|
const [gapSuggestionNoApiKey, setGapSuggestionNoApiKey] = useState(false)
|
|
const [gapBeforeFrame, setGapBeforeFrame] = useState<string | null>(null)
|
|
const [gapAfterFrame, setGapAfterFrame] = useState<string | null>(null)
|
|
const gapDuration = selectedGap ? selectedGap.endTime - selectedGap.startTime : null
|
|
const gapVideoModelSpecs = getVideoGenerationModelSpecs(videoGenerationModelSpecsResponse, {
|
|
useApiSpecs: shouldVideoGenerateWithLtxApi,
|
|
})
|
|
const gapResolvedVideoOptions = (
|
|
gapGenerateMode === 'text-to-image'
|
|
|| gapDuration === null
|
|
|| gapVideoModelSpecs.length === 0
|
|
)
|
|
? null
|
|
: resolveVideoGenerationOptions({
|
|
settings: gapSettings,
|
|
modelSpecs: gapVideoModelSpecs,
|
|
minimumDuration: gapDuration,
|
|
durationSelection: 'smallest_valid',
|
|
})
|
|
const gapCanGenerateVideo = Boolean(
|
|
gapResolvedVideoOptions
|
|
&& gapResolvedVideoOptions.hasCompatibleOptions
|
|
&& !isLoadingVideoGenerationModelSpecs
|
|
&& !videoGenerationModelSpecsErrorMessage
|
|
)
|
|
const gapVideoSettingsMessage = isLoadingVideoGenerationModelSpecs
|
|
? 'Loading generation settings...'
|
|
: videoGenerationModelSpecsErrorMessage
|
|
? `Could not load generation settings: ${videoGenerationModelSpecsErrorMessage}`
|
|
: gapGenerateMode !== 'text-to-image' && selectedGap && !gapCanGenerateVideo
|
|
? 'This gap is too long to fill with a single video generation.'
|
|
: null
|
|
|
|
const selectedGapRef = useRef<GapSelection>(selectedGap)
|
|
selectedGapRef.current = selectedGap
|
|
selectedGapRefBridge.current = selectedGap
|
|
|
|
const gapGenerateModeRef = useRef<GapGenerateMode | null>(gapGenerateMode)
|
|
gapGenerateModeRef.current = gapGenerateMode
|
|
gapGenerateModeRefBridge.current = gapGenerateMode
|
|
|
|
const closeGap = useCallback((gap: NonNullable<GapSelection>) => {
|
|
const gapDuration = gap.endTime - gap.startTime
|
|
|
|
setClips(prev => prev.map(clip => {
|
|
if (clip.startTime >= gap.endTime) {
|
|
return { ...clip, startTime: Math.max(0, clip.startTime - gapDuration) }
|
|
}
|
|
return clip
|
|
}))
|
|
setSubtitles(prev => prev.map(subtitle => {
|
|
if (subtitle.startTime >= gap.endTime) {
|
|
return {
|
|
...subtitle,
|
|
startTime: Math.max(0, subtitle.startTime - gapDuration),
|
|
endTime: Math.max(0.1, subtitle.endTime - gapDuration),
|
|
}
|
|
}
|
|
return subtitle
|
|
}))
|
|
}, [setClips, setSubtitles])
|
|
|
|
const addTrack = useCallback((kind: 'video' | 'audio') => {
|
|
const sameKindCount = tracks.filter(track => track.kind === kind && track.type !== 'subtitle').length
|
|
setTracks(prev => [...prev, {
|
|
id: `track-${Date.now()}`,
|
|
name: kind === 'audio' ? `A${sameKindCount + 1}` : `V${sameKindCount + 1}`,
|
|
muted: false,
|
|
locked: false,
|
|
kind,
|
|
}])
|
|
}, [setTracks, tracks])
|
|
|
|
const deleteTrack = useCallback((idx: number) => {
|
|
if (tracks.length <= 1) return
|
|
setClips(prev => prev
|
|
.filter(clip => clip.trackIndex !== idx)
|
|
.map(clip => clip.trackIndex > idx ? { ...clip, trackIndex: clip.trackIndex - 1 } : clip)
|
|
)
|
|
setSubtitles(prev => prev
|
|
.filter(subtitle => subtitle.trackIndex !== idx)
|
|
.map(subtitle => subtitle.trackIndex > idx ? { ...subtitle, trackIndex: subtitle.trackIndex - 1 } : subtitle)
|
|
)
|
|
setTracks(tracks.filter((_, trackIndex) => trackIndex !== idx))
|
|
}, [setClips, setSubtitles, setTracks, tracks])
|
|
|
|
const addSubtitleClip = useCallback((trackIndex: number) => {
|
|
const subtitle: SubtitleClip = {
|
|
id: `sub-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
text: 'New subtitle',
|
|
startTime: currentTime,
|
|
endTime: currentTime + 3,
|
|
trackIndex,
|
|
}
|
|
setSubtitles(prev => [...prev, subtitle])
|
|
setSelectedSubtitleId(subtitle.id)
|
|
setEditingSubtitleId(subtitle.id)
|
|
}, [currentTime, setEditingSubtitleId, setSelectedSubtitleId, setSubtitles])
|
|
|
|
const addCrossDissolve = useCallback((leftClipId: string, rightClipId: string) => {
|
|
const leftClip = clips.find(clip => clip.id === leftClipId)
|
|
const rightClip = clips.find(clip => clip.id === rightClipId)
|
|
if (!leftClip || !rightClip) return
|
|
|
|
setClips(prev => prev.map(clip => {
|
|
if (clip.id === leftClipId) {
|
|
return { ...clip, transitionOut: { type: 'dissolve' as const, duration: DEFAULT_DISSOLVE_DURATION } }
|
|
}
|
|
if (clip.id === rightClipId) {
|
|
return { ...clip, transitionIn: { type: 'dissolve' as const, duration: DEFAULT_DISSOLVE_DURATION } }
|
|
}
|
|
return clip
|
|
}))
|
|
}, [clips, setClips])
|
|
|
|
const removeCrossDissolve = useCallback((leftClipId: string, rightClipId: string) => {
|
|
setClips(prev => prev.map(clip => {
|
|
if (clip.id === leftClipId) {
|
|
return { ...clip, transitionOut: { type: 'none' as const, duration: 0.5 } }
|
|
}
|
|
if (clip.id === rightClipId) {
|
|
return { ...clip, transitionIn: { type: 'none' as const, duration: 0.5 } }
|
|
}
|
|
return clip
|
|
}))
|
|
}, [setClips])
|
|
|
|
const removeClip = useCallback((clipId: string) => {
|
|
const clip = clips.find(candidate => candidate.id === clipId)
|
|
if (clip && tracks[clip.trackIndex]?.locked) return
|
|
|
|
const removeIds = new Set([clipId])
|
|
clip?.linkedClipIds?.forEach(linkedId => removeIds.add(linkedId))
|
|
setClips(clips.filter(candidate => !removeIds.has(candidate.id)))
|
|
setSelectedClipIds(prev => {
|
|
const next = new Set(prev)
|
|
removeIds.forEach(id => next.delete(id))
|
|
return next
|
|
})
|
|
}, [clips, setClips, setSelectedClipIds, tracks])
|
|
|
|
const clearSelectedGap = useCallback(() => {
|
|
setGapGenerateMode(null)
|
|
setSelectedGap(null)
|
|
setSelectedGapAnchor(null)
|
|
}, [])
|
|
clearSelectedGapRefBridge.current = clearSelectedGap
|
|
|
|
const selectGap = useCallback((gap: NonNullable<GapSelection>, target: HTMLElement) => {
|
|
setSelectedGap(gap)
|
|
setSelectedClipIds(new Set())
|
|
setSelectedSubtitleId(null)
|
|
setGapGenerateMode(null)
|
|
const rect = target.getBoundingClientRect()
|
|
setSelectedGapAnchor({ x: rect.left + rect.width / 2, gapTop: rect.top, gapBottom: rect.bottom })
|
|
}, [setSelectedClipIds, setSelectedSubtitleId])
|
|
|
|
const closeSelectedGap = useCallback(() => {
|
|
const gap = selectedGapRef.current
|
|
if (!gap) return
|
|
clearSelectedGap()
|
|
closeGap(gap)
|
|
}, [clearSelectedGap, closeGap])
|
|
closeSelectedGapRefBridge.current = closeSelectedGap
|
|
|
|
useEffect(() => {
|
|
if (gapGenerateMode === 'text-to-image' && gapImageFile) {
|
|
setGapImageFile(null)
|
|
}
|
|
}, [gapGenerateMode, gapImageFile])
|
|
|
|
useEffect(() => {
|
|
if (!selectedGap) {
|
|
setSelectedGapAnchor(null)
|
|
}
|
|
}, [selectedGap])
|
|
|
|
useEffect(() => {
|
|
if (gapGenerateMode === 'text-to-image' || gapDuration === null || gapVideoModelSpecs.length === 0) return
|
|
setGapSettings((prev) => {
|
|
const next = sanitizeVideoGenerationSettings(prev, gapVideoModelSpecs, {
|
|
minimumDuration: gapDuration,
|
|
durationSelection: 'smallest_valid',
|
|
})
|
|
if (!next || areVideoGenerationSettingsEquivalent(prev, next)) {
|
|
return prev
|
|
}
|
|
return next
|
|
})
|
|
}, [gapDuration, gapGenerateMode, gapVideoModelSpecs])
|
|
|
|
const timelineGaps = useMemo(() => {
|
|
const gaps: Array<{ trackIndex: number; startTime: number; endTime: number }> = []
|
|
|
|
tracks.forEach((track, trackIdx) => {
|
|
if (track.type === 'subtitle') return
|
|
|
|
const trackClips = clips
|
|
.filter(c => c.trackIndex === trackIdx)
|
|
.sort((a, b) => a.startTime - b.startTime)
|
|
|
|
if (trackClips.length === 0) return
|
|
|
|
if (trackClips[0].startTime > 0.05) {
|
|
gaps.push({ trackIndex: trackIdx, startTime: 0, endTime: trackClips[0].startTime })
|
|
}
|
|
|
|
for (let i = 0; i < trackClips.length - 1; i++) {
|
|
const endOfCurrent = trackClips[i].startTime + trackClips[i].duration
|
|
const startOfNext = trackClips[i + 1].startTime
|
|
if (startOfNext - endOfCurrent > 0.05) {
|
|
gaps.push({ trackIndex: trackIdx, startTime: endOfCurrent, endTime: startOfNext })
|
|
}
|
|
}
|
|
})
|
|
|
|
return gaps
|
|
}, [clips, tracks])
|
|
|
|
const gapSuggestionAbortRef = useRef<AbortController | null>(null)
|
|
const gapPromptRef = useRef(gapPrompt)
|
|
gapPromptRef.current = gapPrompt
|
|
const gapImageFileRef = useRef<File | null>(gapImageFile)
|
|
gapImageFileRef.current = gapImageFile
|
|
|
|
const runSuggestion = useCallback(async (forceReplace: boolean = false) => {
|
|
const gap = selectedGapRef.current
|
|
const mode = gapGenerateModeRef.current
|
|
if (!gap || !mode) return
|
|
|
|
gapSuggestionAbortRef.current?.abort()
|
|
const abortController = new AbortController()
|
|
gapSuggestionAbortRef.current = abortController
|
|
|
|
try {
|
|
setGapSuggesting(true)
|
|
setGapSuggestion(null)
|
|
setGapSuggestionError(false)
|
|
setGapSuggestionNoApiKey(false)
|
|
|
|
const trackClips = clips
|
|
.filter(c => c.trackIndex === gap.trackIndex && c.type !== 'audio')
|
|
.sort((a, b) => a.startTime - b.startTime)
|
|
|
|
const clipBefore = trackClips.find(c => {
|
|
const clipEnd = c.startTime + c.duration
|
|
return Math.abs(clipEnd - gap.startTime) < 0.05
|
|
})
|
|
|
|
const clipAfter = trackClips.find(c => Math.abs(c.startTime - gap.endTime) < 0.05)
|
|
|
|
if (!clipBefore && !clipAfter) {
|
|
setGapSuggesting(false)
|
|
return
|
|
}
|
|
|
|
let beforeFrame = ''
|
|
let afterFrame = ''
|
|
let beforePrompt = ''
|
|
let afterPrompt = ''
|
|
let beforeFramePath = ''
|
|
let afterFramePath = ''
|
|
|
|
const framePromises: Promise<void>[] = []
|
|
|
|
if (clipBefore) {
|
|
const clipPath = resolveClipPath(clipBefore)
|
|
beforePrompt = clipBefore.asset?.prompt || ''
|
|
if (clipPath) {
|
|
if (clipBefore.asset?.type === 'video') {
|
|
const seekTime = clipBefore.trimStart + clipBefore.duration * clipBefore.speed - 0.1
|
|
framePromises.push(
|
|
window.electronAPI.extractVideoFrame({ videoPath: clipPath, seekTime: Math.max(0, seekTime), width: 512, quality: 3 })
|
|
.then(result => { beforeFrame = result.path; beforeFramePath = result.path })
|
|
.catch(() => {})
|
|
)
|
|
} else if (clipBefore.asset?.type === 'image') {
|
|
beforeFrame = clipPath
|
|
beforeFramePath = clipPath
|
|
}
|
|
}
|
|
}
|
|
|
|
if (clipAfter) {
|
|
const clipPath = resolveClipPath(clipAfter)
|
|
afterPrompt = clipAfter.asset?.prompt || ''
|
|
if (clipPath) {
|
|
if (clipAfter.asset?.type === 'video') {
|
|
framePromises.push(
|
|
window.electronAPI.extractVideoFrame({ videoPath: clipPath, seekTime: clipAfter.trimStart + 0.1, width: 512, quality: 3 })
|
|
.then(result => { afterFrame = result.path; afterFramePath = result.path })
|
|
.catch(() => {})
|
|
)
|
|
} else if (clipAfter.asset?.type === 'image') {
|
|
afterFrame = clipPath
|
|
afterFramePath = clipPath
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(framePromises)
|
|
|
|
if (abortController.signal.aborted) return
|
|
|
|
if (beforeFramePath) setGapBeforeFrame(beforeFramePath)
|
|
if (afterFramePath) setGapAfterFrame(afterFramePath)
|
|
|
|
if (!beforeFrame && !afterFrame && !beforePrompt && !afterPrompt) {
|
|
setGapSuggesting(false)
|
|
return
|
|
}
|
|
|
|
let inputImagePath = ''
|
|
const imageFile = gapImageFileRef.current
|
|
if (imageFile && mode === 'image-to-video') {
|
|
const electronPath = (imageFile as { path?: string }).path
|
|
if (electronPath) {
|
|
inputImagePath = electronPath
|
|
}
|
|
}
|
|
|
|
const result = await ApiClient.suggestGapPrompt({
|
|
gapDuration: gap.endTime - gap.startTime,
|
|
mode,
|
|
beforePrompt,
|
|
afterPrompt,
|
|
beforeFrame,
|
|
afterFrame,
|
|
...(inputImagePath ? { inputImage: inputImagePath } : {}),
|
|
}, {
|
|
signal: abortController.signal,
|
|
})
|
|
if (!result.ok) {
|
|
const isApiKeyError = result.status === 401 || result.status === 403
|
|
|| JSON.stringify(result.error).toLowerCase().includes('api_key')
|
|
|| JSON.stringify(result.error).toLowerCase().includes('gemini')
|
|
|| JSON.stringify(result.error).toLowerCase().includes('no api key')
|
|
|| JSON.stringify(result.error).toLowerCase().includes('api key')
|
|
if (isApiKeyError) {
|
|
setGapSuggestionNoApiKey(true)
|
|
} else {
|
|
console.warn('Gap prompt suggestion failed:', result.error.message)
|
|
setGapSuggestionError(true)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (abortController.signal.aborted) return
|
|
|
|
const data = result.data
|
|
if (data.suggested_prompt) {
|
|
setGapSuggestion(data.suggested_prompt)
|
|
if (forceReplace || !gapPromptRef.current.trim()) {
|
|
setGapPrompt(data.suggested_prompt)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === 'AbortError') return
|
|
console.warn('Gap prompt suggestion failed:', err)
|
|
setGapSuggestionError(true)
|
|
} finally {
|
|
if (!abortController.signal.aborted) {
|
|
setGapSuggesting(false)
|
|
}
|
|
}
|
|
}, [clips, resolveClipPath])
|
|
|
|
const suggestionFiredKeyRef = useRef<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!selectedGap || !gapGenerateMode) {
|
|
setGapSuggesting(false)
|
|
setGapSuggestion(null)
|
|
setGapSuggestionError(false)
|
|
setGapSuggestionNoApiKey(false)
|
|
setGapBeforeFrame(null)
|
|
setGapAfterFrame(null)
|
|
gapSuggestionAbortRef.current?.abort()
|
|
suggestionFiredKeyRef.current = null
|
|
return
|
|
}
|
|
|
|
const key = `${selectedGap.trackIndex}:${selectedGap.startTime}:${selectedGap.endTime}:${gapGenerateMode}`
|
|
if (suggestionFiredKeyRef.current === key) return
|
|
suggestionFiredKeyRef.current = key
|
|
|
|
runSuggestion(false)
|
|
|
|
return () => { gapSuggestionAbortRef.current?.abort() }
|
|
}, [selectedGap, gapGenerateMode, runSuggestion])
|
|
|
|
const prevImageFileRef = useRef<File | null>(null)
|
|
useEffect(() => {
|
|
const prev = prevImageFileRef.current
|
|
prevImageFileRef.current = gapImageFile
|
|
|
|
if (prev === gapImageFile) return
|
|
if (gapGenerateMode !== 'image-to-video') return
|
|
if (!selectedGap) return
|
|
|
|
runSuggestion(true)
|
|
}, [gapImageFile, gapGenerateMode, selectedGap, runSuggestion])
|
|
|
|
useEffect(() => () => {
|
|
gapSuggestionAbortRef.current?.abort()
|
|
}, [])
|
|
|
|
const regenerateSuggestion = useCallback(() => {
|
|
runSuggestion(true)
|
|
}, [runSuggestion])
|
|
|
|
const handleGapGenerate = useCallback(async () => {
|
|
if (!selectedGap || !gapGenerateMode || !gapPrompt.trim() || !currentProjectId) return
|
|
|
|
const gap = selectedGap
|
|
const mode = gapGenerateMode
|
|
const finalPrompt = gapPrompt.trim()
|
|
const settings = mode === 'text-to-image'
|
|
? gapSettings
|
|
: sanitizeVideoGenerationSettings(gapSettings, gapVideoModelSpecs, {
|
|
minimumDuration: gap.endTime - gap.startTime,
|
|
durationSelection: 'smallest_valid',
|
|
})
|
|
if (!settings) return
|
|
|
|
setGeneratingGap({
|
|
trackIndex: gap.trackIndex,
|
|
startTime: gap.startTime,
|
|
endTime: gap.endTime,
|
|
mode,
|
|
prompt: finalPrompt,
|
|
settings,
|
|
applyAudio: gapApplyAudioToTrack,
|
|
})
|
|
|
|
clearSelectedGap()
|
|
|
|
try {
|
|
if (mode === 'text-to-image') {
|
|
await gapGenerationApi.generateImage(finalPrompt, settings)
|
|
} else {
|
|
let imagePath: string | null = null
|
|
if (gapImageFile) {
|
|
const electronPath = (gapImageFile as { path?: string }).path
|
|
if (electronPath) {
|
|
imagePath = electronPath
|
|
} else {
|
|
const buf = await gapImageFile.arrayBuffer()
|
|
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)))
|
|
const modelsPath = await window.electronAPI.getModelsPath()
|
|
const tmpDir = modelsPath.replace(/[/\\]models$/, '')
|
|
const tmpPath = `${tmpDir}/tmp_gap_image_${Date.now()}.png`
|
|
await window.electronAPI.saveFile({ filePath: tmpPath, data: b64, encoding: 'base64' })
|
|
imagePath = tmpPath
|
|
}
|
|
}
|
|
await gapGenerationApi.generate(finalPrompt, imagePath, settings)
|
|
}
|
|
} catch (err) {
|
|
console.error('Gap generation failed:', err)
|
|
setGeneratingGap(null)
|
|
}
|
|
}, [
|
|
clearSelectedGap,
|
|
currentProjectId,
|
|
gapApplyAudioToTrack,
|
|
gapGenerateMode,
|
|
gapGenerationApi,
|
|
gapImageFile,
|
|
gapPrompt,
|
|
gapSettings,
|
|
gapVideoModelSpecs,
|
|
selectedGap,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!generatingGap || gapGenerationApi.isGenerating) return
|
|
|
|
const isImageResult = generatingGap.mode === 'text-to-image'
|
|
const sourcePath = isImageResult ? gapGenerationApi.imagePath : gapGenerationApi.videoPath
|
|
if (!sourcePath || !currentProjectId) {
|
|
if (!gapGenerationApi.isGenerating && generatingGap) {
|
|
setGeneratingGap(null)
|
|
if (!gapGenerationApi.error) gapGenerationApi.reset()
|
|
}
|
|
return
|
|
}
|
|
|
|
void (async () => {
|
|
const gap = {
|
|
trackIndex: generatingGap.trackIndex,
|
|
startTime: generatingGap.startTime,
|
|
endTime: generatingGap.endTime,
|
|
}
|
|
const assetType = isImageResult ? 'image' : 'video'
|
|
const copied = await addVisualAssetToProject(sourcePath, currentProjectId, assetType)
|
|
if (copied) {
|
|
const asset: Asset = {
|
|
id: `asset-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
type: assetType,
|
|
path: copied.path,
|
|
bigThumbnailPath: copied.bigThumbnailPath,
|
|
smallThumbnailPath: copied.smallThumbnailPath,
|
|
width: copied.width,
|
|
height: copied.height,
|
|
prompt: generatingGap.prompt,
|
|
resolution: isImageResult ? generatingGap.settings.imageResolution : generatingGap.settings.videoResolution,
|
|
duration: assetType === 'video' ? generatingGap.settings.duration ?? undefined : undefined,
|
|
generationParams: {
|
|
mode: generatingGap.mode,
|
|
prompt: generatingGap.prompt,
|
|
model: generatingGap.settings.model,
|
|
duration: generatingGap.settings.duration,
|
|
resolution: isImageResult ? generatingGap.settings.imageResolution : generatingGap.settings.videoResolution,
|
|
fps: generatingGap.settings.fps,
|
|
audio: generatingGap.settings.audio,
|
|
cameraMotion: generatingGap.settings.cameraMotion,
|
|
imageAspectRatio: generatingGap.settings.imageAspectRatio,
|
|
imageSteps: generatingGap.settings.imageSteps,
|
|
},
|
|
takes: [{
|
|
path: copied.path,
|
|
bigThumbnailPath: copied.bigThumbnailPath,
|
|
smallThumbnailPath: copied.smallThumbnailPath,
|
|
width: copied.width,
|
|
height: copied.height,
|
|
createdAt: Date.now(),
|
|
}],
|
|
activeTakeIndex: 0,
|
|
createdAt: Date.now(),
|
|
}
|
|
actions.insertGeneratedGapAsset({
|
|
gap,
|
|
asset,
|
|
createAudio: assetType === 'video' && generatingGap.applyAudio && generatingGap.settings.audio,
|
|
})
|
|
}
|
|
|
|
setGeneratingGap(null)
|
|
setGapPrompt('')
|
|
setGapImageFile(null)
|
|
gapGenerationApi.reset()
|
|
})()
|
|
}, [actions, currentProjectId, gapGenerationApi, generatingGap])
|
|
|
|
const cancelGapGeneration = useCallback(() => {
|
|
if (!gapGenerationApi.canCancel) return
|
|
gapGenerationApi.cancel()
|
|
// Keep gap UI until the generate POST returns cancelled so liveness
|
|
// suppression stays up while the GPU job unwinds.
|
|
}, [gapGenerationApi])
|
|
|
|
const handleCloseGap = useCallback(() => {
|
|
closeSelectedGap()
|
|
}, [closeSelectedGap])
|
|
|
|
const timelineRef = useRef<HTMLDivElement>(null)
|
|
const trackContainerRef = useRef<HTMLDivElement>(null)
|
|
const trackHeadersRef = useRef<HTMLDivElement>(null)
|
|
const rulerScrollRef = useRef<HTMLDivElement>(null)
|
|
const playheadRulerRef = useRef<HTMLDivElement>(null)
|
|
const playheadOverlayRef = useRef<HTMLDivElement>(null)
|
|
const timelineTimecodeRef = useRef<HTMLSpanElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (timelineRefBridge) timelineRefBridge.current = timelineRef.current
|
|
if (trackContainerRefBridge) trackContainerRefBridge.current = trackContainerRef.current
|
|
if (trackHeadersRefBridge) trackHeadersRefBridge.current = trackHeadersRef.current
|
|
if (rulerScrollRefBridge) rulerScrollRefBridge.current = rulerScrollRef.current
|
|
})
|
|
|
|
const DIVIDER_H = 8
|
|
|
|
const orderedTracks: { track: Track; realIndex: number; displayRow: number }[] = useMemo(() => {
|
|
const videoTracks: { track: Track; realIndex: number }[] = []
|
|
const audioTracks: { track: Track; realIndex: number }[] = []
|
|
const subtitleTracks: { track: Track; realIndex: number }[] = []
|
|
|
|
tracks.forEach((track: Track, i: number) => {
|
|
if (track.type === 'subtitle') subtitleTracks.push({ track, realIndex: i })
|
|
else if (track.kind === 'audio') audioTracks.push({ track, realIndex: i })
|
|
else videoTracks.push({ track, realIndex: i })
|
|
})
|
|
|
|
videoTracks.reverse()
|
|
const ordered = [...subtitleTracks, ...videoTracks, ...audioTracks]
|
|
return ordered.map((entry, displayRow) => ({ ...entry, displayRow }))
|
|
}, [tracks])
|
|
|
|
const trackDisplayRow = useMemo(() => {
|
|
const map = new Map<number, number>()
|
|
orderedTracks.forEach(entry => map.set(entry.realIndex, entry.displayRow))
|
|
return map
|
|
}, [orderedTracks])
|
|
|
|
const audioDividerDisplayRow = useMemo(() => {
|
|
const firstAudio = orderedTracks.find(e => e.track.kind === 'audio')
|
|
return firstAudio?.displayRow ?? -1
|
|
}, [orderedTracks])
|
|
|
|
const getTrackHeight = useCallback((trackIndex: number): number => {
|
|
const track = tracks[trackIndex]
|
|
if (!track) return videoTrackHeight
|
|
if (track.type === 'subtitle') return subtitleTrackHeight
|
|
return track.kind === 'audio' ? audioTrackHeight : videoTrackHeight
|
|
}, [tracks, videoTrackHeight, audioTrackHeight, subtitleTrackHeight])
|
|
|
|
const trackTopPx = useCallback((realTrackIndex: number, padding = 0): number => {
|
|
const displayRow = trackDisplayRow.get(realTrackIndex) ?? realTrackIndex
|
|
let top = 0
|
|
for (let r = 0; r < displayRow; r++) {
|
|
const entry = orderedTracks[r]
|
|
if (entry) {
|
|
top += entry.track.type === 'subtitle' ? subtitleTrackHeight : entry.track.kind === 'audio' ? audioTrackHeight : videoTrackHeight
|
|
}
|
|
}
|
|
if (audioDividerDisplayRow >= 0 && displayRow >= audioDividerDisplayRow) top += DIVIDER_H
|
|
return top + padding
|
|
}, [trackDisplayRow, audioDividerDisplayRow, orderedTracks, videoTrackHeight, audioTrackHeight, subtitleTrackHeight])
|
|
|
|
const cutPoints = useMemo(() => {
|
|
const points: { leftClip: TimelineClip; rightClip: TimelineClip; time: number; trackIndex: number; hasDissolve: boolean }[] = []
|
|
const byTrack: Map<number, TimelineClip[]> = new Map()
|
|
for (const clip of clips) {
|
|
if (!byTrack.has(clip.trackIndex)) byTrack.set(clip.trackIndex, [])
|
|
byTrack.get(clip.trackIndex)!.push(clip)
|
|
}
|
|
for (const [trackIdx, trackClips] of byTrack) {
|
|
const sorted = [...trackClips].sort((a, b) => a.startTime - b.startTime)
|
|
for (let i = 0; i < sorted.length - 1; i++) {
|
|
const left = sorted[i]
|
|
const right = sorted[i + 1]
|
|
const leftEnd = left.startTime + left.duration
|
|
if (Math.abs(leftEnd - right.startTime) < CUT_POINT_TOLERANCE) {
|
|
const hasDissolve = (left.transitionOut?.type === 'dissolve') || (right.transitionIn?.type === 'dissolve')
|
|
points.push({ leftClip: left, rightClip: right, time: leftEnd, trackIndex: trackIdx, hasDissolve })
|
|
}
|
|
}
|
|
}
|
|
return points
|
|
}, [clips])
|
|
|
|
const {
|
|
draggingClip,
|
|
resizingClip,
|
|
slipSlideClip,
|
|
lassoRect, setLassoRect,
|
|
handleRulerMouseDown,
|
|
expandWithLinkedClips,
|
|
handleClipMouseDown,
|
|
handleResizeStart,
|
|
handleTrackDrop,
|
|
lassoOriginRef,
|
|
} = useTimelineDrag({
|
|
activeTool, setActiveTool, lastTrimTool, setLastTrimTool,
|
|
pixelsPerSecond, totalDuration,
|
|
clips, setClips, tracks,
|
|
selectedClipIds, setSelectedClipIds,
|
|
currentTime, setCurrentTime, setIsPlaying,
|
|
snapEnabled, resolveClipPath, getMaxClipDuration, addClipToTimeline,
|
|
assets, timelines, activeTimeline, currentProjectId,
|
|
timelineRef, trackContainerRef,
|
|
orderedTracks, getTrackHeight, trackTopPx, cutPoints,
|
|
splitClipAtPlayhead, setSelectedSubtitleId, setSelectedGap,
|
|
audioTrackHeight, videoTrackHeight, subtitleTrackHeight,
|
|
})
|
|
|
|
const startSelectionLasso = useCallback((clientX: number, clientY: number, shiftKey: boolean) => {
|
|
setSelectedSubtitleId(null)
|
|
setEditingSubtitleId(null)
|
|
clearSelectedGap()
|
|
if (!shiftKey) {
|
|
setSelectedClipIds(new Set())
|
|
}
|
|
const container = trackContainerRef.current
|
|
if (!container) return
|
|
const rect = container.getBoundingClientRect()
|
|
lassoOriginRef.current = {
|
|
scrollLeft: container.scrollLeft,
|
|
containerLeft: rect.left,
|
|
containerTop: rect.top,
|
|
}
|
|
setLassoRect({
|
|
startX: clientX,
|
|
startY: clientY,
|
|
currentX: clientX,
|
|
currentY: clientY,
|
|
})
|
|
}, [clearSelectedGap, lassoOriginRef, setEditingSubtitleId, setLassoRect, setSelectedClipIds, setSelectedSubtitleId])
|
|
|
|
const handleClipContextMenu = (e: React.MouseEvent, clip: TimelineClip) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (!selectedClipIds.has(clip.id)) {
|
|
setSelectedClipIds(expandWithLinkedClips(new Set([clip.id])))
|
|
}
|
|
setClipContextMenu({ kind: 'clip', clipId: clip.id, x: e.clientX, y: e.clientY })
|
|
}
|
|
|
|
const handleTimelineBgContextMenu = (e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
const scrollLeft = (e.currentTarget as HTMLElement).scrollLeft || 0
|
|
const clickX = e.clientX - rect.left + scrollLeft
|
|
const clickTime = Math.max(0, clickX / pixelsPerSecond)
|
|
setCurrentTime(clickTime)
|
|
setClipContextMenu({ kind: 'background', x: e.clientX, y: e.clientY })
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!timelineContextMenu) return
|
|
const handler = () => setTimelineContextMenu(null)
|
|
window.addEventListener('click', handler)
|
|
return () => window.removeEventListener('click', handler)
|
|
}, [timelineContextMenu])
|
|
|
|
useEffect(() => {
|
|
if (!timelineContextMenu || !timelineContextMenuRef.current) return
|
|
const el = timelineContextMenuRef.current
|
|
const rect = el.getBoundingClientRect()
|
|
const vw = window.innerWidth
|
|
const vh = window.innerHeight
|
|
let { x, y } = timelineContextMenu
|
|
let adjusted = false
|
|
|
|
if (rect.right > vw - 8) { x = vw - rect.width - 8; adjusted = true }
|
|
if (rect.bottom > vh - 8) { y = vh - rect.height - 8; adjusted = true }
|
|
if (x < 8) { x = 8; adjusted = true }
|
|
if (y < 8) { y = 8; adjusted = true }
|
|
|
|
if (adjusted) {
|
|
el.style.left = `${x}px`
|
|
el.style.top = `${y}px`
|
|
}
|
|
}, [timelineContextMenu])
|
|
|
|
useEffect(() => {
|
|
if (!clipContextMenu) return
|
|
const handler = () => setClipContextMenu(null)
|
|
window.addEventListener('click', handler)
|
|
return () => window.removeEventListener('click', handler)
|
|
}, [clipContextMenu])
|
|
|
|
useEffect(() => {
|
|
if (!clipContextMenu || !clipContextMenuRef.current) return
|
|
const el = clipContextMenuRef.current
|
|
const rect = el.getBoundingClientRect()
|
|
const vw = window.innerWidth
|
|
const vh = window.innerHeight
|
|
let { x, y } = clipContextMenu
|
|
let adjusted = false
|
|
|
|
if (rect.right > vw - 8) { x = vw - rect.width - 8; adjusted = true }
|
|
if (rect.bottom > vh - 8) { y = vh - rect.height - 8; adjusted = true }
|
|
if (x < 8) { x = 8; adjusted = true }
|
|
if (y < 8) { y = 8; adjusted = true }
|
|
|
|
if (adjusted) {
|
|
el.style.left = `${x}px`
|
|
el.style.top = `${y}px`
|
|
}
|
|
}, [clipContextMenu])
|
|
|
|
const contextClip = clipContextMenu?.kind === 'clip'
|
|
? clips.find(clip => clip.id === clipContextMenu.clipId) ?? null
|
|
: null
|
|
|
|
const handleTimelineTabContextMenu = (e: React.MouseEvent, timelineId: string) => {
|
|
e.preventDefault()
|
|
if (onTimelinePanelContextMenu) {
|
|
onTimelinePanelContextMenu(e, timelineId)
|
|
}
|
|
setTimelineContextMenu({ timelineId, x: e.clientX, y: e.clientY })
|
|
}
|
|
|
|
useEffect(() => {
|
|
const onExternalOpen = (event: Event) => {
|
|
const custom = event as CustomEvent<{ timelineId: string; x: number; y: number }>
|
|
if (!custom.detail) return
|
|
setTimelineContextMenu({
|
|
timelineId: custom.detail.timelineId,
|
|
x: custom.detail.x,
|
|
y: custom.detail.y,
|
|
})
|
|
}
|
|
window.addEventListener('video-editor:open-timeline-menu', onExternalOpen as EventListener)
|
|
return () => {
|
|
window.removeEventListener('video-editor:open-timeline-menu', onExternalOpen as EventListener)
|
|
}
|
|
}, [])
|
|
|
|
const syncPlayheadPosition = useCallback((time: number) => {
|
|
const container = trackContainerRef.current
|
|
const scrollLeft = container?.scrollLeft || 0
|
|
const leftPx = time * pixelsPerSecond
|
|
|
|
if (playheadRulerRef.current) {
|
|
playheadRulerRef.current.style.left = `${leftPx}px`
|
|
}
|
|
if (playheadOverlayRef.current) {
|
|
playheadOverlayRef.current.style.left = `${leftPx - scrollLeft}px`
|
|
}
|
|
}, [pixelsPerSecond])
|
|
|
|
const syncTimelineTimecode = useCallback((time: number) => {
|
|
if (editingTimecode) return
|
|
const el = timelineTimecodeRef.current
|
|
if (!el) return
|
|
const nextText = formatTime(time)
|
|
if (el.textContent !== nextText) {
|
|
el.textContent = nextText
|
|
}
|
|
}, [editingTimecode])
|
|
|
|
const syncTimelineScrollMirrors = useCallback(() => {
|
|
const container = trackContainerRef.current
|
|
if (!container) return
|
|
|
|
if (trackHeadersRef.current) {
|
|
trackHeadersRef.current.scrollTop = container.scrollTop
|
|
}
|
|
if (rulerScrollRef.current) {
|
|
rulerScrollRef.current.scrollLeft = container.scrollLeft
|
|
}
|
|
}, [])
|
|
|
|
const maybeAutoScrollToPlayhead = useCallback((time: number) => {
|
|
const container = trackContainerRef.current
|
|
if (!container) return
|
|
|
|
const playheadX = time * pixelsPerSecond
|
|
const { scrollLeft, clientWidth } = container
|
|
const margin = 80
|
|
let nextScrollLeft = scrollLeft
|
|
|
|
if (playheadX > scrollLeft + clientWidth - margin) {
|
|
nextScrollLeft = playheadX - clientWidth + margin
|
|
} else if (playheadX < scrollLeft + margin) {
|
|
nextScrollLeft = Math.max(0, playheadX - margin)
|
|
}
|
|
|
|
if (nextScrollLeft !== scrollLeft) {
|
|
container.scrollLeft = nextScrollLeft
|
|
if (rulerScrollRef.current) {
|
|
rulerScrollRef.current.scrollLeft = nextScrollLeft
|
|
}
|
|
}
|
|
}, [pixelsPerSecond])
|
|
|
|
const handleTimelineScroll = useCallback(() => {
|
|
syncTimelineScrollMirrors()
|
|
syncPlayheadPosition(isPlaying ? playbackTimeRef.current : currentTime)
|
|
}, [currentTime, isPlaying, playbackTimeRef, syncPlayheadPosition, syncTimelineScrollMirrors])
|
|
|
|
const handleFitToView = useCallback(() => {
|
|
const container = trackContainerRef.current
|
|
if (!container || totalDuration <= 0) return
|
|
const containerWidth = container.clientWidth - 20
|
|
const idealZoom = containerWidth / (totalDuration * 100)
|
|
setZoom(Math.min(4, Math.max(getMinZoom(), +idealZoom.toFixed(2))))
|
|
}, [totalDuration, setZoom, getMinZoom])
|
|
|
|
useEffect(() => {
|
|
if (!isPlaying) return
|
|
|
|
let animFrameId = 0
|
|
const tick = () => {
|
|
const playheadTime = playbackTimeRef.current
|
|
maybeAutoScrollToPlayhead(playheadTime)
|
|
syncPlayheadPosition(playheadTime)
|
|
syncTimelineTimecode(playheadTime)
|
|
animFrameId = requestAnimationFrame(tick)
|
|
}
|
|
|
|
animFrameId = requestAnimationFrame(tick)
|
|
return () => {
|
|
cancelAnimationFrame(animFrameId)
|
|
}
|
|
}, [isPlaying, maybeAutoScrollToPlayhead, playbackTimeRef, syncPlayheadPosition, syncTimelineTimecode])
|
|
|
|
useEffect(() => {
|
|
if (isPlaying) return
|
|
syncPlayheadPosition(currentTime)
|
|
syncTimelineTimecode(currentTime)
|
|
}, [currentTime, isPlaying, syncPlayheadPosition, syncTimelineTimecode])
|
|
|
|
useEffect(() => {
|
|
if (!centerOnPlayheadRef.current) return
|
|
centerOnPlayheadRef.current = false
|
|
|
|
const container = trackContainerRef.current
|
|
if (!container) return
|
|
|
|
const playheadTime = isPlaying ? playbackTimeRef.current : currentTime
|
|
const playheadX = playheadTime * pixelsPerSecond
|
|
const centerScroll = playheadX - container.clientWidth / 2
|
|
container.scrollLeft = Math.max(0, centerScroll)
|
|
if (rulerScrollRef.current) {
|
|
rulerScrollRef.current.scrollLeft = container.scrollLeft
|
|
}
|
|
syncPlayheadPosition(playheadTime)
|
|
syncTimelineTimecode(playheadTime)
|
|
}, [centerOnPlayheadRef, currentTime, isPlaying, pixelsPerSecond, playbackTimeRef, syncPlayheadPosition, syncTimelineTimecode])
|
|
|
|
useEffect(() => {
|
|
syncTimelineTimecode(currentTime)
|
|
}, [currentTime, syncTimelineTimecode])
|
|
|
|
useEffect(() => {
|
|
const container = trackContainerRef.current
|
|
if (!container) return
|
|
|
|
const handleWheel = (e: WheelEvent) => {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault()
|
|
centerOnPlayheadRef.current = true
|
|
const delta = e.deltaY > 0 ? -0.15 : 0.15
|
|
setZoom((prev: number) => Math.min(4, Math.max(getMinZoom(), +(prev + delta).toFixed(2))))
|
|
}
|
|
}
|
|
|
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
|
return () => container.removeEventListener('wheel', handleWheel)
|
|
}, [setZoom, centerOnPlayheadRef, getMinZoom])
|
|
|
|
useEffect(() => {
|
|
fitToViewRef.current = handleFitToView
|
|
}, [fitToViewRef, handleFitToView])
|
|
|
|
const rulerInterval = useMemo(() => {
|
|
const minLabelSpacing = 80
|
|
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]
|
|
for (const interval of intervals) {
|
|
if (interval * pixelsPerSecond >= minLabelSpacing) return interval
|
|
}
|
|
return 600
|
|
}, [pixelsPerSecond])
|
|
|
|
const rulerSubInterval = useMemo(() => {
|
|
if (rulerInterval <= 1) return 0.5
|
|
if (rulerInterval <= 5) return 1
|
|
if (rulerInterval <= 15) return 5
|
|
if (rulerInterval <= 60) return 10
|
|
if (rulerInterval <= 300) return 60
|
|
return 60
|
|
}, [rulerInterval])
|
|
|
|
// --- Extracted timeline subtree from VideoEditor ---
|
|
return (
|
|
<>
|
|
<div className="h-full min-h-0 flex flex-col">
|
|
{/* Timeline Tabs */}
|
|
<div className="h-8 bg-zinc-900 flex items-center px-1 gap-0.5 overflow-x-auto flex-shrink-0">
|
|
{timelines.filter(tl => openTimelineIds.has(tl.id)).map(tl => (
|
|
<div
|
|
key={tl.id}
|
|
className={`group flex items-center gap-1 pl-3 pr-1 h-6 rounded-t text-xs font-medium cursor-pointer transition-colors flex-shrink-0 ${
|
|
tl.id === activeTimeline?.id
|
|
? 'bg-zinc-950 text-white border-t border-l border-r border-zinc-700'
|
|
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50'
|
|
}`}
|
|
onClick={() => handleSwitchTimeline(tl.id)}
|
|
onDoubleClick={() => handleStartRename(tl.id, tl.name)}
|
|
onContextMenu={(e) => handleTimelineTabContextMenu(e, tl.id)}
|
|
>
|
|
{renamingTimelineId === tl.id && renameSource === 'tab' ? (
|
|
<input
|
|
ref={renameInputRef}
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={handleFinishRename}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleFinishRename()
|
|
if (e.key === 'Escape') { setRenamingTimelineId(null); setRenameValue('') }
|
|
}}
|
|
className="bg-transparent border-b border-blue-500 outline-none text-white text-xs w-20"
|
|
autoFocus
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<span className="truncate max-w-[120px]">{tl.name}</span>
|
|
)}
|
|
{/* Close tab button */}
|
|
<Tooltip content="Close tab" side="bottom">
|
|
<button
|
|
className={`ml-0.5 p-0.5 rounded transition-colors flex-shrink-0 ${
|
|
tl.id === activeTimeline?.id
|
|
? 'text-zinc-500 hover:text-white hover:bg-zinc-700'
|
|
: 'text-zinc-600 opacity-0 group-hover:opacity-100 hover:text-zinc-300 hover:bg-zinc-700'
|
|
}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleCloseTimelineTab(tl.id)
|
|
}}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
))}
|
|
|
|
{/* Add timeline button */}
|
|
<Tooltip content="New timeline" side="bottom">
|
|
<button
|
|
onClick={handleAddTimeline}
|
|
className="flex items-center justify-center w-6 h-6 rounded text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors flex-shrink-0"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
|
|
|
|
{/* Context menu */}
|
|
{timelineContextMenu && (
|
|
<div
|
|
ref={timelineContextMenuRef}
|
|
className="fixed bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl py-1 z-50 min-w-[140px]"
|
|
style={{ left: timelineContextMenu.x, top: timelineContextMenu.y }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
const tl = timelines.find(t => t.id === timelineContextMenu.timelineId)
|
|
if (tl) handleStartRename(tl.id, tl.name, 'panel')
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
Rename
|
|
</button>
|
|
<button
|
|
onClick={() => handleDuplicateTimeline(timelineContextMenu.timelineId)}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
Duplicate
|
|
</button>
|
|
<div className="h-px bg-zinc-700 my-0.5" />
|
|
<button
|
|
disabled={true}
|
|
title="Coming Soon!"
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-500 flex items-center gap-2 opacity-50 cursor-not-allowed"
|
|
>
|
|
<ZoomIn className="h-3 w-3" />
|
|
Upscale Timeline
|
|
</button>
|
|
<div className="h-px bg-zinc-700 my-0.5" />
|
|
<button
|
|
onClick={() => {
|
|
actions.openImportTimelineModal()
|
|
setTimelineContextMenu(null)
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<FileUp className="h-3 w-3" />
|
|
Import XML Timeline
|
|
</button>
|
|
<div className="relative group/export">
|
|
<button
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<Upload className="h-3 w-3" />
|
|
Export
|
|
<ChevronRight className="h-3 w-3 ml-auto text-zinc-500" />
|
|
</button>
|
|
<div className="absolute left-full top-0 ml-0.5 min-w-[160px] bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl py-1 z-50 hidden group-hover/export:block">
|
|
<button
|
|
onClick={() => {
|
|
actions.openExportModal()
|
|
setTimelineContextMenu(null)
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<Upload className="h-3 w-3" />
|
|
Export Timeline...
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
handleExportTimelineXml()
|
|
setTimelineContextMenu(null)
|
|
}}
|
|
disabled={clips.length === 0}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2 disabled:opacity-40"
|
|
>
|
|
<FileDown className="h-3 w-3" />
|
|
Export as FCP 7 XML
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="h-px bg-zinc-700 my-0.5" />
|
|
<button
|
|
onClick={() => {
|
|
handleCloseTimelineTab(timelineContextMenu.timelineId)
|
|
setTimelineContextMenu(null)
|
|
}}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
Close Tab
|
|
</button>
|
|
{timelines.length > 1 && (
|
|
<>
|
|
<button
|
|
onClick={() => handleDeleteTimeline(timelineContextMenu.timelineId)}
|
|
className="w-full text-left px-3 py-1.5 text-xs text-red-400 hover:bg-zinc-700 flex items-center gap-2"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
Delete
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Timeline with Tools */}
|
|
<div className="bg-zinc-950 border-t border-zinc-800 flex overflow-hidden flex-1 min-h-0">
|
|
{/* Tools Panel */}
|
|
<div className="w-10 flex-shrink-0 bg-zinc-900 border-r border-zinc-800 flex flex-col items-center py-1 gap-0.5 overflow-hidden">
|
|
{PRIMARY_TOOLS.map(tool => (
|
|
<Tooltip key={tool.id} content={tooltipLabel(tool.label, getShortcutLabel(kbLayout, tool.actionId))} side="right">
|
|
<button
|
|
onClick={() => setActiveTool(tool.id)}
|
|
className={`p-1.5 rounded-lg transition-colors relative group flex-shrink-0 ${
|
|
activeTool === tool.id
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<tool.icon className="h-4 w-4" />
|
|
<div className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 rounded text-xs text-white whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50">
|
|
{(() => { const s = getShortcutLabel(kbLayout, tool.actionId); return <>{tool.label}{s && <span className="text-zinc-400"> ({s})</span>}</>; })()}
|
|
</div>
|
|
</button>
|
|
</Tooltip>
|
|
))}
|
|
|
|
{/* Trim tools group button */}
|
|
{(() => {
|
|
const trimToolIds = new Set(TRIM_TOOLS.map(t => t.id))
|
|
const isTrimActive = trimToolIds.has(activeTool)
|
|
const currentTrimTool = TRIM_TOOLS.find(t => t.id === (isTrimActive ? activeTool : lastTrimTool)) || TRIM_TOOLS[0]
|
|
return (
|
|
<div className="relative flex-shrink-0">
|
|
<Tooltip content={(() => { const s = getShortcutLabel(kbLayout, currentTrimTool.actionId); return s ? `${currentTrimTool.label} (${s}) — right-click or hold for more` : `${currentTrimTool.label} — right-click or hold for more` })()} side="right">
|
|
<button
|
|
onClick={() => {
|
|
if (trimFlyoutOpenedRef.current) { trimFlyoutOpenedRef.current = false; return }
|
|
setActiveTool(currentTrimTool.id)
|
|
setLastTrimTool(currentTrimTool.id)
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (trimLongPressRef.current) { clearTimeout(trimLongPressRef.current); trimLongPressRef.current = null }
|
|
trimFlyoutOpenedRef.current = true
|
|
setShowTrimFlyout(true)
|
|
}}
|
|
onMouseDown={(e) => {
|
|
if (e.button !== 0) return
|
|
trimFlyoutOpenedRef.current = false
|
|
trimLongPressRef.current = setTimeout(() => {
|
|
trimLongPressRef.current = null
|
|
trimFlyoutOpenedRef.current = true
|
|
setShowTrimFlyout(true)
|
|
}, 400)
|
|
}}
|
|
onMouseUp={() => {
|
|
if (trimLongPressRef.current) { clearTimeout(trimLongPressRef.current); trimLongPressRef.current = null }
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (trimLongPressRef.current) { clearTimeout(trimLongPressRef.current); trimLongPressRef.current = null }
|
|
}}
|
|
data-trim-group-btn=""
|
|
className={`p-1.5 rounded-lg transition-colors relative group ${
|
|
isTrimActive
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<currentTrimTool.icon className="h-4 w-4" />
|
|
<div className="absolute bottom-0 right-0 w-0 h-0 border-l-[4px] border-l-transparent border-b-[4px] border-b-current opacity-60" />
|
|
<div className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 rounded text-xs text-white whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50">
|
|
{(() => { const s = getShortcutLabel(kbLayout, currentTrimTool.actionId); return <>{currentTrimTool.label}{s && <span className="text-zinc-400"> ({s})</span>}</>; })()}
|
|
</div>
|
|
</button>
|
|
</Tooltip>
|
|
{showTrimFlyout && (() => {
|
|
const btnEl = document.querySelector('[data-trim-group-btn]')
|
|
const rect = btnEl?.getBoundingClientRect()
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-[9998]" onMouseDown={() => setShowTrimFlyout(false)} onContextMenu={(e) => { e.preventDefault(); setShowTrimFlyout(false) }} />
|
|
<div
|
|
className="fixed bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl py-1 z-[9999] min-w-[160px]"
|
|
style={{ top: rect?.top ?? 0, left: (rect?.right ?? 44) + 4 }}
|
|
>
|
|
{TRIM_TOOLS.map(t => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => {
|
|
setActiveTool(t.id)
|
|
setLastTrimTool(t.id)
|
|
setShowTrimFlyout(false)
|
|
}}
|
|
className={`w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
|
|
activeTool === t.id ? 'bg-blue-600/30 text-white' : 'text-zinc-300 hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
<t.icon className="h-3.5 w-3.5" />
|
|
<span className="flex-1">{t.label}</span>
|
|
<span className="text-zinc-500 text-[10px]">{getShortcutLabel(kbLayout, t.actionId)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
<div className="w-6 h-px bg-zinc-700 my-1 flex-shrink-0" />
|
|
|
|
<Tooltip content={snapEnabled ? 'Snapping On' : 'Snapping Off'} side="right">
|
|
<button
|
|
onClick={() => setSnapEnabled(!snapEnabled)}
|
|
className={`p-1.5 rounded-lg transition-colors flex-shrink-0 ${
|
|
snapEnabled
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<Magnet className="h-4 w-4" />
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{/* EFFECTS HIDDEN - FX button hidden because effects are not applied during export
|
|
<div className="w-6 h-px bg-zinc-700 my-1 flex-shrink-0" />
|
|
|
|
<button
|
|
onClick={() => setShowEffectsBrowser(!showEffectsBrowser)}
|
|
className={`p-1.5 rounded-lg transition-colors flex-shrink-0 text-[10px] font-bold ${
|
|
showEffectsBrowser
|
|
? 'bg-blue-600 text-white'
|
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
|
|
}`}
|
|
title="Effects Browser"
|
|
>
|
|
FX
|
|
</button>
|
|
EFFECTS HIDDEN */}
|
|
|
|
<div className="w-6 h-px bg-zinc-700 my-1 flex-shrink-0" />
|
|
|
|
<Tooltip content="Add Text Overlay" side="right">
|
|
<button
|
|
onClick={() => addTextClip()}
|
|
className="p-1.5 rounded-lg transition-colors flex-shrink-0 text-cyan-400 hover:bg-cyan-900/30 hover:text-cyan-300 group relative"
|
|
>
|
|
<Type className="h-4 w-4" />
|
|
<div className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 rounded text-xs text-white whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50">
|
|
Add Text Overlay
|
|
</div>
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{canUseIcLora && (
|
|
<>
|
|
<div className="w-6 h-px bg-zinc-700 my-1 flex-shrink-0" />
|
|
|
|
<Tooltip content="IC-LoRA Style Transfer" side="right">
|
|
<button
|
|
onClick={() => {
|
|
if (selectedClip?.type === 'video') {
|
|
handleICLoraClip(selectedClip)
|
|
}
|
|
}}
|
|
disabled={selectedClip?.type !== 'video'}
|
|
className="p-1.5 rounded-lg transition-colors flex-shrink-0 group relative text-amber-500/70 hover:bg-amber-900/30 hover:text-amber-400 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
<div className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 rounded text-xs text-white whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50">
|
|
IC-LoRA Style Transfer
|
|
</div>
|
|
</button>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
<Tooltip content={showPropertiesPanel ? 'Hide Properties Panel' : 'Show Properties Panel'} side="right">
|
|
<button
|
|
onClick={() => setShowPropertiesPanel(p => !p)}
|
|
className={`p-1.5 rounded-lg transition-colors flex-shrink-0 group relative ${
|
|
showPropertiesPanel ? 'bg-blue-600 text-white' : 'text-zinc-400 hover:bg-zinc-800 hover:text-white'
|
|
}`}
|
|
>
|
|
<PanelRight className="h-4 w-4" />
|
|
<div className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 rounded text-xs text-white whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50">
|
|
{showPropertiesPanel ? 'Hide Properties' : 'Show Properties'}
|
|
</div>
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* EFFECTS HIDDEN - Effects Browser panel hidden because effects are not applied during export */}
|
|
|
|
{/* Timeline content */}
|
|
<div className="flex-1 min-w-0 flex flex-col">
|
|
{/* Ruler row - fixed at top */}
|
|
<div className="flex flex-shrink-0">
|
|
<div
|
|
className="w-32 h-6 flex-shrink-0 border-b border-r border-zinc-800 bg-zinc-900 flex items-center justify-center cursor-text"
|
|
onClick={() => {
|
|
if (!editingTimecode) {
|
|
setTimecodeInput(formatTime(isPlaying ? playbackTimeRef.current : currentTime))
|
|
setEditingTimecode(true)
|
|
requestAnimationFrame(() => timecodeInputRef.current?.select())
|
|
}
|
|
}}
|
|
>
|
|
{editingTimecode ? (
|
|
<input
|
|
ref={timecodeInputRef}
|
|
autoFocus
|
|
className="w-full h-full bg-zinc-950 text-amber-400 text-[11px] font-mono font-medium text-center outline-none border-none tabular-nums tracking-tight px-1"
|
|
value={timecodeInput}
|
|
onChange={e => setTimecodeInput(e.target.value)}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter') {
|
|
const t = parseTime(timecodeInput)
|
|
if (t !== null) {
|
|
const clamped = Math.max(0, Math.min(totalDuration, t))
|
|
setCurrentTime(clamped)
|
|
playbackTimeRef.current = clamped
|
|
}
|
|
setEditingTimecode(false)
|
|
} else if (e.key === 'Escape') {
|
|
setEditingTimecode(false)
|
|
}
|
|
e.stopPropagation()
|
|
}}
|
|
onClick={e => e.stopPropagation()}
|
|
onBlur={() => setEditingTimecode(false)}
|
|
/>
|
|
) : (
|
|
<span
|
|
ref={timelineTimecodeRef}
|
|
className="text-[11px] font-mono font-medium text-amber-400 tabular-nums tracking-tight select-none"
|
|
>
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div ref={rulerScrollRef} className="flex-1 overflow-hidden">
|
|
<div
|
|
ref={timelineRef}
|
|
style={{ minWidth: `${totalDuration * pixelsPerSecond}px` }}
|
|
className={`h-6 bg-zinc-900 border-b border-zinc-800 relative select-none ${
|
|
'cursor-pointer'
|
|
}`}
|
|
onMouseDown={handleRulerMouseDown}
|
|
>
|
|
{(() => {
|
|
const ticks: React.ReactNode[] = []
|
|
// Render major + minor ticks up to totalDuration
|
|
const end = totalDuration + rulerInterval
|
|
for (let t = 0; t < end; t = +(t + rulerSubInterval).toFixed(4)) {
|
|
const isMajor = Math.abs(t % rulerInterval) < 0.001 || Math.abs(t % rulerInterval - rulerInterval) < 0.001
|
|
const leftPx = t * pixelsPerSecond
|
|
ticks.push(
|
|
<div
|
|
key={t}
|
|
className="absolute top-0 bottom-0"
|
|
style={{ left: `${leftPx}px` }}
|
|
>
|
|
<div className={`h-full border-l ${isMajor ? 'border-zinc-700' : 'border-zinc-800'}`} />
|
|
{isMajor && (
|
|
<span className="absolute left-1 bottom-0.5 text-[10px] text-zinc-500 whitespace-nowrap leading-none">
|
|
{formatTime(t)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
return ticks
|
|
})()}
|
|
{/* Dimmed region BEFORE In point on ruler */}
|
|
{inPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 left-0 bg-black/40 pointer-events-none z-10"
|
|
style={{ width: `${inPoint * pixelsPerSecond}px` }}
|
|
/>
|
|
)}
|
|
{/* Dimmed region AFTER Out point on ruler */}
|
|
{outPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 right-0 bg-black/40 pointer-events-none z-10"
|
|
style={{ left: `${outPoint * pixelsPerSecond}px` }}
|
|
/>
|
|
)}
|
|
{/* In/Out range highlight on ruler */}
|
|
{(inPoint !== null || outPoint !== null) && (
|
|
<div
|
|
className="absolute top-0 bottom-0 border-t-2 border-b-2 border-blue-400/60 pointer-events-none z-10"
|
|
style={{
|
|
left: `${(inPoint ?? 0) * pixelsPerSecond}px`,
|
|
width: `${((outPoint ?? totalDuration) - (inPoint ?? 0)) * pixelsPerSecond}px`,
|
|
}}
|
|
/>
|
|
)}
|
|
{/* In point bracket marker — draggable */}
|
|
{inPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 z-[15] cursor-ew-resize"
|
|
style={{ left: `${inPoint * pixelsPerSecond - 6}px`, width: 12 }}
|
|
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); markerDragOriginRef.current = 'timeline'; setDraggingMarker('timelineIn') }}
|
|
>
|
|
{/* Bracket shape */}
|
|
<div className="absolute top-0 bottom-0 left-[5px] w-1.5 bg-blue-400 rounded-l-sm flex flex-col justify-between pointer-events-none">
|
|
<div className="w-3 h-0.5 bg-blue-400 rounded-r" />
|
|
<div className="w-3 h-0.5 bg-blue-400 rounded-r" />
|
|
</div>
|
|
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 text-[8px] font-bold text-blue-400 whitespace-nowrap pointer-events-none">IN</div>
|
|
</div>
|
|
)}
|
|
{/* Out point bracket marker — draggable */}
|
|
{outPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 z-[15] cursor-ew-resize"
|
|
style={{ left: `${outPoint * pixelsPerSecond - 6}px`, width: 12 }}
|
|
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); markerDragOriginRef.current = 'timeline'; setDraggingMarker('timelineOut') }}
|
|
>
|
|
{/* Bracket shape */}
|
|
<div className="absolute top-0 bottom-0 left-[5px] w-1.5 bg-blue-400 rounded-r-sm flex flex-col justify-between pointer-events-none">
|
|
<div className="w-3 h-0.5 bg-blue-400 rounded-l -ml-1.5" />
|
|
<div className="w-3 h-0.5 bg-blue-400 rounded-l -ml-1.5" />
|
|
</div>
|
|
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 text-[8px] font-bold text-blue-400 whitespace-nowrap pointer-events-none">OUT</div>
|
|
</div>
|
|
)}
|
|
{/* Playhead (ruler) — position updated by rAF engine during playback */}
|
|
<div
|
|
ref={playheadRulerRef}
|
|
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-20 pointer-events-none"
|
|
style={{ left: `${currentTime * pixelsPerSecond}px` }}
|
|
>
|
|
<div className="absolute -top-1 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[6px] border-r-[6px] border-t-[8px] border-l-transparent border-r-transparent border-t-red-500" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tracks body - vertically scrollable */}
|
|
<div className="flex flex-1 min-h-0 flex-col">
|
|
{/* Scrollable tracks area */}
|
|
<div className="flex flex-1 min-h-0">
|
|
{/* Track headers column */}
|
|
<div className="w-32 flex-shrink-0 border-r border-zinc-800 bg-zinc-900 flex flex-col overflow-hidden">
|
|
{/* Add track buttons - pinned above scrollable area */}
|
|
<div className="flex-shrink-0 h-7 flex items-center px-2 gap-1.5 border-b border-zinc-700/50">
|
|
<button
|
|
onClick={() => addTrack('video')}
|
|
className="text-[10px] text-zinc-500 hover:text-zinc-300 flex items-center gap-0.5"
|
|
title="Add video track"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
V
|
|
</button>
|
|
<button
|
|
onClick={() => addTrack('audio')}
|
|
className="text-[10px] text-emerald-500/70 hover:text-emerald-400 flex items-center gap-0.5"
|
|
title="Add audio track"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
A
|
|
</button>
|
|
<div className="w-px h-3 bg-zinc-700" />
|
|
<button
|
|
onClick={() => addSubtitleTrack()}
|
|
className="text-[10px] text-amber-500/70 hover:text-amber-400 flex items-center gap-0.5"
|
|
title="Add subtitle track"
|
|
>
|
|
<MessageSquare className="h-3 w-3" />
|
|
Subs
|
|
</button>
|
|
<div className="w-px h-3 bg-zinc-700" />
|
|
<button
|
|
onClick={() => createAdjustmentLayerAsset()}
|
|
className="text-[10px] text-blue-400/70 hover:text-blue-300 flex items-center gap-0.5"
|
|
title="Create adjustment layer asset"
|
|
>
|
|
<Layers className="h-3 w-3" />
|
|
Adj
|
|
</button>
|
|
</div>
|
|
{/* Scrollable track headers - synced with vertical scroll */}
|
|
<div ref={trackHeadersRef} className="flex-1 overflow-hidden flex flex-col select-none">
|
|
{orderedTracks.map(({ track, realIndex, displayRow }) => (
|
|
<React.Fragment key={track.id}>
|
|
{/* Draggable divider between video and audio sections */}
|
|
{displayRow === audioDividerDisplayRow && (
|
|
<div
|
|
className="flex-shrink-0 bg-zinc-700/60 relative cursor-row-resize hover:bg-blue-500/30 transition-colors group/divider"
|
|
style={{ height: DIVIDER_H }}
|
|
onMouseDown={(e) => {
|
|
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)
|
|
}}
|
|
>
|
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-[1px]">
|
|
<div className="w-8 h-[1px] bg-zinc-500 group-hover/divider:bg-blue-400 transition-colors rounded-full" />
|
|
<div className="w-8 h-[1px] bg-zinc-500 group-hover/divider:bg-blue-400 transition-colors rounded-full" />
|
|
</div>
|
|
</div>
|
|
<span className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2 text-[7px] font-bold text-zinc-400 bg-zinc-800 px-1.5 rounded-sm leading-none pointer-events-none">V | A</span>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={`group flex-shrink-0 border-b border-zinc-800 text-xs relative ${
|
|
track.type === 'subtitle'
|
|
? 'bg-amber-950/20 px-1.5 flex flex-col justify-center gap-0'
|
|
: track.kind === 'audio'
|
|
? 'bg-emerald-950/10 px-2 flex items-center justify-between'
|
|
: 'px-2 flex items-center justify-between'
|
|
}`}
|
|
style={{ height: track.type === 'subtitle' ? subtitleTrackHeight : track.kind === 'audio' ? audioTrackHeight : videoTrackHeight }}
|
|
>
|
|
{track.type === 'subtitle' ? (
|
|
<>
|
|
{/* Row 1: track name */}
|
|
<div className="flex items-center gap-1">
|
|
<MessageSquare className="h-3 w-3 text-amber-500/60 flex-shrink-0" />
|
|
<span className={`text-[10px] font-semibold truncate ${track.muted ? 'text-zinc-600' : 'text-amber-400/80'}`}>
|
|
{track.name}
|
|
</span>
|
|
</div>
|
|
{/* Row 2: tools */}
|
|
<div className="flex items-center gap-0">
|
|
<Tooltip content="Track style settings" side="right">
|
|
<button
|
|
onClick={() => setSubtitleTrackStyleIdx(subtitleTrackStyleIdx === realIndex ? null : realIndex)}
|
|
className={`p-0.5 rounded ${subtitleTrackStyleIdx === realIndex ? 'text-amber-400 bg-amber-900/30' : 'text-amber-500/60 hover:text-amber-400'}`}
|
|
>
|
|
<Palette className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Add subtitle" side="right">
|
|
<button
|
|
onClick={() => addSubtitleClip(realIndex)}
|
|
className="p-0.5 rounded text-amber-500/60 hover:text-amber-400"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content={track.locked ? 'Unlock' : 'Lock'} side="right">
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, locked: !t.locked} : t))}
|
|
className={`p-0.5 rounded ${track.locked ? 'text-yellow-400' : 'text-zinc-500 hover:text-zinc-300'}`}
|
|
>
|
|
{track.locked ? <Lock className="h-2.5 w-2.5" /> : <Unlock className="h-2.5 w-2.5" />}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content={track.muted ? 'Show subtitles' : 'Hide subtitles'} side="right">
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, muted: !t.muted} : t))}
|
|
className={`p-0.5 rounded ${track.muted ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}`}
|
|
>
|
|
{track.muted ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Delete track" side="right">
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(`Delete subtitle track "${track.name}"?`)) {
|
|
setTracks(tracks.filter((_, i) => i !== realIndex))
|
|
setSubtitles(prev => prev.filter(s => s.trackIndex !== realIndex))
|
|
}
|
|
}}
|
|
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-1 min-w-0 overflow-hidden">
|
|
<Tooltip content={track.sourcePatched !== false ? 'Source patched (click to unpatch)' : 'Source unpatched (click to patch)'} side="right">
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, sourcePatched: !(t.sourcePatched !== false)} : t))}
|
|
className={`p-0.5 rounded flex-shrink-0 transition-colors ${
|
|
track.sourcePatched !== false
|
|
? track.kind === 'audio'
|
|
? 'text-emerald-400 hover:text-emerald-300'
|
|
: 'text-blue-400 hover:text-blue-300'
|
|
: 'text-zinc-600 hover:text-zinc-400'
|
|
}`}
|
|
>
|
|
{track.sourcePatched !== false
|
|
? <CircleDot className="h-2.5 w-2.5" />
|
|
: <Circle className="h-2.5 w-2.5" />
|
|
}
|
|
</button>
|
|
</Tooltip>
|
|
<span className={`text-[10px] font-semibold truncate ${
|
|
track.muted ? 'text-zinc-600'
|
|
: track.kind === 'audio' ? 'text-emerald-400/80'
|
|
: 'text-zinc-300'
|
|
}`}>
|
|
{track.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-0 flex-shrink-0">
|
|
<Tooltip content={track.locked ? 'Unlock' : 'Lock'} side="right">
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, locked: !t.locked} : t))}
|
|
className={`p-0.5 rounded ${track.locked ? 'text-yellow-400' : 'text-zinc-500 hover:text-zinc-300'}`}
|
|
>
|
|
{track.locked ? <Lock className="h-2.5 w-2.5" /> : <Unlock className="h-2.5 w-2.5" />}
|
|
</button>
|
|
</Tooltip>
|
|
{track.kind !== 'audio' && (
|
|
<Tooltip content={track.enabled === false ? 'Enable track output' : 'Disable track output'} side="right">
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, enabled: !(t.enabled !== false)}: t))}
|
|
className={`p-0.5 rounded ${track.enabled === false ? 'text-zinc-600' : 'text-zinc-500 hover:text-zinc-300'}`}
|
|
>
|
|
{track.enabled === false ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
{track.kind !== 'audio' && (
|
|
<Tooltip content={track.muted ? 'Unmute' : 'Mute'} side="right">
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, muted: !t.muted} : t))}
|
|
className={`p-0.5 rounded ${track.muted ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}`}
|
|
>
|
|
{track.muted ? <VolumeX className="h-2.5 w-2.5" /> : <Volume2 className="h-2.5 w-2.5" />}
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
{track.kind === 'audio' && (
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, muted: !t.muted} : t))}
|
|
className={`px-1 py-0.5 rounded text-[10px] font-bold leading-none ${
|
|
track.muted ? 'bg-red-500/80 text-white' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700'
|
|
}`}
|
|
title={track.muted ? 'Unmute track' : 'Mute track'}
|
|
>
|
|
M
|
|
</button>
|
|
)}
|
|
{track.kind === 'audio' && (
|
|
<button
|
|
onClick={() => setTracks(tracks.map((t, i) => i === realIndex ? {...t, solo: !t.solo} : t))}
|
|
className={`px-1 py-0.5 rounded text-[10px] font-bold leading-none ${
|
|
track.solo ? 'bg-yellow-500/80 text-black' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700'
|
|
}`}
|
|
title={track.solo ? 'Unsolo track' : 'Solo track'}
|
|
>
|
|
S
|
|
</button>
|
|
)}
|
|
{tracks.length > 1 && (
|
|
<Tooltip content="Delete track" side="right">
|
|
<button
|
|
onClick={() => deleteTrack(realIndex)}
|
|
className="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{/* Track height resize handle */}
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-10 group/resize hover:bg-blue-500/40 transition-colors"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const isSubtitle = track.type === 'subtitle'
|
|
const isAudio = track.kind === 'audio'
|
|
const startY = e.clientY
|
|
const startH = isSubtitle ? subtitleTrackHeight : isAudio ? audioTrackHeight : videoTrackHeight
|
|
const onMove = (ev: MouseEvent) => {
|
|
const delta = ev.clientY - startY
|
|
const newH = Math.max(24, Math.min(200, startH + delta))
|
|
if (isSubtitle) setSubtitleTrackHeight(newH)
|
|
else if (isAudio) setAudioTrackHeight(newH)
|
|
else setVideoTrackHeight(newH)
|
|
}
|
|
const onUp = () => {
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
if (suppressGapClickRef.current) {
|
|
window.setTimeout(() => {
|
|
suppressGapClickRef.current = false
|
|
}, 0)
|
|
}
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}}
|
|
>
|
|
<div className="mx-auto w-6 h-0.5 bg-zinc-600 rounded-full mt-0.5 group-hover/resize:bg-blue-400 transition-colors" />
|
|
</div>
|
|
</div>
|
|
</React.Fragment>
|
|
))}
|
|
{/* Spacer at bottom of track list */}
|
|
<div className="h-4 flex-shrink-0" />
|
|
</div>{/* end trackHeadersRef */}
|
|
</div>{/* end track headers column */}
|
|
|
|
{/* Scrollable track content area */}
|
|
<div className="flex-1 flex flex-col min-w-0 relative overflow-hidden">
|
|
{/* Full-height playhead line — spans spacer + tracks, positioned on the wrapper */}
|
|
<div
|
|
ref={playheadOverlayRef}
|
|
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-30 pointer-events-none"
|
|
style={{ left: `${currentTime * pixelsPerSecond - (trackContainerRef.current?.scrollLeft || 0)}px` }}
|
|
/>
|
|
{/* Spacer matching the add-track button bar height */}
|
|
<div className="flex-shrink-0 h-7 border-b border-zinc-700/50" />
|
|
<div
|
|
ref={trackContainerRef}
|
|
className="flex-1 overflow-auto select-none"
|
|
onScroll={handleTimelineScroll}
|
|
>
|
|
<div
|
|
style={{ minWidth: `${totalDuration * pixelsPerSecond}px`,
|
|
...(activeTool === 'blade' ? { cursor: SCISSORS_CURSOR }
|
|
: activeTool === 'trackForward' ? { cursor: bladeShiftHeld ? TRACK_FWD_ONE_CURSOR : TRACK_FWD_ALL_CURSOR }
|
|
: {}),
|
|
}}
|
|
className="relative"
|
|
onDragOver={(e) => {
|
|
// Allow asset/timeline drops anywhere on the timeline area
|
|
if (e.dataTransfer.types.includes('assetid') || e.dataTransfer.types.includes('assetids') || e.dataTransfer.types.includes('asset') || e.dataTransfer.types.includes('timeline')) {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'copy'
|
|
}
|
|
}}
|
|
onDrop={(e) => {
|
|
// Determine which track the drop landed on from the Y position
|
|
const container = trackContainerRef.current
|
|
if (!container) return
|
|
const rect = container.getBoundingClientRect()
|
|
const yInContainer = e.clientY - rect.top + container.scrollTop
|
|
let droppedTrackIndex = 0
|
|
let accY = 0
|
|
for (const entry of orderedTracks) {
|
|
if (entry.displayRow === audioDividerDisplayRow) accY += DIVIDER_H
|
|
const th = entry.track.type === 'subtitle' ? subtitleTrackHeight : entry.track.kind === 'audio' ? audioTrackHeight : videoTrackHeight
|
|
if (yInContainer >= accY && yInContainer < accY + th) {
|
|
droppedTrackIndex = entry.realIndex
|
|
break
|
|
}
|
|
accY += th
|
|
droppedTrackIndex = entry.realIndex
|
|
}
|
|
handleTrackDrop(e, droppedTrackIndex)
|
|
}}
|
|
onContextMenu={(e) => {
|
|
// Right-click on background (not on a clip) opens paste menu
|
|
if (e.target === e.currentTarget || (e.target as HTMLElement).closest('[data-track-bg]')) {
|
|
handleTimelineBgContextMenu(e)
|
|
}
|
|
}}
|
|
onMouseDown={(e) => {
|
|
// Only start lasso on direct click on the tracks area (not on clips)
|
|
if (e.target === e.currentTarget || (e.target as HTMLElement).closest('[data-track-bg]')) {
|
|
if (activeTool === 'trackForward') {
|
|
setSelectedSubtitleId(null)
|
|
setEditingSubtitleId(null)
|
|
clearSelectedGap()
|
|
// Track Select Forward: click empty area → select all clips from click time forward
|
|
const container = trackContainerRef.current
|
|
if (container) {
|
|
const rect = container.getBoundingClientRect()
|
|
const scrollLeft = container.scrollLeft
|
|
const scrollTop = container.scrollTop
|
|
const clickX = e.clientX - rect.left + scrollLeft
|
|
const clickY = e.clientY - rect.top + scrollTop
|
|
const clickTime = clickX / pixelsPerSecond
|
|
|
|
// Determine which track was clicked using display ordering
|
|
let clickedRealTrackIndex = -1
|
|
let accY = 0
|
|
for (const entry of orderedTracks) {
|
|
if (entry.displayRow === audioDividerDisplayRow) accY += DIVIDER_H
|
|
const th = entry.track.type === 'subtitle' ? subtitleTrackHeight : entry.track.kind === 'audio' ? audioTrackHeight : videoTrackHeight
|
|
if (clickY >= accY && clickY < accY + th) {
|
|
clickedRealTrackIndex = entry.realIndex
|
|
break
|
|
}
|
|
accY += th
|
|
}
|
|
|
|
const forwardClips = clips.filter(c => {
|
|
if (e.shiftKey) {
|
|
return c.trackIndex === clickedRealTrackIndex && c.startTime >= clickTime - 0.01
|
|
} else {
|
|
return c.startTime >= clickTime - 0.01
|
|
}
|
|
})
|
|
setSelectedClipIds(new Set(forwardClips.map(c => c.id)))
|
|
}
|
|
} else if (activeTool === 'select') {
|
|
startSelectionLasso(e.clientX, e.clientY, e.shiftKey)
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{/* Dimmed region BEFORE In point on tracks */}
|
|
{inPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 left-0 bg-black/25 pointer-events-none z-[5]"
|
|
style={{ width: `${inPoint * pixelsPerSecond}px` }}
|
|
/>
|
|
)}
|
|
{/* Dimmed region AFTER Out point on tracks */}
|
|
{outPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 bg-black/25 pointer-events-none z-[5]"
|
|
style={{ left: `${outPoint * pixelsPerSecond}px`, right: 0 }}
|
|
/>
|
|
)}
|
|
{/* In/Out range highlight on tracks */}
|
|
{(inPoint !== null || outPoint !== null) && (
|
|
<div
|
|
className="absolute top-0 bottom-0 border-l-2 border-r-2 border-blue-400/40 pointer-events-none z-[5]"
|
|
style={{
|
|
left: `${(inPoint ?? 0) * pixelsPerSecond}px`,
|
|
width: `${((outPoint ?? totalDuration) - (inPoint ?? 0)) * pixelsPerSecond}px`,
|
|
}}
|
|
/>
|
|
)}
|
|
{/* In point line on tracks */}
|
|
{inPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 w-0.5 bg-blue-400/60 z-[15] pointer-events-none"
|
|
style={{ left: `${inPoint * pixelsPerSecond}px` }}
|
|
/>
|
|
)}
|
|
{/* Out point line on tracks */}
|
|
{outPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 w-0.5 bg-blue-400/60 z-[15] pointer-events-none"
|
|
style={{ left: `${outPoint * pixelsPerSecond}px` }}
|
|
/>
|
|
)}
|
|
{/* Playhead is now rendered as overlay on the column wrapper (playheadOverlayRef) */}
|
|
|
|
{orderedTracks.map(({ track, realIndex, displayRow }) => (
|
|
<React.Fragment key={track.id}>
|
|
{/* Divider between video and audio sections */}
|
|
{displayRow === audioDividerDisplayRow && (
|
|
<div
|
|
className="bg-zinc-700/60 cursor-row-resize hover:bg-blue-500/30 transition-colors"
|
|
style={{ height: DIVIDER_H }}
|
|
onMouseDown={(e) => {
|
|
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)
|
|
}}
|
|
/>
|
|
)}
|
|
<div
|
|
data-track-bg="true"
|
|
className={`border-b border-zinc-800 ${
|
|
track.type === 'subtitle'
|
|
? 'bg-amber-950/15'
|
|
: track.kind === 'audio'
|
|
? (displayRow % 2 === 0 ? 'bg-emerald-950/20' : 'bg-emerald-950/10')
|
|
: displayRow % 2 === 0 ? 'bg-zinc-900/50' : 'bg-zinc-950'
|
|
} ${track.locked ? 'opacity-50' : ''}`}
|
|
style={{ height: track.type === 'subtitle' ? subtitleTrackHeight : track.kind === 'audio' ? audioTrackHeight : videoTrackHeight }}
|
|
onDrop={(e) => {
|
|
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)
|
|
}
|
|
}}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
|
|
{/* 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 (
|
|
<div
|
|
className="absolute border border-blue-400 bg-blue-500/10 z-30 pointer-events-none rounded-sm"
|
|
style={{
|
|
left: x1,
|
|
top: y1,
|
|
width: x2 - x1,
|
|
height: y2 - y1,
|
|
}}
|
|
/>
|
|
)
|
|
})()}
|
|
|
|
{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 (
|
|
<div
|
|
key={clip.id}
|
|
data-clip-id={clip.id}
|
|
className={`absolute rounded border-2 transition-all overflow-hidden select-none ${
|
|
selectedClipIds.has(clip.id)
|
|
? 'border-blue-500 shadow-lg shadow-blue-500/20'
|
|
: clipColor
|
|
? `hover:brightness-125`
|
|
: 'border-zinc-600 hover:border-zinc-500'
|
|
} ${!clipColor ? (clip.type === 'audio' ? 'bg-green-900/50' : clip.type === 'adjustment' ? 'bg-blue-900/40 border-dashed' : clip.type === 'text' ? 'bg-cyan-900/50 border-cyan-600/40' : 'bg-zinc-800') : ''} ${
|
|
activeTool === 'select' || activeTool === 'ripple' || activeTool === 'roll' ? 'cursor-grab' : ''
|
|
} ${
|
|
activeTool === 'slip' ? 'cursor-ew-resize' : ''
|
|
} ${activeTool === 'slide' ? 'cursor-col-resize' : ''} ${
|
|
draggingClip?.clipId === clip.id || (draggingClip && selectedClipIds.has(clip.id)) ? 'opacity-80 cursor-grabbing z-30' : ''
|
|
} ${slipSlideClip?.clipId === clip.id ? 'opacity-90 ring-2 ring-yellow-500/50 z-30' : ''
|
|
}`}
|
|
style={{
|
|
left: `${clip.startTime * pixelsPerSecond}px`,
|
|
width: `${clip.duration * pixelsPerSecond}px`,
|
|
top: `${trackTopPx(clip.trackIndex, 4)}px`,
|
|
height: `${getTrackHeight(clip.trackIndex) - 8}px`,
|
|
...(activeTool === 'blade' ? { cursor: SCISSORS_CURSOR }
|
|
: activeTool === 'trackForward' ? { cursor: bladeShiftHeld ? TRACK_FWD_ONE_CURSOR : TRACK_FWD_ALL_CURSOR }
|
|
: {}),
|
|
...(clipColor ? {
|
|
backgroundColor: `${clipColor.color}80`,
|
|
borderColor: selectedClipIds.has(clip.id) ? undefined : clipColor.color,
|
|
} : {}),
|
|
}}
|
|
onMouseDown={(e) => 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 (
|
|
<div
|
|
className={`absolute top-0 bottom-0 w-px z-20 pointer-events-none ${isHoveredClip ? 'bg-red-500' : 'bg-red-500/60'}`}
|
|
style={{ left: `${indicatorPx}px` }}
|
|
>
|
|
<div className={`absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 rotate-45 ${isHoveredClip ? 'bg-red-500' : 'bg-red-500/60'}`} />
|
|
<div className={`absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 rotate-45 ${isHoveredClip ? 'bg-red-500' : 'bg-red-500/60'}`} />
|
|
</div>
|
|
)
|
|
})()}
|
|
<div className={`absolute left-0 top-0 bottom-0 w-4 flex items-center justify-center text-zinc-500 hover:text-white ${activeTool === 'trackForward' || activeTool === 'blade' ? '' : 'cursor-grab'}`}
|
|
style={activeTool === 'blade' ? { cursor: SCISSORS_CURSOR } : activeTool === 'trackForward' ? { cursor: bladeShiftHeld ? TRACK_FWD_ONE_CURSOR : TRACK_FWD_ALL_CURSOR } : {}}>
|
|
<GripVertical className="h-3 w-3" />
|
|
</div>
|
|
|
|
<div className="h-full flex items-center pl-5 pr-2 gap-2">
|
|
{clip.type === 'adjustment' ? (
|
|
<div className="h-8 w-8 flex-shrink-0 rounded bg-blue-800/30 border border-blue-600/30 flex items-center justify-center">
|
|
<Layers className="h-4 w-4 text-blue-400" />
|
|
</div>
|
|
) : clip.type === 'text' ? (
|
|
<div className="h-8 w-8 flex-shrink-0 rounded bg-cyan-800/30 border border-cyan-600/30 flex items-center justify-center">
|
|
<Type className="h-4 w-4 text-cyan-400" />
|
|
</div>
|
|
) : clip.type === 'audio' ? (
|
|
<>
|
|
<ClipWaveform url={pathToFileUrl(getClipPath(clip) || clip.asset?.path || '')} />
|
|
<div className="h-8 w-8 flex-shrink-0 rounded bg-emerald-800/50 flex items-center justify-center relative z-10">
|
|
<Music className="h-4 w-4 text-emerald-400" />
|
|
</div>
|
|
</>
|
|
) : 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 ? (
|
|
<img
|
|
key={`thumb-${clip.id}-${clip.takeIndex ?? 'default'}`}
|
|
src={pathToFileUrl(thumbPath)}
|
|
alt=""
|
|
className="h-8 aspect-video object-cover rounded"
|
|
/>
|
|
) : (
|
|
<div className="h-8 aspect-video rounded" />
|
|
)
|
|
})()}
|
|
<div className={`flex-1 min-w-0 ${clip.type === 'audio' ? 'relative z-10' : ''}`}>
|
|
<p className={`text-[10px] truncate ${clip.type === 'adjustment' ? 'text-blue-300' : clip.type === 'text' ? 'text-cyan-300' : clip.type === 'audio' ? 'text-emerald-300' : 'text-zinc-300'}`}>
|
|
{clip.type === 'adjustment' ? 'Adjustment Layer' : clip.type === 'text' ? (clip.textStyle?.text?.slice(0, 30) || 'Text') : clip.asset?.prompt?.slice(0, 30) || clip.importedName || 'Clip'}
|
|
</p>
|
|
<div className="flex items-center gap-2 text-[9px] text-zinc-500">
|
|
<span>{clip.duration.toFixed(1)}s</span>
|
|
{(() => {
|
|
const resInfo = getClipResolution(clip)
|
|
if (!resInfo) return null
|
|
return <span style={{ color: resInfo.color }} className="font-semibold">{resInfo.displayName}</span>
|
|
})()}
|
|
{clip.speed !== 1 && <span className="text-yellow-400">{clip.speed}x</span>}
|
|
{clip.reversed && <span className="text-blue-400">REV</span>}
|
|
{clip.muted && <span className="text-red-400">M</span>}
|
|
{(clip.flipH || clip.flipV) && <span className="text-cyan-400">FLIP</span>}
|
|
{clip.colorCorrection && Object.values(clip.colorCorrection).some(v => v !== 0) && <span className="text-orange-400">CC</span>}
|
|
{clip.letterbox?.enabled && <span className="text-blue-400">LB</span>}
|
|
{clip.linkedClipIds?.length && <Link2 className="h-2.5 w-2.5 text-zinc-500 inline" />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 (
|
|
<div className="flex-shrink-0 flex items-center gap-0.5" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
{/* Take navigation: prev/next */}
|
|
{liveAsset.takes && liveAsset.takes.length > 1 && (
|
|
<>
|
|
<Tooltip content="Previous take" side="top">
|
|
<button
|
|
onClick={() => handleClipTakeChange(clip.id, 'prev')}
|
|
className="p-0.5 rounded hover:bg-white/10 text-zinc-500 hover:text-white transition-colors"
|
|
>
|
|
<ChevronLeft className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
<span className="text-[8px] text-zinc-400 min-w-[24px] text-center">
|
|
{(clip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes.length - 1)) + 1}/{liveAsset.takes.length}
|
|
</span>
|
|
<Tooltip content="Next take" side="top">
|
|
<button
|
|
onClick={() => handleClipTakeChange(clip.id, 'next')}
|
|
className="p-0.5 rounded hover:bg-white/10 text-zinc-500 hover:text-white transition-colors"
|
|
>
|
|
<ChevronRight className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
<Tooltip content="Delete this take" side="top">
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(`Delete take ${(clip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes!.length - 1)) + 1}?`)) {
|
|
handleDeleteTake(clip.id)
|
|
}
|
|
}}
|
|
className="p-0.5 rounded hover:bg-red-900/50 text-zinc-500 hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
</button>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
<Tooltip content="Regenerate shot" side="top">
|
|
<button
|
|
onClick={() => handleRegenerate(clip.assetId!, clip.id)}
|
|
disabled={isRegenerating}
|
|
className={`p-0.5 rounded transition-colors ${
|
|
clip.isRegenerating
|
|
? 'text-blue-400'
|
|
: 'hover:bg-white/10 text-zinc-500 hover:text-blue-400'
|
|
}`}
|
|
>
|
|
<RefreshCw className={`h-3 w-3 ${clip.isRegenerating ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</Tooltip>
|
|
{canUseRetake && clip.type === 'video' && (
|
|
<Tooltip content="Retake section" side="top">
|
|
<button
|
|
onClick={() => requestRetakeClip(clip)}
|
|
className="p-0.5 rounded transition-colors hover:bg-white/10 text-zinc-500 hover:text-blue-400"
|
|
>
|
|
<Film className="h-3 w-3" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
|
|
{/* Regenerating overlay on the clip */}
|
|
{clip.isRegenerating && (
|
|
<div className="absolute inset-0 bg-blue-900/30 backdrop-blur-[2px] flex items-center justify-center rounded-lg z-10">
|
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-blue-900/80 border border-blue-500/40">
|
|
<Loader2 className="h-3 w-3 text-blue-300 animate-spin" />
|
|
<span className="text-[9px] text-blue-200 font-medium">
|
|
{regenProgress > 0 ? `${regenProgress}%` : 'Regenerating...'}
|
|
</span>
|
|
{canCancelInFlight && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleCancelRegeneration() }}
|
|
className="ml-1 px-1.5 py-0.5 rounded bg-zinc-800/80 border border-zinc-600/60 text-[9px] text-zinc-300 hover:text-red-400 hover:border-red-500/50 hover:bg-red-900/30 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* Transition in indicator */}
|
|
{clip.transitionIn?.type !== 'none' && clip.transitionIn?.duration > 0 && (
|
|
<div
|
|
className="absolute top-0 bottom-0 left-0 pointer-events-none"
|
|
style={{
|
|
width: `${Math.min(clip.transitionIn.duration / clip.duration * 100, 50)}%`,
|
|
background: 'linear-gradient(to right, rgba(139,92,246,0.4), transparent)',
|
|
}}
|
|
/>
|
|
)}
|
|
{/* Transition out indicator */}
|
|
{clip.transitionOut?.type !== 'none' && clip.transitionOut?.duration > 0 && (
|
|
<div
|
|
className="absolute top-0 bottom-0 right-0 pointer-events-none"
|
|
style={{
|
|
width: `${Math.min(clip.transitionOut.duration / clip.duration * 100, 50)}%`,
|
|
background: 'linear-gradient(to left, rgba(139,92,246,0.4), transparent)',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Resolution color-code bar at the bottom of the clip */}
|
|
{(() => {
|
|
const resInfo = getClipResolution(clip)
|
|
if (!resInfo) return null
|
|
return (
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-[3px] pointer-events-none"
|
|
style={{ backgroundColor: resInfo.color }}
|
|
title={resInfo.label}
|
|
/>
|
|
)
|
|
})()}
|
|
|
|
<div
|
|
className={`absolute left-0 top-0 bottom-0 w-3 ${activeTool === 'trackForward' || activeTool === 'blade' ? '' : 'cursor-ew-resize'} transition-colors flex items-center justify-center ${
|
|
resizingClip?.clipId === clip.id && resizingClip?.edge === 'left'
|
|
? activeTool === 'roll' ? 'bg-yellow-500' : activeTool === 'ripple' ? 'bg-green-500' : 'bg-blue-500'
|
|
: activeTool === 'roll' ? 'hover:bg-yellow-500/50' : activeTool === 'ripple' ? 'hover:bg-green-500/50' : 'hover:bg-blue-500/50'
|
|
}`}
|
|
style={activeTool === 'blade' ? { cursor: SCISSORS_CURSOR } : activeTool === 'trackForward' ? { cursor: bladeShiftHeld ? TRACK_FWD_ONE_CURSOR : TRACK_FWD_ALL_CURSOR } : {}}
|
|
onMouseDown={(e) => handleResizeStart(e, clip, 'left')}
|
|
>
|
|
<div className={`w-0.5 h-6 rounded-full ${
|
|
activeTool === 'roll' ? 'bg-yellow-300' : activeTool === 'ripple' ? 'bg-green-300' : 'bg-zinc-500'
|
|
}`} />
|
|
</div>
|
|
<div
|
|
className={`absolute right-0 top-0 bottom-0 w-3 ${activeTool === 'trackForward' || activeTool === 'blade' ? '' : 'cursor-ew-resize'} transition-colors flex items-center justify-center ${
|
|
resizingClip?.clipId === clip.id && resizingClip?.edge === 'right'
|
|
? activeTool === 'roll' ? 'bg-yellow-500' : activeTool === 'ripple' ? 'bg-green-500' : 'bg-blue-500'
|
|
: activeTool === 'roll' ? 'hover:bg-yellow-500/50' : activeTool === 'ripple' ? 'hover:bg-green-500/50' : 'hover:bg-blue-500/50'
|
|
}`}
|
|
style={activeTool === 'blade' ? { cursor: SCISSORS_CURSOR } : activeTool === 'trackForward' ? { cursor: bladeShiftHeld ? TRACK_FWD_ONE_CURSOR : TRACK_FWD_ALL_CURSOR } : {}}
|
|
onMouseDown={(e) => handleResizeStart(e, clip, 'right')}
|
|
>
|
|
<div className={`w-0.5 h-6 rounded-full ${
|
|
activeTool === 'roll' ? 'bg-yellow-300' : activeTool === 'ripple' ? 'bg-green-300' : 'bg-zinc-500'
|
|
}`} />
|
|
</div>
|
|
</div>
|
|
)})}
|
|
|
|
{/* 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 (
|
|
<div
|
|
key={`gap-${i}`}
|
|
className={`absolute rounded cursor-pointer transition-all group ${
|
|
isGeneratingHere
|
|
? 'bg-blue-500/15 border-2 border-dashed border-blue-400/60 shadow-inner'
|
|
: isSelected
|
|
? 'bg-blue-500/20 border-2 border-dashed border-blue-400/60 shadow-inner'
|
|
: 'border-2 border-dashed border-transparent hover:bg-blue-500/10 hover:border-blue-400/30'
|
|
}`}
|
|
style={{
|
|
left: `${leftPx}px`,
|
|
top: `${topPx}px`,
|
|
width: `${widthPx}px`,
|
|
height: `${getTrackHeight(gap.trackIndex) - 8}px`,
|
|
}}
|
|
onMouseDown={(e) => {
|
|
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 ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-0.5">
|
|
<Loader2 className="h-3 w-3 text-blue-400 animate-spin pointer-events-none" />
|
|
{widthPx > 50 && (
|
|
<span className="text-[9px] text-blue-300 font-medium pointer-events-none">
|
|
{gapGenerationApi.progress > 0 ? `${gapGenerationApi.progress}%` : 'Generating...'}
|
|
</span>
|
|
)}
|
|
{widthPx > 30 && (
|
|
<div className="w-3/4 h-0.5 bg-blue-900/40 rounded-full overflow-hidden pointer-events-none">
|
|
<div
|
|
className="h-full bg-blue-400 rounded-full transition-all duration-300"
|
|
style={{ width: `${Math.max(gapGenerationApi.progress, 2)}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{gapGenerationApi.canCancel && (
|
|
<Tooltip content="Cancel generation" side="top">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); cancelGapGeneration() }}
|
|
className="absolute top-0.5 right-0.5 p-0.5 rounded hover:bg-zinc-700/80 text-zinc-500 hover:text-red-400 transition-colors"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className={`absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity ${
|
|
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
}`}>
|
|
<span className="text-[9px] font-medium text-blue-400">
|
|
{(gap.endTime - gap.startTime).toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* 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 (
|
|
<div
|
|
key={sub.id}
|
|
className={`absolute rounded border-2 overflow-hidden cursor-pointer select-none flex items-center ${
|
|
isSelected
|
|
? 'border-amber-400 shadow-lg shadow-amber-500/20 bg-amber-900/60'
|
|
: 'border-amber-700/50 hover:border-amber-600/70 bg-amber-900/40'
|
|
} ${track.locked ? 'pointer-events-none opacity-50' : ''}`}
|
|
style={{
|
|
left: `${leftPx}px`,
|
|
top: `${topPx}px`,
|
|
width: `${widthPx}px`,
|
|
height: `${getTrackHeight(sub.trackIndex) - 8}px`,
|
|
}}
|
|
onClick={(e) => {
|
|
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 */}
|
|
<div className="flex-1 min-w-0 px-2 py-1">
|
|
{isEditing ? (
|
|
<input
|
|
autoFocus
|
|
defaultValue={sub.text}
|
|
className="w-full bg-transparent text-amber-100 text-[10px] leading-tight outline-none border-b border-amber-500/50"
|
|
onBlur={(e) => {
|
|
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()}
|
|
/>
|
|
) : (
|
|
<span className="text-[10px] text-amber-200 leading-tight line-clamp-2 break-all">
|
|
{sub.text}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Left resize handle */}
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-amber-400/30"
|
|
onMouseDown={(e) => {
|
|
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 */}
|
|
<div
|
|
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-amber-400/30"
|
|
onMouseDown={(e) => {
|
|
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)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* 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 (
|
|
<div
|
|
key={`cut-${cp.leftClip.id}-${cp.rightClip.id}`}
|
|
className="absolute z-20"
|
|
style={{
|
|
left: `${cp.hasDissolve ? leftPx - dissolveWidthPx : leftPx - 10}px`,
|
|
top: `${topPx - 24}px`,
|
|
width: `${cp.hasDissolve ? dissolveWidthPx * 2 : 20}px`,
|
|
height: `${48 + 24}px`, /* extend upward to include popup zone */
|
|
}}
|
|
onMouseEnter={() => setHoveredCutPoint({
|
|
leftClipId: cp.leftClip.id,
|
|
rightClipId: cp.rightClip.id,
|
|
time: cp.time,
|
|
trackIndex: cp.trackIndex,
|
|
})}
|
|
onMouseLeave={() => setHoveredCutPoint(null)}
|
|
>
|
|
{/* Visible indicator line */}
|
|
<div
|
|
className={`absolute top-6 bottom-0 w-0.5 transition-colors ${
|
|
isHovered ? 'bg-blue-400' : cp.hasDissolve ? 'bg-blue-500/60' : 'bg-transparent'
|
|
}`}
|
|
style={{ left: `${cp.hasDissolve ? dissolveWidthPx : 10}px`, transform: 'translateX(-50%)' }}
|
|
/>
|
|
|
|
{cp.hasDissolve ? (
|
|
<>
|
|
{/* Dissolve region visual (gradient bar on the clip area) */}
|
|
<div
|
|
className="absolute rounded-sm pointer-events-none"
|
|
style={{
|
|
left: 0,
|
|
top: '24px',
|
|
width: `${dissolveWidthPx * 2}px`,
|
|
height: '48px',
|
|
background: 'linear-gradient(to right, rgba(139,92,246,0.15), rgba(139,92,246,0.3), rgba(139,92,246,0.15))',
|
|
borderTop: '2px solid rgba(139,92,246,0.5)',
|
|
borderBottom: '2px solid rgba(139,92,246,0.5)',
|
|
}}
|
|
/>
|
|
|
|
{/* Dissolve duration label */}
|
|
<div
|
|
className="absolute flex items-center justify-center pointer-events-none"
|
|
style={{
|
|
left: 0,
|
|
top: '24px',
|
|
width: `${dissolveWidthPx * 2}px`,
|
|
height: '48px',
|
|
}}
|
|
>
|
|
<span className="text-[9px] text-blue-300 font-medium bg-blue-900/60 px-1.5 py-0.5 rounded">
|
|
{dissolveDur.toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
|
|
{/* Left drag handle */}
|
|
<div
|
|
className="absolute top-6 bottom-0 w-2 cursor-ew-resize hover:bg-blue-500/40 transition-colors z-30"
|
|
style={{ left: 0 }}
|
|
onMouseDown={(e) => {
|
|
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'
|
|
}}
|
|
>
|
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-blue-400 rounded-full" />
|
|
</div>
|
|
|
|
{/* Right drag handle */}
|
|
<div
|
|
className="absolute top-6 bottom-0 w-2 cursor-ew-resize hover:bg-blue-500/40 transition-colors z-30"
|
|
style={{ right: 0 }}
|
|
onMouseDown={(e) => {
|
|
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'
|
|
}}
|
|
>
|
|
<div className="absolute inset-y-0 right-0 w-0.5 bg-blue-400 rounded-full" />
|
|
</div>
|
|
|
|
{/* Remove button (shown on hover, positioned inside the zone) */}
|
|
{isHovered && (
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2 top-0 whitespace-nowrap z-40"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
className="px-2 py-0.5 rounded bg-red-900/80 border border-red-700 text-[9px] text-red-300 hover:bg-red-800 transition-colors shadow-lg"
|
|
onClick={() => removeCrossDissolve(cp.leftClip.id, cp.rightClip.id)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* No dissolve: show add button on hover (positioned inside the zone) */}
|
|
{isHovered && (
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2 top-0 whitespace-nowrap z-40"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
className="px-2 py-1 rounded-lg bg-blue-600/90 border border-blue-500 text-[10px] text-white hover:bg-blue-500 transition-colors shadow-lg flex items-center gap-1"
|
|
onClick={() => addCrossDissolve(cp.leftClip.id, cp.rightClip.id)}
|
|
>
|
|
<Film className="h-3 w-3" />
|
|
Dissolve
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>{/* close relative inner */}
|
|
</div>{/* close trackContainerRef */}
|
|
</div>{/* close content column */}
|
|
</div>{/* close scrollable area row */}
|
|
</div>{/* close tracks body flex-col */}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom toolbar with zoom bar */}
|
|
<div className="h-9 bg-zinc-900 border-t border-zinc-800 flex items-center px-3 gap-2 flex-shrink-0">
|
|
{selectedClip && (
|
|
<>
|
|
<div className="w-px h-4 bg-zinc-700" />
|
|
<div className="flex items-center gap-1.5 text-[10px] text-zinc-400">
|
|
<Gauge className="h-3 w-3" />
|
|
<select
|
|
value={selectedClip.speed}
|
|
onChange={(e) => {
|
|
const newSpeed = parseFloat(e.target.value)
|
|
const oldSpeed = selectedClip.speed
|
|
let newDuration = selectedClip.duration * (oldSpeed / newSpeed)
|
|
const maxDur = getMaxClipDuration({ ...selectedClip, speed: newSpeed })
|
|
newDuration = Math.min(newDuration, maxDur)
|
|
newDuration = Math.max(0.5, newDuration)
|
|
updateClip(selectedClip.id, { speed: newSpeed, duration: newDuration })
|
|
}}
|
|
className="bg-zinc-800 border border-zinc-700 rounded px-1.5 py-0.5 text-[10px] text-white"
|
|
>
|
|
<option value={0.25}>0.25x</option>
|
|
<option value={0.5}>0.5x</option>
|
|
<option value={0.75}>0.75x</option>
|
|
<option value={1}>1x</option>
|
|
<option value={1.25}>1.25x</option>
|
|
<option value={1.5}>1.5x</option>
|
|
<option value={2}>2x</option>
|
|
<option value={4}>4x</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="w-px h-4 bg-zinc-700" />
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 border-zinc-700 text-zinc-400 text-[10px] px-2"
|
|
onClick={() => actions.openExportModal()}
|
|
>
|
|
<Upload className="h-3 w-3 mr-1" />
|
|
Export
|
|
</Button>
|
|
|
|
|
|
{/* Subtitle import/export */}
|
|
{tracks.some(t => t.type === 'subtitle') && (
|
|
<>
|
|
<div className="w-px h-4 bg-zinc-700" />
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => subtitleFileInputRef.current?.click()}
|
|
className="h-6 px-2 rounded bg-amber-900/30 border border-amber-700/30 text-amber-400 hover:bg-amber-900/50 text-[10px] flex items-center gap-1 transition-colors"
|
|
title="Import SRT subtitles"
|
|
>
|
|
<FileUp className="h-3 w-3" />
|
|
Import SRT
|
|
</button>
|
|
<button
|
|
onClick={handleExportSrt}
|
|
disabled={subtitles.length === 0}
|
|
className="h-6 px-2 rounded bg-amber-900/30 border border-amber-700/30 text-amber-400 hover:bg-amber-900/50 text-[10px] flex items-center gap-1 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
title="Export SRT subtitles"
|
|
>
|
|
<FileDown className="h-3 w-3" />
|
|
Export SRT
|
|
</button>
|
|
</div>
|
|
<input
|
|
ref={subtitleFileInputRef}
|
|
type="file"
|
|
accept=".srt"
|
|
onChange={handleImportSrt}
|
|
className="hidden"
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Zoom slider bar */}
|
|
<div className="flex items-center gap-2">
|
|
<Tooltip content="Zoom out (-)" side="top">
|
|
<button
|
|
onClick={() => { centerOnPlayheadRef.current = true; setZoom(Math.max(getMinZoom(), +(zoom - 0.25).toFixed(2))) }}
|
|
className="p-0.5 rounded hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
>
|
|
<ZoomOut className="h-3.5 w-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
<input
|
|
type="range"
|
|
min={Math.max(1, Math.round(getMinZoom() * 100))}
|
|
max={400}
|
|
step={5}
|
|
value={Math.round(zoom * 100)}
|
|
onChange={(e) => { centerOnPlayheadRef.current = true; setZoom(Math.max(getMinZoom(), +(parseInt(e.target.value) / 100).toFixed(2))) }}
|
|
className="w-28 h-1 accent-blue-500 cursor-pointer"
|
|
title={`Zoom: ${Math.round(zoom * 100)}%`}
|
|
/>
|
|
<Tooltip content="Zoom in (+)" side="top">
|
|
<button
|
|
onClick={() => { centerOnPlayheadRef.current = true; setZoom(Math.min(4, +(zoom + 0.25).toFixed(2))) }}
|
|
className="p-0.5 rounded hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
>
|
|
<ZoomIn className="h-3.5 w-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
<span className="text-[10px] text-zinc-500 tabular-nums w-8 text-right">{Math.round(zoom * 100)}%</span>
|
|
<Tooltip content="Fit to view (Ctrl+0)" side="top">
|
|
<button
|
|
onClick={handleFitToView}
|
|
className="p-0.5 rounded hover:bg-zinc-800 text-zinc-500 hover:text-zinc-300 transition-colors ml-0.5"
|
|
>
|
|
<Maximize2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{clipContextMenu && (
|
|
<ClipContextMenu
|
|
clipContextMenu={clipContextMenu}
|
|
contextClip={contextClip}
|
|
clipContextMenuRef={clipContextMenuRef}
|
|
clips={clips}
|
|
tracks={tracks}
|
|
selectedClipIds={selectedClipIds}
|
|
setSelectedClipIds={setSelectedClipIds}
|
|
currentTime={currentTime}
|
|
hasClipboard={hasClipboard}
|
|
isRegenerating={isRegenerating}
|
|
currentProjectId={currentProjectId}
|
|
updateAsset={updateAsset}
|
|
handleCopy={handleCopy}
|
|
handleCut={handleCut}
|
|
handlePaste={handlePaste}
|
|
setClipContextMenu={setClipContextMenu}
|
|
addTextClip={addTextClip}
|
|
setClips={setClips}
|
|
handleRegenerate={handleRegenerate}
|
|
handleCancelRegeneration={handleCancelRegeneration}
|
|
canCancelInFlight={canCancelInFlight}
|
|
handleClipTakeChange={handleClipTakeChange}
|
|
handleDeleteTake={handleDeleteTake}
|
|
duplicateClip={duplicateClip}
|
|
splitClipAtPlayhead={splitClipAtPlayhead}
|
|
removeClip={removeClip}
|
|
updateClip={updateClip}
|
|
getLiveAsset={getLiveAsset}
|
|
getMaxClipDuration={getMaxClipDuration}
|
|
onRevealAsset={onRevealAsset}
|
|
onCreateVideoFromImage={onCreateVideoFromImage}
|
|
onRetakeClip={requestRetakeClip}
|
|
onICLoraClip={handleICLoraClip}
|
|
canUseIcLora={canUseIcLora}
|
|
canUseRetake={canUseRetake}
|
|
onCaptureFrameForVideo={onCaptureFrameForVideo}
|
|
onCreateVideoFromAudio={onCreateVideoFromAudio}
|
|
/>
|
|
)}
|
|
{selectedGap && tracks[selectedGap.trackIndex]?.kind !== 'audio' && (
|
|
<GapGenerationModal
|
|
selectedGap={selectedGap}
|
|
anchorPosition={selectedGapAnchor}
|
|
gapGenerateMode={gapGenerateMode}
|
|
setGapGenerateMode={setGapGenerateMode}
|
|
gapPrompt={gapPrompt}
|
|
setGapPrompt={setGapPrompt}
|
|
gapSuggesting={gapSuggesting}
|
|
gapSuggestion={gapSuggestion}
|
|
gapBeforeFrame={gapBeforeFrame}
|
|
gapAfterFrame={gapAfterFrame}
|
|
gapSettings={gapSettings}
|
|
setGapSettings={setGapSettings}
|
|
gapVideoModelSpecs={gapVideoModelSpecs}
|
|
gapVideoSettingsMessage={gapVideoSettingsMessage}
|
|
gapCanGenerateVideo={gapCanGenerateVideo}
|
|
gapImageFile={gapImageFile}
|
|
setGapImageFile={setGapImageFile}
|
|
gapImageInputRef={gapImageInputRef}
|
|
isRegenerating={gapGenerationApi.isGenerating}
|
|
regenStatusMessage={gapGenerationApi.statusMessage}
|
|
regenProgress={gapGenerationApi.progress}
|
|
regenReset={gapGenerationApi.reset}
|
|
handleGapGenerate={handleGapGenerate}
|
|
handleCloseGap={handleCloseGap}
|
|
setSelectedGap={setSelectedGap}
|
|
gapApplyAudioToTrack={gapApplyAudioToTrack}
|
|
setGapApplyAudioToTrack={setGapApplyAudioToTrack}
|
|
regenerateSuggestion={regenerateSuggestion}
|
|
gapSuggestionError={gapSuggestionError}
|
|
gapSuggestionNoApiKey={gapSuggestionNoApiKey}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|