Coverage for tests/test_video_utils.py: 99%

366 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 04:47 +0000

1#!/usr/bin/env python3 

2 

3from __future__ import annotations 

4 

5import os 

6import logging 

7import pytest 

8import aiofiles 

9import aiofiles.os 

10 

11import numpy as np 

12 

13from typing import List 

14from typing import Optional 

15 

16from PIL import Image 

17 

18from media_utils import get_frame_with_text 

19from media_utils import video_frames_to_base64 

20from media_utils import base64_to_video_frames 

21from media_utils import chunk_video_binary 

22from media_utils import save_video_frames 

23from media_utils import save_video_audio 

24from media_utils import add_text_to_frame 

25from media_utils import concatenate_videos 

26from media_utils import change_video_fps 

27from media_utils import get_video_file_info 

28from media_utils import get_video_duration 

29from media_utils import get_video_frames 

30from media_utils import get_video_frame 

31from media_utils import get_video_fps 

32from media_utils import get_video_num_frames 

33from media_utils import get_video_with_text 

34from media_utils import get_video_frames_at_fps 

35from media_utils import get_video_size 

36from media_utils import get_font_size 

37from media_utils import get_audio_duration 

38from media_utils import get_ffmpeg_version 

39 

40from file_utils import read_file_base64 

41 

42 

43@pytest.mark.asyncio 

44async def test_video() -> None: 

45 video_frames: List[np.ndarray] = [ 

46 get_frame_with_text(100, 60, f"frame{frame_id:02d}", output_type="numpy") # type: ignore[misc] 

47 for frame_id in range(24) 

48 ] 

49 video_path = await save_video_frames(video_frames, fps=24) 

50 video_file_info = get_video_file_info(video_path) 

51 

52 video_overall_info = video_file_info.get("overall") 

53 assert video_overall_info is not None 

54 assert video_overall_info["num_bytes"] > 1000 

55 assert video_overall_info["duration_seconds"] == 1.0 

56 

57 video_info = video_file_info.get("video") 

58 assert video_info is not None 

59 assert video_info.get("duration_seconds") == 1.0 

60 assert video_info.get("width") == 100 

61 assert video_info.get("height") == 60 

62 assert video_info.get("num_frames") == 24 

63 assert video_info.get("fps") == 24 

64 assert video_info.get("codec") == "h264" 

65 

66 pix_fmt = video_info.get("pix_fmt") 

67 logging.info(f"Pixel format: {pix_fmt}") 

68 

69 del video_frames 

70 del video_path 

71 del video_info 

72 

73 

74@pytest.mark.asyncio 

75async def test_base64() -> None: 

76 """Test video frames to/from base64 conversion.""" 

77 video_frames: List[np.ndarray] = [ 

78 get_frame_with_text(80, 64, f"frame{frame_id:02d}", output_type="pil") # type: ignore[misc] 

79 for frame_id in range(12) 

80 ] 

81 video_base64 = video_frames_to_base64(video_frames) # type: ignore[arg-type] 

82 video_frames_2 = base64_to_video_frames(video_base64) 

83 assert len(video_frames) == len(video_frames_2) 

84 for frame1, frame2 in zip(video_frames, video_frames_2): 

85 assert frame1.size == frame2.size 

86 assert get_video_size(video_frames_2) == 184320 

87 

88 with pytest.raises(TypeError): 

89 video_frames_to_base64(12345) # type: ignore[arg-type] 

90 with pytest.raises(ValueError): 

91 video_frames_to_base64([]) 

92 with pytest.raises(TypeError): 

93 base64_to_video_frames(12345) # type: ignore[arg-type] 

94 

95 assert get_video_size(None) == 0 # type: ignore[arg-type] 

96 assert get_video_size([]) == 0 

97 

98 del video_frames 

99 del video_frames_2 

100 

101 

102def test_get_video_frames_at_fps() -> None: 

103 """Test getting a video frames at a specific FPS.""" 

