From 0c9a52940f59d3abb039104609e92ee327427e74 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 19 Jul 2020 00:24:07 +0900
Subject: [PATCH] feat: Blurhash integration

Resolve #6559
---
 migration/1595075960584-blurhash.ts           | 14 +++
 ...595077605646-blurhash-for-avatar-banner.ts | 20 ++++
 package.json                                  |  1 +
 src/client/components/avatar.vue              | 38 ++------
 .../components/drive-file-thumbnail.vue       | 85 +++--------------
 src/client/components/drive.file.vue          | 12 ---
 src/client/components/img-with-blurhash.vue   | 78 ++++++++++++++++
 src/client/components/media-image.vue         | 91 +++++++++++--------
 src/client/components/media-list.vue          |  2 +-
 .../messaging/messaging-room.message.vue      |  3 +-
 .../page-editor/els/page-editor.el.image.vue  |  2 +-
 src/client/style.scss                         |  4 -
 src/misc/get-file-info.ts                     | 37 ++++----
 src/models/entities/drive-file.ts             |  6 ++
 src/models/entities/user.ts                   |  8 +-
 src/models/repositories/drive-file.ts         |  1 +
 src/models/repositories/user.ts               | 10 +-
 src/remote/activitypub/models/person.ts       | 16 ++--
 src/server/api/endpoints/i/update.ts          |  8 +-
 src/services/drive/add-file.ts                |  6 +-
 test/get-file-info.ts                         | 16 ++--
 yarn.lock                                     |  5 +
 22 files changed, 249 insertions(+), 214 deletions(-)
 create mode 100644 migration/1595075960584-blurhash.ts
 create mode 100644 migration/1595077605646-blurhash-for-avatar-banner.ts
 create mode 100644 src/client/components/img-with-blurhash.vue

diff --git a/migration/1595075960584-blurhash.ts b/migration/1595075960584-blurhash.ts
new file mode 100644
index 000000000..7c716ae17
--- /dev/null
+++ b/migration/1595075960584-blurhash.ts
@@ -0,0 +1,14 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class blurhash1595075960584 implements MigrationInterface {
+    name = 'blurhash1595075960584'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "drive_file" ADD "blurhash" character varying(128)`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "blurhash"`);
+    }
+
+}
diff --git a/migration/1595077605646-blurhash-for-avatar-banner.ts b/migration/1595077605646-blurhash-for-avatar-banner.ts
new file mode 100644
index 000000000..fcf161c35
--- /dev/null
+++ b/migration/1595077605646-blurhash-for-avatar-banner.ts
@@ -0,0 +1,20 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class blurhashForAvatarBanner1595077605646 implements MigrationInterface {
+    name = 'blurhashForAvatarBanner1595077605646'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarColor"`);
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerColor"`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`);
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "bannerColor" character varying(32)`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "avatarColor" character varying(32)`);
+    }
+
+}
diff --git a/package.json b/package.json
index 8868fede5..eef08c93a 100644
--- a/package.json
+++ b/package.json
@@ -112,6 +112,7 @@
 		"autwh": "0.1.0",
 		"aws-sdk": "2.713.0",
 		"bcryptjs": "2.4.3",
+		"blurhash": "1.1.3",
 		"bull": "3.15.0",
 		"cafy": "15.2.1",
 		"cbor": "5.0.2",
diff --git a/src/client/components/avatar.vue b/src/client/components/avatar.vue
index 29b457db8..fd4ab78ce 100644
--- a/src/client/components/avatar.vue
+++ b/src/client/components/avatar.vue
@@ -1,15 +1,9 @@
 <template>
-<span class="eiwwqkts" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick">
-	<span class="inner" :style="icon"></span>
+<span class="eiwwqkts" :class="{ cat }" :title="user | acct" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
+	<img class="inner" :src="url"/>
 </span>
