764 lines
33 KiB
TypeScript
764 lines
33 KiB
TypeScript
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<HTMLDivElement>
|
|
clips: TimelineClip[]
|
|
tracks: Track[]
|
|
selectedClipIds: Set<string>
|
|
setSelectedClipIds: React.Dispatch<React.SetStateAction<Set<string>>>
|
|
currentTime: number
|
|
hasClipboard: boolean
|
|
isRegenerating: boolean
|
|
currentProjectId: string | null
|
|
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
|
|
handleCopy: () => void
|
|
handleCut: () => void
|
|
handlePaste: () => void
|
|
setClipContextMenu: React.Dispatch<React.SetStateAction<ClipContextMenuState | null>>
|
|
addTextClip: (style?: Partial<TextOverlayStyle>, startTime?: number) => void
|
|
setClips: React.Dispatch<React.SetStateAction<TimelineClip[]>>
|
|
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<TimelineClip>) => 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 (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
title={title}
|
|
className={`w-full text-left px-3 py-1.5 flex items-center gap-3 transition-colors ${
|
|
disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-zinc-700'
|
|
} ${danger ? 'text-red-400' : 'text-zinc-300'}`}
|
|
>
|
|
<Icon className={`h-3.5 w-3.5 flex-shrink-0 ${iconClass || (danger ? '' : 'text-zinc-500')}`} />
|
|
<span className="flex-1 truncate">{label}</span>
|
|
{badge && <span className={`text-[10px] font-medium flex-shrink-0 ${badgeClass || 'text-zinc-500'}`}>{badge}</span>}
|
|
{shortcut && <span className="text-zinc-600 text-[10px] flex-shrink-0">{shortcut}</span>}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="px-3 pt-1.5 pb-0.5 text-[9px] text-zinc-500 font-semibold uppercase tracking-widest select-none">
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Divider() {
|
|
return <div className="h-px bg-zinc-700 my-1" />
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
ref={clipContextMenuRef}
|
|
className="fixed bg-zinc-800 border border-zinc-700 rounded-xl shadow-2xl py-1.5 z-[60] min-w-[220px] max-w-[280px] text-xs"
|
|
style={{ left: clipContextMenu.x, top: clipContextMenu.y }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* ════════════════════════════════════════════════
|
|
BACKGROUND CONTEXT MENU (empty area right-click)
|
|
════════════════════════════════════════════════ */}
|
|
{isBackground ? (
|
|
<>
|
|
<MenuItem icon={Clipboard} label="Paste" shortcut="Ctrl+V" disabled={!hasClipboard}
|
|
onClick={() => { handlePaste(); close() }} />
|
|
<Divider />
|
|
<MenuItem icon={Type} iconClass="text-cyan-400" label="Add Text"
|
|
onClick={() => { addTextClip(undefined, currentTime); close() }} />
|
|
{TEXT_PRESETS.slice(0, 4).map(preset => (
|
|
<button
|
|
key={preset.id}
|
|
onClick={() => { addTextClip(preset.style, currentTime); close() }}
|
|
className="w-full text-left px-3 py-1.5 text-zinc-400 hover:bg-zinc-700 flex items-center gap-3 pl-9"
|
|
>
|
|
<span className="text-[10px] text-cyan-500/70 flex-shrink-0">T</span>
|
|
<span className="flex-1 truncate">{preset.name}</span>
|
|
</button>
|
|
))}
|
|
<Divider />
|
|
<MenuItem icon={Layers} label="Select All" shortcut="Ctrl+A"
|
|
onClick={() => { setSelectedClipIds(new Set(clips.map(c => c.id))); close() }} />
|
|
</>
|
|
) : multiSelected ? (
|
|
/* ════════════════════════════════════════════════
|
|
MULTI-CLIP CONTEXT MENU
|
|
════════════════════════════════════════════════ */
|
|
<MultiClipMenu
|
|
clips={clips}
|
|
selectedClipIds={selectedClipIds} setSelectedClipIds={setSelectedClipIds}
|
|
hasClipboard={hasClipboard}
|
|
currentProjectId={currentProjectId}
|
|
updateAsset={updateAsset}
|
|
handleCopy={handleCopy} handleCut={handleCut} handlePaste={handlePaste}
|
|
setClips={setClips}
|
|
getMaxClipDuration={getMaxClipDuration}
|
|
close={close}
|
|
/>
|
|
) : contextClip ? (
|
|
/* ════════════════════════════════════════════════
|
|
SINGLE CLIP CONTEXT MENU
|
|
(For linked groups treated as single, prefer the video/image clip)
|
|
════════════════════════════════════════════════ */
|
|
<SingleClipMenu
|
|
contextClip={(() => {
|
|
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}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ──────────────────────────────────────────────
|
|
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<Asset>) => void
|
|
handleCopy: () => void; handleCut: () => void; handlePaste: () => void
|
|
setClips: React.Dispatch<React.SetStateAction<TimelineClip[]>>
|
|
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<TimelineClip>) => 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 ── */}
|
|
<MenuItem icon={Scissors} label="Cut" shortcut="Ctrl+X" onClick={() => { handleCut(); close() }} />
|
|
<MenuItem icon={Copy} label="Copy" shortcut="Ctrl+C" onClick={() => { handleCopy(); close() }} />
|
|
<MenuItem icon={Clipboard} label="Paste" shortcut="Ctrl+V" disabled={!hasClipboard} onClick={() => { handlePaste(); close() }} />
|
|
|
|
<Divider />
|
|
|
|
{/* ── 2. Edit ── */}
|
|
<MenuItem icon={Copy} label="Duplicate" onClick={() => { duplicateClip(contextClip.id); close() }} />
|
|
<MenuItem icon={Scissors} label="Split at Playhead" shortcut="B" onClick={() => { splitClipAtPlayhead(contextClip.id); close() }} />
|
|
|
|
<Divider />
|
|
|
|
{/* ── 3. Properties ── */}
|
|
<SectionLabel>Speed</SectionLabel>
|
|
<div className="flex items-center gap-1 px-3 py-1">
|
|
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => (
|
|
<button
|
|
key={speed}
|
|
onClick={() => {
|
|
const oldSpeed = contextClip.speed
|
|
let newDuration = contextClip.duration * (oldSpeed / speed)
|
|
const maxDur = getMaxClipDuration({ ...contextClip, speed })
|
|
newDuration = Math.min(newDuration, maxDur)
|
|
newDuration = Math.max(0.5, newDuration)
|
|
updateClip(contextClip.id, { speed, duration: newDuration })
|
|
close()
|
|
}}
|
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors ${
|
|
contextClip.speed === speed
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white'
|
|
}`}
|
|
>
|
|
{speed}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
<MenuItem icon={RotateCcw} label="Reverse"
|
|
badge={contextClip.reversed ? 'ON' : undefined} badgeClass="text-blue-400"
|
|
onClick={() => { updateClip(contextClip.id, { reversed: !contextClip.reversed }); close() }} />
|
|
<MenuItem
|
|
icon={contextClip.muted ? VolumeX : Volume2}
|
|
label={contextClip.muted ? 'Unmute' : 'Mute'}
|
|
badge={contextClip.muted ? 'MUTED' : undefined} badgeClass="text-red-400"
|
|
onClick={() => { updateClip(contextClip.id, { muted: !contextClip.muted }); close() }} />
|
|
|
|
<Divider />
|
|
|
|
{/* ── 4. Transform ── */}
|
|
<MenuItem icon={FlipHorizontal2} label="Flip Horizontal"
|
|
badge={contextClip.flipH ? 'ON' : undefined} badgeClass="text-cyan-400"
|
|
onClick={() => { updateClip(contextClip.id, { flipH: !contextClip.flipH }); close() }} />
|
|
<MenuItem icon={FlipVertical2} label="Flip Vertical"
|
|
badge={contextClip.flipV ? 'ON' : undefined} badgeClass="text-cyan-400"
|
|
onClick={() => { updateClip(contextClip.id, { flipV: !contextClip.flipV }); close() }} />
|
|
|
|
<Divider />
|
|
|
|
{/* ── 5. Structure (Link / Track) ── */}
|
|
{contextClip.linkedClipIds?.length ? (
|
|
<MenuItem icon={Unlink2} label="Unlink Audio" onClick={() => {
|
|
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 (
|
|
<MenuItem icon={Link2} label="Link Audio" onClick={() => {
|
|
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 ── */}
|
|
<Divider />
|
|
<SectionLabel>Label</SectionLabel>
|
|
<div className="px-3 py-1.5 flex items-center gap-1 flex-wrap">
|
|
<button
|
|
onClick={() => {
|
|
if (contextClip.assetId) {
|
|
// Update ALL clips that share this asset
|
|
setClips(prev => prev.map(c => c.assetId === contextClip.assetId ? { ...c, colorLabel: undefined } : c))
|
|
if (currentProjectId) updateAsset(currentProjectId, contextClip.assetId, { colorLabel: undefined })
|
|
} else {
|
|
updateClip(contextClip.id, { colorLabel: undefined })
|
|
}
|
|
close()
|
|
}}
|
|
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all ${
|
|
!contextClip.colorLabel ? 'border-white scale-110' : 'border-zinc-600 hover:border-zinc-400'
|
|
}`}
|
|
title="No label"
|
|
>
|
|
<X className="h-2 w-2 text-zinc-400" />
|
|
</button>
|
|
{COLOR_LABELS.map(cl => (
|
|
<button
|
|
key={cl.id}
|
|
onClick={() => {
|
|
if (contextClip.assetId) {
|
|
// Update ALL clips that share this asset
|
|
setClips(prev => prev.map(c => c.assetId === contextClip.assetId ? { ...c, colorLabel: cl.id } : c))
|
|
if (currentProjectId) updateAsset(currentProjectId, contextClip.assetId, { colorLabel: cl.id })
|
|
} else {
|
|
updateClip(contextClip.id, { colorLabel: cl.id })
|
|
}
|
|
close()
|
|
}}
|
|
className={`w-4 h-4 rounded-full transition-all ${
|
|
contextClip.colorLabel === cl.id ? 'ring-2 ring-white ring-offset-1 ring-offset-zinc-800 scale-110' : 'hover:scale-125'
|
|
}`}
|
|
style={{ backgroundColor: cl.color }}
|
|
title={cl.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── 7. AI Tools ── */}
|
|
{hasAI && (
|
|
<>
|
|
<Divider />
|
|
<SectionLabel>AI Tools</SectionLabel>
|
|
|
|
{contextClip.isRegenerating ? (
|
|
canCancelInFlight ? (
|
|
<MenuItem icon={X} iconClass="text-red-400" label="Cancel Regeneration" onClick={() => { handleCancelRegeneration(); close() }} />
|
|
) : null
|
|
) : (
|
|
<MenuItem icon={RefreshCw} iconClass="text-blue-400" label="Regenerate Shot"
|
|
disabled={isRegenerating} onClick={() => { handleRegenerate(contextClip.assetId!, contextClip.id); close() }} />
|
|
)}
|
|
|
|
{/* Take navigation */}
|
|
{liveAsset!.takes && liveAsset!.takes.length > 1 && (
|
|
<div className="px-3 py-1 flex items-center gap-2">
|
|
<span className="text-[10px] text-zinc-500 flex-shrink-0">Take:</span>
|
|
<button onClick={(e) => { e.stopPropagation(); handleClipTakeChange(contextClip.id, 'prev') }}
|
|
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white">
|
|
<ChevronLeft className="h-3 w-3" />
|
|
</button>
|
|
<span className="text-[10px] text-zinc-300 min-w-[28px] text-center tabular-nums">
|
|
{(contextClip.takeIndex ?? (liveAsset!.activeTakeIndex ?? liveAsset!.takes!.length - 1)) + 1}/{liveAsset!.takes!.length}
|
|
</span>
|
|
<button onClick={(e) => { e.stopPropagation(); handleClipTakeChange(contextClip.id, 'next') }}
|
|
className="p-0.5 rounded hover:bg-zinc-600 text-zinc-400 hover:text-white">
|
|
<ChevronRight className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (confirm(`Delete take ${(contextClip.takeIndex ?? (liveAsset!.activeTakeIndex ?? liveAsset!.takes!.length - 1)) + 1}?`)) {
|
|
handleDeleteTake(contextClip.id)
|
|
}
|
|
}}
|
|
className="p-0.5 rounded hover:bg-red-900/50 text-zinc-500 hover:text-red-400 ml-auto"
|
|
title="Delete this take"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isVideo && contextClip.assetId && (
|
|
<MenuItem icon={ZoomIn} iconClass="text-zinc-500" label="Upscale (2x)"
|
|
disabled={true} title="Coming Soon!" onClick={() => {}} />
|
|
)}
|
|
{isImage && (
|
|
<MenuItem icon={Film} iconClass="text-blue-400" label="Image to Video (I2V)"
|
|
onClick={() => { onCreateVideoFromImage(contextClip); close() }} />
|
|
)}
|
|
{isVideo && contextClip.assetId && (
|
|
<>
|
|
{canUseRetake && (
|
|
<MenuItem icon={Film} iconClass="text-blue-400" label="Retake Section"
|
|
onClick={() => { onRetakeClip(contextClip); close() }} />
|
|
)}
|
|
{canUseIcLora && (
|
|
<MenuItem icon={Sparkles} iconClass="text-amber-400" label="IC-LoRA / Style Transfer"
|
|
onClick={() => { onICLoraClip(contextClip); close() }} />
|
|
)}
|
|
</>
|
|
)}
|
|
{contextClip.type === 'audio' && !contextClip.linkedClipIds?.length && (
|
|
<MenuItem
|
|
icon={Film}
|
|
iconClass="text-emerald-400"
|
|
label="Create Video (A2V)"
|
|
onClick={() => { onCreateVideoFromAudio(contextClip); close() }}
|
|
/>
|
|
)}
|
|
{(isVideo || isImage) && (
|
|
<div className="relative group/capture">
|
|
<button
|
|
className="w-full text-left px-3 py-1.5 flex items-center gap-3 transition-colors hover:bg-zinc-700 text-zinc-300"
|
|
>
|
|
<Camera className="h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
|
<span className="flex-1 truncate">Use Frame As...</span>
|
|
<ChevronRight className="h-3 w-3 text-zinc-500" />
|
|
</button>
|
|
<div className="absolute left-full top-0 ml-0.5 min-w-[200px] bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl py-1 z-[70] hidden group-hover/capture:block">
|
|
<MenuItem icon={Video} iconClass="text-blue-400" label="Generate Video in Gen Space"
|
|
onClick={() => { onCaptureFrameForVideo(contextClip); close() }} />
|
|
{isImage && (
|
|
<MenuItem icon={Film} iconClass="text-blue-400" label="Image to Video (I2V)"
|
|
onClick={() => { onCreateVideoFromImage(contextClip); close() }} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── 7. Navigation ── */}
|
|
{(contextClip.assetId || getLiveAsset(contextClip)?.path) && (
|
|
<>
|
|
<Divider />
|
|
{contextClip.assetId && (
|
|
<MenuItem icon={Eye} label="Reveal in Assets" onClick={() => {
|
|
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 <MenuItem icon={FolderOpen} label={label} onClick={() => { window.electronAPI?.showItemInFolder({ filePath }); close() }} />
|
|
})()}
|
|
</>
|
|
)}
|
|
|
|
{/* ── 8. Delete (always last, always red) ── */}
|
|
<Divider />
|
|
<MenuItem icon={Trash2} label="Delete" shortcut="Del" danger onClick={() => { 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<string>; setSelectedClipIds: React.Dispatch<React.SetStateAction<Set<string>>>
|
|
hasClipboard: boolean
|
|
currentProjectId: string | null
|
|
updateAsset: (projectId: string, assetId: string, updates: Partial<Asset>) => void
|
|
handleCopy: () => void; handleCut: () => void; handlePaste: () => void
|
|
setClips: React.Dispatch<React.SetStateAction<TimelineClip[]>>
|
|
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<TimelineClip>) => {
|
|
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 ── */}
|
|
<SectionLabel>{n} Clips Selected</SectionLabel>
|
|
|
|
{/* ── 1. Clipboard ── */}
|
|
<MenuItem icon={Scissors} label={`Cut ${n} Clips`} shortcut="Ctrl+X" onClick={() => { handleCut(); close() }} />
|
|
<MenuItem icon={Copy} label={`Copy ${n} Clips`} shortcut="Ctrl+C" onClick={() => { handleCopy(); close() }} />
|
|
<MenuItem icon={Clipboard} label="Paste" shortcut="Ctrl+V" disabled={!hasClipboard} onClick={() => { handlePaste(); close() }} />
|
|
|
|
<Divider />
|
|
|
|
{/* ── 2. Properties ── */}
|
|
<SectionLabel>Speed</SectionLabel>
|
|
<div className="flex items-center gap-1 px-3 py-1">
|
|
{[0.25, 0.5, 1, 1.5, 2, 4].map(speed => (
|
|
<button
|
|
key={speed}
|
|
onClick={() => {
|
|
setClips(prev => prev.map(c => {
|
|
if (!selectedClipIds.has(c.id)) return c
|
|
const oldSpeed = c.speed
|
|
let newDuration = c.duration * (oldSpeed / speed)
|
|
const maxDur = getMaxClipDuration({ ...c, speed })
|
|
newDuration = Math.min(newDuration, maxDur)
|
|
newDuration = Math.max(0.5, newDuration)
|
|
return { ...c, speed, duration: newDuration }
|
|
}))
|
|
close()
|
|
}}
|
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white"
|
|
>
|
|
{speed}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
<MenuItem icon={allMuted ? VolumeX : Volume2} label={allMuted ? 'Unmute All' : 'Mute All'}
|
|
badge={allMuted ? 'ALL MUTED' : undefined} badgeClass="text-red-400"
|
|
onClick={() => batchUpdate({ muted: !allMuted })} />
|
|
<MenuItem icon={RotateCcw} label={allReversed ? 'Un-reverse All' : 'Reverse All'}
|
|
badge={allReversed ? 'ALL ON' : undefined} badgeClass="text-blue-400"
|
|
onClick={() => batchUpdate({ reversed: !allReversed })} />
|
|
|
|
<Divider />
|
|
|
|
{/* ── 3. Transform ── */}
|
|
<MenuItem icon={FlipHorizontal2} label={allFlipH ? 'Un-flip All Horizontal' : 'Flip All Horizontal'}
|
|
badge={allFlipH ? 'ALL ON' : undefined} badgeClass="text-cyan-400"
|
|
onClick={() => batchUpdate({ flipH: !allFlipH })} />
|
|
<MenuItem icon={FlipVertical2} label={allFlipV ? 'Un-flip All Vertical' : 'Flip All Vertical'}
|
|
badge={allFlipV ? 'ALL ON' : undefined} badgeClass="text-cyan-400"
|
|
onClick={() => batchUpdate({ flipV: !allFlipV })} />
|
|
|
|
<Divider />
|
|
|
|
{/* ── 4. Structure ── */}
|
|
{anyLinked && (
|
|
<MenuItem icon={Unlink2} label="Unlink" onClick={() => {
|
|
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 && (
|
|
<MenuItem icon={Link2} label="Link" onClick={() => {
|
|
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 ── */}
|
|
<Divider />
|
|
<SectionLabel>Label</SectionLabel>
|
|
<div className="px-3 py-1.5 flex items-center gap-1 flex-wrap">
|
|
<button
|
|
onClick={() => {
|
|
const affectedAssetIds = new Set(selectedClips.map(c => c.assetId).filter(Boolean) as string[])
|
|
// Update selected clips AND all other clips sharing the same assets
|
|
setClips(prev => prev.map(c => {
|
|
if (selectedClipIds.has(c.id) || (c.assetId && affectedAssetIds.has(c.assetId))) {
|
|
return { ...c, colorLabel: undefined }
|
|
}
|
|
return c
|
|
}))
|
|
if (currentProjectId) {
|
|
affectedAssetIds.forEach(id => updateAsset(currentProjectId!, id, { colorLabel: undefined }))
|
|
}
|
|
close()
|
|
}}
|
|
className="w-4 h-4 rounded-full border-2 border-zinc-600 hover:border-zinc-400 flex items-center justify-center transition-all"
|
|
title="No label"
|
|
>
|
|
<X className="h-2 w-2 text-zinc-400" />
|
|
</button>
|
|
{COLOR_LABELS.map(cl => (
|
|
<button
|
|
key={cl.id}
|
|
onClick={() => {
|
|
const affectedAssetIds = new Set(selectedClips.map(c => c.assetId).filter(Boolean) as string[])
|
|
// Update selected clips AND all other clips sharing the same assets
|
|
setClips(prev => prev.map(c => {
|
|
if (selectedClipIds.has(c.id) || (c.assetId && affectedAssetIds.has(c.assetId))) {
|
|
return { ...c, colorLabel: cl.id }
|
|
}
|
|
return c
|
|
}))
|
|
if (currentProjectId) {
|
|
affectedAssetIds.forEach(id => updateAsset(currentProjectId!, id, { colorLabel: cl.id }))
|
|
}
|
|
close()
|
|
}}
|
|
className="w-4 h-4 rounded-full hover:scale-125 transition-all"
|
|
style={{ backgroundColor: cl.color }}
|
|
title={cl.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── 6. Delete ── */}
|
|
<Divider />
|
|
<MenuItem icon={Trash2} label={`Delete ${n} Clips`} shortcut="Del" danger onClick={() => {
|
|
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()
|
|
}} />
|
|
</>
|
|
)
|
|
}
|