更新缩略图
This commit is contained in:
@@ -51,3 +51,9 @@ export async function fetchHealth() {
|
||||
export function generatedUrl(name) {
|
||||
return `${BASE}/images/${name}`
|
||||
}
|
||||
|
||||
/** Small thumbnail URL for list views. The server falls back to the original
|
||||
* when no thumbnail object exists (old images), so this is always safe. */
|
||||
export function thumbUrl(name) {
|
||||
return `${BASE}/images/${name}.thumb.jpg`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api, generatedUrl, thumbUrl } from '../api'
|
||||
import { fmtTs, fmtSize } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import Icon from '../components/Icon.vue'
|
||||
@@ -12,6 +12,9 @@ const stats = ref({ total: 0, image: 0, video: 0, size_bytes: 0 })
|
||||
const loading = ref(false)
|
||||
const kind = ref('') // '' | 'image' | 'video'
|
||||
const selected = ref(null)
|
||||
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||||
// the muted <video> preview for those cards.
|
||||
const thumbFail = reactive({})
|
||||
const toast = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
@@ -139,13 +142,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
@click="selected = f">
|
||||
<!-- media -->
|
||||
<template v-if="f.kind === 'video'">
|
||||
<video :src="generatedUrl(f.name)" muted loop preload="metadata"
|
||||
<!-- first-frame still as background-image (same as image cards); the
|
||||
hidden probe img flips to the <video> fallback for old videos. -->
|
||||
<div v-if="!thumbFail[f.name]" :style="{ backgroundImage: `url(${thumbUrl(f.name)})` }"
|
||||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105">
|
||||
<img :src="thumbUrl(f.name)" class="hidden" @error="thumbFail[f.name] = true" />
|
||||
</div>
|
||||
<video v-else :src="generatedUrl(f.name)" muted loop preload="metadata"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
</template>
|
||||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||||
<div v-else :style="{ backgroundImage: `url(${generatedUrl(f.name)})` }"
|
||||
<div v-else :style="{ backgroundImage: `url(${thumbUrl(f.name)})` }"
|
||||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105"></div>
|
||||
|
||||
<!-- gradient veil (always visible so the prompt overlay reads) -->
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import { generatedUrl } from '../api'
|
||||
import { generatedUrl, thumbUrl } from '../api'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||
|
||||
@@ -107,6 +107,10 @@ function fmtWhen(ts) {
|
||||
return fmtTs(ts)
|
||||
}
|
||||
|
||||
// Video rows whose first-frame thumbnail is missing (old videos) — fall back
|
||||
// to the muted <video> preview for those.
|
||||
const thumbFail = reactive({})
|
||||
|
||||
const previewing = ref(null) // entry whose generated file is open in the lightbox
|
||||
function openPreview(e) {
|
||||
// API (v1) outputs aren't persisted/served by us (image=b64 inline, video=an
|
||||
@@ -251,8 +255,9 @@ const sourcePill = (s) => ({
|
||||
<button v-if="e.status === 'success' && e.file && e.source !== 'v1'"
|
||||
@click="openPreview(e)"
|
||||
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
|
||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy"
|
||||
class="w-full h-full object-cover" />
|
||||
<img v-if="e.kind !== 'video' || !thumbFail[e.id]" :src="thumbUrl(e.file)" loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
@error="e.kind === 'video' && (thumbFail[e.id] = true)" />
|
||||
<video v-else :src="generatedUrl(e.file)" muted loop preload="metadata" playsinline
|
||||
class="w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, jsonBody, generatedUrl } from '../api'
|
||||
import { auth, refreshMe } from '../auth'
|
||||
@@ -50,6 +50,9 @@ const history = ref([])
|
||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||
const error = ref('')
|
||||
const lightbox = ref(null)
|
||||
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||||
// the muted <video> preview for those cards.
|
||||
const thumbFail = reactive({})
|
||||
const toast = ref('')
|
||||
let pollTimer = null
|
||||
|
||||
@@ -436,7 +439,9 @@ function lastFrameDataUrl(url) {
|
||||
// (frame) models only. Triggered by the small button; clicking the video zooms.
|
||||
async function useVideoFrame(item) {
|
||||
if (!item || !item.url) return
|
||||
const dataUrl = await lastFrameDataUrl(item.url)
|
||||
// Prefer the server-stored FULL-RES last-frame still; old videos without one
|
||||
// fall back to grabbing the frame from the video via canvas.
|
||||
const dataUrl = (await storedLastFrameDataUrl(item.url)) || (await lastFrameDataUrl(item.url))
|
||||
if (!dataUrl) { flash('截取末帧失败'); return }
|
||||
const ref = { name: 'frame', dataUrl }
|
||||
if (refImages.value.length === 0) refImages.value = [ref]
|
||||
@@ -444,6 +449,23 @@ async function useVideoFrame(item) {
|
||||
flash('已把视频末帧设为首帧')
|
||||
}
|
||||
|
||||
// Fetch the server-stored last-frame still (url + '.last.jpg'). The server
|
||||
// falls back to the ORIGINAL VIDEO when the still doesn't exist (old videos),
|
||||
// so verify the response really is an image before using it.
|
||||
async function storedLastFrameDataUrl(url) {
|
||||
try {
|
||||
const r = await fetch(url + '.last.jpg')
|
||||
if (!r.ok || !(r.headers.get('content-type') || '').startsWith('image/')) return ''
|
||||
const blob = await r.blob()
|
||||
return await new Promise((res) => {
|
||||
const fr = new FileReader()
|
||||
fr.onload = () => res(fr.result)
|
||||
fr.onerror = () => res('')
|
||||
fr.readAsDataURL(blob)
|
||||
})
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -637,14 +659,21 @@ onUnmounted(() => {
|
||||
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]">
|
||||
<!-- done: media + caption -->
|
||||
<template v-if="item.status === 'done' && item.url">
|
||||
<video v-if="item.kind === 'video'" :src="item.url" muted loop preload="metadata"
|
||||
<!-- first-frame still as background-image (same as image cards); the
|
||||
hidden probe img flips to the <video> fallback for old videos. -->
|
||||
<div v-if="item.kind === 'video' && !thumbFail[item.id]" @click="lightbox = item" title="点击播放"
|
||||
:style="{ backgroundImage: `url(${item.url}.thumb.jpg)` }"
|
||||
class="absolute inset-0 w-full h-full bg-cover bg-center cursor-zoom-in transition-transform duration-300 group-hover:scale-105">
|
||||
<img :src="item.url + '.thumb.jpg'" class="hidden" @error="thumbFail[item.id] = true" />
|
||||
</div>
|
||||
<video v-else-if="item.kind === 'video'" :src="item.url" muted loop preload="metadata"
|
||||
@click="lightbox = item" title="点击放大"
|
||||
class="absolute inset-0 w-full h-full object-cover cursor-zoom-in"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||||
<div v-else @click="lightbox = item" title="点击放大"
|
||||
:style="{ backgroundImage: `url(${item.url})` }"
|
||||
:style="{ backgroundImage: `url(${item.url}.thumb.jpg)` }"
|
||||
class="absolute inset-0 w-full h-full bg-cover bg-center cursor-zoom-in transition-transform duration-300 group-hover:scale-105"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none"></div>
|
||||
<!-- hover action: 上参考图. Image → use as reference; video → 末帧设为首帧
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// generations (success / failed / pending), surfacing failure reasons that the
|
||||
// image-only 记录 gallery hides. Uses the same /logs endpoint (auto-scoped to
|
||||
// the caller), just without the success-only filter.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { api, generatedUrl, thumbUrl } from '../api'
|
||||
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import { points } from '../credits'
|
||||
@@ -23,6 +23,9 @@ const search = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const lightbox = ref(null)
|
||||
// Video rows whose first-frame thumbnail is missing (old videos) — fall back
|
||||
// to the muted <video> preview for those.
|
||||
const thumbFail = reactive({})
|
||||
|
||||
const toast = ref('')
|
||||
let toastTimer = null
|
||||
@@ -221,7 +224,8 @@ const params = (e) => {
|
||||
(not a RustFS path), so it can't be previewed in-browser — show —. -->
|
||||
<button v-if="e.status === 'success' && e.file && !e.file.startsWith('http')" @click="lightbox = e"
|
||||
class="block w-11 h-11 mx-auto rounded-lg overflow-hidden ring-1 ring-slate-200 hover:ring-fuchsia-300 transition-all">
|
||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy" class="w-full h-full object-cover" />
|
||||
<img v-if="e.kind !== 'video' || !thumbFail[e.id]" :src="thumbUrl(e.file)" loading="lazy" class="w-full h-full object-cover"
|
||||
@error="e.kind === 'video' && (thumbFail[e.id] = true)" />
|
||||
<video v-else :src="generatedUrl(e.file)" muted preload="metadata" class="w-full h-full object-cover" />
|
||||
</button>
|
||||
<span v-else class="text-slate-300">—</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, generatedUrl } from '../api'
|
||||
import { api, generatedUrl, thumbUrl } from '../api'
|
||||
import { fmtTs } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import Icon from '../components/Icon.vue'
|
||||
@@ -97,6 +97,9 @@ async function copyPrompt(e) {
|
||||
|
||||
const toast = ref('')
|
||||
const lightbox = ref(null)
|
||||
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||||
// the muted <video> preview for those cards.
|
||||
const thumbFail = reactive({})
|
||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
@@ -157,12 +160,18 @@ onUnmounted(() => {
|
||||
@click="(e.status === 'success' && e.file) && (lightbox = e)">
|
||||
<!-- media -->
|
||||
<template v-if="e.status === 'success' && e.file">
|
||||
<video v-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata"
|
||||
<!-- first-frame still as background-image (same as image cards); the
|
||||
hidden probe img flips to the <video> fallback for old videos. -->
|
||||
<div v-if="e.kind === 'video' && !thumbFail[e.id]" :style="{ backgroundImage: `url(${thumbUrl(e.file)})` }"
|
||||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105">
|
||||
<img :src="thumbUrl(e.file)" class="hidden" @error="thumbFail[e.id] = true" />
|
||||
</div>
|
||||
<video v-else-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
@mouseenter="$event.target.play && $event.target.play()"
|
||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||||
<div v-else :style="{ backgroundImage: `url(${generatedUrl(e.file)})` }"
|
||||
<div v-else :style="{ backgroundImage: `url(${thumbUrl(e.file)})` }"
|
||||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user