From 67c8363270661124d3d3fc6e27932f8e905ad29e Mon Sep 17 00:00:00 2001 From: thanawat saiyota Date: Wed, 15 Jul 2026 12:49:03 +0700 Subject: [PATCH] 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 --- .../(authed)/tools/adv-upload/+page.svelte | 80 ++++++++++++++++++- src/routes/api/adv-thumb/+server.ts | 46 +++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 src/routes/api/adv-thumb/+server.ts diff --git a/src/routes/(authed)/tools/adv-upload/+page.svelte b/src/routes/(authed)/tools/adv-upload/+page.svelte index 0b7d1e6..297b8f1 100644 --- a/src/routes/(authed)/tools/adv-upload/+page.svelte +++ b/src/routes/(authed)/tools/adv-upload/+page.svelte @@ -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>({}); // The server video currently open in the preview lightbox (or null). let viewingName = $state(null); + // name -> object URL of the small JPEG poster (thumbnail) for a slot's video. + let thumbUrls = $state>({}); function formatBytes(bytes: number): string { if (!bytes) return '0 MB'; @@ -348,11 +356,47 @@ const map: Record = {}; 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 . + 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); }; }); @@ -584,6 +629,32 @@ {/if} + {:else if thumbUrls[slot.name]} + + {:else} {/if} diff --git a/src/routes/api/adv-thumb/+server.ts b/src/routes/api/adv-thumb/+server.ts new file mode 100644 index 0000000..b7e6b69 --- /dev/null +++ b/src/routes/api/adv-thumb/+server.ts @@ -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 . +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'); + } +};