Centralize content-type media rules on the ContentType enum.

Share the full editor rule set (sizes, durations, accepts, aspect bounds) via Inertia so useMediaRules no longer hardcodes MB/GB math, and expose per-type byte caps on API/MCP content-type listings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 22:01:48 -03:00
parent b28b18ef72
commit 7b986fca9c
10 changed files with 317 additions and 199 deletions

View file

@ -207,24 +207,173 @@ public function maxVideoDurationSec(): ?int
}
/**
* Media-rule fields the Vue editor needs. Shared once via Inertia so the
* frontend does not hardcode the same numbers.
* Minimum attachments required (0 = no floor beyond requiresMedia()).
*/
public function minMediaCount(): int
{
return match ($this) {
self::PinterestCarousel => 2,
self::TikTokPhoto => 1,
default => 0,
};
}
/**
* Whether animated GIFs are accepted (kept as GIF, not flattened to JPEG).
*/
public function acceptsGif(): bool
{
return match ($this) {
self::XPost, self::BlueskyPost, self::MastodonPost => true,
default => false,
};
}
/**
* Per-type image size cap in bytes. Null when images are not accepted or
* the platform has no tighter editor-side limit than the global media cap.
*/
public function maxImageBytes(): ?int
{
return match ($this) {
self::InstagramFeed, self::InstagramStory => self::bytesFromMb(8),
self::FacebookPost => self::bytesFromMb(4),
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(5),
self::PinterestPin, self::PinterestCarousel => self::bytesFromMb(20),
self::XPost => self::bytesFromMb(5),
self::ThreadsPost => self::bytesFromMb(8),
self::MastodonPost => self::bytesFromMb(10),
default => null,
};
}
/**
* Per-type video size cap in bytes. Null when videos are not accepted or
* the platform has no tighter editor-side limit.
*/
public function maxVideoBytes(): ?int
{
return match ($this) {
self::InstagramFeed, self::InstagramStory => self::bytesFromMb(100),
self::InstagramReel => self::bytesFromGb(1),
self::FacebookPost => self::bytesFromGb(10),
self::FacebookReel, self::FacebookStory => self::bytesFromGb(1),
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromGb(5),
self::YouTubeShort => self::bytesFromGb(256),
self::PinterestVideoPin => self::bytesFromGb(2),
self::XPost => self::bytesFromMb(512),
self::ThreadsPost => self::bytesFromGb(1),
self::BlueskyPost => self::bytesFromMb(100),
self::MastodonPost => self::bytesFromMb(40),
default => null,
};
}
/**
* Per-type PDF size cap in bytes. Null when documents are not accepted.
*/
public function maxDocumentBytes(): ?int
{
return match ($this) {
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(100),
default => null,
};
}
/**
* Soft aspect-ratio window used by the Vue cropper / media picker.
*
* @return array<string, array{max_video_duration_sec: int|null}>
* @return array{min: float, max: float}|null
*/
public function aspectRatioBounds(): ?array
{
return match ($this) {
self::InstagramFeed => ['min' => 0.8, 'max' => 1.91],
self::InstagramReel, self::InstagramStory,
self::FacebookReel, self::FacebookStory,
self::YouTubeShort => ['min' => 0.5, 'max' => 0.6],
default => null,
};
}
/**
* Whether the editor should auto-fit still images into the story frame.
*/
public function autoFitsImage(): bool
{
return $this === self::InstagramStory;
}
/**
* Full media-rule payload for the Vue editor (and any other consumer).
* Shared once via Inertia — do not re-hardcode these numbers in JS.
*
* @return array{
* max_files: int,
* min_files: int|null,
* accept_images: bool,
* accept_videos: bool,
* accept_documents: bool,
* requires_media: bool,
* accepts_gif: bool,
* forbids_mixed_media: bool,
* max_image_bytes: int|null,
* max_video_bytes: int|null,
* max_document_bytes: int|null,
* max_video_duration_sec: int|null,
* aspect_ratio_min: float|null,
* aspect_ratio_max: float|null,
* auto_fits_image: bool
* }
*/
public function mediaRules(): array
{
$bounds = $this->aspectRatioBounds();
$minFiles = $this->minMediaCount();
return [
'max_files' => $this->maxMediaCount(),
'min_files' => $minFiles > 0 ? $minFiles : null,
'accept_images' => $this->supportsImage(),
'accept_videos' => $this->supportsVideo(),
'accept_documents' => $this->supportsDocument(),
'requires_media' => $this->requiresMedia(),
'accepts_gif' => $this->acceptsGif(),
'forbids_mixed_media' => ! $this->supportsMixedMedia(),
'max_image_bytes' => $this->maxImageBytes(),
'max_video_bytes' => $this->maxVideoBytes(),
'max_document_bytes' => $this->maxDocumentBytes(),
'max_video_duration_sec' => $this->maxVideoDurationSec(),
'aspect_ratio_min' => $bounds['min'] ?? null,
'aspect_ratio_max' => $bounds['max'] ?? null,
'auto_fits_image' => $this->autoFitsImage(),
];
}
/**
* @return array<string, array<string, mixed>>
*/
public static function mediaRulesForFrontend(): array
{
$rules = [];
foreach (self::cases() as $type) {
$rules[$type->value] = [
'max_video_duration_sec' => $type->maxVideoDurationSec(),
];
$rules[$type->value] = $type->mediaRules();
}
return $rules;
}
private static function bytesFromMb(int $megabytes): int
{
return $megabytes * 1024 * 1024;
}
private static function bytesFromGb(int $gigabytes): int
{
return $gigabytes * 1024 * 1024 * 1024;
}
public function supportsVideo(): bool
{
return match ($this) {

View file

@ -43,6 +43,9 @@ public function toArray(Request $request): array
'max_media_count' => $type->maxMediaCount(),
'requires_media' => $type->requiresMedia(),
'max_video_duration_sec' => $type->maxVideoDurationSec(),
'max_image_bytes' => $type->maxImageBytes(),
'max_video_bytes' => $type->maxVideoBytes(),
'max_document_bytes' => $type->maxDocumentBytes(),
],
array_values(ContentType::forPlatform($platform)),
),

