From c5fca040a937d16ba7307f54e80dc9db2d37bbc2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 26 Jun 2026 12:23:56 -0300 Subject: [PATCH] fix(tiktok): resize oversized photos before PULL_FROM_URL TikTok photo posts hand the platform our media URLs and let it pull the bytes, so they never went through MediaOptimizer like the upload-based publishers do. An image wider than 1080px reached TikTok untouched and was rejected with picture_size_check_failed ("Image dimensions exceed limits"). Photos within spec still pass through unchanged. Oversized ones (or images with unknown dimensions) are downloaded, resized to TikTok's 1080px spec, hosted on the public disk, and that URL is handed to TikTok instead. The derivative is pruned once TikTok has pulled it. Reuses the same hosted-derivative pattern Instagram/Threads already use for aspect-ratio cropping; the TikTok max_width config was previously dead code. --- app/DataTransferObjects/MediaItem.php | 24 +++ app/Services/Media/MediaOptimizer.php | 12 ++ app/Services/Social/TikTokPublisher.php | 181 +++++++++++++----- .../Services/Social/TikTokPublisherTest.php | 154 +++++++++++++++ 4 files changed, 323 insertions(+), 48 deletions(-) diff --git a/app/DataTransferObjects/MediaItem.php b/app/DataTransferObjects/MediaItem.php index eae00948..2e0abede 100644 --- a/app/DataTransferObjects/MediaItem.php +++ b/app/DataTransferObjects/MediaItem.php @@ -10,6 +10,7 @@ class MediaItem { /** + * @param array|null $meta * @param array|null $source_meta */ public function __construct( @@ -20,6 +21,7 @@ public function __construct( public readonly ?string $original_filename = null, public readonly ?Source $source = null, public readonly ?array $source_meta = null, + public readonly ?array $meta = null, ) {} public function isVideo(): bool @@ -37,6 +39,26 @@ public function isDocument(): bool return Type::classify($this->mime_type, $this->path) === Type::Document; } + /** + * Stored pixel width from upload-time metadata, when known. + */ + public function width(): ?int + { + $width = data_get($this->meta, 'width'); + + return is_numeric($width) ? (int) $width : null; + } + + /** + * Stored pixel height from upload-time metadata, when known. + */ + public function height(): ?int + { + $height = data_get($this->meta, 'height'); + + return is_numeric($height) ? (int) $height : null; + } + /** * @param array $data */ @@ -63,6 +85,7 @@ public static function fromArray(array $data): self $source = is_string($sourceValue) ? Source::tryFrom($sourceValue) : null; $sourceMeta = data_get($data, 'source_meta'); + $meta = data_get($data, 'meta'); return new self( id: data_get($data, 'id', ''), @@ -72,6 +95,7 @@ public static function fromArray(array $data): self original_filename: data_get($data, 'original_filename'), source: $source, source_meta: is_array($sourceMeta) ? $sourceMeta : null, + meta: is_array($meta) ? $meta : null, ); } } diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 65de20e3..d2a7b7a7 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -95,6 +95,18 @@ public function optimizeImage(string $filePath, Platform $platform): string return $tempFile; } + /** + * The maximum image width (px) enforced for a platform. Pull-from-URL + * publishers (e.g. TikTok) use this to decide whether a source image needs + * a resized, spec-compliant derivative before the platform fetches it. + */ + public function maxWidthForPlatform(Platform $platform): ?int + { + $maxWidth = data_get($this->getImageConfig($platform), 'max_width'); + + return is_int($maxWidth) ? $maxWidth : null; + } + /** * Center-crop an image to the given aspect ratio (width / height). * Returns path to a temp file (caller must clean up). diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 55d08c84..37bc6f06 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -4,20 +4,27 @@ namespace App\Services\Social; +use App\DataTransferObjects\MediaItem; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; 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 TikTokPublisher { use HasSocialHttpClient; + private const PHOTO_DERIVATIVE_DIRECTORY = 'social-tiktok-photos'; + private string $baseUrl; private string $accessToken; @@ -198,66 +205,144 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array { - $photoUrls = $mediaCollection - ->filter(fn ($m) => $m->isImage()) - ->map(fn ($m) => $m->url) - ->values() - ->toArray(); + $images = $mediaCollection->filter(fn ($m) => $m->isImage())->values(); - if (empty($photoUrls)) { + if ($images->isEmpty()) { throw new TikTokPublishException( userMessage: 'No valid images found for TikTok photo post', category: ErrorCategory::MediaFormat, ); } - $postInfo = $this->buildPhotoPostInfo($postPlatform, $content); + $derivatives = []; - // Auto add music is only for photos. - $meta = $postPlatform->meta ?? []; - if (data_get($meta, 'auto_add_music', false)) { - $postInfo['auto_add_music'] = true; + try { + $photoUrls = []; + + foreach ($images as $image) { + [$url, $derivativePath] = $this->resolvePhotoUrl($image); + $photoUrls[] = $url; + + if ($derivativePath !== null) { + $derivatives[] = $derivativePath; + } + } + + $postInfo = $this->buildPhotoPostInfo($postPlatform, $content); + + // Auto add music is only for photos. + $meta = $postPlatform->meta ?? []; + if (data_get($meta, 'auto_add_music', false)) { + $postInfo['auto_add_music'] = true; + } + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/content/init/", [ + 'post_info' => $postInfo, + 'source_info' => [ + 'source' => 'PULL_FROM_URL', + 'photo_cover_index' => 0, + 'photo_images' => $photoUrls, + ], + 'post_mode' => 'DIRECT_POST', + 'media_type' => 'PHOTO', + ]); + + if ($response->failed()) { + Log::error('TikTok photo publish failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $data = $response->json(); + + $publishId = data_get($data, 'data.publish_id'); + + if (! $publishId) { + throw new TikTokPublishException( + userMessage: 'TikTok did not return a publish_id', + category: ErrorCategory::ServerError, + ); + } + + // Wait for processing and get final status + $statusData = $this->waitForPublishStatus($publishId); + $postId = data_get($statusData, 'publicaly_available_post_id.0'); + + return [ + 'id' => $postId ?? $publishId, + 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), + ]; + } finally { + // TikTok pulls the images during the synchronous status poll above, + // so by the time we reach here the fetch is finished and the + // temporary derivatives can be safely removed. + if ($derivatives !== []) { + Storage::delete($derivatives); + } + } + } + + /** + * Resolve the URL TikTok will PULL_FROM_URL for a single photo. TikTok rejects + * images wider than 1080px with picture_size_check_failed, and because the + * platform fetches the bytes from us we cannot optimize them in-flight like + * the upload-based publishers do. So an oversized image is rendered to a + * spec-compliant JPEG derivative hosted on our public disk and that URL is + * handed to TikTok instead. Images already within spec pass through untouched. + * + * @return array{0: string, 1: string|null} the URL to publish, and the + * storage path of any derivative + * created (null when passed through) + */ + private function resolvePhotoUrl(MediaItem $image): array + { + $maxWidth = app(MediaOptimizer::class)->maxWidthForPlatform(Platform::TikTok); + $width = $image->width(); + + if ($maxWidth !== null && $width !== null && $width <= $maxWidth) { + return [$image->url, null]; } - $response = $this->getHttpClient() - ->post("{$this->baseUrl}/post/publish/content/init/", [ - 'post_info' => $postInfo, - 'source_info' => [ - 'source' => 'PULL_FROM_URL', - 'photo_cover_index' => 0, - 'photo_images' => $photoUrls, - ], - 'post_mode' => 'DIRECT_POST', - 'media_type' => 'PHOTO', - ]); + return $this->renderCompliantPhoto($image); + } - if ($response->failed()) { - Log::error('TikTok photo publish failed', [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); - $this->handleApiError($response); + /** + * Download the image, resize it to TikTok's spec, and host the copy on the + * public disk so TikTok can pull it. + * + * @return array{0: string, 1: string} the derivative's public URL and its + * storage path (for later cleanup) + */ + private function renderCompliantPhoto(MediaItem $image): array + { + $tempInput = tempnam(sys_get_temp_dir(), 'tiktok_photo_'); + + try { + $download = Http::sink($tempInput)->timeout(120)->get($image->url); + + if ($download->failed()) { + throw new TikTokPublishException( + userMessage: 'Failed to download image for TikTok resizing', + category: ErrorCategory::ServerError, + ); + } + + $optimized = app(MediaOptimizer::class)->optimizeImage($tempInput, Platform::TikTok); + + try { + $path = self::PHOTO_DERIVATIVE_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + Storage::put($path, file_get_contents($optimized)); + } finally { + @unlink($optimized); + } + + return [Storage::url($path), $path]; + } finally { + @unlink($tempInput); } - - $data = $response->json(); - - $publishId = data_get($data, 'data.publish_id'); - - if (! $publishId) { - throw new TikTokPublishException( - userMessage: 'TikTok did not return a publish_id', - category: ErrorCategory::ServerError, - ); - } - - // Wait for processing and get final status - $statusData = $this->waitForPublishStatus($publishId); - $postId = data_get($statusData, 'publicaly_available_post_id.0'); - - return [ - 'id' => $postId ?? $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), - ]; } private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): array diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 96016fa0..16d448d6 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -11,8 +11,10 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\MediaOptimizer; use App\Services\Social\TikTokPublisher; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; beforeEach(function () { $this->user = User::factory()->create(); @@ -92,6 +94,7 @@ 'url' => 'https://example.com/media/2026-01/image1.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'image1.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], ], ], ]); @@ -487,6 +490,7 @@ 'url' => 'https://example.com/media/2026-01/photo.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'photo.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], ], ], ]); @@ -691,3 +695,153 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(TikTokPublishException::class); }); + +test('tiktok publisher resizes an oversized photo and pulls a hosted compliant copy', function () { + Storage::fake(); + + // TikTok rejects images wider than 1080px; this one is 1254px wide. + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_resize_123'], + ], 200), + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + '*' => Http::response('fake-image-content', 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // TikTok must be handed the hosted derivative, never the oversized original. + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + $photoUrl = data_get(json_decode($request->body(), true), 'source_info.photo_images.0'); + + return str_contains($photoUrl, 'social-tiktok-photos/') + && ! str_contains($photoUrl, 'example.com'); + }); + + // The derivative is pruned once TikTok has pulled it. + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher passes a compliant photo through without hosting a copy', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-compliant', + 'path' => 'media/2026-01/ok.jpg', + 'url' => 'https://example.com/media/2026-01/ok.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'ok.jpg', + 'meta' => ['width' => 1080, 'height' => 1920], + ], + ], + ]); + + Http::fake([ + 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_passthrough_123'], + ], 200), + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // The original URL is published unchanged and nothing is downloaded or hosted. + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + + return data_get(json_decode($request->body(), true), 'source_info.photo_images.0') + === 'https://example.com/media/2026-01/ok.jpg'; + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.com')); + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher resizes a photo when its dimensions are unknown', function () { + Storage::fake(); + + // No width/height metadata: fall back to the safe path and host a compliant copy. + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-unknown', + 'path' => 'media/2026-01/unknown.jpg', + 'url' => 'https://example.com/media/2026-01/unknown.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'unknown.jpg', + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_unknown_123'], + ], 200), + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + '*' => Http::response('fake-image-content', 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + + return str_contains( + (string) data_get(json_decode($request->body(), true), 'source_info.photo_images.0'), + 'social-tiktok-photos/' + ); + }); + + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +});