parent
							
								
									705d40ab37
								
							
						
					
					
						commit
						3f71b14637
					
				
					 22 changed files with 249 additions and 214 deletions
				
			
		
							
								
								
									
										14
									
								
								migration/1595075960584-blurhash.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								migration/1595075960584-blurhash.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										20
									
								
								migration/1595077605646-blurhash-for-avatar-banner.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								migration/1595077605646-blurhash-for-avatar-banner.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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)`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
							
								
								
									
										78
									
								
								src/client/components/img-with-blurhash.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/client/components/img-with-blurhash.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
|  | @ -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> | ||||
|  |  | |||
|  | @ -114,7 +114,7 @@ export default Vue.extend({ | |||
| 
 | ||||
| 			> * { | ||||
| 				overflow: hidden; | ||||
| 				border-radius: 4px; | ||||
| 				border-radius: 6px; | ||||
| 			} | ||||
| 
 | ||||
| 			&[data-count="1"] { | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -123,10 +123,6 @@ a { | |||
| 	&:hover { | ||||
| 		text-decoration: underline; | ||||
| 	} | ||||
| 
 | ||||
| 	* { | ||||
| 		cursor: pointer; | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| hr { | ||||
|  |  | |||
|  | @ -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)); | ||||
| 			}); | ||||
| 	}); | ||||
| } | ||||
|  |  | |||
|  | @ -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.' | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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), | ||||
|  |  | |||
|  | @ -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, | ||||
| 		}, | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 : | ||||
|  |  | |||
|  | @ -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
 | ||||
| 		}); | ||||
| 	})); | ||||
| }); | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue