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:
parent
bfbcd642ea
commit
b0cc7ba12b
7 changed files with 102 additions and 106 deletions
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue