File size: 7,178 Bytes
0e84a1f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | 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],
},
}
|