Files
Valentin Le Moign 59dfbe4155 Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios
A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates
cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du
contenu à la génération, partage public opt-in.

Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:37:09 +02:00

187 lines
6.3 KiB
TypeScript

// Service de fichiers médias, partagé par la route authentifiée
// (/api/media/…) et la route publique (/api/public/<id>/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<string, string> = {
'.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<string, number>()
async function imageWidth(abs: string, mtimeMs: number): Promise<number> {
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<FoundFile[]> {
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))
}