View file

@ -14,7 +14,7 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')]
#[Description('List the valid content_types per social platform plus their constraints (max content length, recommended length, max media count, whether media is required, max video duration in seconds, per-type max image/video/document bytes, default content_type). Use before create-post-tool / update-post-tool to know which content_type to set.')]
class ListContentTypesTool extends Tool
{
public function handle(Request $request): ResponseFactory
@ -30,6 +30,9 @@ public function handle(Request $request): ResponseFactory
'max_media_count' => $type->maxMediaCount(),
'requires_media' => $type->requiresMedia(),
'max_video_duration_sec' => $type->maxVideoDurationSec(),
'max_image_bytes' => $type->maxImageBytes(),
'max_video_bytes' => $type->maxVideoBytes(),
'max_document_bytes' => $type->maxDocumentBytes(),
],
array_values(ContentType::forPlatform($platform)),
);

View file

@ -1,154 +1,18 @@
import { computed, type Ref, type ComputedRef } from 'vue';
import { maxVideoDurationSecFor } from '@/lib/contentTypeMediaRules';
import {
mediaRuleFor,
toMediaRules,
type MediaRules,
} from '@/lib/contentTypeMediaRules';
import { fromMimeType, MediaType } from '@/lib/mediaType';
export interface MediaRules {
maxFiles: number;
minFiles?: number;
acceptImages: boolean;
acceptVideos: boolean;
acceptDocuments?: boolean;
requiresMedia: boolean;
acceptsGif: boolean;
forbidsMixedMedia?: boolean;
maxImageBytes?: number;
maxVideoBytes?: number;
maxDocumentBytes?: number;
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
autoFitsImage?: boolean;
}
const MB = 1024 * 1024;
const GB = 1024 * MB;
export type { MediaRules };
/**
* Client-side media constraints. Duration values here are fallbacks when
* Inertia shared contentTypeMediaRules has not synced yet; once synced,
* ContentType::maxVideoDurationSec() wins via withSharedDuration().
* Fallback only when Inertia once-props have not synced yet (or unknown type).
* Real limits live in App\Enums\PostPlatform\ContentType::mediaRules().
*/
const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
// Instagram
instagram_feed: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
aspectRatioMin: 0.8, aspectRatioMax: 1.91,
},
instagram_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
instagram_story: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true,
},
// Facebook
facebook_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60,
},
facebook_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 90,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
facebook_story: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// LinkedIn — one type per account kind; the publish format (single image,
// multi-image, video, or PDF document) is inferred from the attached media.
// Images (up to 10) XOR one video XOR one PDF, never mixed.
linkedin_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true,
requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB,
},
linkedin_page_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true,
requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB,
},
// TikTok
tiktok_video: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
// maxVideoDurationSec is enforced dynamically via creator_info
},
tiktok_photo: {
maxFiles: 35, minFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
},
// YouTube
youtube_short: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 256 * GB, maxVideoDurationSec: 3 * 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// Pinterest
pinterest_pin: {
maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 20 * MB,
},
pinterest_video_pin: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60,
},
pinterest_carousel: {
maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 20 * MB,
},
// X (Twitter) — accepts GIF with animation
x_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140,
},
// Threads
threads_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60,
},
// Bluesky — accepts GIF; tight image size (auto-resized by backend).
// The embed is images XOR video, so the two can't be combined in one post.
bluesky_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true, forbidsMixedMedia: true,
maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
},
// Mastodon — accepts GIF
mastodon_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxImageBytes: 10 * MB, maxVideoBytes: 40 * MB,
},
};
const DEFAULT_RULES: MediaRules = {
maxFiles: 10,
acceptImages: true,
@ -157,33 +21,18 @@ const DEFAULT_RULES: MediaRules = {
acceptsGif: true,
};
const withSharedDuration = (contentType: string, rules: MediaRules): MediaRules => {
const shared = maxVideoDurationSecFor(contentType);
export const getMediaRulesForContentType = (contentType: string): MediaRules => {
const shared = mediaRuleFor(contentType);
// Cache not synced yet — keep local hardcoded fallbacks.
if (shared === undefined) {
return rules;
if (!shared) {
return DEFAULT_RULES;
}
// Explicitly unlimited / dynamic on the server (e.g. TikTok).
if (shared === null) {
const { maxVideoDurationSec: _ignored, ...rest } = rules;
return rest;
}
return {
...rules,
maxVideoDurationSec: shared,
};
return toMediaRules(shared);
};
export const useMediaRules = (contentType: Ref<string> | ComputedRef<string>) => {
const rules = computed<MediaRules>(() => {
const base = CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES;
return withSharedDuration(contentType.value, base);
});
const rules = computed<MediaRules>(() => getMediaRulesForContentType(contentType.value));
const acceptMimeTypes = computed<string>(() => {
const types: string[] = [];
@ -244,9 +93,3 @@ export const useMediaRules = (contentType: Ref<string> | ComputedRef<string>) =>
getAcceptDescription,
};
};
export const getMediaRulesForContentType = (contentType: string): MediaRules => {
const base = CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES;
return withSharedDuration(contentType, base);
};

View file

@ -1,7 +1,43 @@
import type { Page } from '@inertiajs/core';
/** CamelCase shape consumed by the Vue media picker / compliance checks. */
export type MediaRules = {
maxFiles: number;
minFiles?: number;
acceptImages: boolean;
acceptVideos: boolean;
acceptDocuments?: boolean;
requiresMedia: boolean;
acceptsGif: boolean;
forbidsMixedMedia?: boolean;
maxImageBytes?: number;
maxVideoBytes?: number;
maxDocumentBytes?: number;
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
autoFitsImage?: boolean;
};
/**
* Snake_case payload from ContentType::mediaRules() / mediaRulesForFrontend().
*/
export type ContentTypeMediaRule = {
max_files: number;
min_files: number | null;
accept_images: boolean;
accept_videos: boolean;
accept_documents: boolean;
requires_media: boolean;
accepts_gif: boolean;
forbids_mixed_media: boolean;
max_image_bytes: number | null;
max_video_bytes: number | null;
max_document_bytes: number | null;
max_video_duration_sec: number | null;
aspect_ratio_min: number | null;
aspect_ratio_max: number | null;
auto_fits_image: boolean;
};
type ContentTypeMediaRulesMap = Record<string, ContentTypeMediaRule>;
@ -16,22 +52,58 @@ export const syncContentTypeMediaRules = (page: Page): void => {
}
};
/**
* Shared Inertia duration for a content type.
* - `undefined` — cache not synced yet (or unknown type): keep local fallbacks
* - `null` — explicitly unlimited / dynamic (e.g. TikTok)
* - `number` — hard cap in seconds from ContentType::maxVideoDurationSec()
*/
export const maxVideoDurationSecFor = (contentType: string): number | null | undefined => {
if (cachedRules === null) {
return undefined;
}
const rule = cachedRules[contentType];
if (rule === undefined) {
return undefined;
}
return rule.max_video_duration_sec;
export const mediaRuleFor = (contentType: string): ContentTypeMediaRule | undefined => {
return cachedRules?.[contentType];
};
export const toMediaRules = (rule: ContentTypeMediaRule): MediaRules => {
const mapped: MediaRules = {
maxFiles: rule.max_files,
acceptImages: rule.accept_images,
acceptVideos: rule.accept_videos,
requiresMedia: rule.requires_media,
acceptsGif: rule.accepts_gif,
};
if (rule.min_files !== null) {
mapped.minFiles = rule.min_files;
}
if (rule.accept_documents) {
mapped.acceptDocuments = true;
}
if (rule.forbids_mixed_media) {
mapped.forbidsMixedMedia = true;
}
if (rule.max_image_bytes !== null) {
mapped.maxImageBytes = rule.max_image_bytes;
}
if (rule.max_video_bytes !== null) {
mapped.maxVideoBytes = rule.max_video_bytes;
}
if (rule.max_document_bytes !== null) {
mapped.maxDocumentBytes = rule.max_document_bytes;
}
if (rule.max_video_duration_sec !== null) {
mapped.maxVideoDurationSec = rule.max_video_duration_sec;
}
if (rule.aspect_ratio_min !== null) {
mapped.aspectRatioMin = rule.aspect_ratio_min;
}
if (rule.aspect_ratio_max !== null) {
mapped.aspectRatioMax = rule.aspect_ratio_max;
}
if (rule.auto_fits_image) {
mapped.autoFitsImage = true;
}
return mapped;
};

View file

@ -63,7 +63,21 @@ export interface NavItem {
}
export interface ContentTypeMediaRule {
max_files: number;
min_files: number | null;
accept_images: boolean;
accept_videos: boolean;
accept_documents: boolean;
requires_media: boolean;
accepts_gif: boolean;
forbids_mixed_media: boolean;
max_image_bytes: number | null;
max_video_bytes: number | null;
max_document_bytes: number | null;
max_video_duration_sec: number | null;
aspect_ratio_min: number | null;
aspect_ratio_max: number | null;
auto_fits_image: boolean;
}
export interface SharedData {

View file

@ -30,6 +30,9 @@
'max_media_count',
'requires_media',
'max_video_duration_sec',
'max_image_bytes',
'max_video_bytes',
'max_document_bytes',
],
],
],
@ -41,6 +44,7 @@
$facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024);
expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90);
});

