Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/Enums/PostPlatform/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,10 @@ 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],
self::FacebookReel, self::FacebookStory => ['min' => 0.5, 'max' => 0.6],
// YouTube Shorts accepts square as well as vertical video, up to
// the 3-minute cap: https://support.google.com/youtube/answer/15424877
self::YouTubeShort => ['min' => 0.5, 'max' => 1.0],
default => null,
};
}
Expand Down
127 changes: 127 additions & 0 deletions app/Rules/ContentTypeCompatibleWithMedia.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,24 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
return;
}

$maxFiles = $contentType->maxMediaCount();
if ($count > $maxFiles) {
$fail("{$contentType->label()} allows at most {$maxFiles} media file(s).");

return;
}

$minFiles = $contentType->minMediaCount();
if ($minFiles > 0 && $count < $minFiles) {
$fail("{$contentType->label()} requires at least {$minFiles} media files.");

return;
}

$hasImage = collect($media)->contains(fn ($item) => $this->isImage((array) $item));
$hasVideo = collect($media)->contains(fn ($item) => $this->isVideo((array) $item));
$hasDocument = collect($media)->contains(fn ($item) => $this->isDocument((array) $item));
$hasGif = collect($media)->contains(fn ($item) => MediaType::isGif(data_get((array) $item, 'mime_type')));

if ($hasImage && ! $contentType->supportsImage()) {
$fail("{$contentType->label()} does not support images.");
Expand All @@ -158,6 +173,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$fail("{$contentType->label()} does not support PDF documents.");
}

if ($hasGif && ! $contentType->acceptsGif()) {
$fail("{$contentType->label()} does not support animated GIFs.");
}

// A PDF document is always published on its own (LinkedIn document post).
if ($hasDocument && $count > 1) {
$fail('A PDF document must be the only attachment.');
Expand All @@ -166,6 +185,114 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) {
$fail("{$contentType->label()} can't combine an image and a video in the same post.");
}

foreach ($media as $item) {
$this->validateItemConstraints($contentType, (array) $item, $fail);
}
Comment on lines +189 to +191
}