104 video_frames: list[Image.Image] = [ 

105 get_frame_with_text(128, 64, f"frame{frame_id:02d}", output_type="pil") # type: ignore[misc] 

106 for frame_id in range(10) 

107 ] 

108 new_video_frames = get_video_frames_at_fps(video_frames, src_fps=30, dst_fps=30) 

109 assert len(new_video_frames) == 10 

110 new_video_frames = get_video_frames_at_fps(video_frames, src_fps=30, dst_fps=24) 

111 assert len(new_video_frames) == 8 # 10 * 24 / 30 = 8 

112 assert get_video_frames_at_fps([], src_fps=24) == [] 

113 assert get_video_frames_at_fps(None, dst_fps=16) == [] # type: ignore[arg-type] 

114 

115 with pytest.raises(ValueError): 

116 get_video_frames_at_fps(video_frames, src_fps=0, dst_fps=0) 

117 with pytest.raises(ValueError): 

118 get_video_frames_at_fps(video_frames, src_fps=30, dst_fps=-16) 

119 

120 del video_frames 

121 del new_video_frames 

122 

123 

124@pytest.mark.asyncio 

125async def test_chunk() -> None: 

126 """Test chunking a video binary.""" 

127 video_binary = await get_video_with_text(50, 30, "Test, video", duration_seconds=3.0, fps=30) 

128 video_binary_chunked = chunk_video_binary(video_binary, 0.5, 1.5) 

129 duration = await get_video_duration(video_binary_chunked) 

130 assert duration == 1.0 

131 

132 video_binary_chunked = chunk_video_binary(video_binary, 0.0, 1.5) 

133 duration = await get_video_duration(video_binary_chunked) 

134 assert duration == 1.5 

135 

136 video_binary_chunked = chunk_video_binary(video_binary_chunked, 0.5, 2.5) 

137 duration = await get_video_duration(video_binary_chunked) 

138 assert duration == 1.0 

139 

140 with pytest.raises(ValueError, match="end_seconds must be greater than start_seconds"): 

141 chunk_video_binary(video_binary, 0.5, 0.4) 

142 with pytest.raises(ValueError, match="must be non-negative"): 

143 chunk_video_binary(video_binary, -0.5, 0.4) 

144 with pytest.raises(TypeError): 

145 chunk_video_binary(12345, 0.5, 1.5) # type: ignore[arg-type] 

146 with pytest.raises(ValueError, match="Video input is empty"): 

147 chunk_video_binary(None, 0.5, 1.5) # type: ignore[arg-type] 

148 

149 del video_binary 

150 

151 

152@pytest.mark.asyncio 

153async def test_get_frame() -> None: 

154 """Test getting a specific frame from a video binary.""" 

155 video_frames = [ 

156 get_frame_with_text(160, 90, f"frame{frame_id:02d}", output_type="pil") 

157 for frame_id in range(12) 

158 ] 

159 video_path = await save_video_frames(video_frames, fps=24) 

160 async with aiofiles.open(video_path, "rb") as f: 

161 video_binary = await f.read() 

162 frame = await get_video_frame(video_binary, 5) 

163 assert frame.size == (160, 90) 

164 duration_seconds = await get_video_duration(video_binary) 

165 assert duration_seconds == 0.5 

166 duration_seconds = await get_video_duration(video_path) 

167 assert duration_seconds == 0.5 

168 

169 with pytest.raises(TypeError): 

170 await get_video_frame(video_path, 0) # type: ignore[arg-type] 

171 with pytest.raises(ValueError): 

172 await get_video_frame(video_binary, 512) 

173 with pytest.raises(ValueError): 

174 await get_video_frame(video_binary, -1) 

175 with pytest.raises(ValueError): 

176 await get_video_frame(b"", 5) 

177 

178 await aiofiles.os.remove(video_path) 

179 del video_frames 

180 del video_binary 

181 

182 

183def test_add_text() -> None: 

184 """Test adding text to video frames.""" 

185 NUM_FRAMES = 12 

186 video_frames = [ 

187 get_frame_with_text(64, 64, "", output_type="pil") 

188 for frame_id in range(NUM_FRAMES) 

189 ] 

