페이지 커버 썸네일 최적화
This commit is contained in:
@@ -4,10 +4,12 @@ import { createError } from 'h3'
|
||||
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
import {
|
||||
getPageCoverThumbnailUrl,
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
getPostBodyThumbnailUrl,
|
||||
getPostLightboxThumbnailUrl,
|
||||
getPostThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl,
|
||||
isPostThumbnailSource
|
||||
@@ -584,6 +586,17 @@ const buildPostThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
||||
isPostCardThumbnailUrl
|
||||
)
|
||||
|
||||
/**
|
||||
* 페이지 커버 썸네일 URL별 원본 URL 맵을 만든다.
|
||||
* @param {Array<Object>} items - 미디어 항목 목록
|
||||
* @returns {Map<string, string>} 페이지 커버 썸네일 URL별 원본 URL
|
||||
*/
|
||||
const buildPageCoverThumbnailOwnerMap = (items) => buildPostDerivativeOwnerMap(
|
||||
items,
|
||||
getPageCoverThumbnailUrl,
|
||||
isPageCoverThumbnailUrl
|
||||
)
|
||||
|
||||
/**
|
||||
* 본문 표시용 이미지 URL별 원본 URL 맵을 만든다.
|
||||
* @param {Array<Object>} items - 미디어 항목 목록
|
||||
@@ -641,6 +654,32 @@ const getPostThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, site
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 커버 썸네일의 간접 사용처를 조회한다.
|
||||
* @param {string} url - 페이지 커버 썸네일 URL
|
||||
* @param {Map<string, string>} ownerByThumbnailUrl - 썸네일 URL별 원본 URL
|
||||
* @param {Array<Object>} posts - 게시물 목록
|
||||
* @param {Array<Object>} pages - 페이지 목록
|
||||
* @param {Object} siteSettings - 사이트 설정
|
||||
* @returns {Array<Object>} 간접 사용처 목록
|
||||
*/
|
||||
const getPageCoverThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages, siteSettings) => {
|
||||
const originalUrl = ownerByThumbnailUrl.get(url)
|
||||
|
||||
if (!originalUrl) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getDirectMediaUsage(originalUrl, posts, pages, siteSettings)
|
||||
.filter((usage) => usage.type === 'page' && usage.location === 'featuredImage')
|
||||
.map((usage) => ({
|
||||
...usage,
|
||||
location: 'pageCoverThumbnail',
|
||||
label: '페이지 커버 썸네일',
|
||||
sourceUrl: originalUrl
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문 표시용 이미지의 간접 사용처를 조회한다.
|
||||
* @param {string} url - 본문 표시용 이미지 URL
|
||||
@@ -675,7 +714,7 @@ const getPostBodyThumbnailMediaUsage = (url, ownerByThumbnailUrl, posts, pages,
|
||||
* @returns {Object|null} 카드 썸네일 상태
|
||||
*/
|
||||
const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
if (isPostCardThumbnailUrl(item.url) || isPageCoverThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -686,16 +725,47 @@ const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
}
|
||||
|
||||
const hasThumbnail = itemUrlSet.has(thumbnailUrl)
|
||||
const isFeaturedImage = directUsage.some((usage) => usage.location === 'featuredImage')
|
||||
const isFeaturedImage = directUsage.some((usage) => usage.type === 'post' && usage.location === 'featuredImage')
|
||||
|
||||
return {
|
||||
role: 'original',
|
||||
variant: 'postCard',
|
||||
thumbnailUrl,
|
||||
hasThumbnail,
|
||||
isFallbackActive: isFeaturedImage && !hasThumbnail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 커버 원본 이미지의 썸네일 상태를 조회한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @param {Set<string>} itemUrlSet - 전체 미디어 URL 집합
|
||||
* @param {Array<Object>} directUsage - 원본 URL 직접 사용처
|
||||
* @returns {Object|null} 페이지 커버 썸네일 상태
|
||||
*/
|
||||
const getPageCoverThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
if (isPostCardThumbnailUrl(item.url) || isPageCoverThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const thumbnailUrl = getPageCoverThumbnailUrl(item.url)
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasThumbnail = itemUrlSet.has(thumbnailUrl)
|
||||
const isPageCoverImage = directUsage.some((usage) => usage.type === 'page' && usage.location === 'featuredImage')
|
||||
|
||||
return {
|
||||
role: 'original',
|
||||
variant: 'pageCover',
|
||||
thumbnailUrl,
|
||||
hasThumbnail,
|
||||
isFallbackActive: isPageCoverImage && !hasThumbnail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
@@ -704,7 +774,7 @@ const getPostThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
* @returns {Object|null} 본문 표시용 이미지 상태
|
||||
*/
|
||||
const getPostBodyThumbnailStatus = (item, itemUrlSet, directUsage) => {
|
||||
if (isPostCardThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
if (isPostCardThumbnailUrl(item.url) || isPageCoverThumbnailUrl(item.url) || isPostBodyThumbnailUrl(item.url) || !isPostThumbnailSource('', item.name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -809,6 +879,7 @@ export const listMediaItems = async () => {
|
||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
|
||||
const pageCoverThumbnailOwnerByUrl = buildPageCoverThumbnailOwnerMap(items)
|
||||
const bodyThumbnailOwnerByUrl = buildPostBodyThumbnailOwnerMap(items)
|
||||
const itemsWithUsage = items.map((item) => {
|
||||
const rawCategory = metadataMap[item.url]?.category ?? item.category
|
||||
@@ -816,8 +887,10 @@ export const listMediaItems = async () => {
|
||||
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
|
||||
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings, tags)
|
||||
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const pageCoverThumbnailUsage = getPageCoverThumbnailMediaUsage(item.url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
|
||||
const pageCoverOriginalUrl = pageCoverThumbnailOwnerByUrl.get(item.url) || ''
|
||||
const bodyOriginalUrl = bodyThumbnailOwnerByUrl.get(item.url) || ''
|
||||
|
||||
return {
|
||||
@@ -826,6 +899,7 @@ export const listMediaItems = async () => {
|
||||
usage: [
|
||||
...directUsage,
|
||||
...thumbnailUsage,
|
||||
...pageCoverThumbnailUsage,
|
||||
...bodyThumbnailUsage
|
||||
],
|
||||
thumbnailStatus: originalUrl
|
||||
@@ -833,7 +907,12 @@ export const listMediaItems = async () => {
|
||||
role: 'card',
|
||||
originalUrl
|
||||
}
|
||||
: getPostThumbnailStatus(item, itemUrlSet, directUsage),
|
||||
: pageCoverOriginalUrl
|
||||
? {
|
||||
role: 'pageCover',
|
||||
originalUrl: pageCoverOriginalUrl
|
||||
}
|
||||
: getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage),
|
||||
bodyThumbnailStatus: bodyOriginalUrl
|
||||
? {
|
||||
role: 'body',
|
||||
@@ -966,6 +1045,7 @@ export const deleteMediaItem = async (url) => {
|
||||
const usage = [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||
...getPageCoverThumbnailMediaUsage(url, buildPageCoverThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
||||
]
|
||||
|
||||
@@ -994,10 +1074,10 @@ export const deleteMediaItem = async (url) => {
|
||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||
*/
|
||||
export const renameMediaItem = async (url, name) => {
|
||||
if (isPostCardThumbnailUrl(url) || isPostBodyThumbnailUrl(url)) {
|
||||
if (isPostCardThumbnailUrl(url) || isPageCoverThumbnailUrl(url) || isPostBodyThumbnailUrl(url)) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '게시물 자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
|
||||
message: '자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1011,6 +1091,7 @@ export const renameMediaItem = async (url, name) => {
|
||||
const usage = [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||
...getPageCoverThumbnailMediaUsage(url, buildPageCoverThumbnailOwnerMap(items), posts, pages, siteSettings),
|
||||
...getPostBodyThumbnailMediaUsage(url, buildPostBodyThumbnailOwnerMap(items), posts, pages, siteSettings)
|
||||
]
|
||||
|
||||
|
||||
@@ -3,17 +3,21 @@ import { mkdir, stat } from 'node:fs/promises'
|
||||
import { dirname, extname, join, posix } from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import {
|
||||
getPageCoverThumbnailUrl,
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
getPostBodyThumbnailUrl,
|
||||
getPostLightboxThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl
|
||||
} from '../../lib/post-card-thumbnail-url.js'
|
||||
|
||||
export {
|
||||
getPageCoverThumbnailUrl,
|
||||
getPostBodyPreviewThumbnailUrl,
|
||||
getPostBodyThumbnailUrl,
|
||||
getPostLightboxThumbnailUrl,
|
||||
isPageCoverThumbnailUrl,
|
||||
isPostBodyThumbnailUrl,
|
||||
isPostCardThumbnailUrl
|
||||
}
|
||||
@@ -25,6 +29,9 @@ export const POST_BODY_IMAGE_WIDTH = 1600
|
||||
export const POST_BODY_PREVIEW_IMAGE_WIDTH = 720
|
||||
export const POST_LIGHTBOX_IMAGE_WIDTH = 2560
|
||||
export const POST_BODY_IMAGE_QUALITY = 84
|
||||
export const PAGE_COVER_THUMBNAIL_WIDTH = 450
|
||||
export const PAGE_COVER_THUMBNAIL_HEIGHT = 600
|
||||
export const PAGE_COVER_THUMBNAIL_QUALITY = 84
|
||||
|
||||
const postUploadUrlPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/[^/?#]+$/i
|
||||
const postUploadImageUrlPattern = /\/uploads\/posts\/\d{4}\/\d{2}\/[^/\s"'()<>\]]+\.(?:jpe?g|png|webp)/gi
|
||||
@@ -154,6 +161,21 @@ export const getPostLightboxThumbnailDiskPath = (imageUrl) => {
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 커버 썸네일 URL의 디스크 경로를 조회한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {string} 페이지 커버 썸네일 디스크 경로
|
||||
*/
|
||||
export const getPageCoverThumbnailDiskPath = (imageUrl) => {
|
||||
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return join(process.cwd(), 'public', decodeUrlPathPart(thumbnailUrl.replace(/^\/+/, '')))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 원본 이미지 URL의 디스크 경로를 조회한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
@@ -245,6 +267,21 @@ export const getExistingPostLightboxThumbnailUrl = (imageUrl) => {
|
||||
return existsSync(getPostLightboxThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 생성된 페이지 커버 썸네일 URL을 조회한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {string} 존재하는 페이지 커버 썸네일 URL
|
||||
*/
|
||||
export const getExistingPageCoverThumbnailUrl = (imageUrl) => {
|
||||
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
|
||||
|
||||
if (!thumbnailUrl) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return existsSync(getPageCoverThumbnailDiskPath(imageUrl)) ? thumbnailUrl : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 대표 이미지의 카드 썸네일을 생성하거나 다시 생성한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
@@ -282,6 +319,43 @@ export const createPostThumbnailForImageUrl = async (imageUrl) => {
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 홈 진열장용 3:4 커버 썸네일을 생성하거나 다시 생성한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
* @returns {Promise<string>} 생성된 페이지 커버 썸네일 URL
|
||||
*/
|
||||
export const createPageCoverThumbnailForImageUrl = async (imageUrl) => {
|
||||
const sourcePath = getPostImageDiskPath(imageUrl)
|
||||
const thumbnailUrl = getPageCoverThumbnailUrl(imageUrl)
|
||||
const thumbnailPath = getPageCoverThumbnailDiskPath(imageUrl)
|
||||
|
||||
if (!sourcePath || !thumbnailUrl || !thumbnailPath) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(sourcePath)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
await mkdir(dirname(thumbnailPath), { recursive: true })
|
||||
|
||||
await sharp(sourcePath)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: PAGE_COVER_THUMBNAIL_WIDTH,
|
||||
height: PAGE_COVER_THUMBNAIL_HEIGHT,
|
||||
fit: 'cover',
|
||||
position: 'centre',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.webp({ quality: PAGE_COVER_THUMBNAIL_QUALITY })
|
||||
.toFile(thumbnailPath)
|
||||
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 본문 이미지의 표시용 WebP 이미지를 생성하거나 다시 생성한다.
|
||||
* @param {string|null|undefined} imageUrl - 원본 이미지 URL
|
||||
|
||||
Reference in New Issue
Block a user