feat: integrate MediaOptimizer into all image-uploading publishers

This commit is contained in:
Paulo Castellano 2026-03-31 21:22:57 -03:00
parent 02298f31a6
commit 4ddb4d941a
7 changed files with 143 additions and 40 deletions

View file

@ -4,10 +4,12 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\BlueskyPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -124,12 +126,13 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
return null;
}
// Bluesky has 1MB limit for images
if (str_starts_with($mimeType, 'image/') && $fileSize > 1000000) {
Log::warning('Bluesky image exceeds 1MB limit', [
'size' => $fileSize,
'url' => $url,
]);
// Optimize images for Bluesky's 1MB limit
if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky);
@unlink($tempFile);
$tempFile = $optimizedPath;
$mimeType = 'image/jpeg';
}
$stream = fopen($tempFile, 'r');

View file

@ -5,10 +5,12 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -275,24 +277,40 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
Log::info('LinkedIn Page image init success', ['imageUrn' => $imageUrn]);
// Step 2: Upload binary data
$imageContent = file_get_contents($mediaItem->url);
// Step 2: Download and optimize image
$tempFile = tempnam(sys_get_temp_dir(), 'lip_image_');
$uploadResponse = Http::withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/octet-stream',
])
->withBody($imageContent, 'application/octet-stream')
->put($uploadUrl);
try {
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
if ($uploadResponse->failed()) {
Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]);
$this->handleApiError($uploadResponse, 'Failed to upload LinkedIn Page image');
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedInPage);
@unlink($tempFile);
$tempFile = $optimizedPath;
}
$imageContent = file_get_contents($tempFile);
$uploadResponse = Http::withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/octet-stream',
])
->withBody($imageContent, 'application/octet-stream')
->put($uploadUrl);
if ($uploadResponse->failed()) {
Log::error('LinkedIn Page image upload failed', ['body' => $uploadResponse->body()]);
$this->handleApiError($uploadResponse, 'Failed to upload LinkedIn Page image');
}
Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]);
return $imageUrn;
} finally {
@unlink($tempFile);
}
Log::info('LinkedIn Page image upload success', ['imageUrn' => $imageUrn]);
return $imageUrn;
}
private function uploadVideo($mediaItem, string $ownerUrn): ?string

View file

@ -5,10 +5,12 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -256,24 +258,40 @@ private function uploadImage($mediaItem, string $ownerUrn): ?string
Log::info('LinkedIn image init success', ['imageUrn' => $imageUrn]);
// Step 2: Upload binary data
$imageContent = file_get_contents($mediaItem->url);
// Step 2: Download and optimize image
$tempFile = tempnam(sys_get_temp_dir(), 'li_image_');
$uploadResponse = Http::withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/octet-stream',
])
->withBody($imageContent, 'application/octet-stream')
->put($uploadUrl);
try {
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
if ($uploadResponse->failed()) {
Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]);
$this->handleApiError($uploadResponse, 'Failed to upload LinkedIn image');
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::LinkedIn);
@unlink($tempFile);
$tempFile = $optimizedPath;
}
$imageContent = file_get_contents($tempFile);
$uploadResponse = Http::withToken($this->accessToken)
->withHeaders([
'Content-Type' => 'application/octet-stream',
])
->withBody($imageContent, 'application/octet-stream')
->put($uploadUrl);
if ($uploadResponse->failed()) {
Log::error('LinkedIn image upload failed', ['body' => $uploadResponse->body()]);
$this->handleApiError($uploadResponse, 'Failed to upload LinkedIn image');
}
Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]);
return $imageUrn;
} finally {
@unlink($tempFile);
}
Log::info('LinkedIn image upload success', ['imageUrn' => $imageUrn]);
return $imageUrn;
}
private function uploadVideo($mediaItem, string $ownerUrn): ?string

View file

@ -4,9 +4,11 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\MastodonPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -82,6 +84,15 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
return null;
}
// Optimize images (skip GIFs)
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Mastodon);
@unlink($tempFile);
$tempFile = $optimizedPath;
}
$name = $filename ?? basename(parse_url($url, PHP_URL_PATH));
if (empty($name)) {
$name = 'media';

View file

@ -5,9 +5,11 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\PinterestPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -54,11 +56,31 @@ private function publishImagePin(PostPlatform $postPlatform): array
'image_url' => $media->url,
]);
// Download and optimize image
$tempFile = tempnam(sys_get_temp_dir(), 'pin_image_');
try {
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($media->url);
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Pinterest);
@unlink($tempFile);
$tempFile = $optimizedPath;
}
$imageBase64 = base64_encode(file_get_contents($tempFile));
} finally {
@unlink($tempFile);
}
$payload = [
'board_id' => $boardId,
'media_source' => [
'source_type' => 'image_url',
'url' => $media->url,
'source_type' => 'image_base64',
'content_type' => 'image/jpeg',
'data' => $imageBase64,
],
];

View file

@ -4,10 +4,12 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\XPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -111,6 +113,15 @@ private function uploadMedia($mediaItem): ?array
try {
Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
// Optimize images (skip GIFs — they need special handling)
if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X);
@unlink($tempFile);
$tempFile = $optimizedPath;
$mimeType = 'image/jpeg';
}
$fileSize = filesize($tempFile);
$mediaCategory = $this->getMediaCategory($mimeType, $fileSize);

View file

@ -10,6 +10,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\PinterestPublisher;
use Illuminate\Support\Facades\Http;
@ -42,6 +43,17 @@
]);
$this->publisher = new PinterestPublisher;
// Mock MediaOptimizer to return the same temp file path
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
// Copy to a new temp file to simulate optimization
$optimized = tempnam(sys_get_temp_dir(), 'pin_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
});
test('pinterest publisher can publish image pin', function () {
@ -59,6 +71,7 @@
'*/v5/pins' => Http::response([
'id' => 'pin_123456',
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$result = $this->publisher->publish($this->postPlatform);
@ -113,12 +126,14 @@
'*/v5/pins' => Http::response([
'id' => 'pin_123456',
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return $request['board_id'] === 'board_123'; // from account meta
return str_contains($request->url(), '/v5/pins')
&& $request['board_id'] === 'board_123'; // from account meta
});
});
@ -207,6 +222,7 @@
'code' => 400,
'message' => 'Invalid request',
], 400),
'*' => Http::response('fake-image-content', 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -229,6 +245,7 @@
'code' => 1,
'message' => 'Invalid access token',
], 401),
'*' => Http::response('fake-image-content', 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
@ -257,6 +274,7 @@
'*/v5/pins' => Http::response([
'id' => 'pin_123456',
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$this->publisher->publish($this->postPlatform);
@ -292,12 +310,14 @@
'*/v5/pins' => Http::response([
'id' => 'pin_123456',
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return $request['title'] === 'My Pin Title'
return str_contains($request->url(), '/v5/pins')
&& $request['title'] === 'My Pin Title'
&& $request['link'] === 'https://example.com/my-page';
});
});