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.15",
"version": "0.3.16",
"description": "The host-backed FreeCut browser editor surface.",
"license": "MIT",
"repository": {
Expand Down
117 changes: 115 additions & 2 deletions src/features/preview/hooks/use-custom-player.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { usePlaybackStore } from '@/shared/state/playback'
import { useEditorStore } from '@/shared/state/editor'
import { useTimelineSettingsStore } from '@/features/preview/deps/timeline-store'
import { ClockBridgeProvider, useClock } from '../deps/player'
import { useCustomPlayer } from './use-custom-player'

function createPlayer(initialFrame = 0) {
const { result } = renderHook(() => useClock(), {
wrapper: ({ children }) => (
<ClockBridgeProvider fps={30} durationInFrames={900} initialFrame={initialFrame}>
{children}
</ClockBridgeProvider>
),
})
const clock = result.current
return {
clock,
player: {
seekTo: vi.fn((frame: number) => clock.seekToFrame(frame)),
play: vi.fn(() => clock.play()),
pause: vi.fn(() => clock.pause()),
getCurrentFrame: () => clock.currentFrame,
isPlaying: () => clock.isPlaying,
setPlaybackRate: vi.fn((rate: number) => {
clock.playbackRate = rate
}),
},
}
}

beforeEach(() => {
useTimelineSettingsStore.setState({ isTimelineLoading: false })
})

afterEach(() => {
vi.useRealTimers()
cleanup()
usePlaybackStore.setState({ isPlaying: false })
useEditorStore.setState({ hostMode: false })
Expand Down Expand Up @@ -32,3 +63,85 @@ describe('preview mount transport ownership', () => {
expect(usePlaybackStore.getState().currentFrame).toBe(42)
})
})

describe('preview Player transport reconciliation', () => {
it('starts a fresh host Player at the preserved frame and advances its Clock', () => {
vi.useFakeTimers()
useEditorStore.setState({ hostMode: true })
usePlaybackStore.setState({ isPlaying: true, currentFrame: 42 })
const { player, clock } = createPlayer()
renderHook(() => useCustomPlayer({ current: player }))
expect(clock.isPlaying).toBe(true)
expect(clock.currentFrame).toBe(42)
act(() => {
vi.advanceTimersByTime(200)
})
expect(clock.currentFrame).toBeGreaterThan(42)
expect(player.play).toHaveBeenCalledTimes(1)
act(() => {
usePlaybackStore.getState().pause()
})
expect(clock.isPlaying).toBe(false)
})

it('reconciles a Player that becomes available after the former readiness timeout', () => {
vi.useFakeTimers()
useEditorStore.setState({ hostMode: true })
usePlaybackStore.setState({ isPlaying: false, currentFrame: 42 })
const { player, clock } = createPlayer()
const playerRef: { current: typeof player | null } = { current: null }
renderHook(() => useCustomPlayer(playerRef))
act(() => {
usePlaybackStore.setState({ isPlaying: true, currentFrame: 75 })
vi.advanceTimersByTime(1500)
})
playerRef.current = player
act(() => {
vi.advanceTimersByTime(50)
})
expect(clock.isPlaying).toBe(true)
expect(clock.currentFrame).toBe(75)
expect(player.play).toHaveBeenCalledTimes(1)
})

it('uses the latest paused transport when a late Player becomes ready', () => {
vi.useFakeTimers()
useEditorStore.setState({ hostMode: true })
usePlaybackStore.setState({ isPlaying: true, currentFrame: 42 })
const { player, clock } = createPlayer()
const playerRef: { current: typeof player | null } = { current: null }
renderHook(() => useCustomPlayer(playerRef))
act(() => {
usePlaybackStore.setState({ isPlaying: false, currentFrame: 75 })
})
clock.play()
playerRef.current = player
act(() => {
vi.advanceTimersByTime(50)
})
expect(clock.isPlaying).toBe(false)
expect(clock.currentFrame).toBe(75)
expect(player.play).not.toHaveBeenCalled()
expect(player.pause).toHaveBeenCalledTimes(1)
})

it('keeps a fresh standalone Player paused without starting it during mount', () => {
usePlaybackStore.setState({ isPlaying: true, currentFrame: 42 })
const { player, clock } = createPlayer()
renderHook(() => useCustomPlayer({ current: player }))
expect(usePlaybackStore.getState().isPlaying).toBe(false)
expect(clock.isPlaying).toBe(false)
expect(clock.currentFrame).toBe(42)
expect(player.play).not.toHaveBeenCalled()
})

it('does not replay a Player already following host transport', () => {
useEditorStore.setState({ hostMode: true })
usePlaybackStore.setState({ isPlaying: true, currentFrame: 42 })
const { player, clock } = createPlayer(42)
clock.play()
renderHook(() => useCustomPlayer({ current: player }))
expect(player.play).not.toHaveBeenCalled()
expect(clock.isPlaying).toBe(true)
})
})
37 changes: 22 additions & 15 deletions src/features/preview/hooks/use-custom-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,22 +189,20 @@ export function useCustomPlayer(

// Detect when Player becomes ready
useEffect(() => {
if (playerRef.current && !playerReady) {
if (playerReady) return
if (playerRef.current) {
setPlayerReady(true)
return
}
// Lazy preview mounts can take longer than a second. Keep checking until
// the actual Player exists, and stop on readiness or unmount.
const checkReady = setInterval(() => {
if (playerRef.current && !playerReady) {
if (playerRef.current) {
setPlayerReady(true)
clearInterval(checkReady)
}
}, 50)

const timeout = setTimeout(() => clearInterval(checkReady), 1000)

return () => {
clearInterval(checkReady)
clearTimeout(timeout)
}
return () => clearInterval(checkReady)
}, [playerRef, playerReady])

// Timeline → Player: Sync play/pause state
Expand All @@ -219,17 +217,19 @@ export function useCustomPlayer(
if (!playerRef.current) return

const wasPlaying = wasPlayingRef.current
wasPlayingRef.current = isPlaying
const { currentFrame, setPreviewFrame } = usePlaybackStore.getState()
// Read live transport: the standalone mount effect may already have paused
// it, while this render still captured the previous playing value.
const { currentFrame, setPreviewFrame, isPlaying: shouldPlay } = usePlaybackStore.getState()
wasPlayingRef.current = shouldPlay
const playbackPlan = planPlaybackStateCommand({
wasPlaying,
isPlaying,
wasPlaying: playerRef.current.isPlaying(),
isPlaying: shouldPlay,
currentFrame,
playerFrame: getPlayerFrame(),
})

try {
if (isPlaying && !wasPlaying) {
if (shouldPlay && !wasPlaying) {
flushPreviewWarmSeek()
}
if (playbackPlan.clearPreviewFrame) {
Expand All @@ -239,7 +239,14 @@ export function useCustomPlayer(
} catch (error) {
logger.error('Failed to control playback:', error)
}
}, [isPlaying, playerRef, executePlayerCommand, flushPreviewWarmSeek, getPlayerFrame])
}, [
isPlaying,
playerReady,
playerRef,
executePlayerCommand,
flushPreviewWarmSeek,
getPlayerFrame,
])

// Wait for timeline to finish loading before syncing frame position.
// Without this, the Player would seek to frame 0 (the default) before
Expand Down
104 changes: 104 additions & 0 deletions src/runtime/player/video/VideoSourcePool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type MutableVideoElement = HTMLVideoElement & {
}

function installVideoElementMocks() {
let autoLoad = true
const createdVideos: MutableVideoElement[] = []
const originalCreateElement = document.createElement.bind(document)

Expand Down Expand Up @@ -63,6 +64,7 @@ function installVideoElementMocks() {
}

video.load = vi.fn(() => {
if (!autoLoad) return
queueMicrotask(() => {
readyStateValue = 2
video.dispatchEvent(new Event('loadedmetadata'))
Expand All @@ -82,6 +84,9 @@ function installVideoElementMocks() {

return {
createdVideos,
stallLoads: () => {
autoLoad = false
},
restore: () => createElementSpy.mockRestore(),
}
}
Expand All @@ -98,6 +103,105 @@ describe('VideoSourcePool', () => {
vi.useRealTimers()
})

it('keeps an assigned pending video intact after preload timeout so late data can play', async () => {
vi.useFakeTimers()
videoMocks.stallLoads()
const pool = new VideoSourcePool()
const loading = pool.preloadSource('blob:slow').catch((error: Error) => error)
const video = pool.acquireForClip('clip', 'blob:slow') as MutableVideoElement
video.__setPaused(false)
await vi.advanceTimersByTimeAsync(15_000)
expect(await loading).toMatchObject({ message: expect.stringContaining('timed out') })
expect(video.getAttribute('src')).toBe('blob:slow')
expect(video.pause).not.toHaveBeenCalled()
expect(video.load).toHaveBeenCalledTimes(1)
expect(pool.getClipElement('clip')).toBe(video)
video.__setReadyState(4)
video.dispatchEvent(new Event('canplay'))
await pool.preloadSource('blob:slow')
pool.releaseClip('clip')
expect(pool.acquireForClip('next', 'blob:slow')).toBe(video)
expect(video.getAttribute('src')).toBe('blob:slow')
pool.dispose()
})

it('discards an unassigned timed-out video and permits a fresh preload', async () => {
vi.useFakeTimers()
videoMocks.stallLoads()
const pool = new VideoSourcePool()
const loading = pool.preloadSource('blob:slow').catch((error: Error) => error)
const first = videoMocks.createdVideos[0]!
await vi.advanceTimersByTimeAsync(15_000)
await loading
expect(first.getAttribute('src')).toBe('')
expect(pool.getStats().totalElements).toBe(0)
const retry = pool.preloadSource('blob:slow')
const next = videoMocks.createdVideos[1]!
next.dispatchEvent(new Event('canplay'))
await retry
expect(pool.acquireForClip('clip', 'blob:slow')).toBe(next)
pool.dispose()
})

it('defers failed assigned video disposal until its owner releases it', async () => {
videoMocks.stallLoads()
const pool = new VideoSourcePool()
const loading = pool.preloadSource('blob:broken').catch((error: Error) => error)
const video = pool.acquireForClip('clip', 'blob:broken')!
video.dispatchEvent(new Event('error'))
expect(await loading).toBeInstanceOf(Error)
expect(video.getAttribute('src')).toBe('blob:broken')
expect(pool.getClipElement('clip')).toBe(video)
pool.releaseClip('clip')
expect(video.getAttribute('src')).toBe('')
expect(pool.getStats().totalElements).toBe(0)
const retry = pool.preloadSource('blob:broken')
videoMocks.createdVideos[1]!.dispatchEvent(new Event('canplay'))
await retry
pool.dispose()
})

it('cleans up an unassigned media error before retrying', async () => {
videoMocks.stallLoads()
const pool = new VideoSourcePool()
const loading = pool.preloadSource('blob:broken').catch((error: Error) => error)
const video = videoMocks.createdVideos[0]!
video.dispatchEvent(new Event('error'))
expect(await loading).toBeInstanceOf(Error)
expect(video.getAttribute('src')).toBe('')
expect(pool.getStats().totalElements).toBe(0)
const retry = pool.preloadSource('blob:broken')
videoMocks.createdVideos[1]!.dispatchEvent(new Event('canplay'))
await retry
pool.dispose()
})

it('does not promote a ready video when disposal wins the completion microtask race', async () => {
videoMocks.stallLoads()
const pool = new VideoSourcePool()
const controller = pool.getSource('blob:pending')
const loading = pool.preloadSource('blob:pending').catch((error: Error) => error)
const video = videoMocks.createdVideos[0]!
video.dispatchEvent(new Event('canplay'))
pool.dispose()
expect(await loading).toMatchObject({ name: 'AbortError' })
expect(controller.getElementCount()).toBe(0)
expect(video.getAttribute('src')).toBe('')
expect(video.pause).toHaveBeenCalledTimes(1)
})

it('settles a pending preload on disposal without reviving or disposing its video twice', async () => {
videoMocks.stallLoads()
const pool = new VideoSourcePool()
const loading = pool.preloadSource('blob:pending').catch((error: Error) => error)
const video = pool.acquireForClip('clip', 'blob:pending')!
pool.dispose()
expect(await loading).toMatchObject({ name: 'AbortError' })
expect(video.pause).toHaveBeenCalledTimes(1)
expect(video.load).toHaveBeenCalledTimes(2)
expect(pool.getStats()).toEqual({ sourceCount: 0, totalElements: 0, activeClips: 0 })
})

it('ensures ready lanes and warms idle elements near transition boundaries', async () => {
const pool = new VideoSourcePool()

Expand Down
Loading
Loading