diff --git a/backend/package-lock.json b/backend/package-lock.json index 6e60064..b1d1db4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index e933fb8..33d2bd3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/prisma/migrations/20261027_001_apercus_flous/migration.sql b/backend/prisma/migrations/20261027_001_apercus_flous/migration.sql new file mode 100644 index 0000000..ade33b7 --- /dev/null +++ b/backend/prisma/migrations/20261027_001_apercus_flous/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bc300f7..df2ab67 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/app.ts b/backend/src/app.ts index bb92d21..73e8078 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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, { diff --git a/backend/src/lib/blurhash.ts b/backend/src/lib/blurhash.ts new file mode 100644 index 0000000..ca3201a --- /dev/null +++ b/backend/src/lib/blurhash.ts @@ -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 { + 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 { + 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 { + 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) + }) +} diff --git a/backend/src/lib/entity-deletion.ts b/backend/src/lib/entity-deletion.ts index fa087c4..56ae42b 100644 --- a/backend/src/lib/entity-deletion.ts +++ b/backend/src/lib/entity-deletion.ts @@ -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({ diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 284af00..864431b 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -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. diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index ead6773..40cc631 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -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, diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 4995bf6..05a63cf 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -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) => ({ diff --git a/backend/test/apercus-flous.db.test.ts b/backend/test/apercus-flous.db.test.ts new file mode 100644 index 0000000..5974a7f --- /dev/null +++ b/backend/test/apercus-flous.db.test.ts @@ -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, + 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('') + }) +}) diff --git a/backend/test/blurhash.test.ts b/backend/test/blurhash.test.ts new file mode 100644 index 0000000..aaddecc --- /dev/null +++ b/backend/test/blurhash.test.ts @@ -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() + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3d7d589..e497b92 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 8c214a6..f29310c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/LifeTimeline.jsx b/frontend/src/components/LifeTimeline.jsx index 8090f62..63534c3 100644 --- a/frontend/src/components/LifeTimeline.jsx +++ b/frontend/src/components/LifeTimeline.jsx @@ -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 }) {
{photos.map((media) => ( ))}
diff --git a/frontend/src/components/MediaManagerPanel.jsx b/frontend/src/components/MediaManagerPanel.jsx index 952b156..7110d60 100644 --- a/frontend/src/components/MediaManagerPanel.jsx +++ b/frontend/src/components/MediaManagerPanel.jsx @@ -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 = ({