diff --git a/app/Services/Social/Concerns/CropsImageForAspectRatio.php b/app/Services/Social/Concerns/CropsImageForAspectRatio.php new file mode 100644 index 00000000..f006ee8b --- /dev/null +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -0,0 +1,64 @@ +aspectRatioToFloat($aspectRatio); + + $tempInput = tempnam(sys_get_temp_dir(), 'crop_in_'); + + try { + $download = Http::sink($tempInput)->timeout(120)->get($imageUrl); + + if ($download->failed()) { + throw $this->cropFailureException('Failed to download image for cropping'); + } + + $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); + + $path = "{$pathPrefix}/".Str::uuid()->toString().'.jpg'; + Storage::put($path, file_get_contents($cropped)); + + @unlink($cropped); + + return Storage::url($path); + } finally { + @unlink($tempInput); + } + } + + protected function aspectRatioToFloat(string $ratio): float + { + return match ($ratio) { + '4:5' => 4 / 5, + '16:9' => 16 / 9, + default => 1.0, + }; + } + + /** + * The platform-specific exception thrown when the source image cannot be + * downloaded for cropping. + */ + abstract protected function cropFailureException(string $message): SocialPublishException; +} diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index b7133e36..20080e93 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -7,7 +7,9 @@ use App\Enums\PostPlatform\ContentType; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\FacebookPublishException; +use App\Exceptions\Social\SocialPublishException; use App\Models\PostPlatform; +use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; @@ -16,6 +18,7 @@ class FacebookPublisher { + use CropsImageForAspectRatio; use HasSocialHttpClient; private string $baseUrl; @@ -46,16 +49,17 @@ public function publish(PostPlatform $postPlatform): array $media = $postPlatform->post->mediaItems; $contentType = $postPlatform->content_type; + $aspectRatio = data_get($postPlatform->meta, 'aspect_ratio'); return match ($contentType) { ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()), ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), - ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media), + ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $aspectRatio), default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"), }; } - private function publishPost(string $pageId, string $accessToken, ?string $content, $media): array + private function publishPost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { // Text only post if ($media->isEmpty()) { @@ -77,10 +81,10 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte if ($isImage) { // Single or multiple images if ($media->count() === 1) { - return $this->publishSingleImagePost($pageId, $accessToken, $content, $firstMedia); + return $this->publishSingleImagePost($pageId, $accessToken, $content, $firstMedia, $aspectRatio); } - return $this->publishMultiImagePost($pageId, $accessToken, $content, $media); + return $this->publishMultiImagePost($pageId, $accessToken, $content, $media, $aspectRatio); } throw new \Exception('Unsupported media type for Facebook'); @@ -110,10 +114,10 @@ private function publishTextPost(string $pageId, string $accessToken, string $co ]; } - private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array + private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { $payload = [ - 'url' => $media->url, + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'), 'access_token' => $accessToken, ]; @@ -140,7 +144,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st ]; } - private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection): array + private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection, ?string $aspectRatio): array { // Upload each image as unpublished $attachedMedia = []; @@ -151,7 +155,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str } $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ - 'url' => $media->url, + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'), 'published' => 'false', 'access_token' => $accessToken, ]); @@ -392,4 +396,12 @@ private function handleApiError(Response $response): never { throw FacebookPublishException::fromApiResponse($response); } + + protected function cropFailureException(string $message): SocialPublishException + { + return new FacebookPublishException( + userMessage: $message, + category: ErrorCategory::ServerError, + ); + } } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index c22bef71..79540f01 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -7,17 +7,16 @@ use App\Enums\PostPlatform\ContentType; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; +use App\Exceptions\Social\SocialPublishException; use App\Models\PostPlatform; -use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; class InstagramPublisher { + use CropsImageForAspectRatio; use HasSocialHttpClient; private string $baseUrl; @@ -80,7 +79,7 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $ private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { - $imageUrl = $this->cropForFeed($media->url, $aspectRatio); + $imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops'); // Step 1: Create container $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ @@ -206,7 +205,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $params['video_url'] = $media->url; $params['media_type'] = 'VIDEO'; } else { - $params['image_url'] = $this->cropForFeed($media->url, $aspectRatio); + $params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops'); } $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); @@ -311,52 +310,12 @@ private function publishContainer(string $instagramId, string $accessToken, stri ]; } - /** - * Crop the image to the user-selected aspect ratio and return a public URL - * Instagram can fetch. Returns the original URL untouched when no ratio is - * set or 'original' is selected (caller has already validated the original - * fits IG's 4:5 to 1.91:1 range). - */ - private function cropForFeed(string $imageUrl, ?string $aspectRatio): string + protected function cropFailureException(string $message): SocialPublishException { - if (! $aspectRatio || $aspectRatio === 'original') { - return $imageUrl; - } - - $ratio = $this->aspectRatioToFloat($aspectRatio); - - $tempInput = tempnam(sys_get_temp_dir(), 'ig_crop_in_'); - - try { - $download = Http::sink($tempInput)->timeout(120)->get($imageUrl); - - if ($download->failed()) { - throw new InstagramPublishException( - userMessage: 'Failed to download image for cropping', - category: ErrorCategory::ServerError, - ); - } - - $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); - - $path = 'instagram-crops/'.Str::uuid()->toString().'.jpg'; - Storage::put($path, file_get_contents($cropped)); - - @unlink($cropped); - - return Storage::url($path); - } finally { - @unlink($tempInput); - } - } - - private function aspectRatioToFloat(string $ratio): float - { - return match ($ratio) { - '4:5' => 4 / 5, - '16:9' => 16 / 9, - default => 1.0, - }; + return new InstagramPublishException( + userMessage: $message, + category: ErrorCategory::ServerError, + ); } private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void diff --git a/lang/en/posts.php b/lang/en/posts.php index 8488c29a..3c9dd900 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -127,6 +127,13 @@ 'reel' => 'Reel', 'story' => 'Story', ], + 'aspect_label' => 'Aspect ratio', + 'aspect' => [ + 'square' => 'Square (1:1)', + 'portrait' => 'Portrait (4:5)', + 'landscape' => 'Landscape (16:9)', + 'original' => 'Original', + ], ], 'linkedin' => [ 'settings' => 'LinkedIn Settings', diff --git a/lang/es/posts.php b/lang/es/posts.php index cf988ea8..4523a56c 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -127,6 +127,13 @@ 'reel' => 'Reel', 'story' => 'Historia', ], + 'aspect_label' => 'Proporción', + 'aspect' => [ + 'square' => 'Cuadrado (1:1)', + 'portrait' => 'Vertical (4:5)', + 'landscape' => 'Horizontal (16:9)', + 'original' => 'Original', + ], ], 'linkedin' => [ 'settings' => 'Configuración de LinkedIn', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 6dc86360..1d33f786 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -127,6 +127,13 @@ 'reel' => 'Reel', 'story' => 'Story', ], + 'aspect_label' => 'Proporção', + 'aspect' => [ + 'square' => 'Quadrado (1:1)', + 'portrait' => 'Retrato (4:5)', + 'landscape' => 'Paisagem (16:9)', + 'original' => 'Original', + ], ], 'linkedin' => [ 'settings' => 'Configurações do LinkedIn', diff --git a/resources/js/components/posts/editor/FacebookSettings.vue b/resources/js/components/posts/editor/FacebookSettings.vue index 1451dd2e..00711fcb 100644 --- a/resources/js/components/posts/editor/FacebookSettings.vue +++ b/resources/js/components/posts/editor/FacebookSettings.vue @@ -5,6 +5,7 @@ import { computed, ref } from 'vue'; import { Avatar } from '@/components/ui/avatar'; import { getMediaValidationWarning } from '@/composables/useMedia'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; +import { ContentType } from '@/enums/content-type'; import type { MediaItem } from '@/types/media'; interface SocialAccount { @@ -19,30 +20,48 @@ interface Props { socialAccount: SocialAccount | null; contentType: string; media: MediaItem[]; + meta?: Record; disabled?: boolean; } const props = withDefaults(defineProps(), { disabled: false, + meta: () => ({}), }); const emit = defineEmits<{ 'update:contentType': [value: string]; + 'update:meta': [meta: Record]; }>(); const open = ref(false); const variants = [ - { value: 'facebook_post', labelKey: 'posts.form.facebook.variant.post' }, - { value: 'facebook_reel', labelKey: 'posts.form.facebook.variant.reel' }, - { value: 'facebook_story', labelKey: 'posts.form.facebook.variant.story' }, + { value: ContentType.FacebookPost, labelKey: 'posts.form.facebook.variant.post' }, + { value: ContentType.FacebookReel, labelKey: 'posts.form.facebook.variant.reel' }, + { value: ContentType.FacebookStory, labelKey: 'posts.form.facebook.variant.story' }, ]; +const aspectRatios = [ + { value: '1:1', labelKey: 'posts.form.facebook.aspect.square' }, + { value: '4:5', labelKey: 'posts.form.facebook.aspect.portrait' }, + { value: '16:9', labelKey: 'posts.form.facebook.aspect.landscape' }, + { value: 'original', labelKey: 'posts.form.facebook.aspect.original' }, +]; + +const isFeed = computed(() => props.contentType === ContentType.FacebookPost); +const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? 'original'); + const pickVariant = (value: string) => { if (props.disabled) return; emit('update:contentType', value); }; +const pickAspectRatio = (value: string) => { + if (props.disabled) return; + emit('update:meta', { ...props.meta, aspect_ratio: value }); +}; + const warning = computed(() => getMediaValidationWarning(props.contentType, props.media)); @@ -99,6 +118,25 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop +
+

