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:
parent
b28b18ef72
commit
7b986fca9c
10 changed files with 317 additions and 199 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
14
resources/js/types/index.d.ts
vendored
14
resources/js/types/index.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue