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],
        },
    }