mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #177 from IliesAllali/feat/blurhash
Aperçus flous pendant le chargement des photos (BlurHash)
This commit is contained in:
20 files changed
+323
-12
No files matched your search
Generated
+7
@@ -15,6 +15,7 @@
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"dotenv": "^18.0.8",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
@@ -3033,6 +3034,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/blurhash": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
||||
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"dotenv": "^18.0.8",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Aperçus flous (BlurHash) des portraits et des photos, montrés pendant le chargement.
|
||||
-- Migration additive : deux colonnes facultatives, remplies ensuite par l'API (lib/blurhash.ts).
|
||||
-- AlterTable
|
||||
ALTER TABLE "persons" ADD COLUMN "avatarBlurhash" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "media_items" ADD COLUMN "blurhash" TEXT;
|
||||
@@ -310,6 +310,8 @@ model Person {
|
||||
// { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière
|
||||
// recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1).
|
||||
avatarCrop Json?
|
||||
// Aperçu flou du portrait (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
|
||||
avatarBlurhash String?
|
||||
sex String?
|
||||
notes String?
|
||||
createdBy String?
|
||||
@@ -370,6 +372,8 @@ model MediaItem {
|
||||
mimeType String
|
||||
filePath String
|
||||
thumbPath String?
|
||||
// Aperçu flou d'une photo (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
|
||||
blurhash String?
|
||||
caption String?
|
||||
source String?
|
||||
sizeBytes Int
|
||||
|
||||
+4
-1
@@ -10,13 +10,14 @@ import { env } from './config/env.js'
|
||||
import { prisma } from './lib/prisma.js'
|
||||
import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js'
|
||||
import { registerIdempotency } from './lib/idempotency.js'
|
||||
import { startBlurhashFiller } from './lib/blurhash.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { contributionRoutes } from './routes/contributions.js'
|
||||
import { graphRoutes } from './routes/graph.js'
|
||||
import { immichRoutes } from './routes/immich.js'
|
||||
import { managerRoutes } from './routes/managers.js'
|
||||
import { searchSourceRoutes } from './routes/search-sources.js'
|
||||
import { mediaRoutes } from './routes/media.js'
|
||||
import { getStorageRoot, mediaRoutes } from './routes/media.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { publicRoutes } from './routes/public.js'
|
||||
import { annotationRoutes } from './routes/annotations.js'
|
||||
@@ -65,6 +66,8 @@ export function createApp() {
|
||||
app.addHook('onReady', async () => {
|
||||
await loadRevokedTokens(app.prisma)
|
||||
})
|
||||
// Aperçus flous des photos et portraits qui n'en ont pas encore (lib/blurhash.ts) ; pas pendant les tests
|
||||
if (env.NODE_ENV !== 'test') app.addHook('onReady', async () => startBlurhashFiller(app, getStorageRoot()))
|
||||
app.decorateRequest('actor', null)
|
||||
|
||||
app.register(cors, {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { encode } from 'blurhash'
|
||||
import sharp from 'sharp'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
// Aperçus flous (BlurHash) : une courte chaîne par photo, que le navigateur décode en image floue aux bonnes
|
||||
// couleurs pendant que la vraie se charge. Les photos partent en taille réelle, sans miniature : sur un réseau
|
||||
// lent, une vignette restait un carré vide.
|
||||
//
|
||||
// Calculé à l'envoi d'une photo ou d'un portrait (routes/media.ts). Les autres chemins (Immich, import GEDCOM,
|
||||
// propositions acceptées) et les photos d'avant cette version sont rattrapés en tâche de fond. Une image
|
||||
// illisible reçoit une chaîne vide, pour ne pas être relue à chaque passage.
|
||||
|
||||
// Assez petit pour un calcul rapide ; l'aperçu garde les proportions de la photo
|
||||
const SAMPLE_MAX = 32
|
||||
const COMPONENTS_X = 4
|
||||
const COMPONENTS_Y = 3
|
||||
|
||||
export async function computeBlurhash(buf: Buffer): Promise<string | null> {
|
||||
try {
|
||||
const { data, info } = await sharp(buf, { limitInputPixels: 50_000_000, failOn: 'error' })
|
||||
.rotate()
|
||||
.resize(SAMPLE_MAX, SAMPLE_MAX, { fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
return encode(new Uint8ClampedArray(data), info.width, info.height, COMPONENTS_X, COMPONENTS_Y)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function hashStoredFile(storageRoot: string, relativePath: string): Promise<string> {
|
||||
const absolutePath = path.resolve(storageRoot, relativePath)
|
||||
if (!absolutePath.startsWith(storageRoot + path.sep)) return ''
|
||||
const buf = await readFile(absolutePath).catch(() => null)
|
||||
return (buf && await computeBlurhash(buf)) || ''
|
||||
}
|
||||
|
||||
const BATCH = 25
|
||||
const PAUSE_MS = 60_000
|
||||
|
||||
// Un lot de photos puis de portraits sans aperçu ; renvoie le nombre traité
|
||||
export async function fillMissingBlurhashes(app: FastifyInstance, storageRoot: string): Promise<number> {
|
||||
const medias = await app.prisma.mediaItem.findMany({
|
||||
where: { type: 'photo', blurhash: null, deletedAt: null, NOT: { mimeType: 'video/youtube' } },
|
||||
select: { id: true, filePath: true },
|
||||
take: BATCH,
|
||||
})
|
||||
for (const media of medias) {
|
||||
const blurhash = await hashStoredFile(storageRoot, media.filePath)
|
||||
await app.prisma.mediaItem.updateMany({ where: { id: media.id, blurhash: null }, data: { blurhash } })
|
||||
}
|
||||
|
||||
const persons = await app.prisma.person.findMany({
|
||||
where: { avatarPath: { not: null }, avatarBlurhash: null, deletedAt: null },
|
||||
select: { id: true, avatarPath: true },
|
||||
take: BATCH,
|
||||
})
|
||||
for (const person of persons) {
|
||||
const avatarBlurhash = await hashStoredFile(storageRoot, person.avatarPath as string)
|
||||
// Le portrait a pu changer pendant le calcul : on n'écrit que s'il est toujours le même
|
||||
await app.prisma.person.updateMany({ where: { id: person.id, avatarPath: person.avatarPath, avatarBlurhash: null }, data: { avatarBlurhash } })
|
||||
}
|
||||
|
||||
return medias.length + persons.length
|
||||
}
|
||||
|
||||
// Tâche de fond : enchaîne les lots tant qu'il en reste, puis repasse toutes les minutes
|
||||
export function startBlurhashFiller(app: FastifyInstance, storageRoot: string) {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
let stopped = false
|
||||
const run = async () => {
|
||||
let done = 0
|
||||
try {
|
||||
done = await fillMissingBlurhashes(app, storageRoot)
|
||||
} catch (error) {
|
||||
app.log.warn({ err: error }, 'aperçus flous : lot en échec')
|
||||
}
|
||||
if (!stopped) {
|
||||
timer = setTimeout(run, done >= BATCH ? 0 : PAUSE_MS)
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
timer = setTimeout(run, 5_000)
|
||||
timer.unref()
|
||||
app.addHook('onClose', async () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export async function softDeletePersonCascade(
|
||||
|
||||
await db.person.updateMany({
|
||||
where: { id: personId, treeId, deletedAt: null },
|
||||
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null },
|
||||
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, avatarBlurhash: null, immichPersonId: null },
|
||||
})
|
||||
|
||||
const unions = await db.union.findMany({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ImmichUnavailableError,
|
||||
type ImmichPersonSummary,
|
||||
} from '../lib/immich.js'
|
||||
import { computeBlurhash } from '../lib/blurhash.js'
|
||||
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
|
||||
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js'
|
||||
@@ -539,6 +540,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarMimeType: prepared.mimeType,
|
||||
// Nouveau portrait, nouveau cadrage à faire
|
||||
avatarCrop: Prisma.DbNull,
|
||||
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
|
||||
immichPersonId,
|
||||
updatedBy: request.actor.userId,
|
||||
},
|
||||
@@ -619,11 +621,13 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarPath?: null
|
||||
avatarMimeType?: null
|
||||
avatarCrop?: typeof Prisma.DbNull
|
||||
avatarBlurhash?: null
|
||||
} = { immichPersonId: null, updatedBy: request.actor.userId }
|
||||
if (avatarFromImmich) {
|
||||
updateData.avatarPath = null
|
||||
updateData.avatarMimeType = null
|
||||
updateData.avatarCrop = Prisma.DbNull
|
||||
updateData.avatarBlurhash = null
|
||||
}
|
||||
|
||||
// Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour.
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type BinaryMediaType,
|
||||
} from '../lib/media-files.js'
|
||||
import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js'
|
||||
import { computeBlurhash } from '../lib/blurhash.js'
|
||||
import { syncPersonMediaOrder } from '../lib/media-order.js'
|
||||
import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
|
||||
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
|
||||
@@ -501,6 +502,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
status: isPending ? 'pending' : 'approved',
|
||||
contributionSessionId: contributionSession?.id ?? null,
|
||||
lifeEventId,
|
||||
blurhash: payload.data.type === 'photo' ? await computeBlurhash(prepared.buffer) ?? '' : null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -620,6 +622,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarMimeType: prepared.mimeType,
|
||||
// Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci
|
||||
avatarCrop: Prisma.DbNull,
|
||||
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
|
||||
updatedBy: request.actor.userId,
|
||||
// Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association
|
||||
// survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne
|
||||
@@ -676,6 +679,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarPath: null,
|
||||
avatarMimeType: null,
|
||||
avatarCrop: Prisma.DbNull,
|
||||
avatarBlurhash: null,
|
||||
// Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait.
|
||||
immichPersonId: null,
|
||||
updatedBy: request.actor.userId,
|
||||
|
||||
@@ -26,6 +26,7 @@ export type GraphPayload = {
|
||||
notes: string | null
|
||||
avatarFromImmich: boolean
|
||||
avatarCrop: AvatarCrop | null
|
||||
avatarBlurhash: string | null
|
||||
}>
|
||||
unions: Array<{
|
||||
id: string
|
||||
@@ -54,6 +55,7 @@ export type GraphPayload = {
|
||||
source: string | null
|
||||
displayOrder: number
|
||||
isFeatured: boolean
|
||||
blurhash: string | null
|
||||
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
|
||||
people: Array<{ personId: string; x: number | null; y: number | null }>
|
||||
}>
|
||||
@@ -117,6 +119,7 @@ export function buildGraphPayload(
|
||||
notes: person.notes,
|
||||
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
|
||||
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
|
||||
avatarBlurhash: (person.avatarPath && person.avatarBlurhash) || null,
|
||||
})),
|
||||
unions: unions.map((union) => ({
|
||||
id: union.id,
|
||||
@@ -145,6 +148,7 @@ export function buildGraphPayload(
|
||||
source: media.source,
|
||||
displayOrder: media.displayOrder,
|
||||
isFeatured: media.isFeatured,
|
||||
blurhash: media.blurhash || null,
|
||||
people: peopleByMedia.get(media.id) ?? [],
|
||||
})),
|
||||
annotations: annotations.map((ann) => ({
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
// Aperçus flous (lib/blurhash.ts), sur une vraie base : calculés à l'envoi d'une photo et d'un portrait,
|
||||
// publiés dans le graphe, et rattrapés en tâche de fond pour ce qui n'en a pas (photos d'avant la migration,
|
||||
// imports). Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8) :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/apercus-flous.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
|
||||
let ip = 0
|
||||
const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
|
||||
const run = Date.now().toString(36)
|
||||
const storage = mkdtempSync(path.join(tmpdir(), 'pth-apercus-'))
|
||||
const PHOTO_BYTES = readFileSync(fileURLToPath(new URL('../../frontend/public/demo/souvenir-mariage-1918.webp', import.meta.url)))
|
||||
const PHOTO = PHOTO_BYTES.toString('base64')
|
||||
|
||||
async function call(method: string, url: string, token?: string, payload?: unknown) {
|
||||
const res = await app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as Record<string, unknown>,
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
let body = null
|
||||
try { body = res.body ? res.json() : null } catch { body = null }
|
||||
return { status: res.statusCode, body }
|
||||
}
|
||||
|
||||
describe.skipIf(!TEST_DB)('aperçus flous, vraie base', () => {
|
||||
let token: string
|
||||
let treeId: string
|
||||
let personId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = TEST_DB
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.MEDIA_STORAGE_PATH = storage
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.MEDIA_STORAGE_PATH = storage
|
||||
const owner = await call('POST', '/auth/register', undefined, { email: `apercus-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Patch' })
|
||||
expect(owner.status).toBe(201)
|
||||
token = owner.body.token
|
||||
const tree = await call('POST', '/trees', token, { name: 'Aperçus', slug: `apercus-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
|
||||
treeId = tree.body.tree?.id ?? tree.body.id
|
||||
const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' })
|
||||
personId = person.body.person?.id ?? person.body.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
const graph = async () => (await call('GET', `/trees/${treeId}/graph`, token)).body
|
||||
|
||||
it('une photo et un portrait envoyés ont leur aperçu tout de suite, publié dans le graphe', async () => {
|
||||
const media = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, { type: 'photo', fileName: 'mariage.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
|
||||
expect(media.status).toBe(201)
|
||||
const avatar = await call('POST', `/trees/${treeId}/persons/${personId}/avatar`, token, { fileName: 'portrait.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
|
||||
expect(avatar.status).toBeLessThan(300)
|
||||
const g = await graph()
|
||||
expect(g.graph?.medias?.[0]?.blurhash ?? g.medias?.[0]?.blurhash).toMatch(/^[0-9A-Za-z#$%*+,-.:;=?@[\]^_{|}~]{28}$/)
|
||||
const person = (g.graph?.persons ?? g.persons).find((p: { id: string }) => p.id === personId)
|
||||
expect(person.avatarBlurhash).toHaveLength(28)
|
||||
})
|
||||
|
||||
it('la tâche de fond rattrape une photo et un portrait sans aperçu', async () => {
|
||||
await app.prisma.mediaItem.updateMany({ where: { treeId }, data: { blurhash: null } })
|
||||
await app.prisma.person.updateMany({ where: { id: personId }, data: { avatarBlurhash: null } })
|
||||
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
|
||||
const done = await fillMissingBlurhashes(app, path.resolve(storage))
|
||||
expect(done).toBeGreaterThanOrEqual(2)
|
||||
expect((await app.prisma.mediaItem.findFirst({ where: { treeId } }))?.blurhash).toHaveLength(28)
|
||||
expect((await app.prisma.person.findUnique({ where: { id: personId } }))?.avatarBlurhash).toHaveLength(28)
|
||||
})
|
||||
|
||||
it('un fichier illisible reçoit un aperçu vide, pour ne pas être relu à chaque passage', async () => {
|
||||
const media = await app.prisma.mediaItem.findFirst({ where: { treeId } })
|
||||
await app.prisma.mediaItem.update({ where: { id: media!.id }, data: { blurhash: null, filePath: `${treeId}/absent.webp` } })
|
||||
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
|
||||
await fillMissingBlurhashes(app, path.resolve(storage))
|
||||
expect((await app.prisma.mediaItem.findUnique({ where: { id: media!.id } }))?.blurhash).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { decode, isBlurhashValid } from 'blurhash'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeBlurhash } from '../src/lib/blurhash.js'
|
||||
|
||||
// Aperçu flou calculé à l'envoi d'une photo : une chaîne valide, aux couleurs de la photo
|
||||
const demo = (name: string) => fileURLToPath(new URL(`../../frontend/public/demo/${name}`, import.meta.url))
|
||||
|
||||
describe('computeBlurhash', () => {
|
||||
it('donne un aperçu valide pour une vraie photo', async () => {
|
||||
const hash = await computeBlurhash(await readFile(demo('souvenir-mariage-1918.webp')))
|
||||
expect(hash).toBeTruthy()
|
||||
expect(isBlurhashValid(hash as string).result).toBe(true)
|
||||
// 4 × 3 composantes : 4 + 2 × 4 × 3 - 2 = 28 caractères
|
||||
expect(hash).toHaveLength(28)
|
||||
// Photo en noir et blanc : l'aperçu reste gris (rouge, vert et bleu proches)
|
||||
const px = decode(hash as string, 8, 8)
|
||||
const [r, g, b] = [px[0], px[1], px[2]]
|
||||
expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(24)
|
||||
})
|
||||
|
||||
it('renvoie null pour un fichier qui n’est pas une image', async () => {
|
||||
expect(await computeBlurhash(Buffer.from('pas une image'))).toBeNull()
|
||||
})
|
||||
})
|
||||
Generated
+7
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@lucide/icons": "^1.59.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"lucide-react": "^1.59.0",
|
||||
"posthog-js": "^1.438.12",
|
||||
"react": "^19.3.1",
|
||||
@@ -1605,6 +1606,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/blurhash": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
||||
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/icons": "^1.59.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"lucide-react": "^1.59.0",
|
||||
"posthog-js": "^1.438.12",
|
||||
"react": "^19.3.1",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, Play, FileText, Mic, Paperclip } from 'lucide-react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { buildLifeTimeline } from '../utils/lifeTimeline.js'
|
||||
import './LifeTimeline.css'
|
||||
import { blurPlaceholder } from '../utils/blurhash.js'
|
||||
|
||||
// Onglet Vie de la fiche : naissance, unions et décès en grands repères, les autres événements
|
||||
// plus petits entre eux, la famille proche en retrait. Les sources restent repliées.
|
||||
@@ -37,7 +38,7 @@ function Attachments({ medias, onOpen }) {
|
||||
<div className="lt-photos">
|
||||
{photos.map((media) => (
|
||||
<button key={media.id} type="button" className="lt-photo" onClick={() => onOpen?.(media)} aria-label={t('Voir « {title} »', { title: media.label })}>
|
||||
<img src={media.url} alt="" loading="lazy" />
|
||||
<img src={media.url} alt="" loading="lazy" {...blurPlaceholder(media.blurhash)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
import ImmichAssetPicker from './ImmichAssetPicker.jsx'
|
||||
import { fetchImmichConfig } from '../api/immichApi.js'
|
||||
import { blurPlaceholder } from '../utils/blurhash.js'
|
||||
|
||||
const MEDIA_TYPES = [
|
||||
{ value: 'photo', label: t('Photo'), Icon: Image },
|
||||
@@ -358,7 +359,7 @@ const MediaManagerPanel = ({
|
||||
<li key={media.id} className="mm-item">
|
||||
<span className="mm-preview" aria-hidden="true">
|
||||
{media.type === 'photo' && media.url ? (
|
||||
<img src={media.url} alt="" />
|
||||
<img src={media.url} alt="" {...blurPlaceholder(media.blurhash)} />
|
||||
) : media.type === 'geojson' || media.type === 'gpx' ? (
|
||||
<GeoMediaThumbnail media={media} className="media-manager-modal-preview-map" />
|
||||
) : (
|
||||
|
||||
@@ -23,6 +23,7 @@ import { runOnce } from '../utils/saveOnLeave.js'
|
||||
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
|
||||
import { ProposalBanner } from './ProposalCard.jsx'
|
||||
import { initialsOf } from '../utils/initial.js'
|
||||
import { blurPlaceholder } from '../utils/blurhash.js'
|
||||
|
||||
// Aucune modification en cours (référence stable)
|
||||
const NO_EDITS = Object.freeze({})
|
||||
@@ -548,7 +549,7 @@ const PersonCard = ({
|
||||
const renderMediaIcon = (media) => {
|
||||
if (media.type === 'photo') {
|
||||
return media.url
|
||||
? <img src={media.url} alt="" />
|
||||
? <img src={media.url} alt="" {...blurPlaceholder(media.blurhash)} />
|
||||
: <span>📷</span>
|
||||
}
|
||||
|
||||
@@ -691,9 +692,10 @@ const PersonCard = ({
|
||||
/>
|
||||
)}
|
||||
{/* Hors modification, le portrait se regarde : la photo s'ajoute ou se change en mode modification */}
|
||||
<div className="pc-photo">
|
||||
{/* Aperçu flou du portrait sur le cadre pendant le chargement (le recadrage s'applique à l'image) */}
|
||||
<div className="pc-photo" style={person.photo ? blurPlaceholder(person.avatarBlurhash).style : undefined}>
|
||||
{person.photo
|
||||
? <img src={person.photo} alt={fullNameLabel(person)} onLoad={(event) => setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })} style={photoStyle} />
|
||||
? <img src={person.photo} alt={fullNameLabel(person)} onLoad={(event) => { event.currentTarget.parentElement.style.backgroundImage = 'none'; setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight }) }} style={photoStyle} />
|
||||
: <span className="pc-photo-initials">{initials}</span>}
|
||||
</div>
|
||||
{editMode && onAvatarUpload && (
|
||||
@@ -856,7 +858,7 @@ const PersonCard = ({
|
||||
title={media.label || TYPE_LABELS[media.type]}
|
||||
>
|
||||
{media.type === 'photo' && media.url
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" />
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" {...blurPlaceholder(media.blurhash)} />
|
||||
: <span className="pc-tile-video"><Play size={18} fill="currentColor" strokeWidth={0} /></span>}
|
||||
{media.label && <span className="pc-tile-label">{media.label}</span>}
|
||||
</button>
|
||||
@@ -884,7 +886,7 @@ const PersonCard = ({
|
||||
title={media.label || TYPE_LABELS[media.type]}
|
||||
>
|
||||
{media.type === 'photo' && media.url
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" />
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" {...blurPlaceholder(media.blurhash)} />
|
||||
: <span className="pc-tile-video"><Play size={18} fill="currentColor" strokeWidth={0} /></span>}
|
||||
{media.label && <span className="pc-tile-label">{media.label}</span>}
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getEntranceProgress, drawPartialBezier } from './utils'
|
||||
import { fitNickname } from '../../utils/nickname.js'
|
||||
import { firstNameLabel, isFirstNameUnknown } from '../../utils/personName.js'
|
||||
import { initialsOf } from '../../utils/initial.js'
|
||||
import { blurhashCanvas } from '../../utils/blurhash.js'
|
||||
|
||||
// Largeur maximale du surnom sur le nœud (px canevas) : au-delà, coupé avec …
|
||||
const NICKNAME_MAX_W = 150
|
||||
@@ -829,7 +830,8 @@ function drawGpxThumbnail(ctx, ms) {
|
||||
}
|
||||
|
||||
function drawMediaContent(ctx, media, ms, imageCache) {
|
||||
const mImg = media.url ? imageCache.get(media.url) : null
|
||||
// Photo pas encore chargée : son aperçu flou
|
||||
const mImg = media.url ? (imageCache.get(media.url) || (media.type === 'photo' ? blurhashCanvas(media.blurhash) : null)) : null
|
||||
|
||||
if (media.type === 'photo' && mImg) {
|
||||
ctx.save()
|
||||
@@ -1047,8 +1049,10 @@ function drawFrame(ctx, frameType, person, img) {
|
||||
// Dessiner l'image ou le placeholder initiales
|
||||
// ============================================================
|
||||
function drawPersonImage(ctx, img, person, x, y, w, h) {
|
||||
if (img) {
|
||||
drawImageCover(ctx, img, x, y, w, h, person.avatarCrop)
|
||||
// Portrait pas encore chargé : son aperçu flou, cadré pareil
|
||||
const placeholder = !img && person.photo ? blurhashCanvas(person.avatarBlurhash) : null
|
||||
if (img || placeholder) {
|
||||
drawImageCover(ctx, img || placeholder, x, y, w, h, person.avatarCrop)
|
||||
if (!person.isAlive) {
|
||||
ctx.fillStyle = 'rgba(235, 230, 223, 0.25)'
|
||||
ctx.fillRect(x, y, w, h)
|
||||
|
||||
@@ -127,6 +127,8 @@ function mapRemotePersonToLocal(person, current = null) {
|
||||
photo: current?.photo || null,
|
||||
avatarFromImmich: person?.avatarFromImmich ?? false,
|
||||
avatarCrop: person ? (person.avatarCrop ?? null) : (current?.avatarCrop ?? null),
|
||||
// Aperçu flou du portrait pendant son chargement (utils/blurhash.js)
|
||||
avatarBlurhash: person ? (person.avatarBlurhash ?? null) : (current?.avatarBlurhash ?? null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +266,7 @@ export function loadGraphData(graph, options = {}) {
|
||||
displayOrder: Number.isFinite(media.displayOrder) ? media.displayOrder : index + 1,
|
||||
isFeatured: Boolean(media.isFeatured),
|
||||
mimeType: media.mimeType || null,
|
||||
blurhash: media.blurhash || null,
|
||||
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
|
||||
people: (Array.isArray(media.people) ? media.people : [])
|
||||
.filter((tag) => personIdSet.has(tag.personId))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { decode, isBlurhashValid } from 'blurhash'
|
||||
|
||||
// Aperçus flous (BlurHash, calculés par l'API, backend/src/lib/blurhash.ts) : une image floue aux couleurs de la
|
||||
// photo, montrée pendant que la vraie se charge. Pas de fondu : la photo remplace l'aperçu d'un coup.
|
||||
|
||||
const SIZE = 32
|
||||
const canvases = new Map()
|
||||
|
||||
// Petit canvas de l'aperçu, gardé par chaîne ; null si la chaîne est absente ou invalide
|
||||
export function blurhashCanvas(hash) {
|
||||
if (!hash || typeof document === 'undefined') return null
|
||||
if (canvases.has(hash)) return canvases.get(hash)
|
||||
let canvas = null
|
||||
if (isBlurhashValid(hash).result) {
|
||||
canvas = document.createElement('canvas')
|
||||
canvas.width = SIZE
|
||||
canvas.height = SIZE
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
const image = ctx.createImageData(SIZE, SIZE)
|
||||
image.data.set(decode(hash, SIZE, SIZE))
|
||||
ctx.putImageData(image, 0, 0)
|
||||
} else {
|
||||
canvas = null
|
||||
}
|
||||
}
|
||||
if (canvases.size > 500) canvases.delete(canvases.keys().next().value)
|
||||
canvases.set(hash, canvas)
|
||||
return canvas
|
||||
}
|
||||
|
||||
const urls = new Map()
|
||||
|
||||
// Props d'une balise <img> : l'aperçu en fond tant que la photo n'est pas là. fit suit l'object-fit de l'image
|
||||
// (cover pour une vignette, contain pour la visionneuse). Le fond est retiré au chargement, pour une photo
|
||||
// transparente.
|
||||
export function blurPlaceholder(hash, fit = 'cover') {
|
||||
const canvas = blurhashCanvas(hash)
|
||||
if (!canvas) return {}
|
||||
if (!urls.has(hash)) urls.set(hash, canvas.toDataURL())
|
||||
return {
|
||||
style: { backgroundImage: `url(${urls.get(hash)})`, backgroundSize: fit === 'contain' ? 'contain' : 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' },
|
||||
onLoad: (event) => { event.currentTarget.style.backgroundImage = 'none' },
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user