v1.4.3: 관리자 UI·홈·미디어 개선
- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬 - 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정 - 미디어 종류·미사용 필터, 비디오 프레임 썸네일 - NAS 운영 업데이트 절차 문서 추가
This commit is contained in:
107
components/admin/AdminMediaVideoThumbnail.vue
Normal file
107
components/admin/AdminMediaVideoThumbnail.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user