Skip to content

Media constraints: correct the platform limits, then measure video server-side #304

Description

@paulocastellano

Design and implementation plan for the media-constraint work, ready to execute. Supersedes the approach in #287 and covers all three goals of #200.

Full design doc and the Phase 1 task-by-task plan are attached as comments below, so this issue is self-contained.

Why #287 can't be merged as-is

PR #287 extended ContentTypeCompatibleWithMedia from type-only checks to the full numeric rule set and widened YouTube Shorts to accept square video. Reviewing it turned up three problems.

1. Half the new checks can never fire. Nothing measures video metadata. HasMedia::getMediaMetaFromBytes() handles type === 'image' only, and the video path (streamFileToStorage) stores 'meta' => $meta untouched. No frontend code reads videoWidth or listens for loadedmetadata. So meta.duration and video meta.width/height are never populated — the duration and video aspect checks are dead code, live only for an API client that volunteers the values. That's both forgeable and a perverse incentive: the honest client gets rejected, the lazy one doesn't.

2. Three of the six aspect-ratio windows are wrong. They were written as a soft cropper hint — the docblock says so verbatim: "Soft aspect-ratio window used by the Vue cropper / media picker." They're harmless today precisely because video meta never exists. Measuring video for real arms them.

Content type Current Official wording Verdict
instagram_feed (image) 0.8–1.91 "Must be within a 4:5 to 1.91:1 range" correct
instagram_reel 0.5–0.6 "Required aspect ratio is between 0.01:1 and 10:1 but we recommend 9:16" far too strict
instagram_story (video) 0.5–0.6 "between 0.1:1 and 10:1 but we recommend 9:16" too strict
facebook_reel 0.5–0.6 "Aspect Ratio | 9 x 16" keep — declared spec
facebook_story 0.5–0.6 "Aspect Ratio | 9 x 16" keep
youtube_short 0.5–0.6 "square or vertical … up to three minutes" max should be 1.0; the minimum has no basis

Sources: IG User Media reference, FB Reels publishing, FB Page Stories, YouTube Shorts.

Note the asymmetry: "it's only a recommendation" holds for Instagram and not for Facebook, whose spec tables state 9:16 flatly. The fix is per-content-type numbers, not a new hard-limit/recommendation abstraction.

3. Validation reads client-supplied data, so measuring on the server doesn't help by itself. UpdatePost::execute() stores 'media' => data_get($data, 'media', $post->media)posts.media is a verbatim copy of the request payload, never re-hydrated from the medias table. The rule reads that copy.

Other findings

  • instagram_feed video is published as a Reel. InstagramPublisher::publishFeed() routes a single video to publishReel(). Its rules describe a feed image — 60s and 0.8–1.91 — so once video is measured, a valid 90s 9:16 video on Instagram Feed gets rejected twice over, for two reasons that don't apply to it.
  • In production, video never has a local file. ChunkedCloudUploader::shouldUseMultipart() returns true for Video and Document whenever the disk is s3, so the chunked receiver goes straight to object storage. Any probe that assumes a local temp only works on a dev machine. Media::temporaryUrl() already exists and ffprobe reads HTTP directly without downloading the whole file.
  • A live forgery vector. AssetController::store is the only call site forwarding client meta into addMedia; StoreAssetRequest accepts meta.width/height/duration and HasMedia merges client over server. Nothing in the frontend sends those keys — it's dead code whose only present function is letting a client override a server measurement.
  • The API reports the wrong message. errorsFor() overwrites, so with several $fail() calls the caller gets the last while the editor shows the first. Confirmed by running it: a LinkedIn post with a 6MB GIF says "images must be under 5.0 MB" over the API and "does not support animated GIFs" in the editor.
  • We enforce what we don't advertise. toListingArray() — used by ListContentTypesTool and PlatformContentTypesResource — omits the aspect bounds, so API and MCP clients get a 422 for a constraint they have no way to discover.
  • i18n. fix: harden backend media constraints and allow square YouTube Shorts #287 changed the Shorts description in lang/en only; the other 15 locales still read "Vertical video up to 3 minutes". LocalizationParityTest compares keys, never values. Since the aspect change is dormant until video is measured, this copy is the only user-visible part of the Shorts fix.
  • The editor blocks images the publisher would fix. With meta.aspect_ratio set, InstagramPublisher::cropImageForAspectRatio() centre-crops before upload. StreamPostCreation writes that meta on every AI-generated post. So a 3:4 photo with aspect_ratio: '4:5' publishes fine today, yet the editor blocks scheduling and — after fix: harden backend media constraints and allow square YouTube Shorts #287 — the API would 422 it.

