From c08fe1ba3a82ad76f3ac72df22fcda431d46f675 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 23 Apr 2026 14:59:05 -0300 Subject: [PATCH] docs: add media format matrix and converter design spec --- .../2026-04-23-media-format-matrix-design.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-23-media-format-matrix-design.md diff --git a/docs/superpowers/specs/2026-04-23-media-format-matrix-design.md b/docs/superpowers/specs/2026-04-23-media-format-matrix-design.md new file mode 100644 index 00000000..060c63ac --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-media-format-matrix-design.md @@ -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.