You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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"
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\VideoProbe — Process 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.
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
ContentTypeCompatibleWithMediafrom 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()handlestype === 'image'only, and the video path (streamFileToStorage) stores'meta' => $metauntouched. No frontend code readsvideoWidthor listens forloadedmetadata. Someta.durationand videometa.width/heightare 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.
instagram_feed(image)instagram_reelinstagram_story(video)facebook_reelfacebook_storyyoutube_shortSources: 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.mediais a verbatim copy of the request payload, never re-hydrated from themediastable. The rule reads that copy.Other findings
instagram_feedvideo is published as a Reel.InstagramPublisher::publishFeed()routes a single video topublishReel(). 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.ChunkedCloudUploader::shouldUseMultipart()returnstruefor Video and Document whenever the disk iss3, 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.AssetController::storeis the only call site forwarding client meta intoaddMedia;StoreAssetRequestacceptsmeta.width/height/durationandHasMediamerges 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.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.toListingArray()— used byListContentTypesToolandPlatformContentTypesResource— omits the aspect bounds, so API and MCP clients get a 422 for a constraint they have no way to discover.lang/enonly; the other 15 locales still read "Vertical video up to 3 minutes".LocalizationParityTestcompares 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.meta.aspect_ratioset,InstagramPublisher::cropImageForAspectRatio()centre-crops before upload.StreamPostCreationwrites that meta on every AI-generated post. So a 3:4 photo withaspect_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.
aspectRatioBounds()intoimageAspectRatioBounds()/videoAspectRatioBounds(), addminVideoDurationSec(), correct every number, delete the now-redundantautoFitsImage()toListingArray()and the MCP tool descriptionNumber::fileSize(),??=so the first violation winscontentTypeMediaRules.ts, apply the split and the crop inuseMedia.tsusePostComplianceand the Instagram/Facebook settings panelsvideo_too_shortkeys across all 16 localespint,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\VideoProbe—Processfacade in array form,ffprobe -v error -print_format json -show_format -show_streams, binary fromFFPROBE_PATHstream.width/heightare pre-rotation, so swap whenside_data_list[].rotationis ±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.HasMedia::streamFileToStorage()(local temp) andaddMediaFromStoredPath()(S3, viatemporaryUrl)ffmpegin the Dockerfile'ssystem-basestage (coversdevandproduction);apt-get install -y ffmpegon the Forge VPS;FFPROBE_PATHin.env.examplemeta.width/height/durationfromStoreAssetRequestand$clientMetafromAssetController::storephp artisan media:backfill-video-metawith--dry-run, so old and new videos aren't under two different regimesPhase 3 — validation reads the database (~6 files)
size/mime_type/metafrommediasbymedia.*.id, scoped to the post's workspace, memoised per validationid(the API's bare external URL) keep today's fail-open behavioururlis what gets published, and hardening that would break callers over a field that's currently decorativeTo 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
creator_info.max_video_post_duration_sec— needs a fetch-at-validate-time design of its ownuseMedia.tsandtoListingArray()for a rarely-hit caseContentType::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