190 assert len(video_frames) == NUM_FRAMES 

191 for frame in video_frames: 

192 assert frame.size == (64, 64) 

193 assert np.abs(np.array(frame)).sum() == 0 # All black 

194 

195 video_frames = [ 

196 add_text_to_frame(frame, text="Sample Text", position="top-left") 

197 for frame in video_frames 

198 ] 

199 assert len(video_frames) == NUM_FRAMES 

200 for frame in video_frames: 

201 assert frame.size == (64, 64) 

202 assert np.abs(np.array(frame)).sum() > 0 # Not all black 

203 

204 # Centered text 

205 video_frames = [ 

206 add_text_to_frame(frame, text="Sample Text 2", font_color="blue", position="center") 

207 for frame in video_frames 

208 ] 

209 assert len(video_frames) == NUM_FRAMES 

210 for frame in video_frames: 

211 assert frame.size == (64, 64) 

212 assert np.abs(np.array(frame)).sum() > 0 # Not all black 

213 

214 # Specific text position 

215 font_size = get_font_size(64, 64) 

216 video_frames = [ 

217 add_text_to_frame(frame, text="Sample Text 3", font_size=font_size, position=(0, 0)) 

218 for frame in video_frames 

219 ] 

220 assert len(video_frames) == NUM_FRAMES 

221 for frame in video_frames: 

222 assert frame.size == (64, 64) 

223 assert np.abs(np.array(frame)).sum() > 0 # Not all black 

224 

225 del video_frames 

226 

227 

228@pytest.mark.asyncio 

229async def test_concatenate() -> None: 

230 """Test concatenating multiple video binaries.""" 

231 video_frames1 = [ 

232 get_frame_with_text(120, 60, f"frame1_{frame_id:02d}", output_type="numpy") 

233 for frame_id in range(5) 

234 ] 

235 video_frames2 = [ 

236 get_frame_with_text(120, 60, f"frame2_{frame_id:02d}", output_type="numpy") 

237 for frame_id in range(10) 

238 ] 

239 video_path = await save_video_frames(video_frames1, fps=30) 

240 async with aiofiles.open(video_path, 'rb') as file: 

241 video_binary1 = await file.read() 

242 video_path = await save_video_frames(video_frames2, fps=30) 

243 async with aiofiles.open(video_path, 'rb') as file: 

244 video_binary2 = await file.read() 

245 video_binary = await concatenate_videos([ 

246 video_binary1, 

247 video_binary2 

248 ]) 

249 num_video_frames = await get_video_num_frames(video_binary) 

250 assert num_video_frames == 15 # 5 + 10 

251 

252 video_frames = await get_video_frames(video_binary) 

253 assert len(video_frames) == 15 

254 video_frame = await get_video_frame(video_binary, 5) 

255 assert video_frame.size == (120, 60) 

256 

257 video_frames = await get_video_frames(None) 

258 assert video_frames == [] 

259 with pytest.raises(ValueError): 

260 await concatenate_videos([]) 

261 with pytest.raises(TypeError, match="Video input 0 is not bytes or str"): 

262 await concatenate_videos([video_frames1, video_frames2]) # type: ignore[arg-type] 

263 with pytest.raises(FileNotFoundError): 

264 await concatenate_videos(["abc", "def"]) 

265 with pytest.raises(ValueError, match="One of the inputs is corrupted"): 

266 await concatenate_videos([b"abc", b"def"]) 

267 

268 with pytest.raises(FileNotFoundError, match="Video file does not exist"): 

269 await get_video_num_frames("abc") 

270 with pytest.raises(ValueError, match="The video binary is corrupted or has an unsupported format"): 

271 await get_video_num_frames(b"abc") 

272 

273 await aiofiles.os.remove(video_path) 

274 

275 del video_frames1 

276 del video_frames2 

277 del video_binary1 

278 del video_binary2 

279 del video_binary 

280 

281 

282@pytest.mark.asyncio 

