feat(posts): block combining an image and a video in one post
A Bluesky post embed is images XOR video, so a post that carries both can't be published there. Enforce it per selected platform: - Add ContentType::supportsMixedMedia() (false only for BlueskyPost) and reject image+video in ContentTypeCompatibleWithMedia when the type forbids it. - Mirror it client-side (useMediaRules forbidsMixedMedia + usePostCompliance) so the editor blocks scheduling with an inline reason before submit, like every other per-platform compatibility check. - Add the no_mixed_media message in all three locales (en/es/pt-BR). - Cover the rule and enum, including the GIF-counts-as-image case.
This commit is contained in:
parent
7e28d17d2c
commit
beea8a5f97
9 changed files with 76 additions and 2 deletions
|
|
@ -216,6 +216,20 @@ public function supportsImage(): bool
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a single post may carry images and a video together. Most
|
||||
* targets accept only one or the other; this is conservative and lists
|
||||
* only the types we've confirmed reject mixed media (e.g. a Bluesky post
|
||||
* embed is `images` XOR `video`, never both).
|
||||
*/
|
||||
public function supportsMixedMedia(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::BlueskyPost => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this content type carries a text caption visible to viewers.
|
||||
* Stories are image-overlay only — viewers don't see a separate caption.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
|||
if ($hasVideo && ! $contentType->supportsVideo()) {
|
||||
$fail("{$contentType->label()} does not support videos.");
|
||||
}
|
||||
|
||||
if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) {
|
||||
$fail("{$contentType->label()} can't combine an image and a video in the same post.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@
|
|||
'too_few_files' => 'Add at least :min files for this format.',
|
||||
'no_videos' => 'Only images are allowed for this format.',
|
||||
'no_images' => 'Only videos are allowed for this format.',
|
||||
'no_mixed_media' => "Images and videos can't be combined in the same post.",
|
||||
'no_gifs' => 'GIFs are not supported here.',
|
||||
'video_too_large' => 'Video exceeds the size limit for this platform.',
|
||||
'video_too_long' => 'Video must be under :seconds seconds for this format.',
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@
|
|||
'too_few_files' => 'Agrega al menos :min archivos para este formato.',
|
||||
'no_videos' => 'Solo se permiten imágenes en este formato.',
|
||||
'no_images' => 'Solo se permiten videos en este formato.',
|
||||
'no_mixed_media' => 'No se pueden combinar imágenes y videos en la misma publicación.',
|
||||
'no_gifs' => 'Los GIFs no son compatibles aquí.',
|
||||
'video_too_large' => 'El video supera el límite de tamaño de esta plataforma.',
|
||||
'video_too_long' => 'El video debe durar menos de :seconds segundos en este formato.',
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@
|
|||
'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.',
|
||||
'no_videos' => 'Apenas imagens são permitidas neste formato.',
|
||||
'no_images' => 'Apenas vídeos são permitidos neste formato.',
|
||||
'no_mixed_media' => 'Não é possível combinar imagem e vídeo na mesma publicação.',
|
||||
'no_gifs' => 'GIFs não são suportados aqui.',
|
||||
'video_too_large' => 'Vídeo excede o limite de tamanho desta plataforma.',
|
||||
'video_too_long' => 'Vídeo deve ter menos de :seconds segundos neste formato.',
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface MediaRules {
|
|||
acceptVideos: boolean;
|
||||
requiresMedia: boolean;
|
||||
acceptsGif: boolean;
|
||||
forbidsMixedMedia?: boolean;
|
||||
maxImageBytes?: number;
|
||||
maxVideoBytes?: number;
|
||||
maxVideoDurationSec?: number;
|
||||
|
|
@ -131,10 +132,11 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
|
|||
maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60,
|
||||
},
|
||||
|
||||
// Bluesky — accepts GIF; tight image size (auto-resized by backend)
|
||||
// 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,
|
||||
acceptsGif: true, forbidsMixedMedia: true,
|
||||
maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export const getMediaIncompatibilityReason = (
|
|||
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
|
||||
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
|
||||
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
|
||||
if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) return trans('posts.edit.compliance.no_mixed_media');
|
||||
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
|
||||
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
|
||||
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) });
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@
|
|||
expect(ContentType::YouTubeShort->supportsImage())->toBeFalse();
|
||||
});
|
||||
|
||||
test('content type supports mixed media correctly', function () {
|
||||
// A Bluesky embed is images XOR video, so it can't carry both.
|
||||
expect(ContentType::BlueskyPost->supportsMixedMedia())->toBeFalse();
|
||||
// Multi-attachment messengers can mix an image and a video.
|
||||
expect(ContentType::DiscordMessage->supportsMixedMedia())->toBeTrue();
|
||||
expect(ContentType::TelegramPost->supportsMixedMedia())->toBeTrue();
|
||||
});
|
||||
|
||||
test('content type requires media correctly', function () {
|
||||
expect(ContentType::InstagramReel->requiresMedia())->toBeTrue();
|
||||
expect(ContentType::TikTokVideo->requiresMedia())->toBeTrue();
|
||||
|
|
|
|||
|
|
@ -94,6 +94,48 @@ function runMediaRule(string $contentType, array $media): array
|
|||
expect($errors)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('bluesky rejects an image and a video in the same post', function () {
|
||||
$media = [
|
||||
['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'],
|
||||
['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'],
|
||||
];
|
||||
|
||||
$errors = runMediaRule(ContentType::BlueskyPost->value, $media);
|
||||
|
||||
expect($errors)->toHaveCount(1);
|
||||
expect($errors[0])->toContain("can't combine an image and a video");
|
||||
});
|
||||
|
||||
test('bluesky still accepts an image-only or video-only post', function () {
|
||||
$image = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']];
|
||||
$video = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']];
|
||||
|
||||
expect(runMediaRule(ContentType::BlueskyPost->value, $image))->toBe([]);
|
||||
expect(runMediaRule(ContentType::BlueskyPost->value, $video))->toBe([]);
|
||||
});
|
||||
|
||||
test('bluesky rejects an animated gif combined with a video', function () {
|
||||
// A GIF counts as an image, so gif + video is still mixed media.
|
||||
$media = [
|
||||
['type' => MediaType::Image->value, 'mime_type' => 'image/gif'],
|
||||
['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'],
|
||||
];
|
||||
|
||||
$errors = runMediaRule(ContentType::BlueskyPost->value, $media);
|
||||
|
||||
expect($errors)->toHaveCount(1);
|
||||
expect($errors[0])->toContain("can't combine an image and a video");
|
||||
});
|
||||
|
||||
test('a mixed-media content type accepts an image and a video together', function () {
|
||||
$media = [
|
||||
['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'],
|
||||
['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'],
|
||||
];
|
||||
|
||||
expect(runMediaRule(ContentType::DiscordMessage->value, $media))->toBe([]);
|
||||
});
|
||||
|
||||
test('does nothing for invalid content type values', function () {
|
||||
expect(runMediaRule('not_a_real_content_type', []))->toBe([]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue