316 lines
8.8 KiB
Vue
316 lines
8.8 KiB
Vue
<!-- Author: fruit.com.cn -->
|
|
<!-- 剧集转码逐帧、雪碧图与分页预览 -->
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import type {
|
|
MediaThumbnailFrameItem,
|
|
MediaThumbnailResult,
|
|
MediaThumbnailSpriteInfo
|
|
} from '@/api/admin/types'
|
|
import { useI18n } from '@/hooks/web/useI18n'
|
|
import { formatTimeMs, THUMBNAIL_PAGE_SIZES } from './episodeTranscodeUi'
|
|
|
|
interface FramesView {
|
|
data: MediaThumbnailResult | null
|
|
loading: boolean
|
|
errorMessage: string
|
|
pageIndex: number
|
|
pageSize: number
|
|
}
|
|
|
|
const props = defineProps<{
|
|
view: FramesView
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
pageChange: [value: number]
|
|
pageSizeChange: [value: number]
|
|
retry: []
|
|
}>()
|
|
|
|
const FRAME_CARD_WIDTH = 128
|
|
const PREVIEW_MAX_WIDTH = 520
|
|
const PREVIEW_MAX_HEIGHT = 360
|
|
|
|
const { t } = useI18n()
|
|
const frameView = ref<'frames' | 'sprites'>('frames')
|
|
const previewVisible = ref(false)
|
|
const previewFrame = ref<MediaThumbnailFrameItem | null>(null)
|
|
const frames = computed(() => props.view.data?.frames?.list || [])
|
|
const sprite = computed(() => props.view.data?.sprite || null)
|
|
const spriteUrls = computed(() => sprite.value?.spriteUrls || [])
|
|
const framesTotal = computed(() => Number(props.view.data?.frames?.total || 0))
|
|
const previewStyle = computed(() => {
|
|
const frame = previewFrame.value
|
|
const info = sprite.value
|
|
if (!frame || !info) return {}
|
|
const scale = Math.min(
|
|
PREVIEW_MAX_WIDTH / Math.max(1, frame.width),
|
|
PREVIEW_MAX_HEIGHT / Math.max(1, frame.height)
|
|
)
|
|
return frameStyle(frame, info, scale)
|
|
})
|
|
|
|
function openFramePreview(frame: MediaThumbnailFrameItem) {
|
|
previewFrame.value = frame
|
|
previewVisible.value = true
|
|
}
|
|
|
|
function frameStyle(frame: MediaThumbnailFrameItem, info: MediaThumbnailSpriteInfo, scale: number) {
|
|
return {
|
|
width: `${Math.round(frame.width * scale)}px`,
|
|
height: `${Math.round(frame.height * scale)}px`,
|
|
backgroundImage: `url("${frame.spriteUrl || frame.imageUrl}")`,
|
|
backgroundPosition: `-${Math.round(frame.x * scale)}px -${Math.round(frame.y * scale)}px`,
|
|
backgroundSize: `${Math.round((info.columns || 1) * frame.width * scale)}px ${Math.round(
|
|
(info.rows || 1) * frame.height * scale
|
|
)}px`
|
|
}
|
|
}
|
|
|
|
function frameCardStyle(frame: MediaThumbnailFrameItem) {
|
|
if (!sprite.value) return {}
|
|
return frameStyle(frame, sprite.value, FRAME_CARD_WIDTH / Math.max(1, frame.width))
|
|
}
|
|
|
|
function spriteFrameCount(index: number) {
|
|
const info = sprite.value
|
|
if (!info) return 0
|
|
const capacity = Math.max(1, info.columns * info.rows)
|
|
return Math.max(0, Math.min(capacity, info.totalFrames - index * capacity))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="episode-transcode-frames__heading">
|
|
<h4>{{ t('episodeTranscode.frameTitle') }}</h4>
|
|
<div class="episode-transcode-frames__tools">
|
|
<span>{{ t('episodeTranscode.frameCount', { total: framesTotal }) }}</span>
|
|
<el-radio-group v-model="frameView" size="small">
|
|
<el-radio-button label="frames">{{ t('episodeTranscode.frameView') }}</el-radio-button>
|
|
<el-radio-button label="sprites">{{ t('episodeTranscode.spriteView') }}</el-radio-button>
|
|
</el-radio-group>
|
|
</div>
|
|
</div>
|
|
<el-alert
|
|
v-if="view.errorMessage"
|
|
type="error"
|
|
:title="view.errorMessage"
|
|
:closable="false"
|
|
show-icon
|
|
>
|
|
<template #default>
|
|
<el-button size="small" text @click="emit('retry')">
|
|
{{ t('episodeTranscode.retryLoad') }}
|
|
</el-button>
|
|
</template>
|
|
</el-alert>
|
|
<div v-loading="view.loading">
|
|
<template v-if="frameView === 'frames'">
|
|
<div v-if="frames.length" class="episode-transcode-frames__grid">
|
|
<button
|
|
v-for="frame in frames"
|
|
:key="frame.frameIndex"
|
|
class="episode-transcode-frames__card"
|
|
type="button"
|
|
:aria-label="t('episodeTranscode.previewFrame', { time: formatTimeMs(frame.timeMs) })"
|
|
@click="openFramePreview(frame)"
|
|
>
|
|
<span class="episode-transcode-frames__image" :style="frameCardStyle(frame)"></span>
|
|
<span>{{ formatTimeMs(frame.timeMs) }}</span>
|
|
</button>
|
|
</div>
|
|
<el-empty v-else :description="t('episodeTranscode.noFrames')" :image-size="58" />
|
|
<el-pagination
|
|
v-if="framesTotal"
|
|
class="episode-transcode-frames__pagination"
|
|
background
|
|
layout="sizes, prev, pager, next"
|
|
:current-page="view.pageIndex"
|
|
:page-size="view.pageSize"
|
|
:page-sizes="THUMBNAIL_PAGE_SIZES"
|
|
:total="framesTotal"
|
|
@current-change="emit('pageChange', $event)"
|
|
@size-change="emit('pageSizeChange', $event)"
|
|
/>
|
|
</template>
|
|
<div v-else-if="spriteUrls.length && sprite" class="episode-transcode-frames__sprites">
|
|
<figure
|
|
v-for="(url, index) in spriteUrls"
|
|
:key="url"
|
|
class="episode-transcode-frames__sprite"
|
|
>
|
|
<div
|
|
class="episode-transcode-frames__sprite-frame"
|
|
:style="{
|
|
aspectRatio: `${sprite.columns * sprite.thumbWidth} / ${
|
|
sprite.rows * sprite.thumbHeight
|
|
}`
|
|
}"
|
|
>
|
|
<el-image
|
|
:src="url"
|
|
:preview-src-list="spriteUrls"
|
|
:initial-index="index"
|
|
fit="contain"
|
|
preview-teleported
|
|
hide-on-click-modal
|
|
/>
|
|
<span
|
|
class="episode-transcode-frames__sprite-grid"
|
|
:style="{
|
|
backgroundSize: `calc(100% / ${sprite.columns}) calc(100% / ${sprite.rows})`
|
|
}"
|
|
></span>
|
|
</div>
|
|
<figcaption>
|
|
<b>{{ t('episodeTranscode.spriteSheet', { index: index + 1 }) }}</b>
|
|
<span>{{
|
|
t('episodeTranscode.spriteSheetFrames', { count: spriteFrameCount(index) })
|
|
}}</span>
|
|
</figcaption>
|
|
</figure>
|
|
</div>
|
|
<el-empty v-else :description="t('episodeTranscode.noSprites')" :image-size="58" />
|
|
</div>
|
|
|
|
<el-dialog
|
|
v-model="previewVisible"
|
|
:title="t('episodeTranscode.zoomTitle')"
|
|
width="min(92vw, 600px)"
|
|
append-to-body
|
|
destroy-on-close
|
|
>
|
|
<div class="episode-transcode-frames__preview-shell">
|
|
<div class="episode-transcode-frames__preview-image" :style="previewStyle"></div>
|
|
</div>
|
|
</el-dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.episode-transcode-frames__heading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin: 18px 0 10px;
|
|
}
|
|
|
|
.episode-transcode-frames__heading h4 {
|
|
margin: 0;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.episode-transcode-frames__tools {
|
|
display: flex;
|
|
font-size: 12px;
|
|
color: var(--el-text-color-secondary);
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.episode-transcode-frames__grid {
|
|
display: grid;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
grid-template-columns: repeat(4, 128px);
|
|
}
|
|
|
|
.episode-transcode-frames__card {
|
|
display: flex;
|
|
min-width: 0;
|
|
padding: 0;
|
|
font-size: 11px;
|
|
color: var(--el-text-color-secondary);
|
|
cursor: zoom-in;
|
|
background: transparent;
|
|
border: 0;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.episode-transcode-frames__image {
|
|
display: block;
|
|
overflow: hidden;
|
|
background-color: var(--el-fill-color-dark);
|
|
background-repeat: no-repeat;
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 7px;
|
|
}
|
|
|
|
.episode-transcode-frames__card:hover .episode-transcode-frames__image {
|
|
border-color: var(--el-color-primary);
|
|
}
|
|
|
|
.episode-transcode-frames__pagination {
|
|
margin-top: 14px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.episode-transcode-frames__sprites {
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
}
|
|
|
|
.episode-transcode-frames__sprite {
|
|
min-width: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.episode-transcode-frames__sprite-frame {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: var(--el-fill-color-dark);
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.episode-transcode-frames__sprite-frame :deep(.el-image) {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.episode-transcode-frames__sprite-grid {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background-image:
|
|
linear-gradient(to right, rgb(255 255 255 / 34%) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgb(255 255 255 / 34%) 1px, transparent 1px);
|
|
background-repeat: repeat;
|
|
}
|
|
|
|
.episode-transcode-frames__sprite figcaption {
|
|
display: flex;
|
|
padding-top: 6px;
|
|
font-size: 11px;
|
|
color: var(--el-text-color-secondary);
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.episode-transcode-frames__preview-shell {
|
|
display: flex;
|
|
min-height: 240px;
|
|
background: var(--el-fill-color-dark);
|
|
border-radius: 8px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.episode-transcode-frames__preview-image {
|
|
background-repeat: no-repeat;
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
@media (width <= 600px) {
|
|
.episode-transcode-frames__grid {
|
|
grid-template-columns: repeat(2, 128px);
|
|
}
|
|
}
|
|
</style>
|