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 = { '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 setDraggingMarker: React.Dispatch> kbLayout: KeyboardLayout } export interface ProgramMonitorHandle { toggleFullscreen: () => void } export const ProgramMonitor = React.forwardRef(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(null) const videoPoolContainerRef = React.useRef(null) const incomingDissolveVideoRef = React.useRef(null) const incomingDissolveImageRef = React.useRef(null) const activeImageRef = React.useRef(null) const transitionBgRef = React.useRef(null) const videoPoolRef = React.useRef>(new Map()) const compositingMediaRefs = React.useRef>(new Map()) const activePoolPathRef = React.useRef('') const activePoolClipIdRef = React.useRef(null) const contributorSyncStatesRef = React.useRef>(new Map()) const preSeekDoneRef = React.useRef(null) const clipsRef = React.useRef(clips) const tracksRef = React.useRef(tracks) const getClipPathRef = React.useRef(getClipPath) const [previewZoom, setPreviewZoom] = React.useState('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(() => { 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(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() 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 (
{/* Preview (existing) */}
{ 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 ? (

Drop clips here

Click assets or drag them to the timeline

) : (
{/* Video frame wrapper — black bg with exact 16:9 dimensions */}
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 ( { if (el) compositingMediaRefs.current.set(lowerClip.id, el) else compositingMediaRefs.current.delete(lowerClip.id) }} /> ) } return (