Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/freecut-editor/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quantfive/freecut-editor-surface",
"version": "0.3.16",
"version": "0.3.17",
"description": "The host-backed FreeCut browser editor surface.",
"license": "MIT",
"repository": {
Expand Down
56 changes: 51 additions & 5 deletions src/runtime/composition-runtime/components/video-content.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { act, render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { shouldIssueCoalescedReverseVideoSeek } from '../utils/video-sync-plan'
import { getAudioTargetTimeSeconds } from '../utils/video-timing'
import { VideoContent } from './video-content'
Expand All @@ -15,6 +15,7 @@ const testState = vi.hoisted(() => ({
acquireForClip: ReturnType<typeof vi.fn>
releaseClip: ReturnType<typeof vi.fn>
} | null,
clock: { currentFrame: 0, onFrameChange: () => () => {} },
renderedIsPlaying: null as boolean | null,
playbackState: {
currentFrame: 0,
Expand Down Expand Up @@ -103,10 +104,7 @@ function createMockVideoElement(): HTMLVideoElement {
vi.mock('@/runtime/composition-runtime/deps/player', () => ({
useSequenceContext: () => ({ localFrame: 0, from: 0, durationInFrames: 120, parentFrom: 0 }),
useVideoSourcePool: () => testState.pool!,
useClock: () => ({
currentFrame: 0,
onFrameChange: () => () => {},
}),
useClock: () => testState.clock,
useClockPlaybackRate: () => 1,
interpolate: () => 0,
isVideoPoolAbortError: () => false,
Expand Down Expand Up @@ -147,7 +145,9 @@ vi.mock('./video-audio-context', () => ({
}))

describe('VideoContent pooled handoff', () => {
afterEach(() => vi.restoreAllMocks())
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
preloadSourceMock.mockClear()
acquireForClipMock.mockClear()
releaseClipMock.mockClear()
Expand Down Expand Up @@ -195,6 +195,52 @@ describe('VideoContent pooled handoff', () => {
},
)

it('gates recycled pixels on acquisition and same-lane cut until the decoded target frame', async () => {
const video = createMockVideoElement()
const callbacks: VideoFrameRequestCallback[] = []
video.requestVideoFrameCallback = vi.fn((cb) => {
callbacks.push(cb)
return callbacks.length
})
video.cancelVideoFrameCallback = vi.fn()
acquireForClipMock.mockReturnValue(video)
const view = (id: string, trim: number) => (
<VideoContent
item={{
id,
type: 'video',
trackId: 'track',
from: 0,
durationInFrames: 90,
label: id,
src: 'blob:test',
_poolClipId: 'shared-lane',
}}
muted={false}
safeTrimBefore={trim}
playbackRate={1}
sourceFps={30}
audioEqStages={[]}
/>
)
const { rerender, unmount } = render(view('first', 300))
expect(video.currentTime).toBeCloseTo(10)
expect(video.style.opacity).toBe('0')
act(() => callbacks.at(-1)?.(0, { mediaTime: 0 } as VideoFrameCallbackMetadata))
expect(video.style.opacity).toBe('0')
act(() => callbacks.at(-1)?.(0, { mediaTime: 10 } as VideoFrameCallbackMetadata))
expect(video.style.opacity).toBe('')
rerender(view('second', 600))
expect(acquireForClipMock).toHaveBeenCalledTimes(1)
expect(video.currentTime).toBeCloseTo(20)
expect(video.style.opacity).toBe('0')
act(() => callbacks.at(-1)?.(0, { mediaTime: 10 } as VideoFrameCallbackMetadata))
expect(video.style.opacity).toBe('0')
act(() => callbacks.at(-1)?.(0, { mediaTime: 20 } as VideoFrameCallbackMetadata))
expect(video.style.opacity).toBe('')
unmount()
})

it('keeps the acquired pool element when only itemId changes on the same pool lane', async () => {
const pooledElement = createMockVideoElement()
acquireForClipMock.mockReturnValue(pooledElement)
Expand Down
73 changes: 54 additions & 19 deletions src/runtime/composition-runtime/components/video-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
} from './video-audio-context'
import { getBrowserMediaPlaybackRate } from '@/shared/state/playback/shuttle'

import { createVideoFrameHandoff } from '../utils/video-frame-handoff'

const videoLog = createLogger('NativePreviewVideo')
const contentLog = createLogger('VideoContent')

Expand Down Expand Up @@ -89,6 +91,7 @@ function isRecoverableVideoLoadError(message: string): boolean {
*/
const NativePreviewVideo: React.FC<{
poolClipId: string
trackId: string
itemId: string
src: string
safeTrimBefore: number
Expand All @@ -106,6 +109,7 @@ const NativePreviewVideo: React.FC<{
sharedTransitionSync?: boolean
}> = ({
poolClipId,
trackId,
itemId,
src,
safeTrimBefore,
Expand Down Expand Up @@ -134,6 +138,12 @@ const NativePreviewVideo: React.FC<{
const itemIdRef = useRef(itemId)
itemIdRef.current = itemId

const handoffRef = useRef<ReturnType<typeof createVideoFrameHandoff> | null>(null)
const seekVideo = useCallback((video: HTMLVideoElement, time: number) => {
if (Math.abs(video.currentTime - time) > 0.001) handoffRef.current?.prepare(time)
video.currentTime = time
}, [])

// Brief muted play/pause that fills the decode buffer and re-acquires the
// browser's media pipeline, so a subsequent play() starts in ~2 frames
// instead of stalling 200-300ms on pipeline re-init. Debounced so repeated
Expand Down Expand Up @@ -189,7 +199,7 @@ const NativePreviewVideo: React.FC<{
Math.abs(v.currentTime - warmStartTime) > 0.001
) {
try {
v.currentTime = warmStartTime
seekVideo(v, warmStartTime)
} catch {
// The pooled element may be settling or have been released.
}
Expand All @@ -200,7 +210,7 @@ const NativePreviewVideo: React.FC<{
})
}
}, 50)
}, [])
}, [seekVideo])
const audioVolumeRef = useRef(audioVolume)
const audioEqStagesRef = useRef(audioEqStages)
const onErrorRef = useRef(onError)
Expand Down Expand Up @@ -389,9 +399,25 @@ const NativePreviewVideo: React.FC<{
)
const clampedInitial = Math.min(initialTargetTime, (element.duration || Infinity) - 0.1)
const currentlyPlaying = usePlaybackStore.getState().isPlaying
const isNearTarget = Math.abs(element.currentTime - clampedInitial) < 0.2
const isNearTarget = Math.abs(element.currentTime - clampedInitial) < 1 / initialFps
const isContinuousPlayback =
!initialRequiresVisualSeek && currentlyPlaying && isNearTarget && element.readyState >= 2
!initialRequiresVisualSeek &&
currentlyPlaying &&
!element.paused &&
!element.seeking &&
isNearTarget &&
element.readyState >= 2

if (containerRef.current) {
handoffRef.current = createVideoFrameHandoff(
element,
containerRef.current,
clock,
trackId,
Math.max(0.05, 2 / initialFps),
)
handoffRef.current.prepare(clampedInitial)
}

elementRef.current = element
syncRegisteredVideoElement(itemIdRef.current, element)
Expand All @@ -400,7 +426,7 @@ const NativePreviewVideo: React.FC<{
if (initialRequiresVisualSeek) {
element.pause()
element.playbackRate = 1
element.currentTime = clampedInitial
seekVideo(element, clampedInitial)
needsInitialSyncRef.current = false
} else if (isContinuousPlayback) {
// Split boundary during playback: element was just paused by cleanup
Expand All @@ -415,7 +441,7 @@ const NativePreviewVideo: React.FC<{
// instead of pausing and waiting for the sync effect next frame.
// This eliminates ~16-50ms of React scheduling + readyState gate delay.
element.playbackRate = initialMediaPlaybackRate
element.currentTime = clampedInitial
seekVideo(element, clampedInitial)
if (element.readyState >= 2) {
element.play().catch(() => {})
}
Expand Down Expand Up @@ -443,7 +469,7 @@ const NativePreviewVideo: React.FC<{
)
if (Math.abs(element.currentTime - clampedLiveTargetTime) <= 0.016) return
try {
element.currentTime = clampedLiveTargetTime
seekVideo(element, clampedLiveTargetTime)
} catch {
// Seek failed - element may still be stabilizing.
}
Expand Down Expand Up @@ -479,7 +505,7 @@ const NativePreviewVideo: React.FC<{
Math.abs(element.currentTime - latestPausedTarget) > 0.001
) {
try {
element.currentTime = latestPausedTarget
seekVideo(element, latestPausedTarget)
pausedSeekInFlightRef.current = true
} catch {
// The latest skim target remains queued for the next sync pass.
Expand All @@ -496,7 +522,7 @@ const NativePreviewVideo: React.FC<{
const handleEnded = () => {
videoLog.debug(`[${shortId}] ended, seeking to last frame`)
if (element.duration && element.duration > 0.1) {
element.currentTime = element.duration - 0.05
seekVideo(element, element.duration - 0.05)
}
}

Expand Down Expand Up @@ -548,7 +574,7 @@ const NativePreviewVideo: React.FC<{
'seekPastEnd:',
initialTargetTime > element.duration,
)
element.currentTime = clampedInitial
seekVideo(element, clampedInitial)
} else {
videoLog.debug(
`[${shortId}] continuous playback, skipping seek (drift: ${(element.currentTime - clampedInitial).toFixed(3)}s)`,
Expand Down Expand Up @@ -589,6 +615,9 @@ const NativePreviewVideo: React.FC<{
element.removeEventListener('error', handleError)
element.removeEventListener('ended', handleEnded)

handoffRef.current?.dispose()
handoffRef.current = null

// Pause and remove from DOM
element.pause()
if (preWarmTimerRef.current !== null) {
Expand Down Expand Up @@ -621,8 +650,11 @@ const NativePreviewVideo: React.FC<{
// the element across split-boundary transitions.
}, [
poolClipId,
trackId,
clock,
src,
pool,
seekVideo,
containerRef,
shortId,
syncRegisteredVideoElement,
Expand Down Expand Up @@ -699,11 +731,11 @@ const NativePreviewVideo: React.FC<{
targetTime: seekTo,
})
) {
video.currentTime = seekTo
seekVideo(video, seekTo)
pausedSeekInFlightRef.current = true
}
} else {
video.currentTime = seekTo
seekVideo(video, seekTo)
}
if (video.currentTime === seekTo || isPlaying) {
lastSyncTimeRef.current = Date.now()
Expand All @@ -718,6 +750,7 @@ const NativePreviewVideo: React.FC<{
if (layoutPlan.seekTo !== null) applyLayoutSeek(layoutPlan.seekTo)
}, [
frame,
seekVideo,
isPlaying,
isReversed,
mediaPlaybackRate,
Expand Down Expand Up @@ -798,7 +831,7 @@ const NativePreviewVideo: React.FC<{
})
) {
try {
video.currentTime = latestReverseSeekTargetRef.current
seekVideo(video, latestReverseSeekTargetRef.current)
reverseSeekInFlightRef.current = true
lastSyncTimeRef.current = Date.now()
needsInitialSyncRef.current = false
Expand Down Expand Up @@ -831,7 +864,7 @@ const NativePreviewVideo: React.FC<{
video.pause()
}
if (premountPlan.seekTo !== null) {
video.currentTime = premountPlan.seekTo
seekVideo(video, premountPlan.seekTo)
}
return
}
Expand All @@ -853,7 +886,7 @@ const NativePreviewVideo: React.FC<{
})
if (initialSyncPlan.seekTo !== null) {
try {
video.currentTime = initialSyncPlan.seekTo
seekVideo(video, initialSyncPlan.seekTo)
} catch {
// Seek failed - video may not be ready yet
}
Expand All @@ -879,7 +912,7 @@ const NativePreviewVideo: React.FC<{
})
if (driftCorrectionPlan.seekTo !== null) {
try {
video.currentTime = driftCorrectionPlan.seekTo
seekVideo(video, driftCorrectionPlan.seekTo)
lastSyncTimeRef.current = Date.now()
} catch {
// Seek failed - video may not be ready yet
Expand Down Expand Up @@ -927,7 +960,7 @@ const NativePreviewVideo: React.FC<{
targetTime: pausedSyncPlan.seekTo,
})
) {
video.currentTime = pausedSyncPlan.seekTo
seekVideo(video, pausedSyncPlan.seekTo)
pausedSeekInFlightRef.current = true
}
} catch {
Expand All @@ -945,6 +978,7 @@ const NativePreviewVideo: React.FC<{
}, [
frame,
fps,
seekVideo,
isPlaying,
isReversed,
isReverseShuttle,
Expand Down Expand Up @@ -1049,7 +1083,7 @@ const NativePreviewVideo: React.FC<{

if (correctionPlan.kind === 'seek') {
try {
v.currentTime = correctionPlan.seekTo
seekVideo(v, correctionPlan.seekTo)
if (correctionPlan.shouldUpdateLastSyncTime) {
lastSyncTimeRef.current = Date.now()
}
Expand All @@ -1072,7 +1106,7 @@ const NativePreviewVideo: React.FC<{
elementRef.current.playbackRate = mediaPlaybackRateRef.current
}
}
}, [clock, isPlaying, isReversed, isReverseShuttle, poolClipId, sharedTransitionSync])
}, [clock, isPlaying, isReversed, isReverseShuttle, poolClipId, sharedTransitionSync, seekVideo])

// Keep volume/gain in sync for pooled element.
useEffect(() => {
Expand Down Expand Up @@ -1279,6 +1313,7 @@ export const VideoContent: React.FC<{
return (
<NativePreviewVideo
poolClipId={item._poolClipId ?? item.id}
trackId={item.trackId}
itemId={item.id}
src={item.src!}
safeTrimBefore={safeTrimBefore}
Expand Down
Loading
Loading