-<span class="eiwwqkts" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick">
-	<span class="inner" :style="icon"></span>
-</span>
-<router-link class="eiwwqkts" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id">
-	<span class="inner" :style="icon"></span>
-</router-link>
-<router-link class="eiwwqkts" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview">
-	<span class="inner" :style="icon"></span>
+<router-link class="eiwwqkts" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
+	<img class="inner" :src="url"/>
 </router-link>
 </template>
 
@@ -45,22 +39,6 @@ export default Vue.extend({
 				? getStaticImageUrl(this.user.avatarUrl)
 				: this.user.avatarUrl;
 		},
-		icon(): any {
-			return {
-				backgroundColor: this.user.avatarColor,
-				backgroundImage: `url(${this.url})`,
-			};
-		}
-	},
-	watch: {
-		'user.avatarColor'() {
-			this.$el.style.color = this.user.avatarColor;
-		}
-	},
-	mounted() {
-		if (this.user.avatarColor) {
-			this.$el.style.color = this.user.avatarColor;
-		}
 	},
 	methods: {
 		onClick(e) {
@@ -102,15 +80,17 @@ export default Vue.extend({
 	}
 	
 	.inner {
-		background-position: center center;
-		background-size: cover;
+		position: absolute;
 		bottom: 0;
 		left: 0;
-		position: absolute;
 		right: 0;
 		top: 0;
 		border-radius: 100%;
 		z-index: 1;
+		overflow: hidden;
+		object-fit: cover;
+		width: 100%;
+		height: 100%;
 	}
 }
 </style>
diff --git a/src/client/components/drive-file-thumbnail.vue b/src/client/components/drive-file-thumbnail.vue
index 3561be0bc..4bc1e569b 100644
--- a/src/client/components/drive-file-thumbnail.vue
+++ b/src/client/components/drive-file-thumbnail.vue
@@ -1,36 +1,15 @@
 <template>
-<div class="zdjebgpv" :class="{ detail }" ref="thumbnail" :style="`background-color: ${ background }`">
-	<img
-		:src="file.url"
-		:alt="file.name"
-		:title="file.name"
-		@load="onThumbnailLoaded"
-		v-if="detail && is === 'image'"/>
-	<video
-		:src="file.url"
-		ref="volumectrl"
-		preload="metadata"
-		controls
-		v-else-if="detail && is === 'video'"/>
-	<img :src="file.thumbnailUrl" @load="onThumbnailLoaded" :style="`object-fit: ${ fit }`" v-else-if="isThumbnailAvailable"/>
+<div class="zdjebgpv" ref="thumbnail">
+	<img-with-blurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/>
 	<fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/>
 	<fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/>
-
-	<audio
-		:src="file.url"
-		ref="volumectrl"
-		preload="metadata"
-		controls
-		v-else-if="detail && is === 'audio'"/>
 	<fa :icon="faMusic" class="icon" v-else-if="is === 'audio' || is === 'midi'"/>
-
 	<fa :icon="faFileCsv" class="icon" v-else-if="is === 'csv'"/>
 	<fa :icon="faFilePdf" class="icon" v-else-if="is === 'pdf'"/>
 	<fa :icon="faFileAlt" class="icon" v-else-if="is === 'textfile'"/>
 	<fa :icon="faFileArchive" class="icon" v-else-if="is === 'archive'"/>
 	<fa :icon="faFile" class="icon" v-else/>
-
-	<fa :icon="faFilm" class="icon-sub" v-if="!detail && isThumbnailAvailable && is === 'video'"/>
+	<fa :icon="faFilm" class="icon-sub" v-if="isThumbnailAvailable && is === 'video'"/>
 </div>
 </template>
 
@@ -47,8 +26,12 @@ import {
 	faFileArchive,
 	faFilm
 	} from '@fortawesome/free-solid-svg-icons';
+import ImgWithBlurhash from './img-with-blurhash.vue';
 
 export default Vue.extend({
+	components: {
+		ImgWithBlurhash
+	},
 	props: {
 		file: {
 			type: Object,
@@ -59,11 +42,6 @@ export default Vue.extend({
 			required: false,
 			default: 'cover'
 		},
-		detail: {
-			type: Boolean,
-			required: false,
-			default: false
-		}
 	},
 	data() {
 		return {
@@ -108,20 +86,12 @@ export default Vue.extend({
 				? (this.is === 'image' || this.is === 'video')
 				: false;
 		},
-		background(): string {
-			return this.file.properties.avgColor || 'transparent';
-		}
 	},
 	mounted() {
 		const audioTag = this.$refs.volumectrl as HTMLAudioElement;
 		if (audioTag) audioTag.volume = this.$store.state.device.mediaVolume;
 	},
 	methods: {
-		onThumbnailLoaded() {
-			if (this.file.properties.avgColor) {
-				this.$refs.thumbnail.style.backgroundColor = 'transparent';
-			}
-		},
 		volumechange() {
 			const audioTag = this.$refs.volumectrl as HTMLAudioElement;
 			this.$store.commit('device/set', { key: 'mediaVolume', value: audioTag.volume });
@@ -132,14 +102,8 @@ export default Vue.extend({
 
 <style lang="scss" scoped>
 .zdjebgpv {
-	display: flex;
 	position: relative;
 
-	> img,
-	> .icon {
-		pointer-events: none;
-	}
-
 	> .icon-sub {
 		position: absolute;
 		width: 30%;
@@ -153,37 +117,10 @@ export default Vue.extend({
 		margin: auto;
 	}
 
-	&:not(.detail) {
-		> img {
-			height: 100%;
-			width: 100%;
-			object-fit: cover;
-		}
-
-		> .icon {
-			height: 65%;
-			width: 65%;
-		}
-
-		> video,
-		> audio {
-			width: 100%;
-		}
-	}
-
-	&.detail {
-		> .icon {
-			height: 100px;
-			width: 100px;
-			margin: 16px;
-		}
-
-		> *:not(.icon) {
-			max-height: 300px;
-			max-width: 100%;
-			height: 100%;
-			object-fit: contain;
-		}
+	> .icon {
+		pointer-events: none;
+		height: 65%;
+		width: 65%;
 	}
 }
 </style>
diff --git a/src/client/components/drive.file.vue b/src/client/components/drive.file.vue
index 1b24c61df..b31a4e637 100644
--- a/src/client/components/drive.file.vue
+++ b/src/client/components/drive.file.vue
@@ -126,17 +126,6 @@ export default Vue.extend({
 			this.browser.isDragSource = false;
 		},
 
-		onThumbnailLoaded() {
-			if (this.file.properties.avgColor) {
-				anime({
-					targets: this.$refs.thumbnail,
-					backgroundColor: 'transparent', // TODO fade
-					duration: 100,
-					easing: 'linear'
-				});
-			}
-		},
-
 		rename() {
 			this.$root.dialog({
 				title: this.$t('renameFile'),
@@ -332,7 +321,6 @@ export default Vue.extend({
 		width: 128px;
 		height: 128px;
 		margin: auto;
-		color: var(--driveFileIcon);
 	}
 
 	> .name {
diff --git a/src/client/components/img-with-blurhash.vue b/src/client/components/img-with-blurhash.vue
new file mode 100644
index 000000000..6e6a2a896
--- /dev/null
+++ b/src/client/components/img-with-blurhash.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="xubzgfgb" :title="title">
+	<canvas ref="canvas" :width="size" :height="size" :title="title" v-if="!loaded"/>
+	<img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { decode } from 'blurhash';
+
+export default Vue.extend({
+	props: {
+		src: {
+			type: String,
+			required: false,
+			default: null
+		},
+		hash: {
+			type: String,
+			required: true
+		},
+		alt: {
+			type: String,
+			required: false,
+			default: '',
+		},
+		title: {
+			type: String,
+			required: false,
+			default: null,
+		},
+		size: {
+			type: Number,
+			required: false,
+			default: 64
+		},
+	},
+
+	data() {
+		return {
+			loaded: false,
+		};
+	},
+
+	mounted() {
+		this.draw();
+	},
+
+	methods: {
+		draw() {
+			const pixels = decode(this.hash, this.size, this.size);
+			const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d');
+			const imageData = ctx!.createImageData(this.size, this.size);
+			imageData.data.set(pixels);
+			ctx!.putImageData(imageData, 0, 0);
+		},
+
+		onLoad() {
+			this.loaded = true;
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.xubzgfgb {
+	width: 100%;
+	height: 100%;
+
+	> canvas,
+	> img {
+		width: 100%;
+		height: 100%;
+		object-fit: cover;
+	}
+}
+</style>
diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue
index 6d1b5345d..f6ed45dae 100644
--- a/src/client/components/media-image.vue
+++ b/src/client/components/media-image.vue
@@ -1,19 +1,22 @@
 <template>
-<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="hide" @click="hide = false">
-	<div>
-		<b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b>
-		<span>{{ $t('clickToShow') }}</span>
+<div class="qjewsnkg" v-if="hide" @click="hide = false">
+	<img-with-blurhash class="bg" :hash="image.blurhash" :title="image.name"/>
+	<div class="text">
+		<div>
+			<b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b>
+			<span>{{ $t('clickToShow') }}</span>
+		</div>
 	</div>
 </div>
-<div class="gqnyydlzavusgskkfvwvjiattxdzsqlf" v-else>
+<div class="gqnyydlz" v-else>
 	<i><fa :icon="faEyeSlash" @click="hide = true"/></i>
 	<a
 		:href="image.url"
-		:style="style"
 		:title="image.name"
 		@click.prevent="onClick"
 	>
-		<div v-if="image.type === 'image/gif'">GIF</div>
+		<img-with-blurhash :hash="image.blurhash" :src="url" :alt="image.name" :title="image.name"/>
+		<div class="gif" v-if="image.type === 'image/gif'">GIF</div>
 	</a>
 </div>
 </template>
@@ -23,8 +26,12 @@ import Vue from 'vue';
 import { faExclamationTriangle, faEyeSlash } from '@fortawesome/free-solid-svg-icons';
 import { getStaticImageUrl } from '../scripts/get-static-image-url';
 import ImageViewer from './image-viewer.vue';
+import ImgWithBlurhash from './img-with-blurhash.vue';
 
 export default Vue.extend({
+	components: {
+		ImgWithBlurhash
+	},
 	props: {
 		image: {
 			type: Object,
@@ -42,23 +49,18 @@ export default Vue.extend({
 		};
 	},
 	computed: {
-		style(): any {
-			let url = `url(${
-				this.$store.state.device.disableShowingAnimatedImages
-					? getStaticImageUrl(this.image.thumbnailUrl)
-					: this.image.thumbnailUrl
-			})`;
+		url(): any {
+			let url = this.$store.state.device.disableShowingAnimatedImages
+				? getStaticImageUrl(this.image.thumbnailUrl)
+				: this.image.thumbnailUrl;
 
 			if (this.$store.state.device.loadRemoteMedia) {
 				url = null;
 			} else if (this.raw || this.$store.state.device.loadRawImages) {
-				url = `url(${this.image.url})`;
+				url = this.image.url;
 			}
 
-			return {
-				'background-color': this.image.properties.avgColor || 'transparent',
-				'background-image': url
-			};
+			return url;
 		}
 	},
 	created() {
@@ -82,7 +84,38 @@ export default Vue.extend({
 </script>
 
 <style lang="scss" scoped>
-.gqnyydlzavusgskkfvwvjiattxdzsqlf {
+.qjewsnkg {
+	position: relative;
+
+	> .bg {
+		filter: brightness(0.5);
+	}
+
+	> .text {
+		position: absolute;
+		left: 0;
+		top: 0;
+		width: 100%;
+		height: 100%;
+		z-index: 1;
+		display: flex;
+		justify-content: center;
+		align-items: center;
+
+		> div {
+			display: table-cell;
+			text-align: center;
+			font-size: 0.8em;
+			color: #fff;
+
+			> * {
+				display: block;
+			}
+		}
+	}
+}
+
+.gqnyydlz {
 	position: relative;
 
 	> i {
@@ -110,7 +143,7 @@ export default Vue.extend({
 		background-size: contain;
 		background-repeat: no-repeat;
 
-		> div {
+		> .gif {
 			background-color: var(--fg);
 			border-radius: 6px;
 			color: var(--accentLighten);
@@ -126,22 +159,4 @@ export default Vue.extend({
 		}
 	}
 }
-
-.qjewsnkgzzxlxtzncydssfbgjibiehcy {
-	display: flex;
-	justify-content: center;
-	align-items: center;
-	background: #111;
-	color: #fff;
-
-	> div {
-		display: table-cell;
-		text-align: center;
-		font-size: 12px;
-
-		> * {
-			display: block;
-		}
-	}
-}
 </style>
diff --git a/src/client/components/media-list.vue b/src/client/components/media-list.vue
index c757d8091..fd0035f10 100644
--- a/src/client/components/media-list.vue
+++ b/src/client/components/media-list.vue
@@ -114,7 +114,7 @@ export default Vue.extend({
 
 			> * {
 				overflow: hidden;
-				border-radius: 4px;
+				border-radius: 6px;
 			}
 
 			&[data-count="1"] {
diff --git a/src/client/pages/messaging/messaging-room.message.vue b/src/client/pages/messaging/messaging-room.message.vue
index 58e1e54ad..4461740df 100644
--- a/src/client/pages/messaging/messaging-room.message.vue
+++ b/src/client/pages/messaging/messaging-room.message.vue
@@ -10,8 +10,7 @@
 				<mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/>
 				<div class="file" v-if="message.file">
 					<a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
-						<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"
-							:style="{ backgroundColor: message.file.properties.avgColor || 'transparent' }"/>
+						<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
 						<p v-else>{{ message.file.name }}</p>
 					</a>
 				</div>
diff --git a/src/client/pages/page-editor/els/page-editor.el.image.vue b/src/client/pages/page-editor/els/page-editor.el.image.vue
index dd690da6f..d26d7f603 100644
--- a/src/client/pages/page-editor/els/page-editor.el.image.vue
+++ b/src/client/pages/page-editor/els/page-editor.el.image.vue
@@ -8,7 +8,7 @@
 	</template>
 
 	<section class="oyyftmcf">
-		<mk-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/>
+		<mk-file-thumbnail class="preview" v-if="file" :file="file" fit="contain" @click="choose()"/>
 	</section>
 </x-container>
 </template>
diff --git a/src/client/style.scss b/src/client/style.scss
index 972c38338..c3d3cf223 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -123,10 +123,6 @@ a {
 	&:hover {
 		text-decoration: underline;
 	}
-
-	* {
-		cursor: pointer;
-	}
 }
 
 hr {
diff --git a/src/misc/get-file-info.ts b/src/misc/get-file-info.ts
index b838900f6..ce177cc53 100644
--- a/src/misc/get-file-info.ts
+++ b/src/misc/get-file-info.ts
@@ -6,6 +6,7 @@ import * as fileType from 'file-type';
 import isSvg from 'is-svg';
 import * as probeImageSize from 'probe-image-size';
 import * as sharp from 'sharp';
+import { encode } from 'blurhash';
 
 const pipeline = util.promisify(stream.pipeline);
 
@@ -18,7 +19,7 @@ export type FileInfo = {
 	};
 	width?: number;
 	height?: number;
-	avgColor?: number[];
+	blurhash?: string;
 	warnings: string[];
 };
 
@@ -71,12 +72,11 @@ export async function getFileInfo(path: string): Promise<FileInfo> {
 		}
 	}
 
-	// average color
-	let avgColor: number[] | undefined;
+	let blurhash: string | undefined;
 
 	if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) {
-		avgColor = await calcAvgColor(path).catch(e => {
-			warnings.push(`calcAvgColor failed: ${e}`);
+		blurhash = await getBlurhash(path).catch(e => {
+			warnings.push(`getBlurhash failed: ${e}`);
 			return undefined;
 		});
 	}
@@ -87,7 +87,7 @@ export async function getFileInfo(path: string): Promise<FileInfo> {
 		type,
 		width,
 		height,
-		avgColor,
+		blurhash,
 		warnings,
 	};
 }
@@ -173,18 +173,15 @@ async function detectImageSize(path: string): Promise<{
 /**
  * Calculate average color of image
  */
-async function calcAvgColor(path: string): Promise<number[]> {
-	const img = sharp(path);
-
-	const info = await (img as any).stats();
-
-	if (info.isOpaque) {
-		const r = Math.round(info.channels[0].mean);
-		const g = Math.round(info.channels[1].mean);
-		const b = Math.round(info.channels[2].mean);
-
-		return [r, g, b];
-	} else {
-		return [255, 255, 255];
-	}
+function getBlurhash(path: string): Promise<string> {
+	return new Promise((resolve, reject) => {
+		sharp(path)
+			.raw()
+			.ensureAlpha()
+			.resize(64, 64, { fit: 'inside' })
+			.toBuffer((err, buffer, { width, height }) => {
+				if (err) return reject(err);
+				resolve(encode(new Uint8ClampedArray(buffer), width, height, 7, 7));
+			});
+	});
 }
diff --git a/src/models/entities/drive-file.ts b/src/models/entities/drive-file.ts
index 067dc1181..c02b9f363 100644
--- a/src/models/entities/drive-file.ts
+++ b/src/models/entities/drive-file.ts
@@ -67,6 +67,12 @@ export class DriveFile {
 	})
 	public comment: string | null;
 
+	@Column('varchar', {
+		length: 128, nullable: true,
+		comment: 'The BlurHash string.'
+	})
+	public blurhash: string | null;
+
 	@Column('jsonb', {
 		default: {},
 		comment: 'The any properties of the DriveFile. For example, it includes image width/height.'
diff --git a/src/models/entities/user.ts b/src/models/entities/user.ts
index d3086f43f..fee5906a3 100644
--- a/src/models/entities/user.ts
+++ b/src/models/entities/user.ts
@@ -106,14 +106,14 @@ export class User {
 	public bannerUrl: string | null;
 
 	@Column('varchar', {
-		length: 32, nullable: true,
+		length: 128, nullable: true,
 	})
-	public avatarColor: string | null;
+	public avatarBlurhash: string | null;
 
 	@Column('varchar', {
-		length: 32, nullable: true,
+		length: 128, nullable: true,
 	})
-	public bannerColor: string | null;
+	public bannerBlurhash: string | null;
 
 	@Column('boolean', {
 		default: false,
diff --git a/src/models/repositories/drive-file.ts b/src/models/repositories/drive-file.ts
index 28a393cfb..6bdf62be8 100644
--- a/src/models/repositories/drive-file.ts
+++ b/src/models/repositories/drive-file.ts
@@ -115,6 +115,7 @@ export class DriveFileRepository extends Repository<DriveFile> {
 			md5: file.md5,
 			size: file.size,
 			isSensitive: file.isSensitive,
+			blurhash: file.blurhash,
 			properties: file.properties,
 			url: opts.self ? file.url : this.getPublicUrl(file, false, meta),
 			thumbnailUrl: this.getPublicUrl(file, true, meta),
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index c4c9503e0..bbaafc905 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -165,7 +165,8 @@ export class UserRepository extends Repository<User> {
 			username: user.username,
 			host: user.host,
 			avatarUrl: user.avatarUrl ? user.avatarUrl : config.url + '/avatar/' + user.id,
-			avatarColor: user.avatarColor,
+			avatarBlurhash: user.avatarBlurhash,
+			avatarColor: null, // 後方互換性のため
 			isAdmin: user.isAdmin || falsy,
 			isModerator: user.isModerator || falsy,
 			isBot: user.isBot || falsy,
@@ -196,7 +197,8 @@ export class UserRepository extends Repository<User> {
 				createdAt: user.createdAt.toISOString(),
 				updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
 				bannerUrl: user.bannerUrl,
-				bannerColor: user.bannerColor,
+				bannerBlurhash: user.bannerBlurhash,
+				bannerColor: null, // 後方互換性のため
 				isLocked: user.isLocked,
 				isModerator: user.isModerator || falsy,
 				isSilenced: user.isSilenced || falsy,
@@ -331,7 +333,7 @@ export const packedUserSchema = {
 			format: 'url',
 			nullable: true as const, optional: false as const,
 		},
-		avatarColor: {
+		avatarBlurhash: {
 			type: 'any' as const,
 			nullable: true as const, optional: false as const,
 		},
@@ -340,7 +342,7 @@ export const packedUserSchema = {
 			format: 'url',
 			nullable: true as const, optional: true as const,
 		},
-		bannerColor: {
+		bannerBlurhash: {
 			type: 'any' as const,
 			nullable: true as const, optional: true as const,
 		},
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts
index a3093786d..a213abf47 100644
--- a/src/remote/activitypub/models/person.ts
+++ b/src/remote/activitypub/models/person.ts
@@ -226,24 +226,24 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us
 	const bannerId = banner ? banner.id : null;
 	const avatarUrl = avatar ? DriveFiles.getPublicUrl(avatar, true) : null;
 	const bannerUrl = banner ? DriveFiles.getPublicUrl(banner) : null;
-	const avatarColor = avatar && avatar.properties.avgColor ? avatar.properties.avgColor : null;
-	const bannerColor = banner && banner.properties.avgColor ? banner.properties.avgColor : null;
+	const avatarBlurhash = avatar ? avatar.blurhash : null;
+	const bannerBlurhash = banner ? banner.blurhash : null;
 
 	await Users.update(user!.id, {
 		avatarId,
 		bannerId,
 		avatarUrl,
 		bannerUrl,
-		avatarColor,
-		bannerColor
+		avatarBlurhash,
+		bannerBlurhash
 	});
 
 	user!.avatarId = avatarId;
 	user!.bannerId = bannerId;
 	user!.avatarUrl = avatarUrl;
 	user!.bannerUrl = bannerUrl;
-	user!.avatarColor = avatarColor;
-	user!.bannerColor = bannerColor;
+	user!.avatarBlurhash = avatarBlurhash;
+	user!.bannerBlurhash = bannerBlurhash;
 	//#endregion
 
 	//#region カスタム絵文字取得
@@ -341,13 +341,13 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
 	if (avatar) {
 		updates.avatarId = avatar.id;
 		updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true);
-		updates.avatarColor = avatar.properties.avgColor ? avatar.properties.avgColor : null;
+		updates.avatarBlurhash = avatar.blurhash;
 	}
 
 	if (banner) {
 		updates.bannerId = banner.id;
 		updates.bannerUrl = DriveFiles.getPublicUrl(banner);
-		updates.bannerColor = banner.properties.avgColor ? banner.properties.avgColor : null;
+		updates.bannerBlurhash = banner.blurhash;
 	}
 
 	// Update user
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index b33948ee0..48b5e48fc 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -210,8 +210,8 @@ export default define(meta, async (ps, user, token) => {
 
 		updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true);
 
-		if (avatar.properties.avgColor) {
-			updates.avatarColor = avatar.properties.avgColor;
+		if (avatar.blurhash) {
+			updates.avatarBlurhash = avatar.blurhash;
 		}
 	}
 
@@ -223,8 +223,8 @@ export default define(meta, async (ps, user, token) => {
 
 		updates.bannerUrl = DriveFiles.getPublicUrl(banner, false);
 
-		if (banner.properties.avgColor) {
-			updates.bannerColor = banner.properties.avgColor;
+		if (banner.blurhash) {
+			updates.bannerBlurhash = banner.blurhash;
 		}
 	}
 
diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts
index cf0951eba..969dc0406 100644
--- a/src/services/drive/add-file.ts
+++ b/src/services/drive/add-file.ts
@@ -327,7 +327,6 @@ export default async function(
 	const properties: {
 		width?: number;
 		height?: number;
-		avgColor?: string;
 	} = {};
 
 	if (info.width) {
@@ -335,10 +334,6 @@ export default async function(
 		properties['height'] = info.height;
 	}
 
-	if (info.avgColor) {
-		properties['avgColor'] = `rgb(${info.avgColor.join(',')})`;
-	}
-
 	const profile = user ? await UserProfiles.findOne(user.id) : null;
 
 	const folder = await fetchFolder();
@@ -351,6 +346,7 @@ export default async function(
 	file.folderId = folder !== null ? folder.id : null;
 	file.comment = comment;
 	file.properties = properties;
+	file.blurhash = info.blurhash || null;
 	file.isLink = isLink;
 	file.isSensitive = user
 		? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
diff --git a/test/get-file-info.ts b/test/get-file-info.ts
index 920df0738..0c19fb2d7 100644
--- a/test/get-file-info.ts
+++ b/test/get-file-info.ts
@@ -26,7 +26,7 @@ describe('Get file info', () => {
 			},
 			width: undefined,
 			height: undefined,
-			avgColor: undefined
+			blurhash: null
 		});
 	}));
 
@@ -43,7 +43,7 @@ describe('Get file info', () => {
 			},
 			width: 512,
 			height: 512,
-			avgColor: [ 181, 99, 106 ]
+			blurhash: '' // TODO
 		});
 	}));
 
@@ -60,7 +60,7 @@ describe('Get file info', () => {
 			},
 			width: 256,
 			height: 256,
-			avgColor: [ 249, 253, 250 ]
+			blurhash: '' // TODO
 		});
 	}));
 
@@ -77,7 +77,7 @@ describe('Get file info', () => {
 			},
 			width: 256,
 			height: 256,
-			avgColor: [ 249, 253, 250 ]
+			blurhash: '' // TODO
 		});
 	}));
 
@@ -94,7 +94,7 @@ describe('Get file info', () => {
 			},
 			width: 256,
 			height: 256,
-			avgColor: [ 255, 255, 255 ]
+			blurhash: '' // TODO
 		});
 	}));
 
@@ -111,7 +111,7 @@ describe('Get file info', () => {
 			},
 			width: 256,
 			height: 256,
-			avgColor: [ 255, 255, 255 ]
+			blurhash: '' // TODO
 		});
 	}));
 
@@ -129,7 +129,7 @@ describe('Get file info', () => {
 			},
 			width: 256,
 			height: 256,
-			avgColor: [ 255, 255, 255 ]
+			blurhash: '' // TODO
 		});
 	}));
 
@@ -146,7 +146,7 @@ describe('Get file info', () => {
 			},
 			width: 25000,
 			height: 25000,
-			avgColor: undefined
+			blurhash: '' // TODO
 		});
 	}));
 });
diff --git a/yarn.lock b/yarn.lock
index 2000b823e..55e62690a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1669,6 +1669,11 @@ bluebird@^3.1.1, bluebird@^3.4.1:
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
   integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
 
+blurhash@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.3.tgz#dc325af7da836d07a0861d830bdd63694382483e"
+  integrity sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw==
+
 bn.js@^4.0.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"