44
55namespace App \Enums \PostPlatform ;
66
7+ use App \Enums \Media \Type as MediaType ;
78use App \Enums \SocialAccount \Platform as SocialPlatform ;
89
910enum ContentType: string
@@ -179,6 +180,267 @@ public function maxMediaCount(): int
179180 };
180181 }
181182
183+ /**
184+ * Maximum video duration in seconds for this content type, when the
185+ * platform publishes a hard cap via API. Null when unlimited, unknown,
186+ * or enforced dynamically (e.g. TikTok creator_info).
187+ *
188+ * Single source of truth for web (via Inertia shared props), REST API,
189+ * and MCP content-type listings.
190+ */
191+ public function maxVideoDurationSec (): ?int
192+ {
193+ return match ($ this ) {
194+ self ::InstagramFeed => 60 ,
195+ self ::InstagramReel => 15 * 60 ,
196+ self ::InstagramStory => 60 ,
197+ self ::FacebookPost => 240 * 60 ,
198+ self ::FacebookReel => 90 ,
199+ self ::FacebookStory => 60 ,
200+ self ::LinkedInPost, self ::LinkedInPagePost => 10 * 60 ,
201+ self ::YouTubeShort => 3 * 60 ,
202+ self ::PinterestVideoPin => 15 * 60 ,
203+ self ::XPost => 140 ,
204+ self ::ThreadsPost => 5 * 60 ,
205+ self ::BlueskyPost => 60 ,
206+ default => null ,
207+ };
208+ }
209+
210+ /**
211+ * Minimum attachments required (0 = no floor beyond requiresMedia()).
212+ */
213+ public function minMediaCount (): int
214+ {
215+ return match ($ this ) {
216+ self ::PinterestCarousel => 2 ,
217+ self ::TikTokPhoto => 1 ,
218+ default => 0 ,
219+ };
220+ }
221+
222+ /**
223+ * Whether animated GIFs are accepted (kept as GIF, not flattened to JPEG).
224+ */
225+ public function acceptsGif (): bool
226+ {
227+ return match ($ this ) {
228+ self ::XPost, self ::BlueskyPost, self ::MastodonPost,
229+ self ::DiscordMessage, self ::TelegramPost => true ,
230+ default => false ,
231+ };
232+ }
233+
234+ /**
235+ * Per-type image size cap in bytes, capped at the global upload hard limit
236+ * (trypost.media.max_size_mb.image). Null when images are not accepted or
237+ * the platform has no tighter editor-side limit than that hard cap.
238+ */
239+ public function maxImageBytes (): ?int
240+ {
241+ $ bytes = match ($ this ) {
242+ self ::InstagramFeed, self ::InstagramStory => self ::bytesFromMb (8 ),
243+ self ::FacebookPost => self ::bytesFromMb (4 ),
244+ self ::LinkedInPost, self ::LinkedInPagePost => self ::bytesFromMb (5 ),
245+ self ::PinterestPin, self ::PinterestCarousel => self ::bytesFromMb (20 ),
246+ self ::XPost => self ::bytesFromMb (5 ),
247+ self ::ThreadsPost => self ::bytesFromMb (8 ),
248+ self ::MastodonPost => self ::bytesFromMb (10 ),
249+ default => null ,
250+ };
251+
252+ return self ::capToHardLimit ($ bytes , MediaType::Image);
253+ }
254+
255+ /**
256+ * Per-type video size cap in bytes, capped at the global upload hard limit
257+ * (trypost.media.max_size_mb.video). Null when videos are not accepted or
258+ * the platform has no tighter editor-side limit than that hard cap.
259+ */
260+ public function maxVideoBytes (): ?int
261+ {
262+ $ bytes = match ($ this ) {
263+ self ::InstagramFeed, self ::InstagramStory => self ::bytesFromMb (100 ),
264+ self ::InstagramReel => self ::bytesFromGb (1 ),
265+ self ::FacebookPost => self ::bytesFromGb (10 ),
266+ self ::FacebookReel, self ::FacebookStory => self ::bytesFromGb (1 ),
267+ self ::LinkedInPost, self ::LinkedInPagePost => self ::bytesFromGb (5 ),
268+ self ::YouTubeShort => self ::bytesFromGb (256 ),
269+ self ::PinterestVideoPin => self ::bytesFromGb (2 ),
270+ self ::XPost => self ::bytesFromMb (512 ),
271+ self ::ThreadsPost => self ::bytesFromGb (1 ),
272+ self ::BlueskyPost => self ::bytesFromMb (100 ),
273+ self ::MastodonPost => self ::bytesFromMb (40 ),
274+ default => null ,
275+ };
276+
277+ return self ::capToHardLimit ($ bytes , MediaType::Video);
278+ }
279+
280+ /**
281+ * Per-type PDF size cap in bytes, capped at the global upload hard limit.
282+ * Null when documents are not accepted.
283+ */
284+ public function maxDocumentBytes (): ?int
285+ {
286+ $ bytes = match ($ this ) {
287+ self ::LinkedInPost, self ::LinkedInPagePost => self ::bytesFromMb (100 ),
288+ default => null ,
289+ };
290+
291+ return self ::capToHardLimit ($ bytes , MediaType::Document);
292+ }
293+
294+ /**
295+ * Soft aspect-ratio window used by the Vue cropper / media picker.
296+ *
297+ * @return array{min: float, max: float}|null
298+ */
299+ public function aspectRatioBounds (): ?array
300+ {
301+ return match ($ this ) {
302+ self ::InstagramFeed => ['min ' => 0.8 , 'max ' => 1.91 ],
303+ self ::InstagramReel, self ::InstagramStory,
304+ self ::FacebookReel, self ::FacebookStory,
305+ self ::YouTubeShort => ['min ' => 0.5 , 'max ' => 0.6 ],
306+ default => null ,
307+ };
308+ }
309+
310+ /**
311+ * Whether the editor should auto-fit still images into the story frame.
312+ */
313+ public function autoFitsImage (): bool
314+ {
315+ return $ this === self ::InstagramStory;
316+ }
317+
318+ /**
319+ * Full media-rule payload for the Vue editor (and any other consumer).
320+ * Shared once via Inertia — do not re-hardcode these numbers in JS.
321+ *
322+ * @return array{
323+ * max_files: int,
324+ * min_files: int|null,
325+ * accept_images: bool,
326+ * accept_videos: bool,
327+ * accept_documents: bool,
328+ * requires_media: bool,
329+ * accepts_gif: bool,
330+ * forbids_mixed_media: bool,
331+ * max_image_bytes: int|null,
332+ * max_video_bytes: int|null,
333+ * max_document_bytes: int|null,
334+ * max_video_duration_sec: int|null,
335+ * aspect_ratio_min: float|null,
336+ * aspect_ratio_max: float|null,
337+ * auto_fits_image: bool
338+ * }
339+ */
340+ public function mediaRules (): array
341+ {
342+ $ bounds = $ this ->aspectRatioBounds ();
343+ $ minFiles = $ this ->minMediaCount ();
344+
345+ return [
346+ 'max_files ' => $ this ->maxMediaCount (),
347+ 'min_files ' => $ minFiles > 0 ? $ minFiles : null ,
348+ 'accept_images ' => $ this ->supportsImage (),
349+ 'accept_videos ' => $ this ->supportsVideo (),
350+ 'accept_documents ' => $ this ->supportsDocument (),
351+ 'requires_media ' => $ this ->requiresMedia (),
352+ 'accepts_gif ' => $ this ->acceptsGif (),
353+ 'forbids_mixed_media ' => ! $ this ->supportsMixedMedia (),
354+ 'max_image_bytes ' => $ this ->maxImageBytes (),
355+ 'max_video_bytes ' => $ this ->maxVideoBytes (),
356+ 'max_document_bytes ' => $ this ->maxDocumentBytes (),
357+ 'max_video_duration_sec ' => $ this ->maxVideoDurationSec (),
358+ 'aspect_ratio_min ' => $ bounds ['min ' ] ?? null ,
359+ 'aspect_ratio_max ' => $ bounds ['max ' ] ?? null ,
360+ 'auto_fits_image ' => $ this ->autoFitsImage (),
361+ ];
362+ }
363+
364+ /**
365+ * Content-type row for REST / MCP listings (agents + API clients).
366+ * Keep in sync with mediaRules() capability fields — do not omit mins.
367+ *
368+ * @return array{
369+ * value: string,
370+ * label: string,
371+ * description: string,
372+ * max_media_count: int,
373+ * min_media_count: int,
374+ * requires_media: bool,
375+ * accept_images: bool,
376+ * accept_videos: bool,
377+ * accept_documents: bool,
378+ * accepts_gif: bool,
379+ * forbids_mixed_media: bool,
380+ * max_video_duration_sec: int|null,
381+ * max_image_bytes: int|null,
382+ * max_video_bytes: int|null,
383+ * max_document_bytes: int|null
384+ * }
385+ */
386+ public function toListingArray (): array
387+ {
388+ return [
389+ 'value ' => $ this ->value ,
390+ 'label ' => $ this ->label (),
391+ 'description ' => $ this ->description (),
392+ 'max_media_count ' => $ this ->maxMediaCount (),
393+ 'min_media_count ' => $ this ->minMediaCount (),
394+ 'requires_media ' => $ this ->requiresMedia (),
395+ 'accept_images ' => $ this ->supportsImage (),
396+ 'accept_videos ' => $ this ->supportsVideo (),
397+ 'accept_documents ' => $ this ->supportsDocument (),
398+ 'accepts_gif ' => $ this ->acceptsGif (),
399+ 'forbids_mixed_media ' => ! $ this ->supportsMixedMedia (),
400+ 'max_video_duration_sec ' => $ this ->maxVideoDurationSec (),
401+ 'max_image_bytes ' => $ this ->maxImageBytes (),
402+ 'max_video_bytes ' => $ this ->maxVideoBytes (),
403+ 'max_document_bytes ' => $ this ->maxDocumentBytes (),
404+ ];
405+ }
406+
407+ /**
408+ * @return array<string, array<string, mixed>>
409+ */
410+ public static function mediaRulesForFrontend (): array
411+ {
412+ $ rules = [];
413+
414+ foreach (self ::cases () as $ type ) {
415+ $ rules [$ type ->value ] = $ type ->mediaRules ();
416+ }
417+
418+ return $ rules ;
419+ }
420+
421+ private static function bytesFromMb (int $ megabytes ): int
422+ {
423+ return $ megabytes * 1024 * 1024 ;
424+ }
425+
426+ private static function bytesFromGb (int $ gigabytes ): int
427+ {
428+ return $ gigabytes * 1024 * 1024 * 1024 ;
429+ }
430+
431+ /**
432+ * Never advertise a soft platform ceiling above what trypost.media allows
433+ * on upload (web / API / MCP signed URL).
434+ */
435+ private static function capToHardLimit (?int $ platformBytes , MediaType $ type ): ?int
436+ {
437+ if ($ platformBytes === null ) {
438+ return null ;
439+ }
440+
441+ return min ($ platformBytes , $ type ->maxSizeInBytes ());
442+ }
443+
182444 public function supportsVideo (): bool
183445 {
184446 return match ($ this ) {
@@ -263,7 +525,6 @@ public function requiresMedia(): bool
263525 self ::MastodonPost => false ,
264526 self ::TelegramPost => false ,
265527 self ::FacebookPost => false ,
266- self ::InstagramFeed => false ,
267528 self ::DiscordMessage => false ,
268529 default => true ,
269530 };
@@ -288,6 +549,7 @@ public static function aiSupported(): array
288549 self ::MastodonPost,
289550 self ::FacebookPost,
290551 self ::PinterestPin,
552+ self ::PinterestCarousel,
291553 ];
292554 }
293555
0 commit comments