openclaw-meta-bridge / app /services /media_validation.py
Ordo
Initial public release
0e84a1f
from __future__ import annotations
import json
import mimetypes
import os
import struct
import subprocess
from pathlib import Path
from typing import Any
class MediaValidationError(ValueError):
pass
ALLOWED_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
ALLOWED_REEL_SUFFIXES = {".mp4", ".mov"}
MAX_IMAGE_BYTES = 25 * 1024 * 1024
MAX_REEL_BYTES = 1024 * 1024 * 1024
MIN_REEL_DURATION_SECONDS = 3.0
MAX_REEL_DURATION_SECONDS = 90.0
MIN_REEL_WIDTH = 540
MIN_REEL_HEIGHT = 960
REEL_ASPECT_RATIO_MIN = 0.45
REEL_ASPECT_RATIO_MAX = 0.75
def _assert_local_file(path: str) -> Path:
if not path or path.startswith(("http://", "https://")):
raise MediaValidationError("media_path must be an absolute local file path")
p = Path(path)
if not p.is_absolute():
raise MediaValidationError("media_path must be absolute")
try:
resolved = p.resolve(strict=True)
except FileNotFoundError as exc:
raise MediaValidationError(f"media_path does not exist: {path}") from exc
if not resolved.is_file():
raise MediaValidationError(f"media_path is not a file: {path}")
return resolved
def _png_dimensions(path: Path) -> tuple[int, int] | None:
with path.open("rb") as fh:
header = fh.read(24)
if header[:8] != b"\x89PNG\r\n\x1a\n":
return None
return struct.unpack(">II", header[16:24])
def _jpeg_dimensions(path: Path) -> tuple[int, int] | None:
with path.open("rb") as fh:
if fh.read(2) != b"\xff\xd8":
return None
while True:
marker_start = fh.read(1)
if not marker_start:
return None
if marker_start != b"\xff":
continue
marker = fh.read(1)
while marker == b"\xff":
marker = fh.read(1)
if marker in {b"\xc0", b"\xc1", b"\xc2", b"\xc3"}:
fh.read(3)
height, width = struct.unpack(">HH", fh.read(4))
return width, height
length_bytes = fh.read(2)
if len(length_bytes) != 2:
return None
length = struct.unpack(">H", length_bytes)[0]
fh.seek(length - 2, os.SEEK_CUR)
def _image_dimensions(path: Path) -> tuple[int, int] | None:
return _png_dimensions(path) or _jpeg_dimensions(path)
def validate_image_attachment(attachment: dict[str, Any]) -> dict[str, Any]:
path = _assert_local_file(str(attachment.get("media_path") or ""))
suffix = path.suffix.lower()
if suffix not in ALLOWED_IMAGE_SUFFIXES:
raise MediaValidationError(f"unsupported image type: {suffix}")
size = path.stat().st_size
if size <= 0:
raise MediaValidationError("image file is empty")
if size > MAX_IMAGE_BYTES:
raise MediaValidationError(f"image file is too large: {size} bytes")
mime_type = attachment.get("mime_type") or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
if not mime_type.startswith("image/"):
raise MediaValidationError(f"unsupported image MIME type: {mime_type}")
dims = _image_dimensions(path)
return {
**attachment,
"media_path": str(path),
"media_type": "image",
"mime_type": mime_type,
"size_bytes": size,
"width": dims[0] if dims else attachment.get("width"),
"height": dims[1] if dims else attachment.get("height"),
}
def validate_page_post_media(attachments: list[dict[str, Any]], media_required: bool) -> list[dict[str, Any]]:
if media_required and not attachments:
raise MediaValidationError("media_required=true but no media_attachments were provided")
validated: list[dict[str, Any]] = []
for attachment in attachments:
media_type = (attachment.get("media_type") or "image").lower()
if media_type != "image":
raise MediaValidationError("Page post media lane currently supports image attachments only")
validated.append(validate_image_attachment(attachment))
return validated
def validate_reel_asset(video_path: str) -> dict[str, Any]:
path = _assert_local_file(video_path)
suffix = path.suffix.lower()
if suffix not in ALLOWED_REEL_SUFFIXES:
raise MediaValidationError(f"unsupported Reels video type: {suffix}")
size = path.stat().st_size
if size <= 0:
raise MediaValidationError("Reels video file is empty")
if size > MAX_REEL_BYTES:
raise MediaValidationError(f"Reels video is too large: {size} bytes")
try:
proc = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name,width,height,duration:format=duration,format_name",
"-of",
"json",
str(path),
],
text=True,
capture_output=True,
timeout=30,
check=False,
)
except FileNotFoundError as exc:
raise MediaValidationError("ffprobe is required for Reels validation but is not installed") from exc
except subprocess.TimeoutExpired as exc:
raise MediaValidationError("ffprobe timed out during Reels validation") from exc
if proc.returncode != 0:
raise MediaValidationError(f"ffprobe could not read Reels video: {proc.stderr[:240]}")
body = json.loads(proc.stdout or "{}")
streams = body.get("streams") or []
if not streams:
raise MediaValidationError("Reels video has no video stream")
stream = streams[0]
width = int(stream.get("width") or 0)
height = int(stream.get("height") or 0)
duration = float(stream.get("duration") or body.get("format", {}).get("duration") or 0)
codec = stream.get("codec_name")
if codec not in {"h264", "hevc", "h265"}:
raise MediaValidationError(f"unsupported Reels video codec: {codec}")
if duration < MIN_REEL_DURATION_SECONDS or duration > MAX_REEL_DURATION_SECONDS:
raise MediaValidationError(f"Reels duration must be {MIN_REEL_DURATION_SECONDS:g}-{MAX_REEL_DURATION_SECONDS:g} seconds")
if width < MIN_REEL_WIDTH or height < MIN_REEL_HEIGHT:
raise MediaValidationError(f"Reels resolution must be at least {MIN_REEL_WIDTH}x{MIN_REEL_HEIGHT}")
aspect = width / height if height else 0
if aspect < REEL_ASPECT_RATIO_MIN or aspect > REEL_ASPECT_RATIO_MAX:
raise MediaValidationError("Reels video must be vertical 9:16-ish aspect ratio")
return {
"media_path": str(path),
"media_type": "reel",
"mime_type": mimetypes.guess_type(path.name)[0] or "video/mp4",
"size_bytes": size,
"width": width,
"height": height,
"duration_seconds": duration,
"codec": codec,
"requirements": {
"suffixes": sorted(ALLOWED_REEL_SUFFIXES),
"duration_seconds": [MIN_REEL_DURATION_SECONDS, MAX_REEL_DURATION_SECONDS],
"min_resolution": [MIN_REEL_WIDTH, MIN_REEL_HEIGHT],
"aspect_ratio": [REEL_ASPECT_RATIO_MIN, REEL_ASPECT_RATIO_MAX],
},
}