Skip to content

Commit 6c3f92f

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

9 files changed

Lines changed: 212 additions & 61 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: 107 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,140 @@
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')
3231

33-
const result = await fetchVideoMetadata(
34-
'http://localhost:8188/api/view?filename=a.mp4&subfolder=clips&type=input&rand=0.1'
35-
)
32+
const result = await extractVideoMetadata(bufferSource(bytes))
3633

37-
expect(api.fetchApi).toHaveBeenCalledWith(
38-
'/video_metadata?filename=a.mp4&subfolder=clips&type=input',
39-
{ signal: undefined }
40-
)
41-
expect(result).toEqual(metadata)
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)
4252
})
4353

44-
it('returns undefined for non-view urls without fetching', async () => {
45-
const result = await fetchVideoMetadata('blob:abc')
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))
4658

47-
expect(api.fetchApi).not.toHaveBeenCalled()
4859
expect(result).toBeUndefined()
4960
})
5061

51-
it('rejects view urls from untrusted origins without fetching', async () => {
52-
const result = await fetchVideoMetadata(
53-
'https://attacker.invalid/view?filename=a.mp4'
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
5469
)
5570

56-
expect(api.fetchApi).not.toHaveBeenCalled()
5771
expect(result).toBeUndefined()
5872
})
5973

60-
it('returns undefined when the backend lacks the endpoint', async () => {
61-
mockResponse(false)
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
6277

63-
const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
78+
const result = await extractVideoMetadata(source)
6479

65-
expect(result).toBeUndefined()
80+
expect(result).toBeDefined()
81+
expect(result?.size).toBeNull()
82+
expect(result?.width).toBe(64)
6683
})
84+
})
6785

68-
it('returns undefined for malformed responses', async () => {
69-
mockResponse(true, { unexpected: true })
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+
})
7093

71-
const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
94+
it('leaves non-standard rates unchanged', () => {
95+
expect(snapToStandardFrameRate(8)).toBe(8)
96+
expect(snapToStandardFrameRate(33.3)).toBe(33.3)
97+
})
98+
})
7299

73-
expect(result).toBeUndefined()
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+
)
111+
112+
const result = await fetchVideoMetadata(
113+
'http://localhost:8188/api/view?filename=tiny.mp4&type=input'
114+
)
115+
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('returns undefined for non-view urls', async () => {
123+
expect(await fetchVideoMetadata('blob:abc')).toBeUndefined()
124+
expect(
125+
await fetchVideoMetadata('http://localhost:8188/api/other?filename=a.mp4')
126+
).toBeUndefined()
127+
})
128+
129+
it('returns undefined for view urls without a filename', async () => {
130+
expect(
131+
await fetchVideoMetadata('http://localhost:8188/api/view?type=input')
132+
).toBeUndefined()
133+
})
134+
135+
it('rejects view urls from untrusted origins', async () => {
136+
expect(
137+
await fetchVideoMetadata('https://attacker.invalid/view?filename=a.mp4')
138+
).toBeUndefined()
74139
})
75140
})

0 commit comments

Comments
 (0)