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>
187 lines
6.3 KiB
TypeScript
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))
|
|
}
|