- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬 - 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정 - 미디어 종류·미사용 필터, 비디오 프레임 썸네일 - NAS 운영 업데이트 절차 문서 추가
108 lines
2.5 KiB
Vue
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>
|