Skip to content

Commit 050ba6a

Browse files
committed
feat(video): video preview composables and metadata utils
1 parent 4916efd commit 050ba6a

8 files changed

Lines changed: 1050 additions & 0 deletions
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { effectScope, nextTick, ref } from 'vue'
2+
import type { EffectScope } from 'vue'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
5+
import { fetchVideoMetadata } from '@/utils/videoMetadataUtil'
6+
7+
import {
8+
DEFAULT_VIDEO_FPS,
9+
FILMSTRIP_SAMPLE_COUNT,
10+
useVideoFilmstrip
11+
} from './useVideoFilmstrip'
12+
13+
vi.mock('@/utils/videoMetadataUtil', () => ({
14+
fetchVideoMetadata: vi.fn(async () => undefined)
15+
}))
16+
17+
type VideoListener = (event: Event) => void
18+
19+
class MockVideoElement {
20+
preload = ''
21+
muted = false
22+
playsInline = false
23+
crossOrigin = ''
24+
duration = 10
25+
videoWidth = 512
26+
videoHeight = 512
27+
src = ''
28+
readyState = 0
29+
emitSeekedOnSameValue = true
30+
autoEmitMetadata = true
31+
private _currentTime = 0
32+
private listeners = new Map<string, Set<VideoListener>>()
33+
34+
get currentTime() {
35+
return this._currentTime
36+
}
37+
38+
set currentTime(value: number) {
39+
const changed = value !== this._currentTime
40+
this._currentTime = value
41+
if (changed || this.emitSeekedOnSameValue) {
42+
queueMicrotask(() => this.emit('seeked'))
43+
}
44+
}
45+
46+
addEventListener(type: string, listener: VideoListener, options?: boolean) {
47+
if (options === true) {
48+
const wrapped = (event: Event) => {
49+
this.removeEventListener(type, wrapped)
50+
listener(event)
51+
}
52+
this.getListeners(type).add(wrapped)
53+
return
54+
}
55+
this.getListeners(type).add(listener)
56+
}
57+
58+
removeEventListener(type: string, listener: VideoListener) {
59+
this.getListeners(type).delete(listener)
60+
}
61+
62+
load() {
63+
this.src = ''
64+
}
65+
66+
removeAttribute(name: string) {
67+
if (name === 'src') this.src = ''
68+
}
69+
70+
private getListeners(type: string) {
71+
if (!this.listeners.has(type)) {
72+
this.listeners.set(type, new Set())
73+
}
74+
return this.listeners.get(type)!
75+
}
76+
77+
emit(type: string) {
78+
for (const listener of [...this.getListeners(type)]) {
79+
listener(new Event(type))
80+
}
81+
}
82+
}
83+
84+
function createMockCanvas(context: unknown = { drawImage: vi.fn() }) {
85+
return {
86+
width: 0,
87+
height: 0,
88+
getContext: () => context,
89+
toDataURL: () => 'data:image/jpeg;base64,thumb'
90+
} as unknown as HTMLCanvasElement
91+
}
92+
93+
function installVideoMocks({
94+
onVideoCreated,
95+
canvasContext = { drawImage: vi.fn() } as unknown
96+
}: {
97+
onVideoCreated?: (video: MockVideoElement) => void
98+
canvasContext?: unknown
99+
} = {}) {
100+
const originalCreateElement = document.createElement.bind(document)
101+
102+
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
103+
if (tagName === 'video') {
104+
const video = new MockVideoElement()
105+
onVideoCreated?.(video)
106+
if (video.autoEmitMetadata) {
107+
queueMicrotask(() => video.emit('loadedmetadata'))
108+
}
109+
return video as unknown as HTMLVideoElement
110+
}
111+
if (tagName === 'canvas') {
112+
return createMockCanvas(canvasContext)
113+
}
114+
return originalCreateElement(tagName)
115+
})
116+
}
117+
118+
describe('useVideoFilmstrip', () => {
119+
let scope: EffectScope | undefined
120+
121+
function runWithScope<T>(fn: () => T): T {
122+
scope = effectScope()
123+
return scope.run(fn)!
124+
}
125+
126+
afterEach(() => {
127+
scope?.stop()
128+
scope = undefined
129+
vi.restoreAllMocks()
130+
})
131+
132+
it('estimates total frames from duration and default fps', async () => {
133+
installVideoMocks()
134+
135+
const videoUrl = ref('https://example.com/video.mp4')
136+
const { totalFrames, duration, loading } = runWithScope(() =>
137+
useVideoFilmstrip(videoUrl)
138+
)
139+
140+
await vi.waitFor(() => expect(loading.value).toBe(false))
141+
142+
expect(duration.value).toBe(10)
143+
expect(totalFrames.value).toBe(Math.round(10 * DEFAULT_VIDEO_FPS))
144+
})
145+
146+
it('clears state when url is removed', async () => {
147+
installVideoMocks()
148+
149+
const videoUrl = ref<string | undefined>('https://example.com/video.mp4')
150+
const { thumbnails, totalFrames, loading } = runWithScope(() =>
151+
useVideoFilmstrip(videoUrl)
152+
)
153+
154+
await vi.waitFor(() => expect(loading.value).toBe(false))
155+
156+
videoUrl.value = undefined
157+
await nextTick()
158+
159+
expect(thumbnails.value).toEqual([])
160+
expect(totalFrames.value).toBe(0)
161+
expect(loading.value).toBe(false)
162+
})
163+
164+
it('uses backend metadata when available', async () => {
165+
installVideoMocks()
166+
vi.mocked(fetchVideoMetadata).mockResolvedValueOnce({
167+
fps: 24,
168+
duration: 10,
169+
frame_count: 240,
170+
width: 512,
171+
height: 512,
172+
size: 5 * 1024 * 1024
173+
})
174+
175+
const videoUrl = ref('https://example.com/video.mp4')
176+
const { totalFrames, fps, fileSize, loading } = runWithScope(() =>
177+
useVideoFilmstrip(videoUrl)
178+
)
179+
180+
await vi.waitFor(() => expect(loading.value).toBe(false))
181+
182+
expect(fps.value).toBe(24)
183+
expect(totalFrames.value).toBe(240)
184+
expect(fileSize.value).toBe(5 * 1024 * 1024)
185+
})
186+
187+
it('samples the configured number of frames', async () => {
188+
let seekCount = 0
189+
installVideoMocks({
190+
onVideoCreated: (video) => {
191+
video.addEventListener('seeked', () => {
192+
seekCount += 1
193+
})
194+
}
195+
})
196+
197+
const videoUrl = ref('https://example.com/video.mp4')
198+
const { thumbnails, loading } = runWithScope(() =>
199+
useVideoFilmstrip(videoUrl, {
200+
sampleCount: FILMSTRIP_SAMPLE_COUNT
201+
})
202+
)
203+
204+
await vi.waitFor(() => expect(loading.value).toBe(false))
205+
206+
expect(seekCount).toBe(FILMSTRIP_SAMPLE_COUNT)
207+
expect(thumbnails.value).toHaveLength(FILMSTRIP_SAMPLE_COUNT)
208+
})
209+
210+
it('captures the first sample without a seeked event for same-position seeks', async () => {
211+
installVideoMocks({
212+
onVideoCreated: (video) => {
213+
video.readyState = 2
214+
video.emitSeekedOnSameValue = false
215+
}
216+
})
217+
218+
const videoUrl = ref('https://example.com/video.mp4')
219+
const { thumbnails, loading } = runWithScope(() =>
220+
useVideoFilmstrip(videoUrl, { sampleCount: 5 })
221+
)
222+
223+
await vi.waitFor(() => expect(loading.value).toBe(false))
224+
225+
expect(thumbnails.value).toHaveLength(5)
226+
})
227+
228+
it('reports load-failed and resets state when the video errors', async () => {
229+
installVideoMocks({
230+
onVideoCreated: (video) => {
231+
video.autoEmitMetadata = false
232+
queueMicrotask(() => video.emit('error'))
233+
}
234+
})
235+
236+
const videoUrl = ref('https://example.com/broken.mp4')
237+
const { error, duration, totalFrames, thumbnails, loading } = runWithScope(
238+
() => useVideoFilmstrip(videoUrl)
239+
)
240+
241+
await vi.waitFor(() => expect(loading.value).toBe(false))
242+
243+
expect(error.value).toBe('load-failed')
244+
expect(duration.value).toBe(0)
245+
expect(totalFrames.value).toBe(0)
246+
expect(thumbnails.value).toEqual([])
247+
})
248+
249+
it('reports canvas-unavailable when a 2d context cannot be created', async () => {
250+
installVideoMocks({ canvasContext: null })
251+
252+
const videoUrl = ref('https://example.com/video.mp4')
253+
const { error, loading } = runWithScope(() => useVideoFilmstrip(videoUrl))
254+
255+
await vi.waitFor(() => expect(loading.value).toBe(false))
256+
257+
expect(error.value).toBe('canvas-unavailable')
258+
})
259+
260+
it('ignores results from a stale load after the url changes', async () => {
261+
const videos: MockVideoElement[] = []
262+
installVideoMocks({
263+
onVideoCreated: (video) => {
264+
video.duration = videos.length === 0 ? 5 : 10
265+
video.autoEmitMetadata = videos.length > 0
266+
videos.push(video)
267+
}
268+
})
269+
270+
const videoUrl = ref('https://example.com/first.mp4')
271+
const { duration, loading } = runWithScope(() =>
272+
useVideoFilmstrip(videoUrl)
273+
)
274+
await nextTick()
275+
276+
videoUrl.value = 'https://example.com/second.mp4'
277+
await vi.waitFor(() => expect(loading.value).toBe(false))
278+
expect(duration.value).toBe(10)
279+
280+
videos[0].emit('loadedmetadata')
281+
await nextTick()
282+
await nextTick()
283+
284+
expect(duration.value).toBe(10)
285+
expect(loading.value).toBe(false)
286+
})
287+
})

0 commit comments

Comments
 (0)