import React from 'react' import { Clipboard, Copy, Scissors, Trash2, Layers, Type, X, RefreshCw, ZoomIn, Film, Eye, FolderOpen, RotateCcw, Volume2, VolumeX, FlipHorizontal2, FlipVertical2, Link2, Unlink2, ChevronLeft, ChevronRight, Sparkles, Video, Camera, } from 'lucide-react' import type { Asset, TimelineClip, Track, TextOverlayStyle } from '../../types/project-model' import { TEXT_PRESETS } from '../../types/project' import { COLOR_LABELS } from './video-editor-utils' export type ClipContextMenuState = | { kind: 'background'; x: number; y: number } | { kind: 'clip'; clipId: string; x: number; y: number } export interface ClipContextMenuProps { clipContextMenu: ClipContextMenuState contextClip: TimelineClip | null clipContextMenuRef: React.RefObject clips: TimelineClip[] tracks: Track[] selectedClipIds: Set setSelectedClipIds: React.Dispatch>> currentTime: number hasClipboard: boolean isRegenerating: boolean currentProjectId: string | null updateAsset: (projectId: string, assetId: string, updates: Partial) => void handleCopy: () => void handleCut: () => void handlePaste: () => void setClipContextMenu: React.Dispatch> addTextClip: (style?: Partial, startTime?: number) => void setClips: React.Dispatch> handleRegenerate: (assetId: string, clipId: string) => void handleCancelRegeneration: () => void canCancelInFlight: boolean handleClipTakeChange: (clipId: string, direction: 'prev' | 'next') => void handleDeleteTake: (clipId: string) => void duplicateClip: (clipId: string) => void splitClipAtPlayhead: (clipId: string, atTime?: number, batchClipIds?: string[]) => void removeClip: (clipId: string) => void updateClip: (clipId: string, updates: Partial) => void getLiveAsset: (clip: TimelineClip) => Asset | null | undefined getMaxClipDuration: (clip: TimelineClip) => number onRevealAsset: (assetId: string) => void onCreateVideoFromImage: (clip: TimelineClip) => void onRetakeClip: (clip: TimelineClip) => void onICLoraClip: (clip: TimelineClip) => void canUseIcLora: boolean canUseRetake: boolean onCaptureFrameForVideo: (clip: TimelineClip) => void onCreateVideoFromAudio: (clip: TimelineClip) => void } // Reusable menu item component function MenuItem({ icon: Icon, iconClass, label, shortcut, badge, badgeClass, disabled, danger, title, onClick }: { icon: any; iconClass?: string; label: string; shortcut?: string; badge?: string; badgeClass?: string disabled?: boolean; danger?: boolean; title?: string; onClick: () => void }) { return ( ) } function SectionLabel({ children }: { children: React.ReactNode }) { return (
{children}
) } function Divider() { return
} export function ClipContextMenu({ clipContextMenu, contextClip, clipContextMenuRef, clips, selectedClipIds, setSelectedClipIds, currentTime, hasClipboard, isRegenerating, currentProjectId, updateAsset, handleCopy, handleCut, handlePaste, setClipContextMenu, addTextClip, setClips, handleRegenerate, handleCancelRegeneration, canCancelInFlight, handleClipTakeChange, handleDeleteTake, duplicateClip, splitClipAtPlayhead, removeClip, updateClip, getLiveAsset, getMaxClipDuration, onRevealAsset, onCreateVideoFromImage, onRetakeClip, onICLoraClip, canUseIcLora, canUseRetake, onCaptureFrameForVideo, onCreateVideoFromAudio, }: ClipContextMenuProps) { const close = () => setClipContextMenu(null) const isBackground = clipContextMenu.kind === 'background' // Check if all selected clips are in the same linked group — if so, treat as single selection const multiSelected = (() => { if (selectedClipIds.size <= 1) return false if (!contextClip) return clipContextMenu.kind === 'background' // Expand the linked group of the context clip const linkedGroup = new Set([contextClip.id]) const queue = [contextClip.id] while (queue.length > 0) { const id = queue.pop()! const c = clips.find(cl => cl.id === id) if (c?.linkedClipIds) { for (const lid of c.linkedClipIds) { if (!linkedGroup.has(lid)) { linkedGroup.add(lid); queue.push(lid) } } } } // If every selected clip is in this linked group, it's a single logical selection for (const sid of selectedClipIds) { if (!linkedGroup.has(sid)) return true } return false })() return (
e.stopPropagation()} > {/* ════════════════════════════════════════════════ BACKGROUND CONTEXT MENU (empty area right-click) ════════════════════════════════════════════════ */} {isBackground ? ( <> { handlePaste(); close() }} /> { addTextClip(undefined, currentTime); close() }} /> {TEXT_PRESETS.slice(0, 4).map(preset => ( ))} { setSelectedClipIds(new Set(clips.map(c => c.id))); close() }} /> ) : multiSelected ? ( /* ════════════════════════════════════════════════ MULTI-CLIP CONTEXT MENU ════════════════════════════════════════════════ */ ) : contextClip ? ( /* ════════════════════════════════════════════════ SINGLE CLIP CONTEXT MENU (For linked groups treated as single, prefer the video/image clip) ════════════════════════════════════════════════ */ { if (contextClip.type !== 'audio') return contextClip if (contextClip.linkedClipIds?.length) { const primary = clips.find(c => contextClip.linkedClipIds!.includes(c.id) && (c.type === 'video' || c.type === 'image')) if (primary) return primary } return contextClip })()} clips={clips} hasClipboard={hasClipboard} isRegenerating={isRegenerating} currentProjectId={currentProjectId} updateAsset={updateAsset} handleCopy={handleCopy} handleCut={handleCut} handlePaste={handlePaste} 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={onRetakeClip} onICLoraClip={onICLoraClip} canUseIcLora={canUseIcLora} canUseRetake={canUseRetake} onCaptureFrameForVideo={onCaptureFrameForVideo} onCreateVideoFromAudio={onCreateVideoFromAudio} close={close} /> ) : null}
) } /* ────────────────────────────────────────────── Single Clip Menu Layout: 1. Clipboard (Cut / Copy / Paste) 2. Edit (Duplicate / Split) 3. Properties (Speed / Reverse / Mute) 4. Transform (Flip H / Flip V) 5. Structure (Link / Move Track) 6. AI Tools (Regenerate / Upscale / I2V / Retake / IC-LoRA) 7. Navigation (Reveal in Assets / Explorer) 8. Delete (always last, red) ────────────────────────────────────────────── */ function SingleClipMenu({ contextClip, clips, hasClipboard, isRegenerating, currentProjectId, updateAsset, handleCopy, handleCut, handlePaste, setClips, handleRegenerate, handleCancelRegeneration, canCancelInFlight, handleClipTakeChange, handleDeleteTake, duplicateClip, splitClipAtPlayhead, removeClip, updateClip, getLiveAsset, getMaxClipDuration, onRevealAsset, onCreateVideoFromImage, onRetakeClip, onICLoraClip, canUseIcLora, canUseRetake, onCaptureFrameForVideo, onCreateVideoFromAudio, close, }: { contextClip: TimelineClip clips: TimelineClip[] hasClipboard: boolean isRegenerating: boolean currentProjectId: string | null updateAsset: (projectId: string, assetId: string, updates: Partial) => void handleCopy: () => void; handleCut: () => void; handlePaste: () => void setClips: React.Dispatch> handleRegenerate: (assetId: string, clipId: string) => void handleCancelRegeneration: () => void canCancelInFlight: boolean handleClipTakeChange: (clipId: string, direction: 'prev' | 'next') => void handleDeleteTake: (clipId: string) => void duplicateClip: (clipId: string) => void splitClipAtPlayhead: (clipId: string, atTime?: number, batchClipIds?: string[]) => void removeClip: (clipId: string) => void updateClip: (clipId: string, updates: Partial) => void getLiveAsset: (clip: TimelineClip) => Asset | null | undefined getMaxClipDuration: (clip: TimelineClip) => number onRevealAsset: (assetId: string) => void onCreateVideoFromImage: (clip: TimelineClip) => void onRetakeClip: (clip: TimelineClip) => void onICLoraClip: (clip: TimelineClip) => void canUseIcLora: boolean canUseRetake: boolean onCaptureFrameForVideo: (clip: TimelineClip) => void onCreateVideoFromAudio: (clip: TimelineClip) => void close: () => void }) { const liveAsset = getLiveAsset(contextClip) const isAdjustment = contextClip.type === 'adjustment' const isVideo = contextClip.type === 'video' const isImage = contextClip.type === 'image' const hasAI = liveAsset && !isAdjustment return ( <> {/* ── 1. Clipboard ── */} { handleCut(); close() }} /> { handleCopy(); close() }} /> { handlePaste(); close() }} /> {/* ── 2. Edit ── */} { duplicateClip(contextClip.id); close() }} /> { splitClipAtPlayhead(contextClip.id); close() }} /> {/* ── 3. Properties ── */} Speed
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => ( ))}
{ updateClip(contextClip.id, { reversed: !contextClip.reversed }); close() }} /> { updateClip(contextClip.id, { muted: !contextClip.muted }); close() }} /> {/* ── 4. Transform ── */} { updateClip(contextClip.id, { flipH: !contextClip.flipH }); close() }} /> { updateClip(contextClip.id, { flipV: !contextClip.flipV }); close() }} /> {/* ── 5. Structure (Link / Track) ── */} {contextClip.linkedClipIds?.length ? ( { const allLinked = new Set(contextClip.linkedClipIds!) setClips(prev => prev.map(c => { if (c.id === contextClip.id) return { ...c, linkedClipIds: undefined } if (allLinked.has(c.id)) { const remaining = (c.linkedClipIds || []).filter(lid => lid !== contextClip.id) return { ...c, linkedClipIds: remaining.length ? remaining : undefined } } return c })) close() }} /> ) : null} {!contextClip.linkedClipIds?.length && (contextClip.type === 'video' || contextClip.type === 'audio') && (() => { const oppositeType = contextClip.type === 'video' ? 'audio' : 'video' const candidates = clips.filter(c => c.type === oppositeType && !c.linkedClipIds?.length && c.assetId === contextClip.assetId && Math.abs(c.startTime - contextClip.startTime) < 0.05 ) if (!candidates.length) return null return ( { const candidateIds = candidates.map(c => c.id) setClips(prev => prev.map(c => { if (c.id === contextClip.id) return { ...c, linkedClipIds: candidateIds } if (candidateIds.includes(c.id)) return { ...c, linkedClipIds: [contextClip.id] } return c })) close() }} /> ) })()} {/* ── 6. Color Label ── */} Label
{COLOR_LABELS.map(cl => (
{/* ── 7. AI Tools ── */} {hasAI && ( <> AI Tools {contextClip.isRegenerating ? ( canCancelInFlight ? ( { handleCancelRegeneration(); close() }} /> ) : null ) : ( { handleRegenerate(contextClip.assetId!, contextClip.id); close() }} /> )} {/* Take navigation */} {liveAsset!.takes && liveAsset!.takes.length > 1 && (
Take: {(contextClip.takeIndex ?? (liveAsset!.activeTakeIndex ?? liveAsset!.takes!.length - 1)) + 1}/{liveAsset!.takes!.length}
)} {isVideo && contextClip.assetId && ( {}} /> )} {isImage && ( { onCreateVideoFromImage(contextClip); close() }} /> )} {isVideo && contextClip.assetId && ( <> {canUseRetake && ( { onRetakeClip(contextClip); close() }} /> )} {canUseIcLora && ( { onICLoraClip(contextClip); close() }} /> )} )} {contextClip.type === 'audio' && !contextClip.linkedClipIds?.length && ( { onCreateVideoFromAudio(contextClip); close() }} /> )} {(isVideo || isImage) && (
{ onCaptureFrameForVideo(contextClip); close() }} /> {isImage && ( { onCreateVideoFromImage(contextClip); close() }} /> )}
)} )} {/* ── 7. Navigation ── */} {(contextClip.assetId || getLiveAsset(contextClip)?.path) && ( <> {contextClip.assetId && ( { onRevealAsset(contextClip.assetId!) close() }} /> )} {(() => { if (!liveAsset) return null let filePath = liveAsset.path if (liveAsset.takes && liveAsset.takes.length > 0) { const takeIdx = contextClip.takeIndex ?? (liveAsset.activeTakeIndex ?? liveAsset.takes.length - 1) filePath = liveAsset.takes[Math.max(0, Math.min(takeIdx, liveAsset.takes.length - 1))].path } if (!filePath) return null const label = window.electronAPI?.platform === 'darwin' ? 'Reveal in Finder' : window.electronAPI?.platform === 'linux' ? 'Show in Files' : 'Download file' return { window.electronAPI?.showItemInFolder({ filePath }); close() }} /> })()} )} {/* ── 8. Delete (always last, always red) ── */} { removeClip(contextClip.id); close() }} /> ) } /* ────────────────────────────────────────────── Multi-Clip Menu Layout: 1. Header (N clips selected) 2. Clipboard (Cut / Copy / Paste) 3. Properties (Speed / Mute / Reverse) 4. Transform (Flip H / Flip V) 5. Structure (Link / Unlink / Move Track) 6. Delete (always last, red) ────────────────────────────────────────────── */ function MultiClipMenu({ clips, selectedClipIds, setSelectedClipIds, hasClipboard, currentProjectId, updateAsset, handleCopy, handleCut, handlePaste, setClips, getMaxClipDuration, close, }: { clips: TimelineClip[] selectedClipIds: Set; setSelectedClipIds: React.Dispatch>> hasClipboard: boolean currentProjectId: string | null updateAsset: (projectId: string, assetId: string, updates: Partial) => void handleCopy: () => void; handleCut: () => void; handlePaste: () => void setClips: React.Dispatch> getMaxClipDuration: (clip: TimelineClip) => number close: () => void }) { const n = selectedClipIds.size const selectedClips = clips.filter(c => selectedClipIds.has(c.id)) const allMuted = selectedClips.every(c => c.muted) const allReversed = selectedClips.every(c => c.reversed) const allFlipH = selectedClips.every(c => c.flipH) const allFlipV = selectedClips.every(c => c.flipV) const batchUpdate = (updates: Partial) => { setClips(prev => prev.map(c => selectedClipIds.has(c.id) ? { ...c, ...updates } : c)) close() } const anyLinked = selectedClips.some(c => c.linkedClipIds?.length) const hasVideoAndAudio = selectedClips.some(c => c.type === 'video') && selectedClips.some(c => c.type === 'audio') const allFullyLinked = selectedClips.every(c => { const others = [...selectedClipIds].filter(id => id !== c.id) return others.every(oid => c.linkedClipIds?.includes(oid)) }) return ( <> {/* ── Header ── */} {n} Clips Selected {/* ── 1. Clipboard ── */} { handleCut(); close() }} /> { handleCopy(); close() }} /> { handlePaste(); close() }} /> {/* ── 2. Properties ── */} Speed
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => ( ))}
batchUpdate({ muted: !allMuted })} /> batchUpdate({ reversed: !allReversed })} /> {/* ── 3. Transform ── */} batchUpdate({ flipH: !allFlipH })} /> batchUpdate({ flipV: !allFlipV })} /> {/* ── 4. Structure ── */} {anyLinked && ( { const selIds = new Set(selectedClipIds) setClips(prev => prev.map(c => { if (!selIds.has(c.id)) return c const remaining = (c.linkedClipIds || []).filter(lid => !selIds.has(lid)) return { ...c, linkedClipIds: remaining.length ? remaining : undefined } })) close() }} /> )} {hasVideoAndAudio && !allFullyLinked && ( { const selIds = [...selectedClipIds] setClips(prev => prev.map(c => { if (!selectedClipIds.has(c.id)) return c const otherIds = selIds.filter(id => id !== c.id) const existingLinks = new Set(c.linkedClipIds || []) otherIds.forEach(id => existingLinks.add(id)) return { ...c, linkedClipIds: [...existingLinks] } })) close() }} /> )} {/* ── 5. Color Label ── */} Label
{COLOR_LABELS.map(cl => (
{/* ── 6. Delete ── */} { setClips(prev => prev.filter(c => !selectedClipIds.has(c.id)).map(c => { if (!c.linkedClipIds) return c const remaining = c.linkedClipIds.filter(lid => !selectedClipIds.has(lid)) return { ...c, linkedClipIds: remaining.length ? remaining : undefined } })) setSelectedClipIds(new Set()) close() }} /> ) }