283async def test_get_video_frames_empty() -> None: 

284 video_frames = await get_video_frames(None) 

285 assert video_frames == [] 

286 

287 video_frames = await get_video_frames(b"") 

288 assert video_frames == [] 

289 

290 video_frames = await get_video_frames([]) # type: ignore[arg-type] 

291 assert video_frames == [] 

292 

293 

294@pytest.mark.asyncio 

295@pytest.mark.parametrize( 

296 "num_frames,fps", 

297 [ 

298 (5, 30.0), # 5 frames at 30 fps 

299 (11, 23.0), # 11 frames at 23 fps 

300 (17, 23.0), # 17 frames at 23 fps 

301 (23, 23.0), # 23 frames at 23 fps 

302 (32, 23.0), # 32 frames at 23 fps 

303 (33, 23.0), # 33 frames at 23 fps 

304 (81, 23.0), # 81 frames at 23 fps 

305 (5, 16.0), # 5 frames at 16 fps 

306 ], 

307) 

308async def test_get_video_frames(num_frames: int, fps: float) -> None: 

309 width, height = 120, 60 

310 video_frames = [ 

311 get_frame_with_text(width, height, f"frame{frame_id:02d}", output_type="numpy") 

312 for frame_id in range(num_frames) 

313 ] 

314 video_path = await save_video_frames( 

315 video_frames, 

316 fps=fps) 

317 async with aiofiles.open(video_path, "rb") as file: 

318 video_binary = await file.read() 

319 video_frames_out = await get_video_frames(video_binary) 

320 assert len(video_frames_out) == num_frames 

321 

322 video_file_info = get_video_file_info(video_binary) 

323 assert "video" in video_file_info 

324 video_info = video_file_info["video"] 

325 assert video_info is not None 

326 assert video_info.get("width") == width 

327 assert video_info.get("height") == height 

328 assert video_info.get("num_frames") == num_frames 

329 assert video_info.get("num_frames") == len(video_frames_out) 

330 assert video_info.get("fps") == fps 

331 duration_seconds = video_info.get("duration_seconds") 

332 assert duration_seconds is not None 

333 assert abs(duration_seconds - (num_frames / fps)) < 0.01 

334 video_overall_info = video_file_info.get("overall") 

335 assert video_overall_info is not None 

336 num_bytes = video_overall_info.get("num_bytes") 

337 assert num_bytes is not None 

338 assert num_bytes > 1000 

339 

340 os.remove(video_path) 

341 del video_frames 

342 

343 

344@pytest.mark.asyncio 

345async def test_save_video_frames() -> None: 

346 NUM_FRAMES = 81 

347 WIDTH, HEIGHT = 160, 100 

348 FPS = 23.0 

349 

350 video_frames = [ 

351 get_frame_with_text(WIDTH, HEIGHT, f"frame{frame_id:02d}", output_type="numpy") 

352 for frame_id in range(NUM_FRAMES) 

353 ] 

354 

355 video_path = await save_video_frames(video_frames, fps=FPS) 

356 video_file_info = get_video_file_info(video_path) 

357 video_info = video_file_info.get("video") 

358 assert video_info is not None 

359 assert video_info.get("fps") == FPS 

360 assert video_info.get("num_frames") == 81 

361 assert video_info.get("duration_seconds") == 3.521739 # 81 / 23 

362 os.remove(video_path) 

363 

364 """ 

365 # Test with trimming 

366 video_path = await save_video_frames(video_frames, fps=FPS, time_in_seconds=2.0) 

367 video_info = get_video_file_info(video_path) 

368 assert video_info["video"]["fps"] == FPS 

369 assert video_info["video"]["num_frames"] == 46 # 2.0 * 23 

370 assert video_info["video"]["duration_seconds"] == 2.0 

371 os.remove(video_path) 

372 

373 # Test with expansion adds empty frames 

374 video_path = await save_video_frames(video_frames, fps=FPS, time_in_seconds=5.0) 

375 video_info = get_video_file_info(video_path) 

376 assert video_info["video"]["fps"] == FPS 

377 assert video_info["video"]["num_frames"] == 115 

378 assert video_info["video"]["duration_seconds"] == 5.0 

379 os.remove(video_path) 

380 """ 

