feat(previews): show full vertical media without cropping

Vertical previews (stories, reels, TikTok, Shorts) rendered media full-bleed,
so a correctly-sized 9:16 image was clipped by the phone mockup, which is
taller than 9:16.

A shared VerticalMediaCanvas now renders every vertical format consistently:
images show in full with a blurred-background extension filling the gaps (no
crop), while videos stay full-bleed like the real feed. This mirrors how the
platforms themselves display the content and matches the story blur applied at
publish time.

Also reduce fitToCanvas to two image decodes instead of three (reuse the probe
as the foreground), lowering peak memory on the story publish path.
This commit is contained in:
Paulo Castellano 2026-06-29 15:50:24 -03:00
parent bfbcd642ea
commit b0cc7ba12b
7 changed files with 102 additions and 106 deletions

View file

@ -156,14 +156,14 @@ public function cropToAspectRatio(string $filePath, float $ratio): string
*/
public function fitToCanvas(string $filePath, int $width, int $height): string
{
$probe = $this->manager->decodePath($filePath);
$foreground = $this->manager->decodePath($filePath);
$canvasRatio = $width / $height;
$imageRatio = $probe->width() / $probe->height();
$imageRatio = $foreground->width() / $foreground->height();
$tempFile = tempnam(sys_get_temp_dir(), 'media_fit_');
if (abs($imageRatio - $canvasRatio) < 0.01) {
$sized = $this->manager->decodePath($filePath)->scaleDown($width, $height);
$sized = $foreground->scaleDown($width, $height);
file_put_contents($tempFile, (string) $sized->encodeUsingMediaType('image/jpeg', quality: 100));
return $tempFile;
@ -174,8 +174,7 @@ public function fitToCanvas(string $filePath, int $width, int $height): string
->blur(40)
->brightness(-12);
$foreground = $this->manager->decodePath($filePath)->scaleDown($width, $height);
$canvas->insert($foreground, 0, 0, 'center');
$canvas->insert($foreground->scaleDown($width, $height), 0, 0, 'center');
file_put_contents($tempFile, (string) $canvas->encodeUsingMediaType('image/jpeg', quality: 100));

View file

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

View file

@ -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';
@ -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">
@ -229,18 +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"
:blur-background="true"
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">

View file

@ -15,9 +15,6 @@ interface Props {
dotInactiveClass?: string;
mediaClass?: string;
placeholderClass?: string;
// Fit images inside the frame with a blurred copy filling the gaps (matches
// the blurred-background extension applied to story images at publish time).
blurBackground?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@ -28,7 +25,6 @@ const props = withDefaults(defineProps<Props>(), {
dotInactiveClass: 'bg-white/50 hover:bg-white/70',
mediaClass: 'w-full h-full object-cover',
placeholderClass: 'w-full h-full flex items-center justify-center bg-muted',
blurBackground: false,
});
const currentIndex = ref(0);
@ -65,27 +61,12 @@ const goToSlide = (index: number) => {
:src="item.url"
:video-class="mediaClass"
/>
<template v-else-if="index === currentIndex">
<template v-if="blurBackground">
<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>
<img
v-else
:src="item.url"
:alt="item.original_filename"
:class="mediaClass"
/>
</template>
<img
v-else-if="index === currentIndex"
:src="item.url"
:alt="item.original_filename"
:class="mediaClass"
/>
</template>
<template v-if="hasMultiple && showArrows">

View file

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

View file

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

View file

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