View file

@ -21,7 +21,10 @@
->assertInertia(fn (AssertableInertia $page) => $page
->has('contentTypeMediaRules')
->where('contentTypeMediaRules.instagram_reel.max_video_duration_sec', 900)
->where('contentTypeMediaRules.instagram_reel.max_video_bytes', 1 * 1024 * 1024 * 1024)
->where('contentTypeMediaRules.instagram_reel.accept_images', false)
->where('contentTypeMediaRules.facebook_reel.max_video_duration_sec', 90)
->where('contentTypeMediaRules.tiktok_video.max_video_duration_sec', null)
->where('contentTypeMediaRules.linkedin_post.max_document_bytes', 100 * 1024 * 1024)
);
});

View file

@ -42,6 +42,9 @@
'max_media_count',
'requires_media',
'max_video_duration_sec',
'max_image_bytes',
'max_video_bytes',
'max_document_bytes',
])
)
)
@ -71,6 +74,7 @@
$facebookTypes = collect($platforms->firstWhere('platform', 'facebook')['content_types']);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_duration_sec'])->toBe(900);
expect($instagramTypes->firstWhere('value', 'instagram_reel')['max_video_bytes'])->toBe(1 * 1024 * 1024 * 1024);
expect($facebookTypes->firstWhere('value', 'facebook_reel')['max_video_duration_sec'])->toBe(90);
});
});

View file

@ -34,13 +34,36 @@
expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull();
});
test('media rules for frontend expose duration caps keyed by content type', function () {
test('media rules for frontend expose the full editor rule set keyed by content type', function () {
$rules = ContentType::mediaRulesForFrontend();
expect($rules['instagram_reel']['max_video_duration_sec'])->toBe(900);
expect($rules)->toHaveCount(count(ContentType::cases()));
expect($rules['instagram_reel'])->toMatchArray([
'max_files' => 1,
'accept_images' => false,
'accept_videos' => true,
'requires_media' => true,
'max_video_bytes' => 1 * 1024 * 1024 * 1024,
'max_video_duration_sec' => 900,
'aspect_ratio_min' => 0.5,
'aspect_ratio_max' => 0.6,
]);
expect($rules['facebook_reel']['max_video_duration_sec'])->toBe(90);
expect($rules['tiktok_video']['max_video_duration_sec'])->toBeNull();
expect($rules)->toHaveCount(count(ContentType::cases()));
expect($rules['linkedin_post']['max_document_bytes'])->toBe(100 * 1024 * 1024);
expect($rules['pinterest_carousel']['min_files'])->toBe(2);
expect($rules['x_post']['accepts_gif'])->toBeTrue();
});
test('media rules reuse enum capability helpers', function () {
$rules = ContentType::InstagramStory->mediaRules();
expect($rules['accept_images'])->toBe(ContentType::InstagramStory->supportsImage())
->and($rules['accept_videos'])->toBe(ContentType::InstagramStory->supportsVideo())
->and($rules['auto_fits_image'])->toBeTrue()
->and($rules['max_files'])->toBe(ContentType::InstagramStory->maxMediaCount());
});
test('content type maps to correct platform', function () {