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 = `` 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 = `` const TRACK_FWD_ALL_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(TRACK_FWD_ALL_SVG)}") 12 12, e-resize` const TRACK_FWD_ONE_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 generateImage: (prompt: string, settings: GenerationSettings) => Promise 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 centerOnPlayheadRef: React.MutableRefObject getMinZoom: () => number canUseIcLora: boolean handleICLoraClip: (clip: TimelineClip) => void kbLayout: any handleExportTimelineXml: () => void subtitleFileInputRef: React.RefObject handleImportSrt: (e: React.ChangeEvent) => void handleExportSrt: () => void gapGenerationApi: GapGenerationApi selectedGapRefBridge: React.MutableRefObject gapGenerateModeRefBridge: React.MutableRefObject clearSelectedGapRefBridge: React.MutableRefObject<() => void> closeSelectedGapRefBridge: React.MutableRefObject<() => void> timelineRefBridge: React.MutableRefObject trackContainerRefBridge: React.MutableRefObject trackHeadersRefBridge: React.MutableRefObject rulerScrollRefBridge: React.MutableRefObject 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) => { actions.setTimelineClips(value) }, [actions]) const setTracks = useCallback((value: React.SetStateAction) => { actions.setTimelineTracks(value) }, [actions]) const setSubtitles = useCallback((value: React.SetStateAction) => { 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) => { actions.setZoom(applyStateAction(value, zoom)) }, [actions, zoom]) const setSelectedClipIds = useCallback((value: React.SetStateAction>) => { 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) => { 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, 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) => { actions.updateClip(id, patch) }, [actions]) const updateAsset = useCallback((_projectId: string, assetId: string, updates: Partial) => { 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) => { actions.setEditingSubtitleId(value) }, [actions]) const updateSubtitle = useCallback((id: string, patch: Partial) => { 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(null) const [showTrimFlyout, setShowTrimFlyout] = useState(false) const trimLongPressRef = useRef | null>(null) const trimFlyoutOpenedRef = useRef(false) const [timelineContextMenu, setTimelineContextMenu] = useState(null) const timelineContextMenuRef = useRef(null) const [clipContextMenu, setClipContextMenu] = useState(null) const clipContextMenuRef = useRef(null) const renameInputRef = useRef(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(null) const [selectedGap, setSelectedGap] = useState(null) const [gapGenerateMode, setGapGenerateMode] = useState(null) const [gapPrompt, setGapPrompt] = useState('') const [gapSettings, setGapSettings] = useState({ model: 'fast', duration: 5, videoResolution: '540p', fps: 24, audio: true, cameraMotion: 'none', imageResolution: '1080p', imageAspectRatio: '16:9', imageSteps: 30, }) const [gapImageFile, setGapImageFile] = useState(null) const gapImageInputRef = useRef(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(null) const [gapSuggestionError, setGapSuggestionError] = useState(false) const [gapSuggestionNoApiKey, setGapSuggestionNoApiKey] = useState(false) const [gapBeforeFrame, setGapBeforeFrame] = useState(null) const [gapAfterFrame, setGapAfterFrame] = useState(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(selectedGap) selectedGapRef.current = selectedGap selectedGapRefBridge.current = selectedGap const gapGenerateModeRef = useRef(gapGenerateMode) gapGenerateModeRef.current = gapGenerateMode gapGenerateModeRefBridge.current = gapGenerateMode const closeGap = useCallback((gap: NonNullable) => { 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, 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(null) const gapPromptRef = useRef(gapPrompt) gapPromptRef.current = gapPrompt const gapImageFileRef = useRef(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[] = [] 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(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(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(null) const trackContainerRef = useRef(null) const trackHeadersRef = useRef(null) const rulerScrollRef = useRef(null) const playheadRulerRef = useRef(null) const playheadOverlayRef = useRef(null) const timelineTimecodeRef = useRef(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() 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 = 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 ( <>
{/* Timeline Tabs */}
{timelines.filter(tl => openTimelineIds.has(tl.id)).map(tl => (
handleSwitchTimeline(tl.id)} onDoubleClick={() => handleStartRename(tl.id, tl.name)} onContextMenu={(e) => handleTimelineTabContextMenu(e, tl.id)} > {renamingTimelineId === tl.id && renameSource === 'tab' ? ( 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()} /> ) : ( {tl.name} )} {/* Close tab button */}
))} {/* Add timeline button */} {/* Context menu */} {timelineContextMenu && (
e.stopPropagation()} >
{timelines.length > 1 && ( <> )}
)}
{/* Timeline with Tools */}
{/* Tools Panel */}
{PRIMARY_TOOLS.map(tool => ( ))} {/* 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 (
{ 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"> {showTrimFlyout && (() => { const btnEl = document.querySelector('[data-trim-group-btn]') const rect = btnEl?.getBoundingClientRect() return ( <>
setShowTrimFlyout(false)} onContextMenu={(e) => { e.preventDefault(); setShowTrimFlyout(false) }} />
{TRIM_TOOLS.map(t => ( ))}
) })()}
) })()}
{/* EFFECTS HIDDEN - FX button hidden because effects are not applied during export
EFFECTS HIDDEN */}
{canUseIcLora && ( <>
)}
{/* EFFECTS HIDDEN - Effects Browser panel hidden because effects are not applied during export */} {/* Timeline content */}
{/* Ruler row - fixed at top */}
{ if (!editingTimecode) { setTimecodeInput(formatTime(isPlaying ? playbackTimeRef.current : currentTime)) setEditingTimecode(true) requestAnimationFrame(() => timecodeInputRef.current?.select()) } }} > {editingTimecode ? ( 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)} /> ) : ( {formatTime(currentTime)} )}
{(() => { 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(
{isMajor && ( {formatTime(t)} )}
) } return ticks })()} {/* Dimmed region BEFORE In point on ruler */} {inPoint !== null && (
)} {/* Dimmed region AFTER Out point on ruler */} {outPoint !== null && (
)} {/* In/Out range highlight on ruler */} {(inPoint !== null || outPoint !== null) && (
)} {/* In point bracket marker — draggable */} {inPoint !== null && (
{ e.stopPropagation(); e.preventDefault(); markerDragOriginRef.current = 'timeline'; setDraggingMarker('timelineIn') }} > {/* Bracket shape */}
IN
)} {/* Out point bracket marker — draggable */} {outPoint !== null && (
{ e.stopPropagation(); e.preventDefault(); markerDragOriginRef.current = 'timeline'; setDraggingMarker('timelineOut') }} > {/* Bracket shape */}
OUT
)} {/* Playhead (ruler) — position updated by rAF engine during playback */}
{/* Tracks body - vertically scrollable */}
{/* Scrollable tracks area */}
{/* Track headers column */}
{/* Add track buttons - pinned above scrollable area */}
{/* Scrollable track headers - synced with vertical scroll */}
{orderedTracks.map(({ track, realIndex, displayRow }) => ( {/* Draggable divider between video and audio sections */} {displayRow === audioDividerDisplayRow && (
{ 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) }} >
V | A
)}
{track.type === 'subtitle' ? ( <> {/* Row 1: track name */}
{track.name}
{/* Row 2: tools */}
) : ( <>
{track.name}
{track.kind !== 'audio' && ( )} {track.kind !== 'audio' && ( )} {track.kind === 'audio' && ( )} {track.kind === 'audio' && ( )} {tracks.length > 1 && ( )}
)} {/* Track height resize handle */}
{ 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) }} >
))} {/* Spacer at bottom of track list */}
{/* end trackHeadersRef */}
{/* end track headers column */} {/* Scrollable track content area */}
{/* Full-height playhead line — spans spacer + tracks, positioned on the wrapper */}
{/* Spacer matching the add-track button bar height */}
{ // 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 && (
)} {/* Dimmed region AFTER Out point on tracks */} {outPoint !== null && (
)} {/* In/Out range highlight on tracks */} {(inPoint !== null || outPoint !== null) && (
)} {/* In point line on tracks */} {inPoint !== null && (
)} {/* Out point line on tracks */} {outPoint !== null && (
)} {/* Playhead is now rendered as overlay on the column wrapper (playheadOverlayRef) */} {orderedTracks.map(({ track, realIndex, displayRow }) => ( {/* Divider between video and audio sections */} {displayRow === audioDividerDisplayRow && (
{ e.preventDefault() e.stopPropagation() const startY = e.clientY const startVH = videoTrackHeight const startAH = audioTrackHeight const onMove = (ev: MouseEvent) => { const delta = ev.clientY - startY const newVH = Math.max(32, Math.min(200, startVH + delta)) const newAH = Math.max(32, Math.min(200, startAH - delta)) setVideoTrackHeight(newVH) setAudioTrackHeight(newAH) } const onUp = () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }} /> )}
{ e.stopPropagation() if (track.type === 'subtitle') { e.preventDefault() return } handleTrackDrop(e, realIndex) }} onDragOver={(e) => e.preventDefault()} onDoubleClick={() => { if (track.type === 'subtitle' && !track.locked) { addSubtitleClip(realIndex) } }} /> ))} {/* Lasso selection rectangle */} {lassoRect && lassoOriginRef.current && (() => { const origin = lassoOriginRef.current! const container = trackContainerRef.current if (!container) return null const scrollLeft = container.scrollLeft const scrollTop = container.scrollTop const x1 = Math.min(lassoRect.startX, lassoRect.currentX) - origin.containerLeft + scrollLeft const x2 = Math.max(lassoRect.startX, lassoRect.currentX) - origin.containerLeft + scrollLeft const y1 = Math.min(lassoRect.startY, lassoRect.currentY) - origin.containerTop + scrollTop const y2 = Math.max(lassoRect.startY, lassoRect.currentY) - origin.containerTop + scrollTop return (
) })()} {clips.map(clip => { const liveAsset = clip.assetId ? assets.find(a => a.id === clip.assetId) : null const clipColor = getColorLabel(clip.colorLabel || liveAsset?.colorLabel || clip.asset?.colorLabel) return (
handleClipMouseDown(e, clip)} onDoubleClick={() => { setSelectedClipIds(expandWithLinkedClips(new Set([clip.id]))) setShowPropertiesPanel(true) }} onMouseMove={(e) => { if (activeTool === 'blade') { const rect = e.currentTarget.getBoundingClientRect() const ox = e.clientX - rect.left const hoverTime = clip.startTime + (ox / rect.width) * clip.duration setBladeHoverInfo({ clipId: clip.id, offsetX: ox, time: hoverTime }) } }} onMouseLeave={() => { if (activeTool === 'blade' && bladeHoverInfo?.clipId === clip.id) { setBladeHoverInfo(null) } }} onContextMenu={(e) => handleClipContextMenu(e, clip)} /* EFFECTS HIDDEN - drag-drop for effects hidden because effects are not applied during export */ > {/* Blade cut indicator line */} {activeTool === 'blade' && bladeHoverInfo && (() => { // Show indicator on the hovered clip, or on all clips at that time when Shift is held const isHoveredClip = bladeHoverInfo.clipId === clip.id const isShiftTarget = bladeShiftHeld && !isHoveredClip && bladeHoverInfo.time > clip.startTime + 0.05 && bladeHoverInfo.time < clip.startTime + clip.duration - 0.05 if (!isHoveredClip && !isShiftTarget) return null const indicatorPx = isHoveredClip ? bladeHoverInfo.offsetX : (bladeHoverInfo.time - clip.startTime) * pixelsPerSecond return (
) })()}
{clip.type === 'adjustment' ? (
) : clip.type === 'text' ? (
) : clip.type === 'audio' ? ( <>
) : clip.asset && (() => { const liveAsset = getLiveAsset(clip) if (!liveAsset) return null let thumbPath: string | undefined = liveAsset.smallThumbnailPath const takeIdx = clip.takeIndex ?? liveAsset.activeTakeIndex if (liveAsset.takes && liveAsset.takes.length > 0 && takeIdx !== undefined) { const idx = Math.max(0, Math.min(takeIdx, liveAsset.takes.length - 1)) thumbPath = liveAsset.takes[idx].smallThumbnailPath } return thumbPath ? ( ) : (
) })()}

{clip.type === 'adjustment' ? 'Adjustment Layer' : clip.type === 'text' ? (clip.textStyle?.text?.slice(0, 30) || 'Text') : clip.asset?.prompt?.slice(0, 30) || clip.importedName || 'Clip'}

{clip.duration.toFixed(1)}s {(() => { const resInfo = getClipResolution(clip) if (!resInfo) return null return {resInfo.displayName} })()} {clip.speed !== 1 && {clip.speed}x} {clip.reversed && REV} {clip.muted && M} {(clip.flipH || clip.flipV) && FLIP} {clip.colorCorrection && Object.values(clip.colorCorrection).some(v => v !== 0) && CC} {clip.letterbox?.enabled && LB} {clip.linkedClipIds?.length && }
{/* Take navigation + Regenerate (only for clips with gen params) */} {(() => { const liveAsset = getLiveAsset(clip) if (!liveAsset || clip.duration * pixelsPerSecond <= 60 || clip.type === 'adjustment' || clip.type === 'audio') return null return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> {/* Take navigation: prev/next */} {liveAsset.takes && liveAsset.takes.length > 1 && ( <> {(clip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes.length - 1)) + 1}/{liveAsset.takes.length} )} {canUseRetake && clip.type === 'video' && ( )}
) })()}
{/* Regenerating overlay on the clip */} {clip.isRegenerating && (
{regenProgress > 0 ? `${regenProgress}%` : 'Regenerating...'} {canCancelInFlight && ( )}
)} {/* Transition in indicator */} {clip.transitionIn?.type !== 'none' && clip.transitionIn?.duration > 0 && (
)} {/* Transition out indicator */} {clip.transitionOut?.type !== 'none' && clip.transitionOut?.duration > 0 && (
)} {/* Resolution color-code bar at the bottom of the clip */} {(() => { const resInfo = getClipResolution(clip) if (!resInfo) return null return (
) })()}
handleResizeStart(e, clip, 'left')} >
handleResizeStart(e, clip, 'right')} >
)})} {/* Gap indicators between clips */} {timelineGaps.map((gap, i) => { const leftPx = gap.startTime * pixelsPerSecond const widthPx = (gap.endTime - gap.startTime) * pixelsPerSecond const topPx = trackTopPx(gap.trackIndex, 4) const isSelected = selectedGap && selectedGap.trackIndex === gap.trackIndex && Math.abs(selectedGap.startTime - gap.startTime) < 0.01 && Math.abs(selectedGap.endTime - gap.endTime) < 0.01 const isGeneratingHere = generatingGap && generatingGap.trackIndex === gap.trackIndex && Math.abs(generatingGap.startTime - gap.startTime) < 0.01 && Math.abs(generatingGap.endTime - gap.endTime) < 0.01 if (widthPx < 4) return null return (
{ if (e.button !== 0 || activeTool !== 'select' || isGeneratingHere) return startSelectionLasso(e.clientX, e.clientY, e.shiftKey) suppressGapClickRef.current = false const startX = e.clientX const startY = e.clientY const onMove = (event: MouseEvent) => { if ( Math.abs(event.clientX - startX) >= 4 || Math.abs(event.clientY - startY) >= 4 ) { suppressGapClickRef.current = true } } const onUp = () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }} onClick={(e) => { e.stopPropagation() if (suppressGapClickRef.current) { suppressGapClickRef.current = false return } if (isGeneratingHere) return selectGap(gap, e.currentTarget as HTMLElement) }} onContextMenu={(e) => { e.preventDefault() e.stopPropagation() if (isGeneratingHere) return selectGap(gap, e.currentTarget as HTMLElement) }} > {isGeneratingHere ? (
{widthPx > 50 && ( {gapGenerationApi.progress > 0 ? `${gapGenerationApi.progress}%` : 'Generating...'} )} {widthPx > 30 && (
)} {gapGenerationApi.canCancel && ( )}
) : (
{(gap.endTime - gap.startTime).toFixed(1)}s
)}
) })} {/* Subtitle clips on subtitle tracks */} {subtitles.map(sub => { const track = tracks[sub.trackIndex] if (!track || track.type !== 'subtitle') return null const leftPx = sub.startTime * pixelsPerSecond const widthPx = Math.max(20, (sub.endTime - sub.startTime) * pixelsPerSecond) const topPx = trackTopPx(sub.trackIndex, 4) const isSelected = selectedSubtitleId === sub.id const isEditing = editingSubtitleId === sub.id return (
{ e.stopPropagation() setSelectedSubtitleId(sub.id) }} onDoubleClick={(e) => { e.stopPropagation() setEditingSubtitleId(sub.id) }} onContextMenu={(e) => { e.preventDefault() e.stopPropagation() setSelectedSubtitleId(sub.id) }} onMouseDown={(e) => { if (track.locked || e.button !== 0) return e.stopPropagation() const startX = e.clientX const origStart = sub.startTime const origEnd = sub.endTime const dur = origEnd - origStart const onMove = (ev: MouseEvent) => { const dx = ev.clientX - startX const dt = dx / pixelsPerSecond const newStart = Math.max(0, origStart + dt) updateSubtitle(sub.id, { startTime: newStart, endTime: newStart + dur }) } const onUp = () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }} > {/* Subtitle text */}
{isEditing ? ( { updateSubtitle(sub.id, { text: e.target.value }) setEditingSubtitleId(null) }} onKeyDown={(e) => { if (e.key === 'Enter') { updateSubtitle(sub.id, { text: (e.target as HTMLInputElement).value }) setEditingSubtitleId(null) } if (e.key === 'Escape') setEditingSubtitleId(null) e.stopPropagation() }} onClick={(e) => e.stopPropagation()} /> ) : ( {sub.text} )}
{/* Left resize handle */}
{ e.stopPropagation() const startX = e.clientX const origStart = sub.startTime const onMove = (ev: MouseEvent) => { const dx = ev.clientX - startX const dt = dx / pixelsPerSecond const newStart = Math.max(0, Math.min(sub.endTime - 0.2, origStart + dt)) updateSubtitle(sub.id, { startTime: newStart }) } const onUp = () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }} /> {/* Right resize handle */}
{ e.stopPropagation() const startX = e.clientX const origEnd = sub.endTime const onMove = (ev: MouseEvent) => { const dx = ev.clientX - startX const dt = dx / pixelsPerSecond const newEnd = Math.max(sub.startTime + 0.2, origEnd + dt) updateSubtitle(sub.id, { endTime: newEnd }) } const onUp = () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }} />
) })} {/* Cut point indicators for cross-dissolve */} {cutPoints.map((cp) => { const leftPx = cp.time * pixelsPerSecond const topPx = trackTopPx(cp.trackIndex, 4) const isHovered = hoveredCutPoint?.leftClipId === cp.leftClip.id && hoveredCutPoint?.rightClipId === cp.rightClip.id const dissolveDur = cp.hasDissolve ? (cp.leftClip.transitionOut?.duration || DEFAULT_DISSOLVE_DURATION) : 0 const dissolveWidthPx = dissolveDur * pixelsPerSecond return (
setHoveredCutPoint({ leftClipId: cp.leftClip.id, rightClipId: cp.rightClip.id, time: cp.time, trackIndex: cp.trackIndex, })} onMouseLeave={() => setHoveredCutPoint(null)} > {/* Visible indicator line */}
{cp.hasDissolve ? ( <> {/* Dissolve region visual (gradient bar on the clip area) */}
{/* Dissolve duration label */}
{dissolveDur.toFixed(1)}s
{/* Left drag handle */}
{ e.stopPropagation() e.preventDefault() const startX = e.clientX const startDur = dissolveDur const handleMove = (ev: MouseEvent) => { const delta = (startX - ev.clientX) / pixelsPerSecond const newDur = Math.max(0.1, Math.min(cp.leftClip.duration * 0.9, startDur + delta)) setClips(prev => prev.map(c => { if (c.id === cp.leftClip.id) return { ...c, transitionOut: { ...c.transitionOut, duration: +newDur.toFixed(2) } } if (c.id === cp.rightClip.id) return { ...c, transitionIn: { ...c.transitionIn, duration: +newDur.toFixed(2) } } return c })) } const handleUp = () => { document.removeEventListener('mousemove', handleMove) document.removeEventListener('mouseup', handleUp) document.body.style.cursor = '' document.body.style.userSelect = '' } document.addEventListener('mousemove', handleMove) document.addEventListener('mouseup', handleUp) document.body.style.cursor = 'ew-resize' document.body.style.userSelect = 'none' }} >
{/* Right drag handle */}
{ e.stopPropagation() e.preventDefault() const startX = e.clientX const startDur = dissolveDur const handleMove = (ev: MouseEvent) => { const delta = (ev.clientX - startX) / pixelsPerSecond const newDur = Math.max(0.1, Math.min(cp.rightClip.duration * 0.9, startDur + delta)) setClips(prev => prev.map(c => { if (c.id === cp.leftClip.id) return { ...c, transitionOut: { ...c.transitionOut, duration: +newDur.toFixed(2) } } if (c.id === cp.rightClip.id) return { ...c, transitionIn: { ...c.transitionIn, duration: +newDur.toFixed(2) } } return c })) } const handleUp = () => { document.removeEventListener('mousemove', handleMove) document.removeEventListener('mouseup', handleUp) document.body.style.cursor = '' document.body.style.userSelect = '' } document.addEventListener('mousemove', handleMove) document.addEventListener('mouseup', handleUp) document.body.style.cursor = 'ew-resize' document.body.style.userSelect = 'none' }} >
{/* Remove button (shown on hover, positioned inside the zone) */} {isHovered && (
e.stopPropagation()} >
)} ) : ( <> {/* No dissolve: show add button on hover (positioned inside the zone) */} {isHovered && (
e.stopPropagation()} >
)} )}
) })}
{/* close relative inner */}
{/* close trackContainerRef */}
{/* close content column */}
{/* close scrollable area row */}
{/* close tracks body flex-col */}
{/* Bottom toolbar with zoom bar */}
{selectedClip && ( <>
)}
{/* Subtitle import/export */} {tracks.some(t => t.type === 'subtitle') && ( <>
)} {/* Spacer */}
{/* Zoom slider bar */}
{ 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)}%`} /> {Math.round(zoom * 100)}%
{clipContextMenu && ( )} {selectedGap && tracks[selectedGap.trackIndex]?.kind !== 'audio' && ( )} ) }