Plan: three phases, and the order is load-bearing

Doing Phase 2 before Phase 1 is a guaranteed production regression: arming video measurement against the current 0.5–0.6 bounds rejects most Reels.

Phase 1 — correct the numbers before arming them (~12 files + 16 lang)

Full task-by-task plan in the comments. Nothing changes at runtime today — video still has no metadata — so this is pure "make it correct before it matters". This is also the scope #287 was aiming at, corrected.

  • Split aspectRatioBounds() into imageAspectRatioBounds() / videoAspectRatioBounds(), add minVideoDurationSec(), correct every number, delete the now-redundant autoFitsImage()
  • Expose aspect bounds + min duration in toListingArray() and the MCP tool description
  • Rule: split bounds, minimum duration, Number::fileSize(), ??= so the first violation wins
  • Rule: validate the cropped ratio when a platform crop is set (images only)
  • Frontend: reshape contentTypeMediaRules.ts, apply the split and the crop in useMedia.ts
  • Wire the selected crop ratio through usePostCompliance and the Instagram/Facebook settings panels
  • Translate the Shorts copy and the new video_too_short keys across all 16 locales
  • Full verification — pint, npm run lint, php artisan test --compact --parallel (baseline: 3951 passed, 1 skipped)

Phase 2 — VideoProbe + infra (~10 files)

Blocked on Phase 1.

  • App\Services\Media\VideoProbeProcess facade in array form, ffprobe -v error -print_format json -show_format -show_streams, binary from FFPROBE_PATH
  • Handle rotation: stream.width/height are pre-rotation, so swap when side_data_list[].rotation is ±90/270. Needs a genuinely rotated fixture — ffmpeg 8.1.2 transposes rather than writing a display matrix, so one could not be synthesised during design. Do not assume this is handled because the code contains a swap.
  • Wire into HasMedia::streamFileToStorage() (local temp) and addMediaFromStoredPath() (S3, via temporaryUrl)
  • Fail open on every error — missing binary, timeout, bad JSON. ffprobe is never on the critical path of an upload succeeding
  • ffmpeg in the Dockerfile's system-base stage (covers dev and production); apt-get install -y ffmpeg on the Forge VPS; FFPROBE_PATH in .env.example
  • Delete meta.width/height/duration from StoreAssetRequest and $clientMeta from AssetController::store
  • php artisan media:backfill-video-meta with --dry-run, so old and new videos aren't under two different regimes

Phase 3 — validation reads the database (~6 files)

  • Resolve size / mime_type / meta from medias by media.*.id, scoped to the post's workspace, memoised per validation
  • Items with no id (the API's bare external URL) keep today's fail-open behaviour
  • An id that doesn't resolve is treated as unknown metadata, not an error — today an unresolvable id just means the payload's url is what gets published, and hardening that would break callers over a field that's currently decorative

To be precise about what Phase 3 buys: it's a consistency goal, not a security boundary. A client sending no id still lands in fail-open, and that's fine — the only person harmed by dodging validation is the post's author, whose publish then fails at the platform instead. What's worth having is that the editor and an honest API client reach the same verdict, and that declaring your metadata truthfully is never punished while omitting it is rewarded.

Explicitly out of scope

  • TikTok's account-dynamic creator_info.max_video_post_duration_sec — needs a fetch-at-validate-time design of its own
  • Minimum video resolution (FB Reel/Story 540×960) — documented, but one more field across enum, Inertia payload, useMedia.ts and toListingArray() for a rarely-hit case
  • A "recommended framing" warning. Widening Instagram Reel to 0.01–10 means the editor no longer warns that a 16:9 video will letterbox. ContentType::aspectRatio() already holds the 9:16 guidance; it just has no warning surface. The product loss is mild and deliberate — a warning must never block scheduling, which is exactly what the old numbers did.

Refs: #200, #287

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions