Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(media): Blurhash #1381

Merged
merged 26 commits into from Aug 17, 2019
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
75b7ab6
chore(npm): Install blurhash
sorin-davidoi Aug 8, 2019
7dd3ac2
feat(media): Show blurhash
sorin-davidoi Aug 8, 2019
219ff37
fix(media/blurhash): Better sensitive video handling
sorin-davidoi Aug 8, 2019
4dc5a46
feat(media): Preference for using blurhash
sorin-davidoi Aug 8, 2019
ddef2dd
chore(utils/blurhash): Add performance marks
sorin-davidoi Aug 8, 2019
2bebfdd
fix(utils/blurhash): Performance marks
sorin-davidoi Aug 8, 2019
a1f5096
fix(utils/blurhash): Use correct dimension
sorin-davidoi Aug 8, 2019
dcb0c69
refactor(utils/blurhash): Use constant for number of pixels
sorin-davidoi Aug 9, 2019
fdef73b
refactor(media): Simplify logic for displaying blurhash
sorin-davidoi Aug 9, 2019
0562e66
chore(tests/spec): Attempt to adjust sensitivity tests for blurhash
sorin-davidoi Aug 9, 2019
f8f479d
chore(tests/spec): Update sensitivity tests for blurhash
sorin-davidoi Aug 9, 2019
8f25d11
chore(tests/spec): Check for sensitive
sorin-davidoi Aug 9, 2019
5aa04d6
fix(media/blurhash): Handle videos
sorin-davidoi Aug 9, 2019
f080728
fix: Video handling
sorin-davidoi Aug 9, 2019
ba9c396
fix: Videos
sorin-davidoi Aug 9, 2019
557176f
minor refactoring, fix Svelte warning
nolanlawson Aug 10, 2019
dc1d11e
fix: Large inline images and videos
sorin-davidoi Aug 10, 2019
f0306e5
feat(settings): Rename blurhash setting
sorin-davidoi Aug 11, 2019
7af9278
refactor: Use toBlob, block media rendering until blurhash ready
sorin-davidoi Aug 14, 2019
39c24ea
refactor: Move computations to Web Worker
sorin-davidoi Aug 14, 2019
20a6ee8
fix(workers/blurhash): More error handling
sorin-davidoi Aug 15, 2019
66c829a
feat(workers/blurhash): Use quick-lru for caching
sorin-davidoi Aug 15, 2019
421af34
fix: Don't create Context2D needlessly
sorin-davidoi Aug 15, 2019
b711fb6
fix(workers/blurhash): Increase cache size to 100
sorin-davidoi Aug 15, 2019
fa00e04
fix(workers/blurhash): Don't resolve promise twice
sorin-davidoi Aug 15, 2019
6ec0d28
fix(utils/decode-image): Ignore data URLs
sorin-davidoi Aug 15, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 7 additions & 2 deletions package.json
Expand Up @@ -48,6 +48,7 @@
"@webcomponents/custom-elements": "^1.2.4",
"babel-loader": "^8.0.6",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"blurhash": "^1.1.3",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could try https://github.com/fpapado/blurhash-rust-wasm if performance turns out to be an issue.

"cheerio": "^1.0.0-rc.2",
"child-process-promise": "^2.2.1",
"chokidar": "^3.0.1",
Expand Down Expand Up @@ -109,7 +110,8 @@
"mocha": "^6.1.4",
"now": "^15.7.0",
"standard": "^13.1.0",
"testcafe": "^1.2.1"
"testcafe": "^1.2.1",
"worker-loader": "^2.0.0"
},
"engines": {
"node": ">= 8"
Expand Down Expand Up @@ -150,7 +152,10 @@
"customElements",
"AbortController",
"matchMedia",
"MessageChannel"
"MessageChannel",
"ImageData",
"OffscreenCanvas",
"postMessage"
],
"ignore": [
"dist",
Expand Down
5 changes: 3 additions & 2 deletions src/routes/_components/LazyImage.html
Expand Up @@ -53,7 +53,8 @@
height: void 0,
ariaHidden: false,
alt: '',
title: ''
title: '',
blurhash: void 0
}),
computed: {
computedStyle: ({ background }) => {
Expand All @@ -71,7 +72,7 @@
return `object-position: ${coordsToPercent(focus.x)}% ${100 - coordsToPercent(focus.y)}%;`
},
fillFixSize: ({ forceSize, $largeInlineMedia }) => !$largeInlineMedia && !forceSize,
displaySrc: ({ error, src, fallback }) => ((error && fallback) || src)
displaySrc: ({ blurhash, error, src, fallback }) => (blurhash || (error && fallback) || src)
}
}
</script>
5 changes: 4 additions & 1 deletion src/routes/_components/NonAutoplayGifv.html
Expand Up @@ -17,14 +17,17 @@
alt={label || ''}
title={label || ''}
src={staticSrc}
blurhash={blurhash}
fallback={oneTransparentPixel}
{width}
{height}
background="var(--loading-bg)"
{focus}
/>
{/if}
<PlayVideoIcon className={playing ? 'hidden' : ''}/>
{#if !blurhash}
<PlayVideoIcon className={playing ? 'hidden' : ''}/>
{/if}
</div>
<style>
.non-autoplay-gifv {
Expand Down
46 changes: 36 additions & 10 deletions src/routes/_components/status/Media.html
@@ -1,23 +1,40 @@
{#if type === 'video' || type === 'audio'}
<button id={elementId}
type="button"
class="play-video-button focus-after {$largeInlineMedia ? '' : 'fixed-size'} {type === 'audio' ? 'play-audio-button' : ''}"
aria-label="Play video: {description}"
style="width: {inlineWidth}px; height: {inlineHeight}px;">
<PlayVideoIcon />
{#if blurhash}
{#if type === 'video'}
<LazyImage
alt={description}
title={description}
src={previewUrl}
fallback={oneTransparentPixel}
blurhash={blurhash}
width={inlineWidth}
height={inlineHeight}
background="var(--loading-bg)"
{focus}
/>
{/if}
</button>
{:else}
<button id={elementId}
type="button"
class="play-video-button focus-after {$largeInlineMedia ? '' : 'fixed-size'} {type === 'audio' ? 'play-audio-button' : ''}"
aria-label="Play video: {description}"
style="width: {inlineWidth}px; height: {inlineHeight}px;">
<PlayVideoIcon />
{#if type === 'video'}
<LazyImage
alt={description}
title={description}
src={previewUrl}
fallback={oneTransparentPixel}
blurhash={blurhash}
width={inlineWidth}
height={inlineHeight}
background="var(--loading-bg)"
{focus}
/>
{/if}
</button>
{/if}
{:else}
<button id={elementId}
type="button"
Expand All @@ -40,6 +57,7 @@
class={noNativeWidthHeight ? 'no-native-width-height' : ''}
label="Animated GIF: {description}"
poster={previewUrl}
blurhash={blurhash}
src={url}
staticSrc={previewUrl}
width={inlineWidth}
Expand All @@ -53,6 +71,7 @@
title={description}
src={previewUrl}
fallback={oneTransparentPixel}
blurhash={blurhash}
width={inlineWidth}
height={inlineHeight}
background="var(--loading-bg)"
Expand Down Expand Up @@ -91,11 +110,16 @@
import LazyImage from '../LazyImage.html'
import AutoplayVideo from '../AutoplayVideo.html'
import { registerClickDelegate } from '../../_utils/delegate'
import { decode } from '../../_utils/blurhash'

export default {
oncreate () {
const { elementId } = this.get()
export default {
async oncreate () {
const { elementId, media } = this.get()
registerClickDelegate(this, elementId, () => this.onClick())

if (media.blurhash) {
this.set({ decodedBlurhash: await decode(media.blurhash) })
}
},
computed: {
focus: ({ meta }) => meta && meta.focus,
Expand Down Expand Up @@ -126,6 +150,7 @@
elementId: ({ media, uuid }) => `media-${uuid}-${media.id}`,
description: ({ media }) => media.description || '',
previewUrl: ({ media }) => media.preview_url,
blurhash: ({ showBlurhash, decodedBlurhash }) => showBlurhash && decodedBlurhash,
url: ({ media }) => media.url,
type: ({ media }) => media.type
},
Expand All @@ -141,6 +166,7 @@
},
data: () => ({
oneTransparentPixel: ONE_TRANSPARENT_PIXEL,
decodedBlurhash: ONE_TRANSPARENT_PIXEL,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ONE_TRANSPARENT_PIXEL is used while the async computation is pending.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good workaround!

mouseover: void 0
}),
store: () => store,
Expand Down
7 changes: 6 additions & 1 deletion src/routes/_components/status/MediaAttachments.html
@@ -1,7 +1,7 @@
<div class={computedClass}
style="grid-template-columns: repeat({nCols}, 1fr);" >
{#each mediaAttachments as media, index}
<Media {media} {uuid} {mediaAttachments} {index} />
<Media {media} {uuid} {mediaAttachments} {index} {showBlurhash} />
{/each}
</div>
<style>
Expand Down Expand Up @@ -39,6 +39,7 @@
}

.status-media.status-media-is-sensitive {
height: inherit;
margin: 0;
}
</style>
Expand All @@ -55,6 +56,10 @@
twoCols && 'two-cols',
!$largeInlineMedia && 'grouped-images'
),
showBlurhash:
({ sensitive, sensitiveShown, mediaAttachments }) => {
return sensitive && mediaAttachments.every(attachment => !!attachment.blurhash) ? !sensitiveShown : false
},
nCols:
({ mediaAttachments, $largeInlineMedia }) => {
return (!$largeInlineMedia && mediaAttachments.length > 1) ? 2 : 1
Expand Down
37 changes: 31 additions & 6 deletions src/routes/_components/status/StatusMediaAttachments.html
Expand Up @@ -10,28 +10,32 @@
<SvgIcon className="status-sensitive-media-svg" href="#fa-eye-slash" />
</div>
</button>
<MediaAttachments {mediaAttachments} {sensitive} {uuid} />
{:else}
<button id={elementId}
type="button"
class="status-sensitive-media-button"
aria-label="Show sensitive media" >

<div class="status-sensitive-media-warning">
Sensitive content. Click to show.
<div class="{customWarningClass}">
<div class="status-sensitive-media-warning-text">
Sensitive content. Click to show.
</div>
</div>
<div class="svg-wrapper">
<SvgIcon className="status-sensitive-media-svg" href="#fa-eye" />
</div>
</button>
{/if}
{#if sensitiveShown || canUseBlurhash}
<MediaAttachments {mediaAttachments} {sensitive} {sensitiveShown} {uuid} />
{/if}
</div>
</div>
{#if enableShortcuts}
<Shortcut scope={shortcutScope} key="y" on:pressed="toggleSensitiveMedia()"/>
{/if}
{:else}
<MediaAttachments {mediaAttachments} {sensitive} {uuid} />
<MediaAttachments {mediaAttachments} {sensitive} {sensitiveShown} {uuid} />
{/if}
<style>
.status-sensitive-media-container {
Expand Down Expand Up @@ -81,6 +85,7 @@
}

.status-sensitive-media-hidden .status-sensitive-media-button {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
Expand All @@ -96,7 +101,6 @@
}

.status-sensitive-media-container .status-sensitive-media-warning {
position: absolute;
top: 0;
left: 0;
right: 0;
Expand All @@ -109,6 +113,21 @@
padding: 0 10px;
}

.status-sensitive-media-container .status-sensitive-media-warning-transparent {
position: absolute;
}

.status-sensitive-media-container .status-sensitive-media-warning-opaque {
background: var(--mask-bg);
height: 100%;
}

.status-sensitive-media-container .status-sensitive-media-warning-transparent .status-sensitive-media-warning-text {
background: var(--mask-bg);
padding: 10px;
border-radius: 6px;
}

.status-sensitive-media-container .svg-wrapper {
display: flex;
align-items: flex-start;
Expand All @@ -119,6 +138,7 @@
}
.status-sensitive-media-hidden .svg-wrapper {
position: absolute;
background: none;
top: 0;
left: 0;
right: 0;
Expand Down Expand Up @@ -171,6 +191,7 @@
$largeInlineMedia ? 'not-grouped-images' : 'grouped-images'
),
mediaAttachments: ({ originalStatus }) => originalStatus.media_attachments,
canUseBlurhash: ({ $ignoreBlurhash, mediaAttachments }) => !$ignoreBlurhash && mediaAttachments && mediaAttachments.every(media => !!media.blurhash),
sensitiveShown: ({ $sensitivesShown, uuid }) => !!$sensitivesShown[uuid],
sensitive: ({ originalStatus, $markMediaAsSensitive, $neverMarkMediaAsSensitive }) => (
!$neverMarkMediaAsSensitive && ($markMediaAsSensitive || originalStatus.sensitive)
Expand All @@ -181,7 +202,11 @@
return ''
}
return `padding-bottom: ${Math.ceil(mediaAttachments.length / 2) * 29}%;`
}
},
customWarningClass: ({ canUseBlurhash }) => classname(
'status-sensitive-media-warning',
canUseBlurhash ? 'status-sensitive-media-warning-transparent' : 'status-sensitive-media-warning-opaque'
)
},
methods: {
toggleSensitiveMedia () {
Expand Down
5 changes: 5 additions & 0 deletions src/routes/_pages/settings/general.html
Expand Up @@ -8,6 +8,11 @@ <h2>Media</h2>
bind:checked="$neverMarkMediaAsSensitive" on:change="onChange(event)">
<label for="choice-never-mark-media-sensitive">Show sensitive media by default</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-use-blurhash"
bind:checked="$ignoreBlurhash" on:change="onChange(event)">
<label for="choice-use-blurhash">Show a plain gray color for sensitive media</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-mark-media-sensitive"
bind:checked="$markMediaAsSensitive" on:change="onChange(event)">
Expand Down
1 change: 1 addition & 0 deletions src/routes/_store/store.js
Expand Up @@ -30,6 +30,7 @@ const persistedState = {
loggedInInstancesInOrder: [],
markMediaAsSensitive: false,
neverMarkMediaAsSensitive: false,
ignoreBlurhash: false,
omitEmojiInDisplayNames: undefined,
pinnedPages: {},
pushSubscriptions: {},
Expand Down
49 changes: 49 additions & 0 deletions src/routes/_utils/blurhash.js
@@ -0,0 +1,49 @@
import BlurhashWorker from 'worker-loader!../_workers/blurhash' // eslint-disable-line
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't get worker-loader working when added to client.config.js for some reason. Standard complains about the inline use of the loader, disabled it for now.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine; I don't really care whether the worker-loader is referenced in the Webpack config or in the import.


const RESOLUTION = 32
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could maybe lower this based on some heuristics (e.g. navigator.hardwareConcurrency)? Setting it to 1 will basically extract the average color, so maybe we can do that initially and repeat with 32 inside a requestIdleCallback?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the limiting factor is for blurhash, but yeah for now a hard-coded value is probably fine.

let worker
let canvas

export function init () {
worker = worker || new BlurhashWorker()
}

export async function decode (blurhash) {
return new Promise((resolve, reject) => {
try {
init()

const onMessage = ({ data: { encoded, decoded, imageData, error } }) => {
if (encoded !== blurhash) {
return
}

worker.removeEventListener('message', onMessage)

if (error) {
return reject(error)
}

if (decoded) {
resolve(decoded)
} else {
if (!canvas) {
canvas = document.createElement('canvas')
canvas.height = RESOLUTION
canvas.width = RESOLUTION
}

canvas.getContext('2d').putImageData(imageData, 0, 0)
canvas.toBlob(blob => {
resolve(URL.createObjectURL(blob))
})
}
}

worker.addEventListener('message', onMessage)
worker.postMessage({ encoded: blurhash })
} catch (e) {
reject(e)
}
})
}
26 changes: 26 additions & 0 deletions src/routes/_workers/blurhash.js
@@ -0,0 +1,26 @@
import { decode as decodeBlurHash } from 'blurhash'

const RESOLUTION = 32
const OFFSCREEN_CANVAS = typeof OffscreenCanvas === 'function'
? new OffscreenCanvas(RESOLUTION, RESOLUTION) : null

self.addEventListener('message', ({ data: { encoded } }) => {
try {
const pixels = decodeBlurHash(encoded, RESOLUTION, RESOLUTION)

if (pixels) {
const imageData = new ImageData(pixels, RESOLUTION, RESOLUTION)

if (OFFSCREEN_CANVAS) {
OFFSCREEN_CANVAS.getContext('2d').putImageData(imageData, 0, 0)
OFFSCREEN_CANVAS.convertToBlob().then(blob => {
postMessage({ encoded, decoded: URL.createObjectURL(blob), imageData: null, error: null })
})
} else {
postMessage({ encoded, imageData, decoded: null, error: null })
}
}
} catch (error) {
postMessage({ encoded, decoded: null, imageData: null, error })
}
})