Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/Services/Media/MediaOptimizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,39 @@ public function cropToAspectRatio(string $filePath, float $ratio): string
return $tempFile;
}

/**
* Fit an image inside a width×height canvas without cropping: the image is
* scaled to fit and centered, and the empty space is filled with a blurred,
* slightly darkened copy of the image. When the image already matches the
* canvas ratio it's just scaled down (no background). Returns a temp file.
*/
public function fitToCanvas(string $filePath, int $width, int $height): string
{
$foreground = $this->manager->decodePath($filePath);
$canvasRatio = $width / $height;
$imageRatio = $foreground->width() / $foreground->height();

$tempFile = tempnam(sys_get_temp_dir(), 'media_fit_');

if (abs($imageRatio - $canvasRatio) < 0.01) {
$sized = $foreground->scaleDown($width, $height);
file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: 100));

return $tempFile;
}

$canvas = $this->manager->decodePath($filePath)
->cover($width, $height)
->blur(40)
->brightness(-12);

$canvas->insert($foreground->scaleDown($width, $height), 0, 0, 'center');

file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: 100));

return $tempFile;
}

/**
* @return array{max_width: int, max_size: int, format: string, quality: int}
*/
Expand Down
29 changes: 29 additions & 0 deletions app/Services/Social/Concerns/CropsImageForAspectRatio.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,35 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati
}
}

/**
* Fit the image inside a width×height canvas with a blurred-background
* extension (no cropping), host it, and return a public URL. Used for
* stories so an off-ratio image isn't clipped by the platform.
*/
protected function fitImageToCanvas(string $imageUrl, int $width, int $height): string
{
$tempInput = tempnam(sys_get_temp_dir(), 'fit_in_');

try {
$download = Http::sink($tempInput)->timeout(120)->get($imageUrl);

if ($download->failed()) {
throw $this->cropFailureException('Failed to download image for story fitting');
}

$fitted = app(MediaOptimizer::class)->fitToCanvas($tempInput, $width, $height);

$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($fitted));

@unlink($fitted);

return Storage::url($path);
} finally {
@unlink($tempInput);
}
}

protected function aspectRatioToFloat(string $ratio): float
{
return AspectRatio::tryFrom($ratio)?->toFloat() ?? 1.0;
Expand Down
3 changes: 2 additions & 1 deletion app/Services/Social/InstagramPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ private function publishStory(string $instagramId, string $accessToken, $media):
if ($isVideo) {
$params['video_url'] = $media->url;
} else {
$params['image_url'] = $media->url;
$dimensions = ContentType::InstagramStory->aiImageDimensions();
$params['image_url'] = $this->fitImageToCanvas($media->url, $dimensions['width'], $dimensions['height']);
}

// Step 1: Create story container
Expand Down
37 changes: 17 additions & 20 deletions resources/js/components/posts/previews/FacebookPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IconDots, IconPhoto } from '@tabler/icons-vue';
import { computed } from 'vue';

import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
import type { MediaItem } from '@/types/media';

