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(); +});