// Service de fichiers médias, partagé par la route authentifiée // (/api/media/…) et la route publique (/api/public//media/…). // - `width` > 0 : image redimensionnée via sharp (cache disque). La source est // choisie parmi les variantes @1x…@4x : la plus petite qui couvre la largeur // demandée (spec §10.1 : on descend, on n'agrandit pas). // - `wantPoster` : première frame d'une vidéo (ffmpeg), servie comme image. // - `roots` : racines de résolution testées dans l'ordre (snapshot figé d'abord, // contenu live ensuite ; la route publique ne passe que le snapshot figé). import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { extname, join, normalize } from 'node:path' import type { H3Event } from 'h3' import sharp from 'sharp' const execFileAsync = promisify(execFile) const MIME: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.mp4': 'video/mp4', '.webm': 'video/webm', } // Cache mémoire des largeurs d'images (clé : chemin + mtime) const widthCache = new Map() async function imageWidth(abs: string, mtimeMs: number): Promise { const key = `${abs}:${mtimeMs}` const cached = widthCache.get(key) if (cached) return cached const meta = await sharp(abs).metadata() const w = meta.width ?? 0 widthCache.set(key, w) return w } interface FoundFile { abs: string size: number mtimeMs: number } async function findExisting(paths: string[]): Promise { const found: FoundFile[] = [] for (const abs of paths) { try { const s = await stat(abs) found.push({ abs, size: s.size, mtimeMs: s.mtimeMs }) } catch { // absent } } return found } /** Découpe le chemin d'URL `projectId/.../filename` en (projectId, filename). */ export function splitMediaPath(rawPath: string): { projectId: string; filename: string } { const segments = rawPath.split('/').map(decodeURIComponent).filter(Boolean) if (segments.length < 2 || segments.some(s => s === '..')) { throw createError({ statusCode: 400, statusMessage: 'Chemin invalide' }) } return { filename: segments[segments.length - 1], projectId: segments.slice(0, -1).join('/'), } } export interface ServeMediaOptions { roots: string[] // racines de résolution, dans l'ordre de priorité projectId: string filename: string width: number wantPoster: boolean dataDir: string } /** Résout, redimensionne/extrait puis renvoie un média. Logique commune. */ export async function serveMedia(event: H3Event, opts: ServeMediaOptions) { const { roots, projectId, filename, width, wantPoster, dataDir } = opts // Le nom canonique peut ne pas exister sur disque : variantes @Nx en repli const ext = extname(filename).toLowerCase() const variantNames = [ filename, ...['1', '2', '3', '4', '5', '6'].map(n => `${filename.slice(0, -ext.length)}@${n}x${ext}`), ] let candidates: FoundFile[] = [] for (const root of roots) { const paths = variantNames.map((name) => { const abs = normalize(join(root, projectId, name)) if (!abs.startsWith(normalize(root) + '/')) { throw createError({ statusCode: 400, statusMessage: 'Chemin invalide' }) } return abs }) candidates = await findExisting(paths) if (candidates.length) break } if (!candidates.length) { throw createError({ statusCode: 404, statusMessage: 'Média introuvable' }) } const primary = candidates[0] setHeader(event, 'Cache-Control', 'public, max-age=3600') // ---- Poster de vidéo ---- if (wantPoster && MIME[ext]?.startsWith('video/')) { const cacheDir = join(dataDir, 'cache', 'posters') const key = createHash('sha1').update(`${primary.abs}:${primary.mtimeMs}`).digest('hex') const posterPath = join(cacheDir, `${key}.jpg`) try { await stat(posterPath) } catch { await mkdir(cacheDir, { recursive: true }) try { await execFileAsync('ffmpeg', [ '-y', '-ss', '1', '-i', primary.abs, '-frames:v', '1', '-q:v', '3', posterPath, ]) } catch { // vidéos plus courtes qu'une seconde : première frame await execFileAsync('ffmpeg', ['-y', '-i', primary.abs, '-frames:v', '1', '-q:v', '3', posterPath]) } } setHeader(event, 'Content-Type', 'image/jpeg') if (width > 0) { return await sharp(posterPath) .resize({ width: Math.min(width, 3200), withoutEnlargement: true }) .jpeg({ quality: 82 }) .toBuffer() } return sendStream(event, createReadStream(posterPath)) } // ---- Image redimensionnée ---- if (width > 0 && MIME[ext]?.startsWith('image/') && ext !== '.gif') { const target = Math.min(width, 3200) // Choix de la source : la plus petite variante couvrant la cible let best = candidates[0] let bestWidth = await imageWidth(best.abs, best.mtimeMs) for (const c of candidates.slice(1)) { const w = await imageWidth(c.abs, c.mtimeMs) const covers = w >= target const bestCovers = bestWidth >= target if ((covers && (!bestCovers || w < bestWidth)) || (!covers && !bestCovers && w > bestWidth)) { best = c bestWidth = w } } const cacheDir = join(dataDir, 'cache', 'thumbs') const key = createHash('sha1').update(`${best.abs}:${target}:${best.mtimeMs}`).digest('hex') const cachePath = join(cacheDir, `${key}.webp`) try { const cached = await readFile(cachePath) setHeader(event, 'Content-Type', 'image/webp') return cached } catch { // pas en cache : on génère } await mkdir(cacheDir, { recursive: true }) const resized = await sharp(best.abs) .resize({ width: target, withoutEnlargement: true }) .webp({ quality: 80 }) .toBuffer() await writeFile(cachePath, resized) setHeader(event, 'Content-Type', 'image/webp') return resized } // ---- Fichier original ---- setHeader(event, 'Content-Type', MIME[ext] ?? 'application/octet-stream') setHeader(event, 'Content-Length', primary.size) return sendStream(event, createReadStream(primary.abs)) }