/**
* Per-item size, duration, and aspect-ratio checks against the content
* type's numeric rules (the same rules `ContentType::mediaRules()` gives
* the Vue editor via `useMediaRules`/`useMedia`). Duration and dimensions
* come from client-supplied `meta` - there is no server-side probe (e.g.
* ffprobe) yet, so a missing value is treated as unknown and skipped
* rather than rejected.
*
* @param array<string, mixed> $item
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
private function validateItemConstraints(ContentType $contentType, array $item, Closure $fail): void
{
$size = (int) data_get($item, 'size', 0);

if ($this->isDocument($item)) {
$maxDocumentBytes = $contentType->maxDocumentBytes();

if ($maxDocumentBytes && $size > $maxDocumentBytes) {
$fail("{$contentType->label()} documents must be under ".self::formatBytes($maxDocumentBytes).'.');
}

return;
}

if ($this->isVideo($item)) {
$maxVideoBytes = $contentType->maxVideoBytes();

if ($maxVideoBytes && $size > $maxVideoBytes) {
$fail("{$contentType->label()} videos must be under ".self::formatBytes($maxVideoBytes).'.');
}

$maxDuration = $contentType->maxVideoDurationSec();
$duration = (float) data_get($item, 'meta.duration', 0);

if ($maxDuration && $duration > $maxDuration) {
$fail("{$contentType->label()} videos must be under ".self::formatDuration($maxDuration).'.');
}
} elseif ($this->isImage($item)) {
$maxImageBytes = $contentType->maxImageBytes();

if ($maxImageBytes && $size > $maxImageBytes) {
$fail("{$contentType->label()} images must be under ".self::formatBytes($maxImageBytes).'.');
}
}

$this->validateAspectRatio($contentType, $item, $fail);
}

/**
* @param array<string, mixed> $item
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
private function validateAspectRatio(ContentType $contentType, array $item, Closure $fail): void
{
$width = (float) data_get($item, 'meta.width', 0);
$height = (float) data_get($item, 'meta.height', 0);

if ($width <= 0 || $height <= 0) {
return;
}

if ($contentType->autoFitsImage() && $this->isImage($item)) {
return;
}

$bounds = $contentType->aspectRatioBounds();

if (! $bounds) {
return;
}

$ratio = $width / $height;

if ($ratio < $bounds['min'] || $ratio > $bounds['max']) {
$fail("{$contentType->label()} media must have an aspect ratio between {$bounds['min']} and {$bounds['max']}.");
}
}

private static function formatBytes(int $bytes): string
{
if ($bytes >= 1024 * 1024 * 1024) {
return number_format($bytes / (1024 * 1024 * 1024), 1).' GB';
}

if ($bytes >= 1024 * 1024) {
return number_format($bytes / (1024 * 1024), 1).' MB';
}

return number_format($bytes / 1024, 1).' KB';
}

private static function formatDuration(int $seconds): string
{
$minutes = intdiv($seconds, 60);
$remainingSeconds = $seconds % 60;

if ($minutes === 0) {
return "{$seconds}s";
}

return $remainingSeconds === 0 ? "{$minutes}m" : "{$minutes}m {$remainingSeconds}s";
}

/**
Expand Down
2 changes: 1 addition & 1 deletion lang/en/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@
],
'youtube_short' => [
'label' => 'Short',
'description' => 'Vertical video up to 3 minutes',
'description' => 'Vertical or square video up to 3 minutes',
],
Comment on lines 513 to 516
'x_post' => [
'label' => 'Post',
Expand Down
4 changes: 4 additions & 0 deletions tests/Unit/Enums/ContentTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull();
});

test('youtube short accepts square as well as vertical video', function () {
expect(ContentType::YouTubeShort->aspectRatioBounds())->toBe(['min' => 0.5, 'max' => 1.0]);
});

test('media rules for frontend expose the full editor rule set keyed by content type', function () {
$rules = ContentType::mediaRulesForFrontend();

Expand Down
113 changes: 113 additions & 0 deletions tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,116 @@ function runMediaRule(string $contentType, array $media): array
test('does nothing for invalid content type values', function () {
expect(runMediaRule('not_a_real_content_type', []))->toBe([]);
});

test('rejects more files than the content type allows', function () {
$media = array_fill(0, ContentType::XPost->maxMediaCount() + 1, [
'type' => MediaType::Image->value, 'mime_type' => 'image/jpeg',
]);

$errors = runMediaRule(ContentType::XPost->value, $media);

expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('allows at most');
});

test('rejects fewer files than the content type requires', function () {
$media = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']];

$errors = runMediaRule(ContentType::PinterestCarousel->value, $media);

expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('requires at least 2 media files');
});

test('rejects an animated gif when the content type does not accept gifs', function () {
$media = [['type' => MediaType::Image->value, 'mime_type' => 'image/gif']];

$errors = runMediaRule(ContentType::LinkedInPost->value, $media);

expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('does not support animated GIFs');
});

test('accepts an animated gif when the content type allows it', function () {
$media = [['type' => MediaType::Image->value, 'mime_type' => 'image/gif']];

expect(runMediaRule(ContentType::XPost->value, $media))->toBe([]);
});

test('rejects an image over the content type size limit', function () {
$media = [[
'type' => MediaType::Image->value,
'mime_type' => 'image/jpeg',
'size' => ContentType::FacebookPost->maxImageBytes() + 1,
]];

$errors = runMediaRule(ContentType::FacebookPost->value, $media);

expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('images must be under');
});

test('rejects a video over the content type size limit', function () {
$media = [[
'type' => MediaType::Video->value,
'mime_type' => 'video/mp4',
'size' => ContentType::InstagramReel->maxVideoBytes() + 1,
]];

$errors = runMediaRule(ContentType::InstagramReel->value, $media);

expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('videos must be under');
});

test('rejects a video longer than the content type max duration', function () {
$media = [[
'type' => MediaType::Video->value,
'mime_type' => 'video/mp4',
'meta' => ['duration' => ContentType::FacebookReel->maxVideoDurationSec() + 10],
]];

$errors = runMediaRule(ContentType::FacebookReel->value, $media);

expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('videos must be under');
});

test('accepts a video with unknown duration rather than rejecting it', function () {
$media = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']];

expect(runMediaRule(ContentType::FacebookReel->value, $media))->toBe([]);
});

test('rejects media outside the content type aspect ratio window', function () {
$media = [[
'type' => MediaType::Video->value,
'mime_type' => 'video/mp4',
'meta' => ['width' => 1000, 'height' => 1000],
]];

$errors = runMediaRule(ContentType::InstagramReel->value, $media);

expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('aspect ratio between 0.5 and 0.6');
});

test('youtube short accepts a square video', function () {
$media = [[
'type' => MediaType::Video->value,
'mime_type' => 'video/mp4',
'meta' => ['width' => 1000, 'height' => 1000],
]];

expect(runMediaRule(ContentType::YouTubeShort->value, $media))->toBe([]);
});

test('instagram story auto-fits images so aspect ratio is not enforced for them', function () {
$media = [[
'type' => MediaType::Image->value,
'mime_type' => 'image/jpeg',
'meta' => ['width' => 2000, 'height' => 200],
]];

expect(runMediaRule(ContentType::InstagramStory->value, $media))->toBe([]);
});