Skip to content

Commit 4903871

Browse files
committed
feat: extract video metadata client-side with mediabunny
1 parent 19452ac commit 4903871

9 files changed

Lines changed: 386 additions & 59 deletions

File tree

.github/workflows/ci-oss-assets-validation.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ jobs:
112112
--summary \
113113
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/ingest-types;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
114114
--clarificationsFile .github/license-clarifications.json \
115-
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
115+
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);MPL-2.0;CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
116116
echo ''
117117
echo '✅ All production dependency licenses are approved!'
118118
else

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
"jsonata": "catalog:",
120120
"loglevel": "^1.9.2",
121121
"marked": "^15.0.11",
122+
"mediabunny": "catalog:",
122123
"minisearch": "catalog:",
123124
"pinia": "catalog:",
124125
"posthog-js": "catalog:",

pnpm-lock.yaml

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ catalog:
102102
lenis: ^1.3.21
103103
lint-staged: ^16.2.7
104104
markdown-table: ^3.0.4
105+
mediabunny: ^1.53.1
105106
minisearch: ^7.2.0
106107
mixpanel-browser: ^2.71.0
107108
monocart-coverage-reports: ^2.12.9

src/composables/video/useVideoFilmstrip.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ export function useVideoFilmstrip(
267267
width.value = metadata?.width ?? video.videoWidth
268268
height.value = metadata?.height ?? video.videoHeight
269269
fps.value = metadata?.fps ?? options.fps ?? DEFAULT_VIDEO_FPS
270-
fileSize.value = metadata?.size
270+
fileSize.value = metadata?.size ?? undefined
271271
totalFrames.value =
272272
metadata?.frame_count ??
273273
Math.max(Math.round(effectiveDuration * fps.value), 1)

src/utils/__fixtures__/tiny.mp4

1.87 KB
Binary file not shown.

src/utils/__fixtures__/tiny.webm

795 Bytes
Binary file not shown.
Lines changed: 203 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,237 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { BufferSource } from 'mediabunny'
2+
import { readFileSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
25

3-
import { api } from '@/scripts/api'
4-
import { fetchVideoMetadata } from '@/utils/videoMetadataUtil'
6+
import {
7+
extractVideoMetadata,
8+
fetchVideoMetadata,
9+
snapToStandardFrameRate
10+
} from '@/utils/videoMetadataUtil'
511

612
vi.mock('@/scripts/api', () => ({
713
api: {
8-
fetchApi: vi.fn(),
914
apiURL: (path: string) => `http://localhost:8188/api${path}`
1015
}
1116
}))
1217

13-
const metadata = {
14-
fps: 30,
15-
duration: 4.2,
16-
frame_count: 126,
17-
width: 1920,
18-
height: 1080,
19-
size: 1024
18+
function bufferSource(bytes: Uint8Array) {
19+
return new BufferSource(bytes)
2020
}
2121

22-
function mockResponse(ok: boolean, body?: unknown) {
23-
vi.mocked(api.fetchApi).mockResolvedValueOnce({
24-
ok,
25-
json: async () => body
26-
} as Response)
22+
function readFixture(name: string): Uint8Array {
23+
return new Uint8Array(
24+
readFileSync(join(process.cwd(), 'src', 'utils', '__fixtures__', name))
25+
)
2726
}
2827

29-
describe('fetchVideoMetadata', () => {
30-
it('queries the backend with the view resource params', async () => {
31-
mockResponse(true, metadata)
28+
describe('extractVideoMetadata', () => {
29+
it('reads dimensions, duration, fps and size from an mp4', async () => {
30+
const bytes = readFixture('tiny.mp4')
31+
32+
const result = await extractVideoMetadata(bufferSource(bytes))
33+
34+
expect(result).toBeDefined()
35+
expect(result?.width).toBe(64)
36+
expect(result?.height).toBe(48)
37+
expect(result?.duration).toBeCloseTo(12 / 8, 1)
38+
expect(result?.fps).toBeCloseTo(8, 1)
39+
expect(result?.size).toBe(bytes.byteLength)
40+
})
41+
42+
it('reads a webm container', async () => {
43+
const bytes = readFixture('tiny.webm')
44+
45+
const result = await extractVideoMetadata(bufferSource(bytes))
46+
47+
expect(result).toBeDefined()
48+
expect(result?.width).toBe(64)
49+
expect(result?.height).toBe(48)
50+
expect(result?.duration).toBeCloseTo(12 / 8, 1)
51+
expect(result?.fps).toBeCloseTo(8, 1)
52+
})
53+
54+
it('returns undefined for non-video bytes', async () => {
55+
const bytes = new TextEncoder().encode('not actually a video file')
56+
57+
const result = await extractVideoMetadata(bufferSource(bytes))
58+
59+
expect(result).toBeUndefined()
60+
})
61+
62+
it('returns undefined when the signal is already aborted', async () => {
63+
const controller = new AbortController()
64+
controller.abort()
65+
66+
const result = await extractVideoMetadata(
67+
bufferSource(readFixture('tiny.mp4')),
68+
controller.signal
69+
)
70+
71+
expect(result).toBeUndefined()
72+
})
73+
74+
it('reports a null size when the source size is unknown', async () => {
75+
const source = bufferSource(readFixture('tiny.mp4'))
76+
source.getSizeOrNull = async () => null
77+
78+
const result = await extractVideoMetadata(source)
79+
80+
expect(result).toBeDefined()
81+
expect(result?.size).toBeNull()
82+
expect(result?.width).toBe(64)
83+
})
84+
})
85+
86+
describe('snapToStandardFrameRate', () => {
87+
it('snaps near-standard measurements to the exact rate', () => {
88+
expect(snapToStandardFrameRate(29.972)).toBe(30_000 / 1_001)
89+
expect(snapToStandardFrameRate(23.98)).toBe(24_000 / 1_001)
90+
expect(snapToStandardFrameRate(30.005)).toBe(30)
91+
expect(snapToStandardFrameRate(59.945)).toBe(60_000 / 1_001)
92+
})
93+
94+
it('leaves non-standard rates unchanged', () => {
95+
expect(snapToStandardFrameRate(8)).toBe(8)
96+
expect(snapToStandardFrameRate(33.3)).toBe(33.3)
97+
})
98+
})
99+
100+
describe('fetchVideoMetadata url gating', () => {
101+
afterEach(() => {
102+
vi.unstubAllGlobals()
103+
})
104+
105+
it('extracts metadata from a trusted view url', async () => {
106+
const bytes = readFixture('tiny.mp4')
107+
vi.stubGlobal(
108+
'fetch',
109+
vi.fn(async () => new Response(new Uint8Array(bytes).buffer))
110+
)
32111

33112
const result = await fetchVideoMetadata(
34-
'http://localhost:8188/api/view?filename=a.mp4&subfolder=clips&type=input&rand=0.1'
113+
'http://localhost:8188/api/view?filename=tiny.mp4&type=input'
35114
)
36115

37-
expect(api.fetchApi).toHaveBeenCalledWith(
38-
'/video_metadata?filename=a.mp4&subfolder=clips&type=input',
39-
{ signal: undefined }
116+
expect(result).toBeDefined()
117+
expect(result?.width).toBe(64)
118+
expect(result?.height).toBe(48)
119+
expect(result?.fps).toBeCloseTo(8, 1)
120+
})
121+
122+
it('caches metadata per view resource ignoring cache-busting params', async () => {
123+
const fetchMock = vi.fn(
124+
async () => new Response(new Uint8Array(readFixture('tiny.mp4')).buffer)
125+
)
126+
vi.stubGlobal('fetch', fetchMock)
127+
128+
const first = await fetchVideoMetadata(
129+
'http://localhost:8188/api/view?filename=cached.mp4&type=input&rand=0.1'
40130
)
41-
expect(result).toEqual(metadata)
131+
const second = await fetchVideoMetadata(
132+
'http://localhost:8188/api/view?filename=cached.mp4&type=input&rand=0.2'
133+
)
134+
135+
expect(first).toBeDefined()
136+
expect(second).toEqual(first)
137+
expect(fetchMock).toHaveBeenCalledTimes(1)
42138
})
43139

44-
it('returns undefined for non-view urls without fetching', async () => {
45-
const result = await fetchVideoMetadata('blob:abc')
140+
it('does not share cache across different origins or paths', async () => {
141+
const fetchMock = vi.fn(
142+
async () => new Response(new Uint8Array(readFixture('tiny.mp4')).buffer)
143+
)
144+
vi.stubGlobal('fetch', fetchMock)
145+
146+
const fromApiBase = await fetchVideoMetadata(
147+
'http://localhost:8188/api/view?filename=origins.mp4&type=input'
148+
)
149+
const fromWindowOrigin = await fetchVideoMetadata(
150+
'/api/view?filename=origins.mp4&type=input'
151+
)
46152

47-
expect(api.fetchApi).not.toHaveBeenCalled()
48-
expect(result).toBeUndefined()
153+
expect(fromApiBase).toBeDefined()
154+
expect(fromWindowOrigin).toBeDefined()
155+
expect(fetchMock).toHaveBeenCalledTimes(2)
49156
})
50157

51-
it('rejects view urls from untrusted origins without fetching', async () => {
52-
const result = await fetchVideoMetadata(
53-
'https://attacker.invalid/view?filename=a.mp4'
158+
it('deduplicates concurrent probes for the same resource', async () => {
159+
const fetchMock = vi.fn(
160+
async () => new Response(new Uint8Array(readFixture('tiny.mp4')).buffer)
54161
)
162+
vi.stubGlobal('fetch', fetchMock)
163+
164+
const url =
165+
'http://localhost:8188/api/view?filename=concurrent.mp4&type=input'
166+
const [first, second] = await Promise.all([
167+
fetchVideoMetadata(url),
168+
fetchVideoMetadata(url)
169+
])
170+
171+
expect(first).toBeDefined()
172+
expect(second).toEqual(first)
173+
expect(fetchMock).toHaveBeenCalledTimes(1)
174+
})
55175

56-
expect(api.fetchApi).not.toHaveBeenCalled()
57-
expect(result).toBeUndefined()
176+
it('unblocks an aborted caller while the shared probe continues', async () => {
177+
let releaseFetch!: () => void
178+
const gate = new Promise<void>((resolve) => {
179+
releaseFetch = resolve
180+
})
181+
const fetchMock = vi.fn(async () => {
182+
await gate
183+
return new Response(new Uint8Array(readFixture('tiny.mp4')).buffer)
184+
})
185+
vi.stubGlobal('fetch', fetchMock)
186+
187+
const url = 'http://localhost:8188/api/view?filename=aborted.mp4&type=input'
188+
const controller = new AbortController()
189+
const pending = fetchVideoMetadata(url, controller.signal)
190+
controller.abort()
191+
192+
expect(await pending).toBeUndefined()
193+
194+
releaseFetch()
195+
const result = await fetchVideoMetadata(url)
196+
expect(result).toBeDefined()
197+
expect(fetchMock).toHaveBeenCalledTimes(1)
58198
})
59199

60-
it('returns undefined when the backend lacks the endpoint', async () => {
61-
mockResponse(false)
200+
it('does not cache failed probes', async () => {
201+
const failing = vi.fn(async () => {
202+
throw new Error('network down')
203+
})
204+
vi.stubGlobal('fetch', failing)
62205

63-
const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
206+
const url = 'http://localhost:8188/api/view?filename=flaky.mp4&type=input'
207+
expect(await fetchVideoMetadata(url)).toBeUndefined()
64208

65-
expect(result).toBeUndefined()
209+
const working = vi.fn(
210+
async () => new Response(new Uint8Array(readFixture('tiny.mp4')).buffer)
211+
)
212+
vi.stubGlobal('fetch', working)
213+
214+
const result = await fetchVideoMetadata(url)
215+
expect(result).toBeDefined()
216+
expect(working).toHaveBeenCalled()
66217
})
67218

68-
it('returns undefined for malformed responses', async () => {
69-
mockResponse(true, { unexpected: true })
219+
it('returns undefined for non-view urls', async () => {
220+
expect(await fetchVideoMetadata('blob:abc')).toBeUndefined()
221+
expect(
222+
await fetchVideoMetadata('http://localhost:8188/api/other?filename=a.mp4')
223+
).toBeUndefined()
224+
})
70225

71-
const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
226+
it('returns undefined for view urls without a filename', async () => {
227+
expect(
228+
await fetchVideoMetadata('http://localhost:8188/api/view?type=input')
229+
).toBeUndefined()
230+
})
72231

73-
expect(result).toBeUndefined()
232+
it('rejects view urls from untrusted origins', async () => {
233+
expect(
234+
await fetchVideoMetadata('https://attacker.invalid/view?filename=a.mp4')
235+
).toBeUndefined()
74236
})
75237
})

0 commit comments

Comments
 (0)