381 

382 with pytest.raises(ValueError): 

383 await save_video_frames(None, fps=FPS) # type: ignore[arg-type] 

384 with pytest.raises(ValueError): 

385 await save_video_frames([], fps=FPS) 

386 with pytest.raises(TypeError): 

387 await save_video_frames(b"BLAH", fps=FPS) # type: ignore[arg-type] 

388 

389 

390def assert_approx( 

391 a: float, 

392 b: float, 

393 tol: float = 1e-3, 

394 msg: Optional[str] = None 

395) -> None: 

396 if msg: 

397 assert abs(a - b) < tol, f"{a} !~= {b}: {msg}" 

398 else: 

399 assert abs(a - b) < tol, f"{a} !~= {b}" 

400 

401 

402@pytest.mark.asyncio 

403async def test_save_video_frames_audio() -> None: 

404 # Get the audio 

405 audio_path = "tests/data/audio_4675.wav" 

406 audio_base64 = await read_file_base64(audio_path) 

407 audio_duration_secs = get_audio_duration(audio_base64) 

408 assert audio_duration_secs == 4.675 

409 

410 # Get video only with the video frames 

411 NUM_FRAMES = 113 

412 WIDTH, HEIGHT = 180, 100 

413 FPS = 23.0 

414 video_frames: list[Image.Image] = [ 

415 get_frame_with_text( # type: ignore[misc] 

416 WIDTH, HEIGHT, 

417 text=f"frame{frame_id:02d}", 

418 font_size=24, 

419 output_type="pil") 

420 for frame_id in range(NUM_FRAMES) 

421 ] 

422 assert len(video_frames) == NUM_FRAMES 

423 

424 # Video without audio 

425 video_path = await save_video_frames( 

426 video_frames=video_frames, 

427 fps=FPS) 

428 video_file_info = get_video_file_info(video_path) 

429 video_overall_info = video_file_info.get("overall") 

430 assert video_overall_info is not None 

431 num_bytes = video_overall_info.get("num_bytes") 

432 assert num_bytes is not None 

433 assert num_bytes > 1000 

434 duration_seconds = video_overall_info.get("duration_seconds") 

435 assert duration_seconds is not None 

436 assert_approx(duration_seconds, 4.913) 

437 

438 assert "video" in video_file_info 

439 video_info = video_file_info["video"] 

440 assert video_info is not None 

441 assert video_info.get("fps") == FPS, f"Video info: {video_file_info}" 

442 assert video_info.get("num_frames") == 113, f"Video info: {video_file_info}" 

443 assert video_info.get("width") == 180, f"Video info: {video_file_info}" 

444 assert video_info.get("height") == 100, f"Video info: {video_file_info}" 

445 video_duration_seconds = video_info.get("duration_seconds") 

446 assert video_duration_seconds is not None 

447 assert_approx(video_duration_seconds, 4.913) # 113 / 23 

448 

449 assert "audio" not in video_file_info 

450 

451 os.remove(video_path) 

452 

453 # Video + audio (video longer than audio) 

454 video_path = await save_video_audio( 

455 video_content=video_frames, 

456 audio_path=audio_path, 

457 fps=FPS) 

458 video_file_info = get_video_file_info(video_path) 

459 assert video_file_info is not None 

460 video_overall_info = video_file_info.get("overall") 

461 assert video_overall_info is not None 

462 num_bytes = video_overall_info.get("num_bytes") 

463 assert num_bytes is not None 

464 assert num_bytes > 1000 

465 duration_seconds = video_overall_info.get("duration_seconds") 

466 assert duration_seconds is not None 

467 assert_approx(duration_seconds, 4.913) 

468 

469 assert "video" in video_file_info 

470 video_info = video_file_info["video"] 

471 assert video_info is not None 

472 assert video_info.get("fps") == FPS, f"Video info: {video_file_info}" 

