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.
This commit is contained in:
Paulo Castellano 2026-06-26 12:23:56 -03:00
parent 98f13d38b5
commit c5fca040a9
4 changed files with 323 additions and 48 deletions

View file

@ -10,6 +10,7 @@
class MediaItem
{
/**
* @param array<string, mixed>|null $meta
* @param array<string, mixed>|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<string, mixed> $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,
);
}
}

View file

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

View file

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

View file

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