docs: add media format matrix and converter design spec

This commit is contained in:
Paulo Castellano 2026-04-23 14:59:05 -03:00
parent 5960def594
commit c08fe1ba3a

View file

@ -0,0 +1,245 @@
# Media Format Matrix for Social Networks
**Date:** 2026-04-23
**Status:** Draft — needs verification of cells marked `?`
Reference map of accepted media formats per platform/variant. Drives the `MediaConverter` service design.
## Images
| Platform / Variant | JPEG | PNG | WebP | GIF | HEIC | Max size | Max dimensions | Aspect ratio | Notes |
|-------------------|------|-----|------|-----|------|----------|----------------|--------------|-------|
| **Instagram Feed** | ✅ | ❌ | ❌ | ❌ | ❌ | ~8 MB | 1920 × 1080 | 4:5 to 1.91:1 | JPEG **only** per official docs. PNG returns error 2207052/2207005. |
| **Instagram Reel** | ❌ | ❌ | ❌ | ❌ | ❌ | — | — | — | **Reel = video only** (media_type REELS requires video_url) |
| **Instagram Story** | ✅ | ❌ | ❌ | ❌ | ❌ | ~8 MB | 1920 × 1080 | 9:16 preferred | JPEG only. Same rules as Feed for image stories. |
| **Facebook Post** | ✅ | ✅ | ? | ? | ❌ | 4 MB | ? | wide range | Postiz maps error 1366046 for size/format violations |
| **Facebook Reel** | ❌ | ❌ | ❌ | ❌ | ❌ | — | — | — | Video only |
| **Facebook Story** | ✅ | ✅ | ? | ? | ❌ | 4 MB | ? | 9:16 preferred | Same as Post format-wise |
| **LinkedIn Post** | ✅ | ✅ | ? | ❌ | ❌ | 5 MB | 6000 × 6000 | wide range | JPEG/PNG safest |
| **LinkedIn Carousel** | ✅ | ✅ | ? | ❌ | ❌ | 5 MB each | 6000 × 6000 | 1:1 to 1:1.91 | Up to 20 images |
| **TikTok Video** | ❌ | ❌ | ❌ | ❌ | ❌ | — | — | — | Video only via this content_type |
| **TikTok Photo** (`auto_add_music`) | ✅ | ✅ | ? | ❌ | ❌ | ? | ? | ? | Postiz has `convertToJPEG: true` |
| **YouTube Shorts** | ❌ | ❌ | ❌ | ❌ | ❌ | — | — | — | Video only |
| **X Post** | ✅ | ✅ | ✅ | ✅ | ❌ | 5 MB | 8192 × 8192 | ≥ 4:1 to ≥ 1:3 | Up to 4 images or 1 video |
| **Threads Post** | ✅ | ✅ | ✅ | ❌ | ❌ | 8 MB | ? | 10:16.5 max | Same as Instagram stack |
| **Pinterest Pin** | ✅ | ✅ | ? | ❌ | ❌ | 20 MB | ? | 2:3 preferred | |
| **Pinterest Carousel** | ✅ | ✅ | ? | ❌ | ❌ | 20 MB | ? | 2:3 preferred | 2–5 images |
| **Bluesky Post** | ✅ | ✅ | ✅ | ✅ | ❌ | **976 KB** | ? | flexible | Postiz auto-resizes to fit 976 KB |
| **Mastodon Post** | ✅ | ✅ | ✅ | ✅ | ❌ | 10 MB (instance-dep) | ? | flexible | Varies by instance |
## Videos
| Platform / Variant | MP4 | MOV | WebM | Max size | Max duration | Min duration | Aspect ratio | Notes |
|-------------------|-----|-----|------|----------|--------------|--------------|--------------|-------|
| **Instagram Feed (video)** | ✅ | ✅ | ❌ | 100 MB | 60 min | 3s | 4:5 to 1.91:1 | Becomes VIDEO or REELS based on aspect |
| **Instagram Reel** | ✅ | ✅ | ❌ | 1 GB | 15 min | 3s | 9:16 preferred | H.264, AAC audio |
| **Instagram Story** | ✅ | ✅ | ❌ | 100 MB | 60s | 3s | 9:16 | |
| **Facebook Post (video)** | ✅ | ✅ | ? | 10 GB | 240 min | — | flexible | Very permissive |
| **Facebook Reel** | ✅ | ✅ | ? | 1 GB | 90s | 3s | 9:16 | |
| **Facebook Story** | ✅ | ✅ | ? | ? | 60s | — | 9:16 | |
| **LinkedIn Post** | ✅ | ? | ? | 5 GB | 10 min | 3s | flexible | |
| **TikTok Video** | ✅ | ✅ | ✅ | max from `creator_info` | max from `creator_info` | 3s | 9:16 preferred | Duration fetched from API per account |
| **YouTube Shorts** | ✅ | ✅ | ✅ | 256 GB | 60s | — | 9:16 | Shorts detection is automatic on vertical vídeo ≤ 60s |
| **X Post (video)** | ✅ | ✅ | ❌ | 512 MB | 140s (std) / 2h 20m (Premium) | 0.5s | wide range | Postiz caps at 2min |
| **Threads Post (video)** | ✅ | ✅ | ❌ | 1 GB | 5 min | — | flexible | |
| **Pinterest Video Pin** | ✅ | ✅ | ❌ | 2 GB | 15 min | 4s | flexible | |
| **Bluesky Post (video)** | ✅ | ✅ | ❌ | 100 MB | 60s | — | flexible | Upload via dedicated endpoint |
| **Mastodon Post (video)** | ✅ | ? | ✅ | 40 MB (instance-dep) | instance-dep | — | flexible | |
## Per-platform "from-to" image normalization
**Philosophy:** every media item passes through the converter before publishing. No env flag. Converter is a no-op when source already matches the platform's accepted set.
| Platform / Variant | Accepted natively (no-op) | Everything else converts to | Also enforces |
|-------------------|---------------------------|-----------------------------|---------------|
| Instagram Feed / Story | JPEG | **JPEG** (q90) | ≤ 1920×1080, aspect 4:5..1.91:1 |
| Instagram Reel | — (video only) | — | — |
| Facebook Post / Story | JPEG | **JPEG** (q90) | ≤ 4 MB |
| Facebook Reel | — (video only) | — | — |
| TikTok Photo mode | JPEG | **JPEG** (q90) | — |
| LinkedIn Post / Carousel | JPEG, PNG | **JPEG** (q90) from WebP/HEIC/GIF | ≤ 5 MB, ≤ 6000×6000 |
| X Post | JPEG, PNG, WebP, GIF | **JPEG** (q90) from HEIC | ≤ 5 MB |
| Threads Post | JPEG, PNG, WebP | **JPEG** (q90) from HEIC/GIF | ≤ 8 MB |
| Pinterest Pin / Carousel | JPEG, PNG | **JPEG** (q90) from WebP/HEIC/GIF | ≤ 20 MB |
| Bluesky Post | JPEG, PNG, WebP, GIF | **JPEG** from HEIC; **iterative resize** if > 976 KB | ≤ 976 KB |
| Mastodon Post | JPEG, PNG, WebP, GIF | **JPEG** (q90) from HEIC | instance-dep |
**Derived rules:**
- **HEIC → always JPEG** (nenhuma plataforma aceita HEIC)
- **WebP → JPEG** para Meta (IG/FB), TikTok Photo, LinkedIn, Pinterest; nativo em X/Threads/Bluesky/Mastodon
- **PNG → JPEG** para Meta + TikTok Photo; nativo no resto
- **GIF → JPEG** (primeiro frame) para Meta + LinkedIn + Pinterest + Threads; nativo em X/Bluesky/Mastodon
- **Animated GIF / Animated WebP**: perde animação quando convertido (limitação v1, flag futuro)
## Video normalization (v1)
**Fora do escopo do converter.** Vídeos passam direto pelo publisher — plataformas validam server-side e a gente surfa erro pro usuário. Transcoding (FFmpeg, codec fix, re-mux) fica pra v2.
## Converter service design
### Service signature
```php
namespace App\Services\Media;
use App\Enums\PostPlatform\ContentType;
use App\Models\Media;
class MediaConverter
{
/**
* Ensure a media item is compatible with the given content_type.
* Returns a Media (original or a newly-created converted copy).
* Idempotent: if the original already complies, returns it as-is.
* Cached: repeated calls for the same (media, content_type) reuse the previously-converted file.
*/
public function ensureCompatible(Media $media, ContentType $contentType): Media;
}
```
### Rules catalog
A single class centralizes the "from-to" table above:
```php
final class PlatformMediaRules
{
public function __construct(
public readonly array $acceptedImageMimes, // ['image/jpeg']
public readonly string $targetImageMime, // 'image/jpeg'
public readonly int $targetJpegQuality, // 90
public readonly ?int $maxImageWidth, // 1920
public readonly ?int $maxImageHeight, // 1080
public readonly ?int $maxImageBytes, // 4_000_000
) {}
public static function for(ContentType $ct): self { /* match per variant */ }
}
```
### When it's called
Each publisher invokes once, at the top of `publish()`:
```php
public function publish(PostPlatform $pp): array
{
$media = $pp->post->mediaItems->map(
fn ($m) => $this->converter->ensureCompatible($m, $pp->content_type),
);
// continue with normalized $media
}
```
Same pattern in every publisher: Instagram, Facebook, TikTok, LinkedIn, X, Threads, Pinterest, Bluesky, Mastodon, YouTube.
### Pipeline (image)
```
1. Fetch original file from storage
2. Detect real MIME via finfo (not trusting extension)
3. If MIME ∈ acceptedImageMimes AND bytes ≤ maxImageBytes AND dims ≤ max → return original Media (no-op)
4. Else:
a. Load into Intervention\Image
b. Resize if > maxImageWidth/Height (keep aspect)
c. For Bluesky: loop shrinking 10% until bytes ≤ 976 KB (Postiz precedent)
d. Encode to targetImageMime at targetJpegQuality
e. Upload to storage: `converted/{media_id}/{file_hash}/{content_type_value}.jpg`
f. Create Media row pointing at converted file (or return cached row if exists)
g. Return the new Media
```
### Caching / idempotency
Path strategy: `converted/{original_media_id}/{original_file_sha256_prefix}/{content_type_value}.{ext}`
- If original file changes → sha256 differs → new path → fresh conversion
- If same original + same target → path exists → reuse
- DB table `media_conversions` (migration) tracks `(source_media_id, content_type, derived_media_id)` for fast lookup without listing storage
### Failure handling
- Conversion happens inside the publish job (already async/queued).
- On failure, publisher catches and marks `post_platform.status = failed` with a clear error ("Could not convert image for Instagram: PNG to JPEG conversion failed"). Same path as any other publish failure.
- Original media is never mutated or deleted. Converted derivatives are never mutated either.
## Dependencies
- **`intervention/image` v3.x** (composer install)
- PHP GD or Imagick extension (already required by Laravel's `Illuminate/Http/UploadedFile` image validation — safe assumption)
- No new queue, no config changes
- **New migration:** `media_conversions` table
## Decisions (confirmed with user)
1. **Bluesky iterative resize:** replicate Postiz — shrink 10% per iteration until ≤ 976 KB. Done **in memory at publish time**, not stored on CDN (Bluesky API accepts bytes directly).
2. **Converted file storage:** **replace the original in place** at upload time. No parallel `converted/` directory, no `media_conversions` table, no cleanup job. One file per Media, always JPEG.
3. **GIF policy:** accepted only on X, Bluesky, Mastodon. Frontend validation blocks submit when GIF is attached and a GIF-incompatible platform is selected. No silent first-frame conversion.
## Revised pipeline
### At upload time (asset controller)
```
1. Receive upload
2. Detect real MIME via finfo
3. If MIME is an image (any format):
a. Load into Intervention\Image
b. If dims > 1920×1080 → resize keeping aspect
c. Encode as JPEG q90
d. Store at `medias/{uuid}.jpg`
e. Save Media row with mime_type='image/jpeg'
4. GIFs: keep as GIF (for X/Bluesky/Mastodon). Don't convert — frontend will block incompatible platforms.
5. Videos: unchanged v1 (passthrough).
```
This means: by the time any post reaches a publisher, the CDN already has a JPEG (or a GIF, for the 3 platforms that accept it).
### At publish time (per publisher)
- **Instagram, Facebook, TikTok Photo, LinkedIn, Threads, Pinterest, YouTube thumbnail, Mastodon:** already have compatible image, no converter call needed.
- **X:** already has compatible image (native GIF/PNG/WebP also work; we normalize to JPEG but X accepts both).
- **Bluesky (only):** if JPEG > 976 KB, run in-memory iterative resize (Postiz style) and upload bytes directly to Bluesky's blob endpoint. No CDN round-trip.
### Frontend validation additions
Update `useMediaRules.ts` to track per-`content_type` GIF acceptance:
| Content type | Accepts GIF? |
|--------------|--------------|
| `x_post` | ✅ |
| `bluesky_post` | ✅ |
| `mastodon_post` | ✅ |
| everything else | ❌ |
Extend `isMediaValidForContentType()` in `Edit.vue` (and per-platform compliance computeds) to reject when media contains a GIF and the current `content_type` doesn't accept it.
## Updated dependencies
- **`intervention/image` v3.x** (composer install)
- No new migrations
- No new tables
- No cleanup job
## Cells still needing verification (`?` in tables)
- Facebook Post/Story image max dimensions + WebP support
- LinkedIn Post/Carousel WebP support
- TikTok Photo mode max size/dimensions
- Pinterest WebP support + max dimensions
- Threads image max dimensions
- Mastodon instance-dependent limits
These gaps don't block — normalizing to JPEG q90 ≤ 1920×1080 ≤ ~4 MB covers all of them comfortably.
## Cells still needing verification (`?` in tables)
- Facebook Post/Story image max dimensions + WebP support
- LinkedIn Post/Carousel WebP support
- TikTok Photo mode max size/dimensions
- Pinterest WebP support + max dimensions
- Threads image max dimensions
- Mastodon instance-dependent limits
These gaps don't block the design — falling back to "convert to JPEG" covers all of them safely.