{{ $t('posts.form.facebook.aspect_label') }}

+
+ +
+
+

{ :social-account="pp.social_account" :content-type="platformContentTypes[pp.id] ?? ''" :media="media ?? []" + :meta="platformMeta[pp.id] ?? {}" :disabled="isReadOnly" @update:content-type="emit('update:platformContentType', pp.id, $event)" + @update:meta="emit('update:platformMeta', pp.id, $event)" /> diff --git a/resources/js/components/posts/previews/FacebookPreview.vue b/resources/js/components/posts/previews/FacebookPreview.vue index bb5c4422..f0302ae2 100644 --- a/resources/js/components/posts/previews/FacebookPreview.vue +++ b/resources/js/components/posts/previews/FacebookPreview.vue @@ -18,6 +18,7 @@ interface Props { content: string; media: MediaItem[]; contentType?: string; + meta?: Record; charCount?: number; maxLength?: number; isValid?: boolean; @@ -31,6 +32,21 @@ const isReel = computed(() => props.contentType === 'facebook_reel'); const isStory = computed(() => props.contentType === 'facebook_story'); const isFeed = computed(() => !isReel.value && !isStory.value); +// Padding-bottom percentage = height/width. Reflects the user's chosen crop in +// the feed preview. `null` (original / unset) keeps the natural fill layout. +const ASPECT_PADDING: Record = { + '1:1': 100, + '4:5': 125, + '16:9': 56.25, +}; +const feedAspectPadding = computed(() => ASPECT_PADDING[props.meta?.aspect_ratio ?? ''] ?? null); + +const feedMediaProps = { + placeholderIcon: IconPhoto, + dotActiveClass: 'bg-[#1877f2]', + placeholderClass: 'w-full h-full flex items-center justify-center bg-[#f0f2f5] dark:bg-[#3a3b3c]', +}; + // Format numbers like Facebook const formatNumber = (num: number): string => { if (num >= 1000000) { @@ -122,14 +138,18 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc

- -
- + +
+
+ +
+
+
+
diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index cad4cfc0..4a8ce7b3 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -13,6 +13,17 @@ use App\Models\Workspace; use App\Services\Social\FacebookPublisher; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; +use Intervention\Image\Drivers\Gd\Driver; +use Intervention\Image\ImageManager; + +function facebookJpegBytes(int $width = 1200, int $height = 800): string +{ + $manager = new ImageManager(Driver::class); + $image = $manager->createImage($width, $height)->fill('888888'); + + return (string) $image->encodeUsingMediaType('image/jpeg', quality: 80); +} beforeEach(function () { $this->user = User::factory()->create(); @@ -636,3 +647,116 @@ && ! array_key_exists('description', $data); }); }); + +test('facebook single image post applies the selected aspect ratio crop and uploads the crop', function (string $ratio, float $expected) { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => $ratio]]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response(facebookJpegBytes(1600, 900), 200), + '*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + expect($crops)->toHaveCount(1); + + $manager = new ImageManager(Driver::class); + $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); + file_put_contents($tempFile, Storage::get($crops->first())); + $image = $manager->decodePath($tempFile); + expect(round($image->width() / $image->height(), 2))->toBe(round($expected, 2)); + @unlink($tempFile); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') + && str_contains($request['url'], 'facebook-crops/') + && ! str_contains($request['url'], 'example.com')); +})->with([ + '1:1' => ['1:1', 1.0], + '4:5' => ['4:5', 4 / 5], + '16:9' => ['16:9', 16 / 9], +]); + +test('facebook multi image post applies the aspect ratio crop to every image', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => '1:1']]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://example.com/media/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response(facebookJpegBytes(1600, 900), 200), + 'https://example.com/media/b.jpg' => Http::response(facebookJpegBytes(900, 1600), 200), + '*/page_123/photos' => Http::sequence() + ->push(['id' => 'up_1'], 200) + ->push(['id' => 'up_2'], 200), + '*/page_123/feed' => Http::response(['id' => 'post_1'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + expect($crops)->toHaveCount(2); + + $manager = new ImageManager(Driver::class); + foreach ($crops as $cropPath) { + $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); + file_put_contents($tempFile, Storage::get($cropPath)); + $image = $manager->decodePath($tempFile); + expect($image->width())->toBe($image->height()); + @unlink($tempFile); + } + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') + && str_contains($request['url'] ?? '', 'facebook-crops/')); +}); + +test('facebook image post throws when the source image cannot be downloaded for cropping', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response('', 404), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Failed to download image for cropping'); +}); + +test('facebook single image post without aspect ratio uploads the original image (no crop)', function () { + Storage::fake(); + + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + '*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')))->toBeEmpty(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') + && $request['url'] === 'https://example.com/media/a.jpg'); +});