Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
66 changes: 66 additions & 0 deletions app/Services/Social/Concerns/CropsImageForAspectRatio.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?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
{
private const CROP_DIRECTORY = 'social-crops';

/**
* 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
{
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 = self::CROP_DIRECTORY.'/'.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;
}
28 changes: 20 additions & 8 deletions app/Services/Social/FacebookPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +18,7 @@

class FacebookPublisher
{
use CropsImageForAspectRatio;
use HasSocialHttpClient;

private string $baseUrl;
Expand Down Expand Up @@ -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()) {
Expand All @@ -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');
Expand Down Expand Up @@ -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),
'access_token' => $accessToken,
];

Expand All @@ -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 = [];
Expand All @@ -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),
'published' => 'false',
'access_token' => $accessToken,
]);
Expand Down Expand Up @@ -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,
);
}
}
61 changes: 10 additions & 51 deletions app/Services/Social/InstagramPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
Expand Down Expand Up @@ -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);
}

$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lang/en/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions lang/es/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions lang/pt-BR/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
44 changes: 41 additions & 3 deletions resources/js/components/posts/editor/FacebookSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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>

Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions resources/js/components/posts/editor/ScheduleTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
Loading
Loading