Aperçus flous (BlurHash) pendant le chargement des photos et des portraits

Les photos partent en taille réelle, sans miniature : sur un réseau lent, une vignette restait un carré
vide et un portrait montrait ses initiales. Chaque photo et chaque portrait a maintenant un aperçu flou
aux bonnes couleurs (une chaîne de 28 caractères), affiché jusqu'à l'arrivée de l'image, sans fondu.

- Base : media_items.blurhash et persons.avatarBlurhash (migration additive 20261027_001).
- API : calculé à l'envoi d'une photo et d'un portrait (sharp, 32 px), publié dans le graphe ; les autres
  chemins (Immich, import GEDCOM, propositions acceptées) et les photos d'avant sont rattrapés en tâche de
  fond (lots de 25, puis une passe par minute). Un fichier illisible reçoit un aperçu vide.
- Navigateur : vignettes de la fiche, de la frise et du gestionnaire de souvenirs, portrait de la fiche,
  portraits et souvenirs dessinés sur l'arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-27 16:17:58 +01:00
1 parent d5d446bc32
commit 291dde8073
20 files changed
+323 -12

No files matched your search

+7
View File
@@ -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",
+1
View File
@@ -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;
+4
View File
@@ -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
View File
@@ -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, {
+92
View File
@@ -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)
})
}
+1 -1
View File
@@ -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({
+4
View File
@@ -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.
+4
View File
@@ -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,
+4
View File
@@ -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) => ({
+95
View File
@@ -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('')
})
})
+26
View File
@@ -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()
})
})
+7
View File
@@ -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",
+1
View File
@@ -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",
+2 -1
View File
@@ -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" />
) : (
+7 -5
View File
@@ -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>&#128247;</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>
+7 -3
View File
@@ -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)
+3
View File
@@ -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))
+45
View File
@@ -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' },
}
}