473 # TODO the following works with ffmpeg 4.2 but not with 6.0 

474 """ 

475 assert video_info.get("num_frames") == 113, f"Video info: {video_file_info}" 

476 assert_approx( 

477 video_info.get("duration_seconds"), 4.913, 

478 msg=f"Video info: {video_file_info}") 

479 """ 

480 logging.warning(f"Video info: {video_file_info}") 

481 num_frames = video_info.get("num_frames") 

482 assert num_frames is not None 

483 assert num_frames > 100 

484 video_duration_seconds = video_info.get("duration_seconds") 

485 assert video_duration_seconds is not None 

486 assert video_duration_seconds > 4.0 

487 

488 assert "audio" in video_file_info 

489 audio_info = video_file_info["audio"] 

490 assert audio_info is not None 

491 audio_duration_seconds = audio_info.get("duration_seconds") 

492 assert audio_duration_seconds is not None 

493 assert audio_duration_seconds > 0 

494 """ 

495 # assert_approx(audio_info["duration_seconds"], 4.913) 

496 assert_approx( 

497 audio_duration_seconds, 4.864, 

498 msg=f"Video info: {video_file_info}") # TODO this is not good 

499 """ 

500 logging.info(f"Audio info: {audio_info}") 

501 assert audio_duration_seconds > 4.0 

502 

503 os.remove(video_path) 

504 

505 

506@pytest.mark.asyncio 

507async def test_ffmpeg_version() -> None: 

508 """Test getting the FFmpeg version.""" 

509 ffmpeg_version = get_ffmpeg_version() 

510 logging.info(f"FFmpeg version: {get_ffmpeg_version()}") 

511 assert ffmpeg_version is not None 

512 

513 

514@pytest.mark.asyncio 

515async def test_change_fps() -> None: 

516 """Test changing the FPS of a video binary.""" 

517 NUM_FRAMES = 30 

518 FPS = 30 

519 video_frames = [ 

520 get_frame_with_text(320, 240, f"frame{frame_id:02d}", output_type="numpy") 

521 for frame_id in range(NUM_FRAMES) 

522 ] 

523 video_path = await save_video_frames(video_frames, fps=FPS) 

524 async with aiofiles.open(video_path, 'rb') as f: 

525 video_binary = await f.read() 

526 video_file_info = get_video_file_info(video_path) 

527 assert "video" in video_file_info 

528 video_info = video_file_info["video"] 

529 assert video_info.get("fps") == FPS 

530 assert video_info.get("num_frames") == 30 

531 assert video_info.get("duration_seconds") == 1.0 

532 video_num_frames = await get_video_num_frames(video_binary) 

533 assert video_num_frames == 30 

534 

535 # Change FPS to 15 

536 new_video_binary = change_video_fps(video_binary, 15) 

537 new_video_size = len(new_video_binary) 

538 assert new_video_size > 100 

539 video_file_info = get_video_file_info(new_video_binary) 

540 assert "video" in video_file_info 

541 video_info = video_file_info["video"] 

542 assert video_info.get("fps") == 15 

543 assert video_info.get("num_frames") == 15 

544 assert video_info.get("duration_seconds") == 1.0 

545 assert get_video_fps(new_video_binary) == 15 

546 

547 with pytest.raises(TypeError): 

548 change_video_fps(video_path, 15) # type: ignore[arg-type] 

549 with pytest.raises(ValueError): 

550 change_video_fps(video_binary, 0) 

551 

552 with pytest.raises(ValueError, match="No video frames provided."): 

553 await save_video_frames([], fps=30) 

554 

555 with pytest.raises(ValueError, match="No video frames provided."): 

556 await save_video_frames(None, fps=30) # type: ignore[arg-type] 

557 

558 with pytest.raises(TypeError): 

559 get_video_fps(12345) # type: ignore[arg-type] 

560 with pytest.raises(ValueError): 

561 get_video_fps(b"") 

562 

563 os.remove(video_path) 

564 del video_frames 

565 del video_binary