feat: adv slot thumbnails via ffmpeg poster + view current

- show a small JPEG poster per slot (new /api/adv-thumb proxy) instead of
  downloading the full video; per-slot 'View video' still loads on demand
- 'On server' badge + View button, auto-refreshed after upload
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
thanawat saiyota 2026-07-15 12:49:03 +07:00
parent 60d0e40b76
commit 67c8363270
2 changed files with 124 additions and 2 deletions

View file

@ -13,6 +13,7 @@
const UPLOAD_PROXY_ENDPOINT = '/api/adv-upload';
const LIST_PROXY_ENDPOINT = '/api/adv-list';
const FILE_PROXY_ENDPOINT = '/api/adv-file';
const THUMB_PROXY_ENDPOINT = '/api/adv-thumb';
const ALLOWED_EXTENSIONS = ['.mp4'];
// ─────────────────────────────────────────────────────────────────────────
@ -203,6 +204,11 @@
const { [slot.name]: _drop, ...rest } = videoBlobUrls;
videoBlobUrls = rest;
}
if (thumbUrls[slot.name]) {
URL.revokeObjectURL(thumbUrls[slot.name]);
const { [slot.name]: _t, ...restThumb } = thumbUrls;
thumbUrls = restThumb;
}
refreshServerIndex();
return true;
} catch (error) {
@ -329,6 +335,8 @@
let serverFilesByName = $state<Record<string, number>>({});
// The server video currently open in the preview lightbox (or null).
let viewingName = $state<string | null>(null);
// name -> object URL of the small JPEG poster (thumbnail) for a slot's video.
let thumbUrls = $state<Record<string, string>>({});
function formatBytes(bytes: number): string {
if (!bytes) return '0 MB';
@ -348,11 +356,47 @@
const map: Record<string, number> = {};
for (const f of (data.files ?? []) as ServerVideo[]) map[f.name] = f.size;
serverFilesByName = map;
// Fetch a tiny JPEG poster for each slot video so the grid shows a
// thumbnail (backend grabs one frame — no full-video download).
autoLoadSlotThumbs();
} catch (error) {
logger.error('[Adv] refresh server index error:', error);
}
}
// Fetch the small JPEG poster for one slot video → object URL for an <img>.
async function loadSlotThumb(name: string) {
if (thumbUrls[name]) return;
try {
const url = `${THUMB_PROXY_ENDPOINT}?country=${encodeURIComponent(selectedCountry)}&filename=${encodeURIComponent(name)}`;
const res = await fetch(url, { headers: await authHeaders() });
if (!res.ok) return;
const blob = await res.blob();
thumbUrls = { ...thumbUrls, [name]: URL.createObjectURL(blob) };
} catch (error) {
logger.error('[Adv] load thumb error:', error);
}
}
// Load posters for the slot videos on the server (posters are tiny, so a
// larger concurrency pool is fine).
async function autoLoadSlotThumbs() {
const slotNames = new Set(slots.map((s) => s.name));
const names = Object.keys(serverFilesByName).filter(
(n) => slotNames.has(n) && !thumbUrls[n]
);
if (names.length === 0) return;
const CONCURRENCY = 5;
let next = 0;
async function worker() {
while (next < names.length) {
await loadSlotThumb(names[next++]);
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker));
}
// Open the current server video for a slot in the lightbox.
async function viewCurrent(name: string) {
await playServerVideo(name); // loads the blob if not already
@ -438,6 +482,7 @@
return () => {
for (const s of slots) if (s.preview) URL.revokeObjectURL(s.preview);
for (const url of Object.values(videoBlobUrls)) URL.revokeObjectURL(url);
for (const url of Object.values(thumbUrls)) URL.revokeObjectURL(url);
};
});
</script>
@ -584,6 +629,32 @@
<CheckCircle class="h-6 w-6 text-white drop-shadow" />
</div>
{/if}
{:else if thumbUrls[slot.name]}
<!-- JPEG poster of the video already on the server; still a picker
so clicking replaces it with a new file. -->
<label class="group/thumb relative block h-full w-full cursor-pointer">
<input
type="file"
accept=".mp4,video/mp4"
class="hidden"
onchange={(e) => {
const el = e.currentTarget as HTMLInputElement;
pickSlotFile(slot.index, el.files);
el.value = '';
}}
/>
<img
src={thumbUrls[slot.name]}
alt="Current {slot.name}"
class="h-full w-full object-cover"
/>
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/50 text-white opacity-0 transition-opacity group-hover/thumb:opacity-100"
>
<Upload class="h-4 w-4" />
<span class="text-[10px] font-medium">Replace</span>
</div>
</label>
{:else}
<label
class="flex h-full w-full cursor-pointer flex-col items-center justify-center gap-1 border border-dashed border-muted-foreground/30 text-muted-foreground transition-colors hover:border-primary/50 hover:text-primary"
@ -598,8 +669,13 @@
el.value = '';
}}
/>
<Upload class="h-4 w-4" />
<span class="text-[10px] font-medium">Choose</span>
{#if serverFilesByName[slot.name] !== undefined}
<Spinner class="h-4 w-4" />
<span class="text-[10px] font-medium">Loading…</span>
{:else}
<Upload class="h-4 w-4" />
<span class="text-[10px] font-medium">Choose</span>
{/if}
</label>
{/if}
</div>

View file

@ -0,0 +1,46 @@
import { logger } from '$lib/core/utils/logger';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/public';
import { verifyAuthToken } from '$lib/server/auth';
// Adv videos are served by the same taobin_image service as menu images.
const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
// Return a small JPEG poster (single frame) for an adv video so the slot grid can
// show a thumbnail without downloading the whole mp4. The frontend fetches this
// with a Bearer token and turns the response into a Blob/object URL for an <img>.
export const GET: RequestHandler = async ({ request, url }) => {
try {
await verifyAuthToken(request);
const country = url.searchParams.get('country');
const filename = url.searchParams.get('filename');
if (!country || !filename) {
throw error(400, 'Missing country or filename');
}
// POST (not GET): the kong gateway only routes POST to the taobin-image service.
const endpoint = `${ADV_API_BASE}/adv/thumb/${encodeURIComponent(country)}/${encodeURIComponent(filename)}`;
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
throw error(response.status, errorData.detail || 'Failed to fetch thumbnail');
}
return new Response(response.body, {
status: 200,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'image/jpeg',
'Cache-Control': 'no-store'
}
});
} catch (err) {
logger.error('[Adv Thumb Proxy] Error:', err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
throw error(500, err instanceof Error ? err.message : 'Internal server error');
}
};