Files
sori.studio/components/admin/AdminMediaVideoThumbnail.vue
zenn 10c5a099fc v1.4.3: 관리자 UI·홈·미디어 개선
- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬
- 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정
- 미디어 종류·미사용 필터, 비디오 프레임 썸네일
- NAS 운영 업데이트 절차 문서 추가
2026-05-21 18:30:50 +09:00

108 lines
2.5 KiB
Vue

<script setup>
const props = defineProps({
/** 비디오 파일 URL */
src: {
type: String,
required: true
},
/** 접근성 대체 텍스트 */
alt: {
type: String,
default: '비디오 썸네일'
}
})
const videoRef = ref(null)
const canvasRef = ref(null)
const thumbnailUrl = ref('')
const failed = ref(false)
/**
* 비디오 프레임을 캔버스 이미지로 변환한다.
* @returns {void}
*/
const captureVideoFrame = () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas || !video.videoWidth || !video.videoHeight) {
failed.value = true
return
}
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
if (!context) {
failed.value = true
return
}
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height)
thumbnailUrl.value = canvas.toDataURL('image/jpeg', 0.78)
} catch {
failed.value = true
}
}
/**
* 메타데이터 로드 후 초반 프레임으로 이동한다.
* @returns {void}
*/
const seekPreviewFrame = () => {
const video = videoRef.value
if (!video) {
failed.value = true
return
}
const duration = Number.isFinite(video.duration) ? video.duration : 0
const targetTime = duration > 1 ? Math.min(1, duration * 0.1) : 0
try {
video.currentTime = targetTime
} catch {
captureVideoFrame()
}
}
</script>
<template>
<span class="admin-media-video-thumbnail relative block aspect-square w-full overflow-hidden bg-surface">
<img
v-if="thumbnailUrl"
class="admin-media-video-thumbnail__image h-full w-full object-cover"
:src="thumbnailUrl"
:alt="alt"
loading="lazy"
>
<span
v-else
class="admin-media-video-thumbnail__fallback flex h-full w-full items-center justify-center text-xs font-bold uppercase tracking-[0.18em] text-muted"
>
video
</span>
<span class="admin-media-video-thumbnail__badge absolute bottom-1.5 left-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white">
video
</span>
<video
ref="videoRef"
class="hidden"
:src="src"
muted
playsinline
preload="metadata"
crossorigin="anonymous"
@loadedmetadata="seekPreviewFrame"
@seeked="captureVideoFrame"
@error="failed = true"
/>
<canvas ref="canvasRef" class="hidden" aria-hidden="true" />
<span v-if="failed && !thumbnailUrl" class="sr-only">
{{ alt }}
</span>
</span>
</template>