interface SocialAccount {
Expand Down Expand Up @@ -214,17 +215,15 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc

<!-- ==================== REELS ==================== -->
<template v-else-if="isReel">
<div class="relative flex-1 bg-black overflow-hidden">
<div class="relative flex-1 overflow-hidden">
<!-- Video/Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center bg-[#18191a]"
/>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center bg-[#18191a]">
<IconPhoto class="h-12 w-12 text-muted-foreground/40" />
</div>
</template>
</VerticalMediaCanvas>

<!-- Gradient overlay -->
<div v-if="media.length > 0"
Expand Down Expand Up @@ -353,17 +352,15 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc

<!-- ==================== STORIES ==================== -->
<template v-else-if="isStory">
<div class="relative flex-1 bg-black overflow-hidden">
<div class="relative flex-1 overflow-hidden">
<!-- Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center bg-gradient-to-b from-[#1877f2]/50 to-[#833ab4]/50"
/>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center bg-gradient-to-b from-[#1877f2]/50 to-[#833ab4]/50">
<IconPhoto class="h-12 w-12 text-muted-foreground/40" />
</div>
</template>
</VerticalMediaCanvas>

<!-- Progress Bars -->
<div v-if="media.length > 0" class="absolute top-1 left-2 right-2 flex gap-0.5 z-10">
Expand Down
37 changes: 17 additions & 20 deletions resources/js/components/posts/previews/InstagramPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { computed } from 'vue';

import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';

Expand Down Expand Up @@ -163,17 +164,15 @@ const username = computed(() => props.socialAccount.username || props.socialAcco

<!-- ==================== REELS ==================== -->
<template v-else-if="isReel">
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
<div class="relative flex-1 overflow-hidden">
<!-- Video/Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPlayerPlayFilled"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center"
/>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center">
<IconPlayerPlayFilled class="h-12 w-12 text-muted-foreground/40" />
</div>
</template>
</VerticalMediaCanvas>

<!-- Top Bar - below status bar -->
<div class="absolute top-1 left-0 right-0 px-3 flex items-center justify-between z-10">
Expand Down Expand Up @@ -229,17 +228,15 @@ const username = computed(() => props.socialAccount.username || props.socialAcco

<!-- ==================== STORIES ==================== -->
<template v-else-if="isStory">
<div class="relative flex-1 bg-[#fafafa] dark:bg-black overflow-hidden">
<div class="relative flex-1 overflow-hidden">
<!-- Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center"
/>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center">
<IconPhoto class="h-12 w-12 text-muted-foreground/40" />
</div>
</template>
</VerticalMediaCanvas>

<!-- Progress Bars - below status bar -->
<div class="absolute top-0.5 left-2 right-2 flex gap-0.5 z-10">
Expand Down
28 changes: 11 additions & 17 deletions resources/js/components/posts/previews/TikTokPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import { IconPlus } from '@tabler/icons-vue';
import { computed } from 'vue';

import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia } from '@/composables/useMedia';
import VerticalMediaCanvas from "@/components/posts/previews/VerticalMediaCanvas.vue";
import type { MediaItem } from '@/types/media';

interface SocialAccount {
Expand Down Expand Up @@ -39,21 +38,16 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<template>
<div class="w-full h-full bg-black text-white overflow-hidden flex flex-col relative">
<!-- Video/Media Area - Full screen -->
<div class="absolute inset-0">
<!-- Video content -->
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
<VideoPreview :src="media[0].url" />
</div>
<div v-else-if="media.length > 0" class="w-full h-full">
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
</div>
<div v-else class="w-full h-full flex items-center justify-center bg-[#161823]">
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
</div>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center bg-[#161823]">
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
</div>
</template>
</VerticalMediaCanvas>

<!-- Top Header (only when media exists) -->
<div v-if="media.length > 0"
Expand Down
35 changes: 35 additions & 0 deletions resources/js/components/posts/previews/VerticalMediaCanvas.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { IconPhoto } from '@tabler/icons-vue';
import { computed } from 'vue';

import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';

const props = defineProps<{
media: MediaItem[];
}>();

const item = computed<MediaItem | null>(() => props.media[0] ?? null);
</script>

<template>
<div class="absolute inset-0 overflow-hidden bg-black">
<template v-if="item">
<VideoPreview v-if="isVideoMedia(item)" :src="item.url" video-class="h-full w-full object-cover" />
<template v-else>
<img :src="item.url" alt="" aria-hidden="true"
class="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl brightness-90" />
<img :src="item.url" :alt="item.original_filename"
class="absolute inset-0 h-full w-full object-contain" />
</template>
</template>
<template v-else>
<slot name="placeholder">
<div class="flex h-full w-full items-center justify-center">
<IconPhoto class="h-12 w-12 text-muted-foreground/40" />
</div>
</slot>
</template>
</div>
</template>
30 changes: 12 additions & 18 deletions resources/js/components/posts/previews/YouTubePreview.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';

import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia } from '@/composables/useMedia';
import VerticalMediaCanvas from "@/components/posts/previews/VerticalMediaCanvas.vue";
import type { MediaItem } from '@/types/media';

interface SocialAccount {
Expand Down Expand Up @@ -38,22 +37,17 @@ const username = computed(() => props.socialAccount.username || props.socialAcco
<template>
<div class="w-full h-full bg-[#0f0f0f] text-white overflow-hidden flex flex-col relative">
<!-- Video/Media Area - Full screen -->
<div class="absolute inset-0">
<!-- Video content -->
<div v-if="media.length > 0 && isVideoMedia(media[0])" class="w-full h-full">
<VideoPreview :src="media[0].url" />
</div>
<div v-else-if="media.length > 0" class="w-full h-full">
<img :src="media[0].url" :alt="media[0].original_filename" class="w-full h-full object-cover" />
</div>
<div v-else class="w-full h-full flex items-center justify-center bg-[#0f0f0f]">
<!-- YouTube Shorts icon -->
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M10 14.65v-5.3L15 12l-5 2.65zm7.77-4.33c-.77-.32-1.2-.5-1.2-.5L18 9.06c1.84-.96 2.53-3.23 1.56-5.06s-3.24-2.53-5.07-1.56L6 6.94c-1.29.68-2.07 2.04-2 3.49.07 1.42.93 2.67 2.22 3.25.03.01 1.2.5 1.2.5L6 14.93c-1.83.97-2.53 3.24-1.56 5.07.97 1.83 3.24 2.53 5.07 1.56l8.5-4.5c1.29-.68 2.06-2.04 1.99-3.49-.07-1.42-.94-2.68-2.23-3.25z" />
</svg>
</div>
</div>
<VerticalMediaCanvas :media="media">
<template #placeholder>
<div class="flex h-full w-full items-center justify-center bg-[#0f0f0f]">
<!-- YouTube Shorts icon -->
<svg class="h-12 w-12 text-white/20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M10 14.65v-5.3L15 12l-5 2.65zm7.77-4.33c-.77-.32-1.2-.5-1.2-.5L18 9.06c1.84-.96 2.53-3.23 1.56-5.06s-3.24-2.53-5.07-1.56L6 6.94c-1.29.68-2.07 2.04-2 3.49.07 1.42.93 2.67 2.22 3.25.03.01 1.2.5 1.2.5L6 14.93c-1.83.97-2.53 3.24-1.56 5.07.97 1.83 3.24 2.53 5.07 1.56l8.5-4.5c1.29-.68 2.06-2.04 1.99-3.49-.07-1.42-.94-2.68-2.23-3.25z" />
</svg>
</div>
</template>
</VerticalMediaCanvas>

<!-- Top Header -->
<div v-if="media.length > 0"
Expand Down
4 changes: 2 additions & 2 deletions resources/js/composables/useMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const getMediaValidationWarning = (
};
}

if (width > 0 && height > 0) {
if (width > 0 && height > 0 && ! (rules.autoFitsImage && isImage(m))) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) {
return {
Expand Down Expand Up @@ -163,7 +163,7 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string

const width = item.meta?.width ?? 0;
const height = item.meta?.height ?? 0;
if (width > 0 && height > 0) {
if (width > 0 && height > 0 && ! (rules.autoFitsImage && ! itemIsVideo)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return 'aspect_ratio_too_narrow';
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return 'aspect_ratio_too_wide';
Expand Down
5 changes: 4 additions & 1 deletion resources/js/composables/useMediaRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export interface MediaRules {
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
// Images off the target ratio are auto-fitted with a blurred background at
// publish time, so the aspect-ratio warning is suppressed for images.
autoFitsImage?: boolean;
}

const MB = 1024 * 1024;
Expand All @@ -40,7 +43,7 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true,
},

// Facebook
Expand Down
Loading
Loading