1899 lines
81 KiB
TypeScript
1899 lines
81 KiB
TypeScript
import React from 'react'
|
|
import {
|
|
Layers, Video, ChevronDown,
|
|
ChevronLeft, ChevronRight, Pause, Play, Repeat,
|
|
Expand, Shrink,
|
|
} from 'lucide-react'
|
|
import { Button } from '../../components/ui/button'
|
|
import { Tooltip } from '../../components/ui/tooltip'
|
|
import { AudioWaveform } from '../../components/AudioWaveform'
|
|
import { pathToFileUrl } from '../../lib/file-url'
|
|
import { DEFAULT_SUBTITLE_STYLE } from '../../types/project-model'
|
|
import type { Asset, TimelineClip, Track, SubtitleClip } from '../../types/project-model'
|
|
import { getClipEffectStyles, getTransitionBgColor, formatTime, getShortcutLabel, tooltipLabel, getMaskedEffectOverlays } from './video-editor-utils'
|
|
import type { KeyboardLayout } from '../../lib/keyboard-shortcuts'
|
|
import {
|
|
selectActiveTimelineInPoint,
|
|
selectActiveTimelineOutPoint,
|
|
selectAssets,
|
|
selectClips,
|
|
selectCurrentTime,
|
|
selectIsPlaying,
|
|
selectPlayingInOut,
|
|
selectSelectedClipIds,
|
|
selectShowPropertiesPanel,
|
|
selectSubtitles,
|
|
selectTotalDuration,
|
|
selectTracks,
|
|
} from './editor-selectors'
|
|
import { useEditorActions, useEditorStore } from './editor-store'
|
|
|
|
type MonitorRenderMode = 'playback' | 'scrub'
|
|
type SyncTarget = 'active' | 'incoming' | 'compositing'
|
|
type VideoContributorRole = 'primary' | 'dissolveIncoming' | 'compositing'
|
|
|
|
interface ActiveLetterboxState {
|
|
ratio: number
|
|
color: string
|
|
opacity: number
|
|
key: string
|
|
}
|
|
|
|
interface AdjustmentEffectState {
|
|
clip: TimelineClip
|
|
filterStyle: React.CSSProperties
|
|
hasVignette: boolean
|
|
vignetteAmount: number
|
|
hasGrain: boolean
|
|
grainAmount: number
|
|
}
|
|
|
|
interface DissolvePair {
|
|
outgoing: TimelineClip
|
|
incoming: TimelineClip
|
|
}
|
|
|
|
interface ActiveVideoContributor {
|
|
clip: TimelineClip
|
|
target: SyncTarget
|
|
role: VideoContributorRole
|
|
opacity: number
|
|
}
|
|
|
|
interface FrameOverlayState {
|
|
activeClip: TimelineClip | null
|
|
crossDissolve: DissolvePair | null
|
|
compositingStack: TimelineClip[]
|
|
activeTextClips: TimelineClip[]
|
|
activeSubtitles: SubtitleClip[]
|
|
activeLetterbox: ActiveLetterboxState | null
|
|
activeAdjustmentEffects: AdjustmentEffectState[]
|
|
audioOnlyClips: TimelineClip[]
|
|
}
|
|
|
|
interface FrameRenderState extends FrameOverlayState {
|
|
atTime: number
|
|
crossDissolveProgress: number
|
|
activeVideoContributors: ActiveVideoContributor[]
|
|
}
|
|
|
|
interface FrameRenderCache {
|
|
mediaClips: TimelineClip[]
|
|
videoClips: TimelineClip[]
|
|
textClips: TimelineClip[]
|
|
adjustmentClips: TimelineClip[]
|
|
audioClips: TimelineClip[]
|
|
subtitles: SubtitleClip[]
|
|
}
|
|
|
|
interface VideoContributorSyncState {
|
|
lastAtTime: number | null
|
|
pendingHardSync: boolean
|
|
}
|
|
|
|
const BASE_VIDEO_STYLE = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;opacity:0;z-index:0;pointer-events:none;'
|
|
const VIDEO_POOL_PREROLL_SECONDS = 1.5
|
|
|
|
function resolveClipPathFromAssets(assets: Asset[], clip: TimelineClip): string {
|
|
const liveAsset = clip.assetId
|
|
? assets.find(asset => asset.id === clip.assetId) || clip.asset
|
|
: clip.asset
|
|
if (!liveAsset) return ''
|
|
if (liveAsset.takes && liveAsset.takes.length > 0 && clip.takeIndex !== undefined) {
|
|
const idx = Math.max(0, Math.min(clip.takeIndex, liveAsset.takes.length - 1))
|
|
return liveAsset.takes[idx].path || ''
|
|
}
|
|
return liveAsset.path || ''
|
|
}
|
|
|
|
function createMonitorVideoElement(src: string): HTMLVideoElement {
|
|
const video = document.createElement('video')
|
|
video.preload = 'auto'
|
|
video.playsInline = true
|
|
video.muted = true
|
|
video.style.cssText = BASE_VIDEO_STYLE
|
|
video.src = pathToFileUrl(src)
|
|
video.load()
|
|
return video
|
|
}
|
|
|
|
function applyPlaybackResolution(video: HTMLVideoElement, playbackResolution: 1 | 0.5 | 0.25) {
|
|
if (playbackResolution < 1) {
|
|
video.style.width = `${playbackResolution * 100}%`
|
|
video.style.height = `${playbackResolution * 100}%`
|
|
video.style.transform = `scale(${1 / playbackResolution})`
|
|
video.style.transformOrigin = 'top left'
|
|
return
|
|
}
|
|
|
|
video.style.width = '100%'
|
|
video.style.height = '100%'
|
|
video.style.transform = ''
|
|
video.style.transformOrigin = ''
|
|
}
|
|
|
|
function buildFrameRenderCache(clips: TimelineClip[], subtitles: SubtitleClip[]): FrameRenderCache {
|
|
return {
|
|
mediaClips: clips.filter(clip => clip.type !== 'audio' && clip.type !== 'adjustment' && clip.type !== 'text'),
|
|
videoClips: clips.filter(clip => clip.asset?.type === 'video' && clip.type !== 'audio' && clip.type !== 'adjustment' && clip.type !== 'text'),
|
|
textClips: clips.filter(clip => clip.type === 'text' && Boolean(clip.textStyle)),
|
|
adjustmentClips: clips.filter(clip => clip.type === 'adjustment'),
|
|
audioClips: clips.filter(clip => clip.type === 'audio'),
|
|
subtitles,
|
|
}
|
|
}
|
|
|
|
function getClipTargetTime(clip: TimelineClip, mediaDuration: number, atTime: number): number {
|
|
const timeInClip = atTime - clip.startTime
|
|
const usableMediaDuration = mediaDuration - clip.trimStart - clip.trimEnd
|
|
return clip.reversed
|
|
? Math.max(0, Math.min(mediaDuration, clip.trimStart + usableMediaDuration - timeInClip * clip.speed))
|
|
: Math.max(0, Math.min(mediaDuration, clip.trimStart + timeInClip * clip.speed))
|
|
}
|
|
|
|
function getTopVisibleClipAtTime(mediaClips: TimelineClip[], tracks: Track[], time: number): TimelineClip | null {
|
|
let best: { clip: TimelineClip; arrayIndex: number } | null = null
|
|
|
|
for (let arrayIndex = 0; arrayIndex < mediaClips.length; arrayIndex += 1) {
|
|
const clip = mediaClips[arrayIndex]
|
|
if (tracks[clip.trackIndex]?.enabled === false) continue
|
|
if (time < clip.startTime || time >= clip.startTime + clip.duration) continue
|
|
if (!best) {
|
|
best = { clip, arrayIndex }
|
|
continue
|
|
}
|
|
if (clip.trackIndex > best.clip.trackIndex || (clip.trackIndex === best.clip.trackIndex && arrayIndex > best.arrayIndex)) {
|
|
best = { clip, arrayIndex }
|
|
}
|
|
}
|
|
|
|
return best?.clip ?? null
|
|
}
|
|
|
|
function getDissolveAtTime(mediaClips: TimelineClip[], tracks: Track[], time: number): { pair: DissolvePair; progress: number } | null {
|
|
for (const clipA of mediaClips) {
|
|
if (tracks[clipA.trackIndex]?.enabled === false) continue
|
|
if (clipA.transitionOut?.type !== 'dissolve' || clipA.transitionOut.duration <= 0) continue
|
|
const clipAEnd = clipA.startTime + clipA.duration
|
|
const dissolveStart = clipAEnd - clipA.transitionOut.duration
|
|
if (time < dissolveStart || time >= clipAEnd) continue
|
|
const clipB = mediaClips.find(candidate =>
|
|
candidate.id !== clipA.id &&
|
|
tracks[candidate.trackIndex]?.enabled !== false &&
|
|
candidate.trackIndex === clipA.trackIndex &&
|
|
candidate.transitionIn?.type === 'dissolve' &&
|
|
Math.abs(candidate.startTime - clipAEnd) < 0.05
|
|
)
|
|
if (!clipB) continue
|
|
const progress = Math.max(0, Math.min(1, (time - dissolveStart) / clipA.transitionOut.duration))
|
|
return { pair: { outgoing: clipA, incoming: clipB }, progress }
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getActiveTextClips(textClips: TimelineClip[], tracks: Track[], time: number): TimelineClip[] {
|
|
return textClips
|
|
.filter(clip =>
|
|
tracks[clip.trackIndex]?.enabled !== false &&
|
|
time >= clip.startTime &&
|
|
time < clip.startTime + clip.duration
|
|
)
|
|
.sort((a, b) => a.trackIndex - b.trackIndex)
|
|
}
|
|
|
|
function getActiveSubtitles(subtitles: SubtitleClip[], tracks: Track[], time: number): SubtitleClip[] {
|
|
return subtitles.filter(subtitle => {
|
|
const track = tracks[subtitle.trackIndex]
|
|
return Boolean(track) && !track.muted && time >= subtitle.startTime && time < subtitle.endTime
|
|
})
|
|
}
|
|
|
|
function getActiveLetterbox(adjustmentClips: TimelineClip[], tracks: Track[], time: number): ActiveLetterboxState | null {
|
|
const ratioMap: Record<string, number> = {
|
|
'2.35:1': 2.35,
|
|
'2.39:1': 2.39,
|
|
'2.76:1': 2.76,
|
|
'1.85:1': 1.85,
|
|
'4:3': 4 / 3,
|
|
}
|
|
|
|
const activeAdjustments = adjustmentClips
|
|
.filter(clip =>
|
|
tracks[clip.trackIndex]?.enabled !== false &&
|
|
time >= clip.startTime &&
|
|
time < clip.startTime + clip.duration
|
|
)
|
|
.sort((a, b) => b.trackIndex - a.trackIndex)
|
|
|
|
for (const clip of activeAdjustments) {
|
|
if (!clip.letterbox?.enabled) continue
|
|
const ratio = clip.letterbox.aspectRatio === 'custom'
|
|
? (clip.letterbox.customRatio || 2.35)
|
|
: (ratioMap[clip.letterbox.aspectRatio] || 2.35)
|
|
return {
|
|
ratio,
|
|
color: clip.letterbox.color || '#000000',
|
|
opacity: (clip.letterbox.opacity ?? 100) / 100,
|
|
key: `${clip.id}:${ratio}:${clip.letterbox.color || '#000000'}:${clip.letterbox.opacity ?? 100}`,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function getCompositingStack(mediaClips: TimelineClip[], tracks: Track[], activeClip: TimelineClip | null, time: number): TimelineClip[] {
|
|
if (!activeClip || (activeClip.opacity ?? 100) >= 100) return []
|
|
|
|
return mediaClips
|
|
.filter(clip =>
|
|
clip.id !== activeClip.id &&
|
|
tracks[clip.trackIndex]?.enabled !== false &&
|
|
clip.trackIndex < activeClip.trackIndex &&
|
|
time >= clip.startTime &&
|
|
time < clip.startTime + clip.duration
|
|
)
|
|
.sort((a, b) => a.trackIndex - b.trackIndex)
|
|
}
|
|
|
|
function getStyleOpacity(style: React.CSSProperties): number {
|
|
if (typeof style.opacity === 'number') return style.opacity
|
|
if (typeof style.opacity === 'string') {
|
|
const parsed = Number(style.opacity)
|
|
return Number.isFinite(parsed) ? parsed : 1
|
|
}
|
|
return 1
|
|
}
|
|
|
|
function toStyleValue(value: string | number | undefined): string {
|
|
if (value === undefined) return ''
|
|
return String(value)
|
|
}
|
|
|
|
function clearEffectStyle(element: HTMLElement): void {
|
|
element.style.filter = ''
|
|
element.style.transform = ''
|
|
element.style.clipPath = ''
|
|
element.style.opacity = ''
|
|
}
|
|
|
|
function applyEffectStyle(
|
|
element: HTMLElement,
|
|
style: React.CSSProperties,
|
|
opacityOverride?: number,
|
|
): void {
|
|
element.style.filter = toStyleValue(style.filter as string | undefined)
|
|
element.style.transform = toStyleValue(style.transform as string | undefined)
|
|
element.style.clipPath = toStyleValue(style.clipPath as string | undefined)
|
|
element.style.opacity = toStyleValue(
|
|
opacityOverride !== undefined ? opacityOverride : (style.opacity as string | number | undefined),
|
|
)
|
|
}
|
|
|
|
function getActiveVideoContributors(
|
|
activeClip: TimelineClip | null,
|
|
crossDissolve: DissolvePair | null,
|
|
crossDissolveProgress: number,
|
|
compositingStack: TimelineClip[],
|
|
time: number,
|
|
): ActiveVideoContributor[] {
|
|
const contributors: ActiveVideoContributor[] = []
|
|
const primaryClip = crossDissolve?.outgoing ?? activeClip
|
|
|
|
if (primaryClip?.asset?.type === 'video') {
|
|
const primaryOpacity = crossDissolve
|
|
? (1 - crossDissolveProgress) * ((crossDissolve.outgoing.opacity ?? 100) / 100)
|
|
: getStyleOpacity(getClipEffectStyles(primaryClip, Math.max(0, time - primaryClip.startTime)))
|
|
contributors.push({
|
|
clip: primaryClip,
|
|
target: 'active',
|
|
role: 'primary',
|
|
opacity: primaryOpacity,
|
|
})
|
|
}
|
|
|
|
if (crossDissolve?.incoming.asset?.type === 'video') {
|
|
contributors.push({
|
|
clip: crossDissolve.incoming,
|
|
target: 'incoming',
|
|
role: 'dissolveIncoming',
|
|
opacity: crossDissolveProgress * ((crossDissolve.incoming.opacity ?? 100) / 100),
|
|
})
|
|
}
|
|
|
|
for (const clip of compositingStack) {
|
|
if (clip.asset?.type !== 'video') continue
|
|
contributors.push({
|
|
clip,
|
|
target: 'compositing',
|
|
role: 'compositing',
|
|
opacity: getStyleOpacity(getClipEffectStyles(clip, Math.max(0, time - clip.startTime))),
|
|
})
|
|
}
|
|
|
|
return contributors
|
|
}
|
|
|
|
function deriveFrameRenderState(cache: FrameRenderCache, tracks: Track[], time: number): FrameRenderState {
|
|
const activeClip = getTopVisibleClipAtTime(cache.mediaClips, tracks, time)
|
|
const dissolve = getDissolveAtTime(cache.mediaClips, tracks, time)
|
|
const compositingStack = getCompositingStack(cache.mediaClips, tracks, activeClip, time)
|
|
|
|
return {
|
|
atTime: time,
|
|
activeClip,
|
|
crossDissolve: dissolve?.pair ?? null,
|
|
crossDissolveProgress: dissolve?.progress ?? 0,
|
|
compositingStack,
|
|
activeTextClips: getActiveTextClips(cache.textClips, tracks, time),
|
|
activeSubtitles: getActiveSubtitles(cache.subtitles, tracks, time),
|
|
activeLetterbox: getActiveLetterbox(cache.adjustmentClips, tracks, time),
|
|
activeAdjustmentEffects: [],
|
|
audioOnlyClips: cache.audioClips.filter(clip => time >= clip.startTime && time < clip.startTime + clip.duration),
|
|
activeVideoContributors: getActiveVideoContributors(activeClip, dissolve?.pair ?? null, dissolve?.progress ?? 0, compositingStack, time),
|
|
}
|
|
}
|
|
|
|
function sameClipList(a: TimelineClip[], b: TimelineClip[]): boolean {
|
|
if (a.length !== b.length) return false
|
|
return a.every((clip, index) => clip === b[index])
|
|
}
|
|
|
|
function sameSubtitleList(a: SubtitleClip[], b: SubtitleClip[]): boolean {
|
|
if (a.length !== b.length) return false
|
|
return a.every((subtitle, index) => subtitle === b[index])
|
|
}
|
|
|
|
function sameLetterbox(a: ActiveLetterboxState | null, b: ActiveLetterboxState | null): boolean {
|
|
if (a === b) return true
|
|
if (!a || !b) return false
|
|
return a.key === b.key
|
|
}
|
|
|
|
function sameDissolve(a: DissolvePair | null, b: DissolvePair | null): boolean {
|
|
if (a === b) return true
|
|
if (!a || !b) return false
|
|
return a.outgoing === b.outgoing && a.incoming === b.incoming
|
|
}
|
|
|
|
function sameFrameOverlayState(a: FrameOverlayState, b: FrameOverlayState): boolean {
|
|
return (
|
|
a.activeClip === b.activeClip &&
|
|
sameDissolve(a.crossDissolve, b.crossDissolve) &&
|
|
sameClipList(a.compositingStack, b.compositingStack) &&
|
|
sameClipList(a.activeTextClips, b.activeTextClips) &&
|
|
sameSubtitleList(a.activeSubtitles, b.activeSubtitles) &&
|
|
sameLetterbox(a.activeLetterbox, b.activeLetterbox) &&
|
|
sameClipList(a.audioOnlyClips, b.audioOnlyClips) &&
|
|
a.activeAdjustmentEffects.length === b.activeAdjustmentEffects.length
|
|
)
|
|
}
|
|
|
|
function sameVideoContributors(a: ActiveVideoContributor[], b: ActiveVideoContributor[]): boolean {
|
|
if (a.length !== b.length) return false
|
|
return a.every((contributor, index) => {
|
|
const candidate = b[index]
|
|
return (
|
|
contributor.clip === candidate.clip &&
|
|
contributor.target === candidate.target &&
|
|
contributor.role === candidate.role &&
|
|
contributor.opacity === candidate.opacity
|
|
)
|
|
})
|
|
}
|
|
|
|
function sameFrameRenderState(a: FrameRenderState, b: FrameRenderState): boolean {
|
|
return (
|
|
a.atTime === b.atTime &&
|
|
a.crossDissolveProgress === b.crossDissolveProgress &&
|
|
sameFrameOverlayState(a, b) &&
|
|
sameVideoContributors(a.activeVideoContributors, b.activeVideoContributors)
|
|
)
|
|
}
|
|
|
|
export interface ProgramMonitorProps {
|
|
playbackTimeRef: React.MutableRefObject<number>
|
|
setDraggingMarker: React.Dispatch<React.SetStateAction<'timelineIn' | 'timelineOut' | null>>
|
|
kbLayout: KeyboardLayout
|
|
}
|
|
|
|
export interface ProgramMonitorHandle {
|
|
toggleFullscreen: () => void
|
|
}
|
|
|
|
export const ProgramMonitor = React.forwardRef<ProgramMonitorHandle, ProgramMonitorProps>(function ProgramMonitor({
|
|
playbackTimeRef,
|
|
setDraggingMarker,
|
|
kbLayout,
|
|
}: ProgramMonitorProps, ref) {
|
|
const {
|
|
clearClipSelection,
|
|
pause,
|
|
play,
|
|
selectClip,
|
|
setClipTextPosition,
|
|
setCurrentTime,
|
|
setShowPropertiesPanel,
|
|
setTimelineInPoint,
|
|
setTimelineOutPoint,
|
|
stepCurrentTime,
|
|
stopShuttle,
|
|
togglePlayInOut,
|
|
} = useEditorActions()
|
|
const currentTime = useEditorStore(selectCurrentTime)
|
|
const totalDuration = useEditorStore(selectTotalDuration)
|
|
const isPlaying = useEditorStore(selectIsPlaying)
|
|
const assets = useEditorStore(selectAssets)
|
|
const clips = useEditorStore(selectClips)
|
|
const tracks = useEditorStore(selectTracks)
|
|
const subtitles = useEditorStore(selectSubtitles)
|
|
const getClipPath = React.useCallback((clip: TimelineClip) => resolveClipPathFromAssets(assets, clip), [assets])
|
|
const selectedClipIds = useEditorStore(selectSelectedClipIds)
|
|
const showPropertiesPanel = useEditorStore(selectShowPropertiesPanel)
|
|
const inPoint = useEditorStore(selectActiveTimelineInPoint)
|
|
const outPoint = useEditorStore(selectActiveTimelineOutPoint)
|
|
const playingInOut = useEditorStore(selectPlayingInOut)
|
|
|
|
// Flag to prevent the video frame wrapper's onClick from clearing selection
|
|
// when the user clicked on a text overlay (mousedown fires first on the overlay,
|
|
// but click may bubble up to the wrapper if the mouse moved slightly).
|
|
const clickedTextOverlayRef = React.useRef(false)
|
|
const previewContainerRef = React.useRef<HTMLDivElement>(null)
|
|
const videoPoolContainerRef = React.useRef<HTMLDivElement>(null)
|
|
const incomingDissolveVideoRef = React.useRef<HTMLVideoElement | null>(null)
|
|
const incomingDissolveImageRef = React.useRef<HTMLImageElement | null>(null)
|
|
const activeImageRef = React.useRef<HTMLImageElement | null>(null)
|
|
const transitionBgRef = React.useRef<HTMLDivElement | null>(null)
|
|
const videoPoolRef = React.useRef<Map<string, HTMLVideoElement>>(new Map())
|
|
const compositingMediaRefs = React.useRef<Map<string, HTMLVideoElement | HTMLImageElement>>(new Map())
|
|
const activePoolPathRef = React.useRef('')
|
|
const activePoolClipIdRef = React.useRef<string | null>(null)
|
|
const contributorSyncStatesRef = React.useRef<Map<string, VideoContributorSyncState>>(new Map())
|
|
const preSeekDoneRef = React.useRef<string | null>(null)
|
|
const clipsRef = React.useRef(clips)
|
|
const tracksRef = React.useRef(tracks)
|
|
const getClipPathRef = React.useRef(getClipPath)
|
|
const [previewZoom, setPreviewZoom] = React.useState<number | 'fit'>('fit')
|
|
const [previewZoomOpen, setPreviewZoomOpen] = React.useState(false)
|
|
const [previewPan, setPreviewPan] = React.useState({ x: 0, y: 0 })
|
|
const previewPanRef = React.useRef({ dragging: false, startX: 0, startY: 0, startPanX: 0, startPanY: 0 })
|
|
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
|
const [playbackResOpen, setPlaybackResOpen] = React.useState(false)
|
|
const [playbackResolution, setPlaybackResolution] = React.useState<1 | 0.5 | 0.25>(0.5)
|
|
const [videoFrameSize, setVideoFrameSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0 })
|
|
const frameRenderCache = React.useMemo(() => buildFrameRenderCache(clips, subtitles), [clips, subtitles])
|
|
const frameRenderCacheRef = React.useRef(frameRenderCache)
|
|
const [frameScene, setFrameScene] = React.useState<FrameOverlayState>(() => {
|
|
const initial = deriveFrameRenderState(frameRenderCache, tracks, currentTime)
|
|
return {
|
|
activeClip: initial.activeClip,
|
|
crossDissolve: initial.crossDissolve,
|
|
compositingStack: initial.compositingStack,
|
|
activeTextClips: initial.activeTextClips,
|
|
activeSubtitles: initial.activeSubtitles,
|
|
activeLetterbox: initial.activeLetterbox,
|
|
activeAdjustmentEffects: initial.activeAdjustmentEffects,
|
|
audioOnlyClips: initial.audioOnlyClips,
|
|
}
|
|
})
|
|
const frameSceneRef = React.useRef(frameScene)
|
|
const lastFrameRequestRef = React.useRef<{ state: FrameRenderState; mode: MonitorRenderMode } | null>(null)
|
|
const playbackTimecodeRef = React.useRef<HTMLSpanElement | null>(null)
|
|
|
|
const toggleFullscreen = React.useCallback(() => {
|
|
const el = previewContainerRef.current
|
|
if (!el) return
|
|
if (document.fullscreenElement === el) {
|
|
document.exitFullscreen().catch(() => {})
|
|
return
|
|
}
|
|
el.requestFullscreen().catch(() => {})
|
|
}, [])
|
|
|
|
React.useImperativeHandle(ref, () => ({ toggleFullscreen }), [toggleFullscreen])
|
|
|
|
React.useEffect(() => {
|
|
clipsRef.current = clips
|
|
}, [clips])
|
|
|
|
React.useEffect(() => {
|
|
tracksRef.current = tracks
|
|
}, [tracks])
|
|
|
|
React.useEffect(() => {
|
|
getClipPathRef.current = getClipPath
|
|
}, [getClipPath])
|
|
|
|
React.useEffect(() => {
|
|
frameRenderCacheRef.current = frameRenderCache
|
|
}, [frameRenderCache])
|
|
|
|
const resolveClipPathRef = React.useCallback((clip: TimelineClip): string => {
|
|
const resolved = getClipPathRef.current(clip)
|
|
return resolved || clip.asset?.path || ''
|
|
}, [])
|
|
|
|
const getNextVideoClipRef = React.useCallback((afterClip: TimelineClip): TimelineClip | null => {
|
|
const all = clipsRef.current
|
|
const endTime = afterClip.startTime + afterClip.duration
|
|
let best: TimelineClip | null = null
|
|
for (const clip of all) {
|
|
if (clip.type === 'audio' || clip.type === 'adjustment' || clip.type === 'text') continue
|
|
if (clip.asset?.type !== 'video') continue
|
|
if (clip.startTime >= endTime - 0.01) {
|
|
if (!best || clip.startTime < best.startTime) best = clip
|
|
}
|
|
}
|
|
return best
|
|
}, [])
|
|
|
|
const syncFrameScene = React.useCallback((nextState: FrameRenderState) => {
|
|
setFrameScene(prevState => {
|
|
const nextOverlayState: FrameOverlayState = {
|
|
activeClip: nextState.activeClip,
|
|
crossDissolve: nextState.crossDissolve,
|
|
compositingStack: nextState.compositingStack,
|
|
activeTextClips: nextState.activeTextClips,
|
|
activeSubtitles: nextState.activeSubtitles,
|
|
activeLetterbox: nextState.activeLetterbox,
|
|
activeAdjustmentEffects: nextState.activeAdjustmentEffects,
|
|
audioOnlyClips: nextState.audioOnlyClips,
|
|
}
|
|
if (sameFrameOverlayState(prevState, nextOverlayState)) {
|
|
frameSceneRef.current = prevState
|
|
return prevState
|
|
}
|
|
frameSceneRef.current = nextOverlayState
|
|
return nextOverlayState
|
|
})
|
|
}, [])
|
|
|
|
const syncPlaybackTimecode = React.useCallback((time: number) => {
|
|
const el = playbackTimecodeRef.current
|
|
if (!el) return
|
|
const nextText = formatTime(time)
|
|
if (el.textContent !== nextText) {
|
|
el.textContent = nextText
|
|
}
|
|
}, [])
|
|
|
|
const ensurePoolVideo = React.useCallback((filePath: string) => {
|
|
let video = videoPoolRef.current.get(filePath)
|
|
if (video) return video
|
|
|
|
video = createMonitorVideoElement(filePath)
|
|
applyPlaybackResolution(video, playbackResolution)
|
|
videoPoolRef.current.set(filePath, video)
|
|
if (videoPoolContainerRef.current) videoPoolContainerRef.current.appendChild(video)
|
|
return video
|
|
}, [playbackResolution])
|
|
|
|
const destroyPoolVideo = React.useCallback((filePath: string) => {
|
|
const video = videoPoolRef.current.get(filePath)
|
|
if (!video) return
|
|
|
|
video.pause()
|
|
video.removeAttribute('src')
|
|
video.load()
|
|
if (video.parentElement) video.parentElement.removeChild(video)
|
|
if (activePoolPathRef.current === filePath) activePoolPathRef.current = ''
|
|
videoPoolRef.current.delete(filePath)
|
|
}, [])
|
|
|
|
const syncVideoElement = React.useCallback((
|
|
video: HTMLVideoElement,
|
|
clip: TimelineClip,
|
|
atTime: number,
|
|
options: { forceSeek?: boolean; paused?: boolean },
|
|
) => {
|
|
const { forceSeek = false, paused = false } = options
|
|
video.muted = true
|
|
video.volume = 0
|
|
|
|
if (!video.duration || Number.isNaN(video.duration)) {
|
|
if (forceSeek) {
|
|
video.play().then(() => { video.pause() }).catch(() => {})
|
|
}
|
|
return
|
|
}
|
|
|
|
const targetTime = getClipTargetTime(clip, video.duration, atTime)
|
|
const shouldPause = paused || clip.reversed
|
|
const driftThreshold = shouldPause ? 0.04 : 0.3
|
|
|
|
video.playbackRate = clip.reversed ? 1 : clip.speed
|
|
if (!Number.isNaN(targetTime) && (forceSeek || Math.abs(video.currentTime - targetTime) > driftThreshold)) {
|
|
if (!shouldPause && typeof (video as { fastSeek?: (time: number) => void }).fastSeek === 'function' && !forceSeek) {
|
|
;(video as { fastSeek: (time: number) => void }).fastSeek(targetTime)
|
|
} else {
|
|
if (forceSeek && Math.abs(video.currentTime - targetTime) < 0.001) {
|
|
video.currentTime = targetTime + 0.001
|
|
}
|
|
video.currentTime = targetTime
|
|
}
|
|
}
|
|
|
|
if (shouldPause) {
|
|
if (!video.paused) {
|
|
video.pause()
|
|
}
|
|
} else if (video.paused) {
|
|
video.play().catch(() => {})
|
|
}
|
|
}, [])
|
|
|
|
const getContributorKey = React.useCallback((contributor: ActiveVideoContributor) => {
|
|
return `${contributor.target}:${contributor.clip.id}`
|
|
}, [])
|
|
|
|
const ensureContributorSyncState = React.useCallback((contributor: ActiveVideoContributor) => {
|
|
const key = `${contributor.target}:${contributor.clip.id}`
|
|
let syncState = contributorSyncStatesRef.current.get(key)
|
|
if (!syncState) {
|
|
syncState = { lastAtTime: null, pendingHardSync: false }
|
|
contributorSyncStatesRef.current.set(key, syncState)
|
|
}
|
|
return syncState
|
|
}, [])
|
|
|
|
const syncPlaybackContributorVideo = React.useCallback((
|
|
video: HTMLVideoElement,
|
|
fallbackContributor: ActiveVideoContributor,
|
|
fallbackAtTime: number,
|
|
fallbackMode: MonitorRenderMode,
|
|
) => {
|
|
const lastFrame = lastFrameRequestRef.current
|
|
const latestState = lastFrame?.state
|
|
const latestMode = lastFrame?.mode ?? fallbackMode
|
|
const latestContributor = latestState?.activeVideoContributors.find(contributor =>
|
|
contributor.target === fallbackContributor.target && contributor.clip.id === fallbackContributor.clip.id
|
|
) ?? fallbackContributor
|
|
if (latestContributor.clip.asset?.type !== 'video') return
|
|
if (latestContributor.target === 'active' && resolveClipPathRef(latestContributor.clip) !== activePoolPathRef.current) return
|
|
|
|
const syncState = ensureContributorSyncState(latestContributor)
|
|
const atTime = latestMode === 'playback'
|
|
? playbackTimeRef.current
|
|
: latestState?.atTime ?? fallbackAtTime
|
|
|
|
syncVideoElement(video, latestContributor.clip, atTime, {
|
|
forceSeek: syncState.pendingHardSync,
|
|
paused: latestMode !== 'playback' || latestContributor.clip.reversed,
|
|
})
|
|
|
|
if (syncState.pendingHardSync) {
|
|
syncState.pendingHardSync = false
|
|
}
|
|
syncState.lastAtTime = atTime
|
|
}, [ensureContributorSyncState, playbackTimeRef, resolveClipPathRef, syncVideoElement])
|
|
|
|
const syncRetainedPoolVideos = React.useCallback((state: FrameRenderState, mode: MonitorRenderMode) => {
|
|
const desiredSources = new Set<string>()
|
|
|
|
for (const contributor of state.activeVideoContributors) {
|
|
if (contributor.clip.asset?.type !== 'video') continue
|
|
const src = resolveClipPathRef(contributor.clip)
|
|
if (src) desiredSources.add(src)
|
|
}
|
|
|
|
const activeVideoContributor = state.activeVideoContributors.find(contributor => contributor.target === 'active') ?? null
|
|
if (mode === 'playback' && activeVideoContributor) {
|
|
const nextClip = getNextVideoClipRef(activeVideoContributor.clip)
|
|
if (nextClip) {
|
|
const remainingInCurrent = (activeVideoContributor.clip.startTime + activeVideoContributor.clip.duration) - state.atTime
|
|
if (remainingInCurrent < VIDEO_POOL_PREROLL_SECONDS && remainingInCurrent > 0) {
|
|
const nextSrc = resolveClipPathRef(nextClip)
|
|
if (nextSrc) desiredSources.add(nextSrc)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const poolPath of Array.from(videoPoolRef.current.keys())) {
|
|
if (!desiredSources.has(poolPath)) destroyPoolVideo(poolPath)
|
|
}
|
|
|
|
for (const src of desiredSources) {
|
|
ensurePoolVideo(src)
|
|
}
|
|
}, [destroyPoolVideo, ensurePoolVideo, getNextVideoClipRef, resolveClipPathRef])
|
|
|
|
const applyFrameVisuals = React.useCallback((state: FrameRenderState, mode: MonitorRenderMode) => {
|
|
syncRetainedPoolVideos(state, mode)
|
|
|
|
const { activeClip, crossDissolve, crossDissolveProgress, compositingStack, atTime, activeVideoContributors } = state
|
|
const pool = videoPoolRef.current
|
|
const poolContainer = videoPoolContainerRef.current
|
|
const contributorsByKey = new Map(activeVideoContributors.map(contributor => [getContributorKey(contributor), contributor]))
|
|
const activeVideoContributor = activeVideoContributors.find(contributor => contributor.target === 'active') ?? null
|
|
const incomingVideoContributor = activeVideoContributors.find(contributor => contributor.target === 'incoming') ?? null
|
|
const compositingVideoContributors = new Map(
|
|
activeVideoContributors
|
|
.filter(contributor => contributor.target === 'compositing')
|
|
.map(contributor => [contributor.clip.id, contributor]),
|
|
)
|
|
|
|
for (const key of contributorSyncStatesRef.current.keys()) {
|
|
if (!contributorsByKey.has(key)) contributorSyncStatesRef.current.delete(key)
|
|
}
|
|
|
|
if (poolContainer) {
|
|
const shouldShowPool = activeClip?.asset?.type === 'video' || Boolean(crossDissolve?.outgoing.asset?.type === 'video')
|
|
poolContainer.classList.toggle('hidden', !shouldShowPool)
|
|
}
|
|
|
|
const outgoingClip = crossDissolve?.outgoing ?? activeClip
|
|
if (activeVideoContributor) {
|
|
const clipPath = resolveClipPathRef(activeVideoContributor.clip)
|
|
if (clipPath) {
|
|
const video = ensurePoolVideo(clipPath)
|
|
const contributorSyncState = ensureContributorSyncState(activeVideoContributor)
|
|
const isNewClip = activePoolClipIdRef.current !== activeVideoContributor.clip.id
|
|
const previousPoolPath = activePoolPathRef.current
|
|
const hasPlaybackJump = mode === 'playback' &&
|
|
contributorSyncState.lastAtTime !== null &&
|
|
Math.abs(atTime - contributorSyncState.lastAtTime) > 0.5
|
|
const shouldForceSyncActive = mode === 'scrub' || isNewClip || hasPlaybackJump
|
|
if (poolContainer && !video.parentElement) poolContainer.appendChild(video)
|
|
|
|
for (const [poolPath, pooledVideo] of pool) {
|
|
if (poolPath === clipPath) continue
|
|
pooledVideo.style.opacity = '0'
|
|
pooledVideo.style.zIndex = '0'
|
|
}
|
|
|
|
if (clipPath !== previousPoolPath) {
|
|
const oldVid = pool.get(previousPoolPath)
|
|
if (oldVid) {
|
|
oldVid.style.opacity = '0'
|
|
oldVid.style.zIndex = '0'
|
|
oldVid.pause()
|
|
}
|
|
activePoolPathRef.current = clipPath
|
|
preSeekDoneRef.current = null
|
|
}
|
|
if (isNewClip) {
|
|
activePoolClipIdRef.current = activeVideoContributor.clip.id
|
|
preSeekDoneRef.current = null
|
|
}
|
|
video.style.opacity = '1'
|
|
video.style.zIndex = '1'
|
|
if (shouldForceSyncActive) {
|
|
contributorSyncState.pendingHardSync = true
|
|
}
|
|
if (video.readyState >= 2) {
|
|
syncPlaybackContributorVideo(video, activeVideoContributor, atTime, mode)
|
|
} else if (!(video as { __pendingCanplay?: boolean }).__pendingCanplay) {
|
|
;(video as { __pendingCanplay?: boolean }).__pendingCanplay = true
|
|
const onReady = () => {
|
|
video.removeEventListener('canplay', onReady)
|
|
;(video as { __pendingCanplay?: boolean }).__pendingCanplay = false
|
|
syncPlaybackContributorVideo(video, activeVideoContributor, atTime, mode)
|
|
}
|
|
video.addEventListener('canplay', onReady)
|
|
}
|
|
|
|
if (!crossDissolve && mode === 'playback') {
|
|
const nextClip = getNextVideoClipRef(activeVideoContributor.clip)
|
|
if (nextClip && nextClip.id !== preSeekDoneRef.current) {
|
|
const remainingInCurrent = (activeVideoContributor.clip.startTime + activeVideoContributor.clip.duration) - atTime
|
|
if (remainingInCurrent < VIDEO_POOL_PREROLL_SECONDS && remainingInCurrent > 0) {
|
|
const nextSrc = resolveClipPathRef(nextClip)
|
|
const nextVideo = nextSrc ? ensurePoolVideo(nextSrc) : null
|
|
if (nextVideo && nextVideo.readyState >= 1) {
|
|
const nextTargetTime = nextClip.reversed
|
|
? nextClip.trimStart + (nextVideo.duration || 0) - nextClip.trimStart - nextClip.trimEnd
|
|
: nextClip.trimStart
|
|
if (!Number.isNaN(nextTargetTime)) {
|
|
if (typeof (nextVideo as { fastSeek?: (time: number) => void }).fastSeek === 'function') {
|
|
;(nextVideo as { fastSeek: (time: number) => void }).fastSeek(nextTargetTime)
|
|
} else {
|
|
nextVideo.currentTime = nextTargetTime
|
|
}
|
|
}
|
|
preSeekDoneRef.current = nextClip.id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const curVid = pool.get(activePoolPathRef.current)
|
|
if (curVid) {
|
|
curVid.style.opacity = '0'
|
|
curVid.style.zIndex = '0'
|
|
if (!curVid.paused) {
|
|
curVid.pause()
|
|
}
|
|
}
|
|
activePoolClipIdRef.current = null
|
|
}
|
|
|
|
if (poolContainer) {
|
|
if (outgoingClip?.asset?.type === 'video') {
|
|
const baseStyle = getClipEffectStyles(outgoingClip, Math.max(0, atTime - outgoingClip.startTime))
|
|
const outgoingOpacity = crossDissolve
|
|
? (1 - crossDissolveProgress) * ((crossDissolve.outgoing.opacity ?? 100) / 100)
|
|
: baseStyle.opacity
|
|
applyEffectStyle(poolContainer, baseStyle, typeof outgoingOpacity === 'number' ? outgoingOpacity : undefined)
|
|
} else {
|
|
clearEffectStyle(poolContainer)
|
|
poolContainer.style.opacity = '0'
|
|
}
|
|
}
|
|
|
|
if (activeImageRef.current && activeClip?.asset?.type === 'image') {
|
|
const baseStyle = getClipEffectStyles(activeClip, Math.max(0, atTime - activeClip.startTime))
|
|
const opacity = crossDissolve
|
|
? (1 - crossDissolveProgress) * ((crossDissolve.outgoing.opacity ?? 100) / 100)
|
|
: baseStyle.opacity
|
|
applyEffectStyle(activeImageRef.current, baseStyle, typeof opacity === 'number' ? opacity : undefined)
|
|
} else if (activeImageRef.current) {
|
|
clearEffectStyle(activeImageRef.current)
|
|
}
|
|
|
|
if (transitionBgRef.current) {
|
|
if (activeClip) {
|
|
const tInBg = activeClip.transitionIn?.type !== 'none' ? getTransitionBgColor(activeClip.transitionIn.type) : null
|
|
const tOutBg = activeClip.transitionOut?.type !== 'none' ? getTransitionBgColor(activeClip.transitionOut.type) : null
|
|
const bg = tInBg || tOutBg
|
|
if (bg) {
|
|
const effectStyles = getClipEffectStyles(activeClip, Math.max(0, atTime - activeClip.startTime))
|
|
const overlayOpacity = effectStyles.opacity !== undefined ? 1 - (effectStyles.opacity as number) : 0
|
|
transitionBgRef.current.style.backgroundColor = bg
|
|
transitionBgRef.current.style.opacity = overlayOpacity > 0 ? String(overlayOpacity) : '0'
|
|
transitionBgRef.current.style.display = overlayOpacity > 0 ? 'block' : 'none'
|
|
} else {
|
|
transitionBgRef.current.style.display = 'none'
|
|
}
|
|
} else {
|
|
transitionBgRef.current.style.display = 'none'
|
|
}
|
|
}
|
|
|
|
if (crossDissolve) {
|
|
const incomingOffset = Math.max(0, atTime - crossDissolve.incoming.startTime)
|
|
const baseIncomingStyle = getClipEffectStyles(crossDissolve.incoming, incomingOffset)
|
|
const incomingOpacity = String(crossDissolveProgress * ((crossDissolve.incoming.opacity ?? 100) / 100))
|
|
const inStyle = {
|
|
...baseIncomingStyle,
|
|
opacity: incomingOpacity,
|
|
}
|
|
|
|
if (incomingVideoContributor && incomingDissolveVideoRef.current) {
|
|
const incomingPath = resolveClipPathRef(incomingVideoContributor.clip)
|
|
const video = incomingDissolveVideoRef.current
|
|
const contributorSyncState = ensureContributorSyncState(incomingVideoContributor)
|
|
const hasPlaybackJump = mode === 'playback' &&
|
|
contributorSyncState.lastAtTime !== null &&
|
|
Math.abs(atTime - contributorSyncState.lastAtTime) > 0.5
|
|
const shouldForceSyncIncoming = mode === 'scrub' || contributorSyncState.lastAtTime === null || hasPlaybackJump
|
|
const incomingFileUrl = incomingPath ? pathToFileUrl(incomingPath) : ''
|
|
if (incomingFileUrl && video.src !== incomingFileUrl && !video.src.endsWith(incomingFileUrl)) {
|
|
video.src = incomingFileUrl
|
|
video.load()
|
|
}
|
|
applyEffectStyle(video, inStyle)
|
|
if (shouldForceSyncIncoming) {
|
|
contributorSyncState.pendingHardSync = true
|
|
}
|
|
if (video.readyState >= 2) {
|
|
syncVideoElement(video, incomingVideoContributor.clip, atTime, {
|
|
forceSeek: contributorSyncState.pendingHardSync,
|
|
paused: true,
|
|
})
|
|
if (contributorSyncState.pendingHardSync) {
|
|
contributorSyncState.pendingHardSync = false
|
|
}
|
|
contributorSyncState.lastAtTime = atTime
|
|
} else if (!(video as { __pendingLoadedData?: boolean }).__pendingLoadedData) {
|
|
;(video as { __pendingLoadedData?: boolean }).__pendingLoadedData = true
|
|
const onLoaded = () => {
|
|
video.removeEventListener('loadeddata', onLoaded)
|
|
;(video as { __pendingLoadedData?: boolean }).__pendingLoadedData = false
|
|
syncVideoElement(video, incomingVideoContributor.clip, atTime, {
|
|
forceSeek: contributorSyncState.pendingHardSync,
|
|
paused: true,
|
|
})
|
|
if (contributorSyncState.pendingHardSync) {
|
|
contributorSyncState.pendingHardSync = false
|
|
}
|
|
contributorSyncState.lastAtTime = atTime
|
|
}
|
|
video.addEventListener('loadeddata', onLoaded)
|
|
}
|
|
}
|
|
|
|
if (crossDissolve.incoming.asset?.type === 'image' && incomingDissolveImageRef.current) {
|
|
applyEffectStyle(incomingDissolveImageRef.current, inStyle)
|
|
}
|
|
|
|
if (crossDissolve.incoming.asset?.type === 'video') {
|
|
const inPath = resolveClipPathRef(crossDissolve.incoming)
|
|
if (inPath) ensurePoolVideo(inPath)
|
|
}
|
|
} else {
|
|
if (incomingDissolveVideoRef.current) clearEffectStyle(incomingDissolveVideoRef.current)
|
|
if (incomingDissolveImageRef.current) clearEffectStyle(incomingDissolveImageRef.current)
|
|
}
|
|
|
|
const compositingIds = new Set(compositingStack.map(clip => clip.id))
|
|
for (const [clipId, element] of compositingMediaRefs.current.entries()) {
|
|
if (!compositingIds.has(clipId)) {
|
|
clearEffectStyle(element)
|
|
}
|
|
}
|
|
|
|
for (const clip of compositingStack) {
|
|
const element = compositingMediaRefs.current.get(clip.id)
|
|
if (!element) continue
|
|
const clipStyle = getClipEffectStyles(clip, Math.max(0, atTime - clip.startTime))
|
|
applyEffectStyle(element, clipStyle)
|
|
if (element instanceof HTMLVideoElement) {
|
|
const contributor = compositingVideoContributors.get(clip.id)
|
|
if (!contributor) {
|
|
if (!element.paused) {
|
|
element.pause()
|
|
}
|
|
continue
|
|
}
|
|
const contributorSyncState = ensureContributorSyncState(contributor)
|
|
const hasPlaybackJump = mode === 'playback' &&
|
|
contributorSyncState.lastAtTime !== null &&
|
|
Math.abs(atTime - contributorSyncState.lastAtTime) > 0.5
|
|
const shouldForceSyncCompositing = mode === 'scrub' || contributorSyncState.lastAtTime === null || hasPlaybackJump
|
|
if (shouldForceSyncCompositing) {
|
|
contributorSyncState.pendingHardSync = true
|
|
}
|
|
if (element.readyState >= 2) {
|
|
syncPlaybackContributorVideo(element, contributor, atTime, mode)
|
|
} else if (!(element as { __pendingLoadedData?: boolean }).__pendingLoadedData) {
|
|
;(element as { __pendingLoadedData?: boolean }).__pendingLoadedData = true
|
|
const onLoaded = () => {
|
|
element.removeEventListener('loadeddata', onLoaded)
|
|
;(element as { __pendingLoadedData?: boolean }).__pendingLoadedData = false
|
|
syncPlaybackContributorVideo(element, contributor, atTime, mode)
|
|
}
|
|
element.addEventListener('loadeddata', onLoaded)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (activeClip?.asset?.type === 'video') {
|
|
const poolVideo = activePoolPathRef.current ? videoPoolRef.current.get(activePoolPathRef.current) ?? null : null
|
|
if (poolVideo) {
|
|
const overlays = getMaskedEffectOverlays(activeClip)
|
|
for (const overlay of overlays) {
|
|
const maskVideo = document.getElementById(`mask-video-${overlay.effectId}`) as HTMLVideoElement | null
|
|
if (maskVideo && Math.abs(maskVideo.currentTime - poolVideo.currentTime) > 0.04) {
|
|
maskVideo.currentTime = poolVideo.currentTime
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, [ensureContributorSyncState, ensurePoolVideo, getContributorKey, getNextVideoClipRef, resolveClipPathRef, syncPlaybackContributorVideo, syncRetainedPoolVideos])
|
|
|
|
const renderFrame = React.useCallback((atTime: number, mode: MonitorRenderMode) => {
|
|
const nextState = deriveFrameRenderState(frameRenderCacheRef.current, tracksRef.current, atTime)
|
|
const lastFrame = lastFrameRequestRef.current
|
|
|
|
if (lastFrame && lastFrame.mode === mode && sameFrameRenderState(lastFrame.state, nextState)) {
|
|
syncPlaybackTimecode(atTime)
|
|
return
|
|
}
|
|
|
|
lastFrameRequestRef.current = { state: nextState, mode }
|
|
syncPlaybackTimecode(atTime)
|
|
syncFrameScene(nextState)
|
|
applyFrameVisuals(nextState, mode)
|
|
}, [applyFrameVisuals, syncFrameScene, syncPlaybackTimecode])
|
|
|
|
React.useEffect(() => {
|
|
const pool = videoPoolRef.current
|
|
for (const [, video] of pool) {
|
|
applyPlaybackResolution(video, playbackResolution)
|
|
}
|
|
}, [playbackResolution])
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
for (const poolPath of Array.from(videoPoolRef.current.keys())) {
|
|
destroyPoolVideo(poolPath)
|
|
}
|
|
}
|
|
}, [destroyPoolVideo])
|
|
|
|
React.useLayoutEffect(() => {
|
|
const lastFrame = lastFrameRequestRef.current
|
|
if (!lastFrame) return
|
|
applyFrameVisuals(lastFrame.state, lastFrame.mode)
|
|
}, [applyFrameVisuals, frameScene])
|
|
|
|
React.useEffect(() => {
|
|
syncPlaybackTimecode(currentTime)
|
|
}, [currentTime, syncPlaybackTimecode])
|
|
|
|
React.useEffect(() => {
|
|
if (!isPlaying) return
|
|
|
|
let animFrameId = 0
|
|
const tick = () => {
|
|
renderFrame(playbackTimeRef.current, 'playback')
|
|
animFrameId = requestAnimationFrame(tick)
|
|
}
|
|
|
|
animFrameId = requestAnimationFrame(tick)
|
|
return () => {
|
|
cancelAnimationFrame(animFrameId)
|
|
}
|
|
}, [isPlaying, playbackTimeRef, renderFrame])
|
|
|
|
React.useEffect(() => {
|
|
if (isPlaying) return
|
|
renderFrame(currentTime, 'scrub')
|
|
}, [clips, currentTime, isPlaying, renderFrame, subtitles, tracks])
|
|
|
|
React.useEffect(() => {
|
|
const handler = () => setIsFullscreen(document.fullscreenElement === previewContainerRef.current)
|
|
document.addEventListener('fullscreenchange', handler)
|
|
return () => document.removeEventListener('fullscreenchange', handler)
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
if (!previewZoomOpen) return
|
|
const handler = () => setPreviewZoomOpen(false)
|
|
const raf = requestAnimationFrame(() => {
|
|
window.addEventListener('click', handler)
|
|
})
|
|
return () => {
|
|
cancelAnimationFrame(raf)
|
|
window.removeEventListener('click', handler)
|
|
}
|
|
}, [previewZoomOpen])
|
|
|
|
React.useEffect(() => {
|
|
if (!playbackResOpen) return
|
|
const handler = () => setPlaybackResOpen(false)
|
|
const raf = requestAnimationFrame(() => {
|
|
window.addEventListener('click', handler)
|
|
})
|
|
return () => {
|
|
cancelAnimationFrame(raf)
|
|
window.removeEventListener('click', handler)
|
|
}
|
|
}, [playbackResOpen])
|
|
|
|
React.useEffect(() => {
|
|
if (previewZoom === 'fit') {
|
|
setPreviewPan({ x: 0, y: 0 })
|
|
}
|
|
}, [previewZoom])
|
|
|
|
React.useEffect(() => {
|
|
const el = previewContainerRef.current
|
|
if (!el) return
|
|
const handler = (e: WheelEvent) => {
|
|
if (!e.ctrlKey && !e.metaKey) return
|
|
e.preventDefault()
|
|
setPreviewZoom(prev => {
|
|
const current = prev === 'fit' ? 100 : prev
|
|
const delta = e.deltaY < 0 ? 1.15 : 1 / 1.15
|
|
return Math.round(Math.min(1600, Math.max(10, current * delta)))
|
|
})
|
|
}
|
|
el.addEventListener('wheel', handler, { passive: false })
|
|
return () => el.removeEventListener('wheel', handler)
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
const el = previewContainerRef.current
|
|
if (!el) return
|
|
const PROJECT_RATIO = 16 / 9
|
|
const compute = () => {
|
|
const { width, height } = el.getBoundingClientRect()
|
|
if (width === 0 || height === 0) return
|
|
let fw: number
|
|
let fh: number
|
|
if (width / height > PROJECT_RATIO) {
|
|
fh = height
|
|
fw = height * PROJECT_RATIO
|
|
} else {
|
|
fw = width
|
|
fh = width / PROJECT_RATIO
|
|
}
|
|
setVideoFrameSize(prev => (prev.width === fw && prev.height === fh ? prev : { width: fw, height: fh }))
|
|
}
|
|
compute()
|
|
const observer = new ResizeObserver(compute)
|
|
observer.observe(el)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
// Compositing stack video sync is handled inside renderFrame.
|
|
|
|
const activeClip = frameScene.activeClip
|
|
const monitorClip = activeClip
|
|
const compositingStack = frameScene.compositingStack
|
|
const activeTextClips = frameScene.activeTextClips
|
|
const activeSubtitles = frameScene.activeSubtitles
|
|
const activeLetterbox = frameScene.activeLetterbox
|
|
const activeAdjustmentEffects = frameScene.activeAdjustmentEffects
|
|
const crossDissolveState = frameScene.crossDissolve
|
|
? {
|
|
...frameScene.crossDissolve,
|
|
progress: lastFrameRequestRef.current?.state.crossDissolveProgress ?? 0,
|
|
}
|
|
: null
|
|
|
|
return (
|
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
|
{/* Preview (existing) */}
|
|
<div
|
|
ref={previewContainerRef}
|
|
className={`flex-1 relative overflow-hidden min-h-0 min-w-0 ${isFullscreen ? 'bg-black' : ''}`}
|
|
style={{ backgroundColor: isFullscreen ? '#000' : '#333', ...(previewZoom !== 'fit' ? { cursor: 'grab' } : {}) }}
|
|
onMouseDown={(e) => {
|
|
if (previewZoom === 'fit') return
|
|
if (e.button !== 0 && e.button !== 1) return
|
|
previewPanRef.current = { dragging: true, startX: e.clientX, startY: e.clientY, startPanX: previewPan.x, startPanY: previewPan.y }
|
|
}}
|
|
onMouseMove={(e) => {
|
|
if (!previewPanRef.current.dragging) return
|
|
setPreviewPan({
|
|
x: previewPanRef.current.startPanX + (e.clientX - previewPanRef.current.startX),
|
|
y: previewPanRef.current.startPanY + (e.clientY - previewPanRef.current.startY),
|
|
})
|
|
}}
|
|
onMouseUp={() => { previewPanRef.current.dragging = false }}
|
|
onMouseLeave={() => { previewPanRef.current.dragging = false }}
|
|
>
|
|
{clips.length === 0 ? (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="w-48 h-28 border-2 border-dashed border-zinc-700 rounded-lg flex flex-col items-center justify-center mb-4 mx-auto">
|
|
<Layers className="h-8 w-8 text-zinc-600 mb-2" />
|
|
<p className="text-zinc-500 text-xs">Drop clips here</p>
|
|
</div>
|
|
<p className="text-zinc-600 text-xs">Click assets or drag them to the timeline</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center"
|
|
style={previewZoom !== 'fit' ? {
|
|
transform: `translate(${previewPan.x}px, ${previewPan.y}px) scale(${(previewZoom as number) / 100})`,
|
|
transformOrigin: 'center center',
|
|
} : undefined}
|
|
>
|
|
{/* Video frame wrapper — black bg with exact 16:9 dimensions */}
|
|
<div
|
|
className="relative bg-black overflow-hidden"
|
|
style={videoFrameSize.width > 0 ? { width: videoFrameSize.width, height: videoFrameSize.height } : { width: '100%', aspectRatio: '16/9' }}
|
|
onClick={() => {
|
|
if (clickedTextOverlayRef.current) {
|
|
return
|
|
}
|
|
clearClipSelection()
|
|
}}
|
|
>
|
|
{(() => {
|
|
return (
|
|
<>
|
|
{/* Compositing: render clips from lower tracks underneath the active clip */}
|
|
{compositingStack.map(lowerClip => {
|
|
const lowerPath = getClipPath(lowerClip) || lowerClip.asset?.path || ''
|
|
const lowerFileUrl = lowerPath ? pathToFileUrl(lowerPath) : ''
|
|
if (lowerClip.asset?.type === 'image' || lowerClip.type === 'image') {
|
|
return (
|
|
<img
|
|
key={`comp-${lowerClip.id}`}
|
|
src={lowerFileUrl}
|
|
alt=""
|
|
className="absolute inset-0 w-full h-full object-contain pointer-events-none z-[1]"
|
|
ref={(el) => {
|
|
if (el) compositingMediaRefs.current.set(lowerClip.id, el)
|
|
else compositingMediaRefs.current.delete(lowerClip.id)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<video
|
|
key={`comp-${lowerClip.id}`}
|
|
id={`comp-video-${lowerClip.id}`}
|
|
src={lowerFileUrl}
|
|
className="absolute inset-0 w-full h-full object-contain pointer-events-none z-[1]"
|
|
muted
|
|
playsInline
|
|
preload="auto"
|
|
ref={(el) => {
|
|
if (el) {
|
|
compositingMediaRefs.current.set(lowerClip.id, el)
|
|
el.muted = true
|
|
} else {
|
|
compositingMediaRefs.current.delete(lowerClip.id)
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* Transition background overlay */}
|
|
{activeClip && (() => {
|
|
const tInBg = activeClip.transitionIn?.type !== 'none' ? getTransitionBgColor(activeClip.transitionIn.type) : null
|
|
const tOutBg = activeClip.transitionOut?.type !== 'none' ? getTransitionBgColor(activeClip.transitionOut.type) : null
|
|
const bg = tInBg || tOutBg
|
|
if (!bg) return null
|
|
return <div ref={transitionBgRef} className="absolute inset-0 z-10 pointer-events-none hidden" />
|
|
})()}
|
|
{/* Video pool container — during dissolve, fade out with progress */}
|
|
<div
|
|
ref={videoPoolContainerRef}
|
|
className="absolute inset-0 w-full h-full pointer-events-none z-[2] hidden"
|
|
/>
|
|
|
|
{activeClip?.asset?.type === 'image' && (
|
|
<img
|
|
ref={activeImageRef}
|
|
src={pathToFileUrl(getClipPath(activeClip) || activeClip.asset.path)}
|
|
alt=""
|
|
className="absolute inset-0 w-full h-full object-contain z-[2]"
|
|
/>
|
|
)}
|
|
|
|
{/* Cross-dissolve incoming clip overlay */}
|
|
{crossDissolveState && (() => {
|
|
const { incoming } = crossDissolveState
|
|
const inPath = getClipPath(incoming) || incoming.asset?.path || ''
|
|
const inFileUrl = inPath ? pathToFileUrl(inPath) : ''
|
|
if (incoming.asset?.type === 'video') {
|
|
return (
|
|
<video
|
|
ref={incomingDissolveVideoRef}
|
|
key={`dissolve-in-${incoming.id}`}
|
|
src={inFileUrl}
|
|
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
|
playsInline
|
|
muted
|
|
preload="auto"
|
|
/>
|
|
)
|
|
}
|
|
if (incoming.asset?.type === 'image') {
|
|
return (
|
|
<img
|
|
ref={incomingDissolveImageRef}
|
|
key={`dissolve-in-${incoming.id}`}
|
|
src={inFileUrl}
|
|
alt=""
|
|
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
|
/>
|
|
)
|
|
}
|
|
return null
|
|
})()}
|
|
|
|
{/* EFFECTS HIDDEN - masked effect overlays, vignette, and grain hidden because effects are not applied during export */}
|
|
|
|
{/* Audio waveform or empty state when no video/image clip is visible */}
|
|
{!monitorClip && (() => {
|
|
const audioAtPlayhead = frameScene.audioOnlyClips
|
|
return audioAtPlayhead.length > 0 ? (
|
|
<div className="absolute inset-0">
|
|
<AudioWaveform
|
|
audioClips={audioAtPlayhead.map(c => ({
|
|
url: pathToFileUrl(getClipPath(c) || c.asset?.path || ''),
|
|
name: c.asset?.path || c.importedName || 'Audio',
|
|
startTime: c.startTime,
|
|
duration: c.duration,
|
|
}))}
|
|
currentTime={currentTime}
|
|
isPlaying={isPlaying}
|
|
/>
|
|
</div>
|
|
) : !isPlaying ? (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
|
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-3">
|
|
<Video className="h-8 w-8 text-zinc-600" />
|
|
</div>
|
|
<p className="text-zinc-500 text-sm">No clip at playhead</p>
|
|
<p className="text-zinc-600 text-xs mt-1">Move playhead over a clip to preview</p>
|
|
</div>
|
|
) : null
|
|
})()}
|
|
</>
|
|
)
|
|
})()}
|
|
|
|
{/* Adjustment layer effects */}
|
|
{activeAdjustmentEffects.map(({ clip: adjClip, filterStyle, hasVignette, vignetteAmount, hasGrain, grainAmount }) => {
|
|
const backdropFilter = filterStyle.filter && filterStyle.filter !== 'none' ? String(filterStyle.filter) : undefined
|
|
return (
|
|
<React.Fragment key={`adj-fx-${adjClip.id}`}>
|
|
{backdropFilter && (
|
|
<div
|
|
className="absolute inset-0 z-[22] pointer-events-none"
|
|
style={{ backdropFilter, WebkitBackdropFilter: backdropFilter }}
|
|
/>
|
|
)}
|
|
{hasVignette && (
|
|
<div
|
|
className="absolute inset-0 z-[22] pointer-events-none"
|
|
style={{
|
|
background: `radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,${vignetteAmount}) 100%)`,
|
|
}}
|
|
/>
|
|
)}
|
|
{hasGrain && (
|
|
<canvas
|
|
ref={(canvas) => {
|
|
if (!canvas) return
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
const w = canvas.width = 256
|
|
const h = canvas.height = 256
|
|
const imageData = ctx.createImageData(w, h)
|
|
for (let i = 0; i < imageData.data.length; i += 4) {
|
|
const v = Math.random() * 255
|
|
imageData.data[i] = v
|
|
imageData.data[i + 1] = v
|
|
imageData.data[i + 2] = v
|
|
imageData.data[i + 3] = (grainAmount / 100) * 80
|
|
}
|
|
ctx.putImageData(imageData, 0, 0)
|
|
}}
|
|
className="absolute inset-0 z-[22] pointer-events-none w-full h-full"
|
|
style={{ mixBlendMode: 'overlay', imageRendering: 'pixelated' }}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
|
|
{/* Text overlay clips */}
|
|
{activeTextClips.map(tc => {
|
|
const ts = tc.textStyle!
|
|
const isSelected = selectedClipIds.has(tc.id)
|
|
return (
|
|
<div
|
|
key={`text-${tc.id}`}
|
|
className={`absolute z-[24] ${isSelected ? 'ring-2 ring-cyan-400/60 ring-offset-1 ring-offset-transparent' : ''}`}
|
|
style={{
|
|
left: `${ts.positionX}%`,
|
|
top: `${ts.positionY}%`,
|
|
transform: 'translate(-50%, -50%)',
|
|
maxWidth: ts.maxWidth > 0 ? `${ts.maxWidth}%` : undefined,
|
|
opacity: ts.opacity / 100,
|
|
pointerEvents: 'auto',
|
|
cursor: 'move',
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.stopPropagation()
|
|
clickedTextOverlayRef.current = true
|
|
selectClip(tc.id)
|
|
// Capture panel state at mousedown time so we can restore it after any
|
|
// spurious onClick handlers that might close it
|
|
const wasOpen = showPropertiesPanel
|
|
const clipId = tc.id
|
|
const container = (e.currentTarget.parentElement as HTMLElement)
|
|
if (!container) return
|
|
const rect = container.getBoundingClientRect()
|
|
const onMove = (ev: MouseEvent) => {
|
|
const px = Math.max(0, Math.min(100, ((ev.clientX - rect.left) / rect.width) * 100))
|
|
const py = Math.max(0, Math.min(100, ((ev.clientY - rect.top) / rect.height) * 100))
|
|
setClipTextPosition(tc.id, px, py)
|
|
}
|
|
const onUp = () => {
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
// Reset the ref and restore state after all click events have fired
|
|
requestAnimationFrame(() => {
|
|
clickedTextOverlayRef.current = false
|
|
selectClip(clipId)
|
|
if (wasOpen) setShowPropertiesPanel(true)
|
|
})
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation()
|
|
selectClip(tc.id)
|
|
setShowPropertiesPanel(true)
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontFamily: ts.fontFamily,
|
|
fontSize: `${ts.fontSize * 0.05}vh`,
|
|
fontWeight: ts.fontWeight,
|
|
fontStyle: ts.fontStyle,
|
|
color: ts.color,
|
|
backgroundColor: ts.backgroundColor,
|
|
textAlign: ts.textAlign,
|
|
padding: ts.padding > 0 ? `${ts.padding * 0.04}vh` : undefined,
|
|
borderRadius: ts.borderRadius > 0 ? `${ts.borderRadius}px` : undefined,
|
|
letterSpacing: ts.letterSpacing !== 0 ? `${ts.letterSpacing}px` : undefined,
|
|
lineHeight: ts.lineHeight,
|
|
textShadow: ts.shadowBlur > 0 || ts.shadowOffsetX !== 0 || ts.shadowOffsetY !== 0
|
|
? `${ts.shadowOffsetX}px ${ts.shadowOffsetY}px ${ts.shadowBlur}px ${ts.shadowColor}`
|
|
: undefined,
|
|
WebkitTextStroke: ts.strokeWidth > 0 && ts.strokeColor !== 'transparent'
|
|
? `${ts.strokeWidth}px ${ts.strokeColor}`
|
|
: undefined,
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{ts.text}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Subtitle overlay */}
|
|
{activeSubtitles.length > 0 && (
|
|
<div className="absolute inset-0 z-[25] pointer-events-none flex flex-col justify-end">
|
|
{activeSubtitles.map(sub => {
|
|
const track = tracks[sub.trackIndex]
|
|
const style = { ...DEFAULT_SUBTITLE_STYLE, ...(track?.subtitleStyle || {}), ...sub.style }
|
|
return (
|
|
<div
|
|
key={sub.id}
|
|
className={`w-full flex ${
|
|
style.position === 'top' ? 'self-start' : style.position === 'center' ? 'self-center absolute inset-0 items-center justify-center' : 'self-end'
|
|
}`}
|
|
style={style.position !== 'center' ? { padding: style.position === 'top' ? '12px 16px 0' : '0 16px 12px' } : undefined}
|
|
>
|
|
<span
|
|
className="inline-block max-w-[90%] text-center mx-auto rounded px-3 py-1.5 leading-snug whitespace-pre-wrap"
|
|
style={{
|
|
fontSize: `${style.fontSize}px`,
|
|
fontFamily: style.fontFamily,
|
|
fontWeight: style.fontWeight,
|
|
fontStyle: style.italic ? 'italic' : 'normal',
|
|
color: style.color,
|
|
backgroundColor: style.backgroundColor,
|
|
textShadow: '1px 1px 3px rgba(0,0,0,0.8)',
|
|
}}
|
|
>
|
|
{sub.text}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Letterbox overlay from adjustment layers */}
|
|
{activeLetterbox && (() => {
|
|
const containerRatio = 16 / 9
|
|
const targetRatio = activeLetterbox.ratio
|
|
if (targetRatio >= containerRatio) {
|
|
const barPct = ((1 - containerRatio / targetRatio) / 2) * 100
|
|
return barPct > 0 ? (
|
|
<>
|
|
<div
|
|
className="absolute left-0 right-0 top-0 z-[18] pointer-events-none"
|
|
style={{ height: `${barPct}%`, backgroundColor: activeLetterbox.color, opacity: activeLetterbox.opacity }}
|
|
/>
|
|
<div
|
|
className="absolute left-0 right-0 bottom-0 z-[18] pointer-events-none"
|
|
style={{ height: `${barPct}%`, backgroundColor: activeLetterbox.color, opacity: activeLetterbox.opacity }}
|
|
/>
|
|
</>
|
|
) : null
|
|
} else {
|
|
const barPct = ((1 - targetRatio / containerRatio) / 2) * 100
|
|
return barPct > 0 ? (
|
|
<>
|
|
<div
|
|
className="absolute top-0 bottom-0 left-0 z-[18] pointer-events-none"
|
|
style={{ width: `${barPct}%`, backgroundColor: activeLetterbox.color, opacity: activeLetterbox.opacity }}
|
|
/>
|
|
<div
|
|
className="absolute top-0 bottom-0 right-0 z-[18] pointer-events-none"
|
|
style={{ width: `${barPct}%`, backgroundColor: activeLetterbox.color, opacity: activeLetterbox.opacity }}
|
|
/>
|
|
</>
|
|
) : null
|
|
}
|
|
})()}
|
|
{/* EFFECTS HIDDEN - mask shape visual overlay hidden because effects are not applied during export */}
|
|
</div>{/* end video frame wrapper */}
|
|
|
|
{/* Transparent overlay to prevent video element default interactions */}
|
|
<div
|
|
className="absolute inset-0 z-20 pointer-events-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timecode + clip info moved to bottom status bar */}
|
|
</div>
|
|
|
|
{/* Program monitor mini scrub bar with IN/OUT markers */}
|
|
{clips.length > 0 && (
|
|
<div className="bg-zinc-900 border-t border-zinc-800 flex-shrink-0 relative px-2 py-1">
|
|
<div
|
|
id="program-scrub-bar"
|
|
className="relative h-5 cursor-pointer group"
|
|
onMouseDown={(e) => {
|
|
const bar = e.currentTarget
|
|
const rect = bar.getBoundingClientRect()
|
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
|
const t = pct * totalDuration
|
|
pause()
|
|
setCurrentTime(t)
|
|
const onMove = (ev: MouseEvent) => {
|
|
const p = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width))
|
|
setCurrentTime(p * totalDuration)
|
|
}
|
|
const onUp = () => {
|
|
window.removeEventListener('mousemove', onMove)
|
|
window.removeEventListener('mouseup', onUp)
|
|
}
|
|
window.addEventListener('mousemove', onMove)
|
|
window.addEventListener('mouseup', onUp)
|
|
}}
|
|
>
|
|
{/* Base track */}
|
|
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-1 bg-zinc-700 rounded-full" />
|
|
{/* Progress fill */}
|
|
<div
|
|
className="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-zinc-500 rounded-full"
|
|
style={{ width: totalDuration > 0 ? `${(currentTime / totalDuration) * 100}%` : '0%' }}
|
|
/>
|
|
{/* Dimmed region BEFORE In */}
|
|
{inPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 left-0 bg-black/50 rounded-l"
|
|
style={{ width: `${(inPoint / totalDuration) * 100}%` }}
|
|
/>
|
|
)}
|
|
{/* Dimmed region AFTER Out */}
|
|
{outPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 right-0 bg-black/50 rounded-r"
|
|
style={{ width: `${100 - (outPoint / totalDuration) * 100}%` }}
|
|
/>
|
|
)}
|
|
{/* In/Out range highlight */}
|
|
{(inPoint !== null || outPoint !== null) && (
|
|
<div
|
|
className="absolute top-0 bottom-0 border-t-2 border-b-2 border-blue-400/70 pointer-events-none"
|
|
style={{
|
|
left: `${((inPoint ?? 0) / totalDuration) * 100}%`,
|
|
width: `${(((outPoint ?? totalDuration) - (inPoint ?? 0)) / totalDuration) * 100}%`,
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 top-1/2 -translate-y-1/2 h-1 bg-blue-400/30 rounded-full" />
|
|
</div>
|
|
)}
|
|
{/* In bracket — draggable */}
|
|
{inPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 flex items-center z-10 cursor-ew-resize"
|
|
style={{ left: `calc(${(inPoint / totalDuration) * 100}% - 6px)`, width: 12 }}
|
|
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); setDraggingMarker('timelineIn') }}
|
|
>
|
|
<div className="w-1 h-full bg-blue-400 rounded-l-sm flex flex-col justify-between py-0.5 pointer-events-none ml-auto">
|
|
<div className="w-2 h-0.5 bg-blue-400 rounded-r" />
|
|
<div className="w-2 h-0.5 bg-blue-400 rounded-r" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Out bracket — draggable */}
|
|
{outPoint !== null && (
|
|
<div
|
|
className="absolute top-0 bottom-0 flex items-center z-10 cursor-ew-resize"
|
|
style={{ left: `${(outPoint / totalDuration) * 100}%`, width: 12 }}
|
|
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); setDraggingMarker('timelineOut') }}
|
|
>
|
|
<div className="w-1 h-full bg-blue-400 rounded-r-sm flex flex-col justify-between py-0.5 pointer-events-none">
|
|
<div className="w-2 h-0.5 bg-blue-400 rounded-l -ml-1" />
|
|
<div className="w-2 h-0.5 bg-blue-400 rounded-l -ml-1" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Playhead */}
|
|
<div
|
|
className="absolute top-0 bottom-0 z-20 pointer-events-none"
|
|
style={{ left: totalDuration > 0 ? `${(currentTime / totalDuration) * 100}%` : '0%' }}
|
|
>
|
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-white rounded-full" />
|
|
<div className="absolute top-1.5 bottom-0 left-1/2 -translate-x-1/2 w-px bg-white" />
|
|
</div>
|
|
</div>
|
|
{/* Timecode labels */}
|
|
{(inPoint !== null || outPoint !== null) && (
|
|
<div className="flex justify-between items-center mt-0.5 h-3">
|
|
<span className="text-[9px] font-mono text-blue-400/80">
|
|
{inPoint !== null ? `IN ${formatTime(inPoint)}` : ''}
|
|
</span>
|
|
<span className="text-[9px] font-mono text-zinc-500">
|
|
{inPoint !== null && outPoint !== null ? `Duration: ${formatTime(outPoint - inPoint)}` : ''}
|
|
</span>
|
|
<span className="text-[9px] font-mono text-blue-400/80">
|
|
{outPoint !== null ? `OUT ${formatTime(outPoint)}` : ''}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status bar: timecode | Fit | transport controls | resolution | duration */}
|
|
<div className="h-8 bg-zinc-950 border-t border-zinc-800 flex items-center px-3 flex-shrink-0 gap-2">
|
|
{/* Left: current timecode */}
|
|
<span
|
|
ref={playbackTimecodeRef}
|
|
className="text-[12px] font-mono font-medium text-amber-400 tabular-nums tracking-tight select-none flex-shrink-0"
|
|
>
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
|
|
{/* Fit / Zoom dropdown */}
|
|
<div className="relative flex-shrink-0">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setPreviewZoomOpen(prev => !prev) }}
|
|
className={`h-6 px-2 rounded text-[11px] font-medium tabular-nums flex items-center gap-1 transition-colors border ${
|
|
previewZoomOpen
|
|
? 'bg-zinc-700 text-white border-zinc-600'
|
|
: 'bg-zinc-900 text-zinc-400 hover:text-zinc-200 border-zinc-700 hover:border-zinc-600'
|
|
}`}
|
|
>
|
|
{previewZoom === 'fit' ? 'Fit' : `${previewZoom}%`}
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
{previewZoomOpen && (
|
|
<div className="absolute bottom-full left-0 mb-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl py-1 min-w-[100px] z-50">
|
|
{[
|
|
{ label: 'Fit', value: 'fit' as const },
|
|
{ label: '10%', value: 10 },
|
|
{ label: '25%', value: 25 },
|
|
{ label: '50%', value: 50 },
|
|
{ label: '75%', value: 75 },
|
|
{ label: '100%', value: 100 },
|
|
{ label: '150%', value: 150 },
|
|
{ label: '200%', value: 200 },
|
|
{ label: '400%', value: 400 },
|
|
{ label: '800%', value: 800 },
|
|
].map(opt => (
|
|
<button
|
|
key={opt.label}
|
|
onClick={() => { setPreviewZoom(opt.value); setPreviewZoomOpen(false) }}
|
|
className={`w-full text-left px-3 py-1.5 text-[11px] flex items-center gap-2 transition-colors ${
|
|
previewZoom === opt.value
|
|
? 'text-blue-300 bg-blue-600/20'
|
|
: 'text-zinc-300 hover:bg-zinc-800'
|
|
}`}
|
|
>
|
|
{previewZoom === opt.value && <span className="text-blue-400">✓</span>}
|
|
<span className={previewZoom === opt.value ? '' : 'ml-5'}>{opt.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Center: transport controls — Premiere-style 5-button strip */}
|
|
<div className="flex-1 flex items-center justify-center gap-0.5">
|
|
{/* Set In */}
|
|
<Tooltip content={`${inPoint !== null ? `In: ${formatTime(inPoint)} — ` : ''}${tooltipLabel('Set In point', getShortcutLabel(kbLayout, 'mark.setIn'))}`} side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className={`h-6 w-6 ${inPoint !== null ? 'text-yellow-400' : 'text-zinc-500'}`}
|
|
onClick={() => setTimelineInPoint(inPoint !== null && Math.abs(inPoint - currentTime) < 0.01 ? null : currentTime)}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="7,4 4,4 4,20 7,20" />
|
|
<line x1="10" y1="12" x2="20" y2="12" />
|
|
<polyline points="16,8 20,12 16,16" />
|
|
</svg>
|
|
</Button>
|
|
</Tooltip>
|
|
<div className="w-px h-3 bg-zinc-700" />
|
|
{/* Go to In */}
|
|
<Tooltip content={tooltipLabel('Go to In Point', getShortcutLabel(kbLayout, 'transport.goToIn'))} side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className={`h-6 w-6 ${inPoint !== null ? 'text-zinc-400' : 'text-zinc-500'}`}
|
|
onClick={() => {
|
|
const target = inPoint ?? (clips.length > 0 ? Math.min(...clips.map(c => c.startTime)) : 0)
|
|
pause()
|
|
stopShuttle()
|
|
setCurrentTime(target)
|
|
}}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="4" y1="4" x2="4" y2="20" />
|
|
<line x1="8" y1="12" x2="20" y2="12" />
|
|
<polyline points="14,7 8,12 14,17" />
|
|
</svg>
|
|
</Button>
|
|
</Tooltip>
|
|
{/* Step Back */}
|
|
<Tooltip content={tooltipLabel('Step Back', getShortcutLabel(kbLayout, 'transport.stepBackward'))} side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className="h-6 w-6 text-zinc-500"
|
|
onClick={() => {
|
|
pause()
|
|
stopShuttle()
|
|
stepCurrentTime(-1 / 24)
|
|
}}
|
|
>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</Tooltip>
|
|
{/* Play/Pause toggle */}
|
|
<Tooltip content={isPlaying ? tooltipLabel('Pause', getShortcutLabel(kbLayout, 'transport.playPause')) : tooltipLabel('Play', getShortcutLabel(kbLayout, 'transport.playPause'))} side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
onClick={() => {
|
|
stopShuttle()
|
|
if (isPlaying) pause()
|
|
else play()
|
|
}}
|
|
className="h-6 w-6 text-zinc-400"
|
|
>
|
|
{isPlaying ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3 ml-0.5" />}
|
|
</Button>
|
|
</Tooltip>
|
|
{/* Step Forward */}
|
|
<Tooltip content={tooltipLabel('Step Forward', getShortcutLabel(kbLayout, 'transport.stepForward'))} side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className="h-6 w-6 text-zinc-500"
|
|
onClick={() => {
|
|
pause()
|
|
stopShuttle()
|
|
setCurrentTime(Math.min(totalDuration, currentTime + (1 / 24)))
|
|
}}
|
|
>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</Tooltip>
|
|
{/* Go to Out */}
|
|
<Tooltip content={tooltipLabel('Go to Out Point', getShortcutLabel(kbLayout, 'transport.goToOut'))} side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className={`h-6 w-6 ${outPoint !== null ? 'text-zinc-400' : 'text-zinc-500'}`}
|
|
onClick={() => {
|
|
const target = outPoint ?? (clips.length > 0 ? Math.max(...clips.map(c => c.startTime + c.duration)) : totalDuration)
|
|
pause()
|
|
stopShuttle()
|
|
setCurrentTime(target)
|
|
}}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="20" y1="4" x2="20" y2="20" />
|
|
<line x1="16" y1="12" x2="4" y2="12" />
|
|
<polyline points="10,7 16,12 10,17" />
|
|
</svg>
|
|
</Button>
|
|
</Tooltip>
|
|
<div className="w-px h-3 bg-zinc-700" />
|
|
{/* Set Out */}
|
|
<Tooltip content={`${outPoint !== null ? `Out: ${formatTime(outPoint)} — ` : ''}${tooltipLabel('Set Out point', getShortcutLabel(kbLayout, 'mark.setOut'))}`} side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className={`h-6 w-6 ${outPoint !== null ? 'text-yellow-400' : 'text-zinc-500'}`}
|
|
onClick={() => setTimelineOutPoint(outPoint !== null && Math.abs(outPoint - currentTime) < 0.01 ? null : currentTime)}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="17,4 20,4 20,20 17,20" />
|
|
<line x1="14" y1="12" x2="4" y2="12" />
|
|
<polyline points="8,8 4,12 8,16" />
|
|
</svg>
|
|
</Button>
|
|
</Tooltip>
|
|
{/* Loop In/Out */}
|
|
<Tooltip content="Loop In/Out" side="top">
|
|
<Button
|
|
variant="ghost" size="icon"
|
|
className={`h-6 w-6 ${playingInOut ? 'text-yellow-400 bg-yellow-400/10' : 'text-zinc-500'} ${inPoint === null || outPoint === null ? 'opacity-30 cursor-not-allowed' : ''}`}
|
|
disabled={inPoint === null || outPoint === null}
|
|
onClick={() => {
|
|
if (inPoint === null || outPoint === null) return
|
|
stopShuttle()
|
|
if (playingInOut) {
|
|
togglePlayInOut()
|
|
pause()
|
|
return
|
|
}
|
|
setCurrentTime(inPoint)
|
|
togglePlayInOut()
|
|
play()
|
|
}}
|
|
>
|
|
<Repeat className="h-3 w-3" />
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* Resolution dropdown */}
|
|
<div className="relative flex-shrink-0">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setPlaybackResOpen(prev => !prev) }}
|
|
className={`h-6 px-2 rounded text-[11px] font-medium flex items-center gap-1 transition-colors border ${
|
|
playbackResolution === 1
|
|
? 'bg-zinc-900 text-green-400 border-zinc-700 hover:border-zinc-600'
|
|
: playbackResolution === 0.5
|
|
? 'bg-zinc-900 text-yellow-400 border-zinc-700 hover:border-zinc-600'
|
|
: 'bg-zinc-900 text-orange-400 border-zinc-700 hover:border-zinc-600'
|
|
}`}
|
|
title="Playback resolution"
|
|
>
|
|
{playbackResolution === 1 ? 'Full' : playbackResolution === 0.5 ? '1/2' : '1/4'}
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
{playbackResOpen && (
|
|
<div className="absolute bottom-full right-0 mb-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl py-1 min-w-[120px] z-50">
|
|
{([
|
|
{ label: 'Full (1:1)', value: 1 as const, desc: 'Highest quality' },
|
|
{ label: 'Half (1/2)', value: 0.5 as const, desc: 'Balanced' },
|
|
{ label: 'Quarter (1/4)', value: 0.25 as const, desc: 'Smoothest' },
|
|
] as const).map(opt => (
|
|
<button
|
|
key={opt.label}
|
|
onClick={() => { setPlaybackResolution(opt.value); setPlaybackResOpen(false) }}
|
|
className={`w-full text-left px-3 py-1.5 text-[11px] flex flex-col gap-0 transition-colors ${
|
|
playbackResolution === opt.value
|
|
? 'text-blue-300 bg-blue-600/20'
|
|
: 'text-zinc-300 hover:bg-zinc-800'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{playbackResolution === opt.value && <span className="text-blue-400">✓</span>}
|
|
<span className={playbackResolution === opt.value ? '' : 'ml-5'}>{opt.label}</span>
|
|
</div>
|
|
<span className={`text-[10px] ${playbackResolution === opt.value ? 'text-blue-400/60' : 'text-zinc-500'} ml-5`}>{opt.desc}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Fullscreen */}
|
|
<Tooltip content={isFullscreen ? tooltipLabel('Exit fullscreen', getShortcutLabel(kbLayout, 'view.fullscreen')) : tooltipLabel('Fullscreen', getShortcutLabel(kbLayout, 'view.fullscreen'))} side="top">
|
|
<button
|
|
onClick={toggleFullscreen}
|
|
className="p-1 rounded hover:bg-zinc-800 transition-colors text-zinc-500 hover:text-zinc-300"
|
|
>
|
|
{isFullscreen
|
|
? <Shrink className="h-3.5 w-3.5" />
|
|
: <Expand className="h-3.5 w-3.5" />
|
|
}
|
|
</button>
|
|
</Tooltip>
|
|
|
|
{/* Right: total duration */}
|
|
<span className="text-[12px] font-mono font-medium text-zinc-400 tabular-nums tracking-tight select-none flex-shrink-0 text-right">
|
|
{formatTime(totalDuration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|