Add aspect ratio selection for Facebook posts

Facebook feed posts now have the same aspect ratio selector as Instagram
(1:1, 4:5, 16:9, Original), letting users control how their image is cropped
before publishing instead of leaving it to Facebook's auto-crop.

The Facebook Graph API has no crop/aspect-ratio parameter for organic Page
photo posts (image crops are ads-only) — Facebook displays the bytes it
receives at their native ratio. So, exactly like Instagram, the crop is done
server-side before upload.

- Extract the Instagram crop logic into a shared CropsImageForAspectRatio
  concern (download -> crop -> store -> URL), with a per-publisher
  cropFailureException hook. InstagramPublisher refactored to use it
  (behaviour identical; existing tests unchanged).
- FacebookPublisher reads meta.aspect_ratio and crops single- and multi-image
  feed posts (facebook-crops/ prefix). Reels/Stories are unaffected.
- FacebookSettings: aspect ratio picker on the Feed variant (default Original
  -> no crop). ScheduleTab wires meta; FacebookPreview reflects the chosen
  ratio. Adds posts.form.facebook.aspect.* in en/es/pt-BR.

Closes #69
This commit is contained in:
Paulo Castellano 2026-06-05 18:01:45 -03:00
parent a06fe78d62
commit e25b320ad2
10 changed files with 310 additions and 70 deletions

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Concerns;
use App\Exceptions\Social\SocialPublishException;
use App\Services\Media\MediaOptimizer;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
trait CropsImageForAspectRatio
{
/**
* Crop the image to the user-selected aspect ratio and return a public URL
* the platform can fetch. Returns the original URL untouched when no ratio
* is set or 'original' is selected.
*/
protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio, string $pathPrefix): string
{
if (! $aspectRatio || $aspectRatio === 'original') {
return $imageUrl;
}
$ratio = $this->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;
}

View file

@ -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,
);
}
}

View file

@ -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

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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<string, any>;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
meta: () => ({}),
});
const emit = defineEmits<{
'update:contentType': [value: string];
'update:meta': [meta: Record<string, any>];
}>();
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));
</script>
@ -99,6 +118,25 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</div>
</div>
<div v-if="isFeed" class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.aspect_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="ratio in aspectRatios"
:key="ratio.value"
type="button"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="selectedAspectRatio === ratio.value
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickAspectRatio(ratio.value)"
>
{{ $t(ratio.labelKey) }}
</button>
</div>
</div>
<p
v-if="warning"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"

View file

@ -315,8 +315,10 @@ const contentTypeErrorFor = (pp: PostPlatform): string | undefined => {
: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)"
/>
</div>

View file

@ -18,6 +18,7 @@ interface Props {
content: string;
media: MediaItem[];
contentType?: string;
meta?: Record<string, any>;
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<string, number> = {
'1:1': 100,
'4:5': 125,
'16:9': 56.25,
};
const feedAspectPadding = computed<number | null>(() => 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
</p>
</div>
<!-- Post Media -->
<div class="flex-1 relative bg-black min-h-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
dot-active-class="bg-[#1877f2]"
placeholder-class="w-full h-full flex items-center justify-center bg-[#f0f2f5] dark:bg-[#3a3b3c]"
/>
<!-- Post Media - Aspect ratio matches user's chosen crop -->
<div
v-if="feedAspectPadding !== null"
class="relative w-full shrink-0 bg-black"
:style="{ paddingBottom: `${feedAspectPadding}%` }"
>
<div class="absolute inset-0">
<PostMediaPreview :media="media" v-bind="feedMediaProps" />
</div>
</div>
<div v-else class="flex-1 relative bg-black min-h-0">
<PostMediaPreview :media="media" v-bind="feedMediaProps" />
</div>
<!-- Reactions Bar -->

View file

@ -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');
});