Skip to content

Commit 77b9e1d

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

8 files changed

Lines changed: 886 additions & 0 deletions
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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+
private listeners = new Map<string, Set<VideoListener>>()
29+
30+
set currentTime(_value: number) {
31+
queueMicrotask(() => this.emit('seeked'))
32+
}
33+
34+
addEventListener(type: string, listener: VideoListener, options?: boolean) {
35+
if (options === true) {
36+
const wrapped = (event: Event) => {
37+
this.removeEventListener(type, wrapped)
38+
listener(event)
39+
}
40+
this.getListeners(type).add(wrapped)
41+
return
42+
}
43+
this.getListeners(type).add(listener)
44+
}
45+
46+
removeEventListener(type: string, listener: VideoListener) {
47+
this.getListeners(type).delete(listener)
48+
}
49+
50+
load() {
51+
this.src = ''
52+
}
53+
54+
removeAttribute(name: string) {
55+
if (name === 'src') this.src = ''
56+
}
57+
58+
private getListeners(type: string) {
59+
if (!this.listeners.has(type)) {
60+
this.listeners.set(type, new Set())
61+
}
62+
return this.listeners.get(type)!
63+
}
64+
65+
emit(type: string) {
66+
for (const listener of [...this.getListeners(type)]) {
67+
listener(new Event(type))
68+
}
69+
}
70+
}
71+
72+
function createMockCanvas(): HTMLCanvasElement {
73+
return {
74+
width: 0,
75+
height: 0,
76+
getContext: () => ({
77+
drawImage: vi.fn()
78+
}),
79+
toDataURL: () => 'data:image/jpeg;base64,thumb'
80+
} as unknown as HTMLCanvasElement
81+
}
82+
83+
function installVideoMocks() {
84+
const originalCreateElement = document.createElement.bind(document)
85+
86+
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
87+
if (tagName === 'video') {
88+
const video = new MockVideoElement()
89+
queueMicrotask(() => video.emit('loadedmetadata'))
90+
return video as unknown as HTMLVideoElement
91+
}
92+
if (tagName === 'canvas') {
93+
return createMockCanvas()
94+
}
95+
return originalCreateElement(tagName)
96+
})
97+
}
98+
99+
describe('useVideoFilmstrip', () => {
100+
let scope: EffectScope | undefined
101+
102+
function runWithScope<T>(fn: () => T): T {
103+
scope = effectScope()
104+
return scope.run(fn)!
105+
}
106+
107+
afterEach(() => {
108+
scope?.stop()
109+
scope = undefined
110+
vi.restoreAllMocks()
111+
})
112+
113+
it('estimates total frames from duration and default fps', async () => {
114+
installVideoMocks()
115+
116+
const videoUrl = ref('https://example.com/video.mp4')
117+
const { totalFrames, duration, loading } = runWithScope(() =>
118+
useVideoFilmstrip(videoUrl)
119+
)
120+
121+
await vi.waitFor(() => expect(loading.value).toBe(false))
122+
123+
expect(duration.value).toBe(10)
124+
expect(totalFrames.value).toBe(Math.round(10 * DEFAULT_VIDEO_FPS))
125+
})
126+
127+
it('clears state when url is removed', async () => {
128+
installVideoMocks()
129+
130+
const videoUrl = ref<string | undefined>('https://example.com/video.mp4')
131+
const { thumbnails, totalFrames, loading } = runWithScope(() =>
132+
useVideoFilmstrip(videoUrl)
133+
)
134+
135+
await vi.waitFor(() => expect(loading.value).toBe(false))
136+
137+
videoUrl.value = undefined
138+
await nextTick()
139+
140+
expect(thumbnails.value).toEqual([])
141+
expect(totalFrames.value).toBe(0)
142+
expect(loading.value).toBe(false)
143+
})
144+
145+
it('uses backend metadata when available', async () => {
146+
installVideoMocks()
147+
vi.mocked(fetchVideoMetadata).mockResolvedValueOnce({
148+
fps: 24,
149+
duration: 10,
150+
frame_count: 240,
151+
width: 512,
152+
height: 512,
153+
size: 5 * 1024 * 1024
154+
})
155+
156+
const videoUrl = ref('https://example.com/video.mp4')
157+
const { totalFrames, fps, fileSize, loading } = runWithScope(() =>
158+
useVideoFilmstrip(videoUrl)
159+
)
160+
161+
await vi.waitFor(() => expect(loading.value).toBe(false))
162+
163+
expect(fps.value).toBe(24)
164+
expect(totalFrames.value).toBe(240)
165+
expect(fileSize.value).toBe(5 * 1024 * 1024)
166+
})
167+
168+
it('samples the configured number of frames', async () => {
169+
let seekCount = 0
170+
const originalCreateElement = document.createElement.bind(document)
171+
172+
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
173+
if (tagName === 'video') {
174+
const video = new MockVideoElement()
175+
video.addEventListener('seeked', () => {
176+
seekCount += 1
177+
})
178+
queueMicrotask(() => video.emit('loadedmetadata'))
179+
return video as unknown as HTMLVideoElement
180+
}
181+
if (tagName === 'canvas') {
182+
return createMockCanvas()
183+
}
184+
return originalCreateElement(tagName)
185+
})
186+
187+
const videoUrl = ref('https://example.com/video.mp4')
188+
const { thumbnails, loading } = runWithScope(() =>
189+
useVideoFilmstrip(videoUrl, {
190+
sampleCount: FILMSTRIP_SAMPLE_COUNT
191+
})
192+
)
193+
194+
await vi.waitFor(() => expect(loading.value).toBe(false))
195+
196+
expect(seekCount).toBe(FILMSTRIP_SAMPLE_COUNT)
197+
expect(thumbnails.value).toHaveLength(FILMSTRIP_SAMPLE_COUNT)
198+
})
199+
})

0 commit comments

Comments
 (0)