basyx commited on
Commit
e32e185
·
verified ·
1 Parent(s): fba3278

Validate outputs across all media endpoints

Browse files
Files changed (1) hide show
  1. app/services/media_service.py +70 -0
app/services/media_service.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  import os
5
  import time
6
  from collections.abc import Awaitable, Callable, Sequence
@@ -8,6 +9,7 @@ from pathlib import Path
8
  from typing import Any
9
 
10
  from app.core.config import Settings
 
11
  from app.core.logger import get_logger
12
  from app.core.response import SuccessResponse
13
  from app.models.media import InputMedia, OperationResult, ResolvedRequest
@@ -244,6 +246,8 @@ class MediaProcessor:
244
  output_size = 0
245
  canonical_asset_id = None
246
  if result.path is not None:
 
 
247
  published = await self.cleanup.publish(
248
  resolved.request_id, result.path, result.filename or result.path.name
249
  )
@@ -291,6 +295,72 @@ class MediaProcessor:
291
  metadata=metadata,
292
  )
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  @staticmethod
295
  def _optional_int(
296
  value: Any, name: str, minimum: int, maximum: int
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import math
5
  import os
6
  import time
7
  from collections.abc import Awaitable, Callable, Sequence
 
9
  from typing import Any
10
 
11
  from app.core.config import Settings
12
+ from app.core.exceptions import ProcessingError
13
  from app.core.logger import get_logger
14
  from app.core.response import SuccessResponse
15
  from app.models.media import InputMedia, OperationResult, ResolvedRequest
 
246
  output_size = 0
247
  canonical_asset_id = None
248
  if result.path is not None:
249
+ output_report = await self.validate_output_result(result)
250
+ result.metadata = {**result.metadata, "output_validation": output_report}
251
  published = await self.cleanup.publish(
252
  resolved.request_id, result.path, result.filename or result.path.name
253
  )
 
295
  metadata=metadata,
296
  )
297
 
298
+ async def validate_output_result(self, result: OperationResult) -> dict[str, Any]:
299
+ """Validate every published media artifact, regardless of which API produced it."""
300
+ path = result.path
301
+ if path is None:
302
+ return {"valid": True, "file_artifact": False}
303
+ if not path.is_file():
304
+ raise ProcessingError("Output artifact is missing")
305
+ try:
306
+ size = path.stat().st_size
307
+ except OSError as exc:
308
+ raise ProcessingError("Unable to inspect output artifact") from exc
309
+ if size <= 0:
310
+ raise ProcessingError("Output artifact is empty")
311
+
312
+ mime_type = result.mime_type or self.validator.infer_mime(path)
313
+ report: dict[str, Any] = {
314
+ "valid": True,
315
+ "file_artifact": True,
316
+ "size_bytes": size,
317
+ "mime_type": mime_type,
318
+ "probed": False,
319
+ "container": None,
320
+ "duration_seconds": None,
321
+ "video_stream_count": None,
322
+ "audio_stream_count": None,
323
+ "resolution": None,
324
+ }
325
+ if not mime_type.startswith(("audio/", "video/", "image/")):
326
+ return report
327
+
328
+ try:
329
+ probe = await self.ffprobe.probe(path)
330
+ self.validator.validate_probe(probe)
331
+ except Exception as exc:
332
+ if isinstance(exc, ProcessingError):
333
+ raise
334
+ raise ProcessingError("Output media failed integrity validation") from exc
335
+
336
+ video_streams = probe.get("video_streams") or []
337
+ audio_streams = probe.get("audio_streams") or []
338
+ if mime_type.startswith("video/") and not video_streams:
339
+ raise ProcessingError("Output is declared as video but contains no video stream")
340
+ if mime_type.startswith("audio/") and not audio_streams:
341
+ raise ProcessingError("Output is declared as audio but contains no audio stream")
342
+ if mime_type.startswith("image/") and not (video_streams or audio_streams):
343
+ raise ProcessingError("Output is declared as an image but contains no decodable stream")
344
+
345
+ duration = probe.get("duration")
346
+ if mime_type.startswith(("audio/", "video/")):
347
+ try:
348
+ valid_duration = duration is not None and math.isfinite(float(duration)) and float(duration) > 0
349
+ except (TypeError, ValueError):
350
+ valid_duration = False
351
+ if not valid_duration:
352
+ raise ProcessingError("Output media has an invalid duration")
353
+
354
+ report.update({
355
+ "probed": True,
356
+ "container": probe.get("container"),
357
+ "duration_seconds": duration,
358
+ "video_stream_count": len(video_streams),
359
+ "audio_stream_count": len(audio_streams),
360
+ "resolution": probe.get("resolution"),
361
+ })
362
+ return report
363
+
364
  @staticmethod
365
  def _optional_int(
366
  value: Any, name: str, minimum: int, maximum: int