Merge branch 'dev' into 'master'
feat: adv slot thumbnails via ffmpeg poster + view current See merge request Pakin/supra-app!8
This commit is contained in:
commit
c02af91353
2 changed files with 124 additions and 2 deletions
|
|
@ -25,6 +25,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'];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -219,6 +220,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) {
|
||||
|
|
@ -345,6 +351,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';
|
||||
|
|
@ -364,11 +372,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
|
||||
|
|
@ -458,6 +502,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>
|
||||
|
|
@ -611,6 +656,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"
|
||||
|
|
@ -625,8 +696,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>
|
||||
|
|
|
|||
46
src/routes/api/adv-thumb/+server.ts
Normal file
46
src/routes/api/adv-thumb/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue