-
-
Notifications
You must be signed in to change notification settings - Fork 114
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
Images v2 #513
Images v2 #513
Conversation
0837930
to
de79ccd
Compare
I looked at https://web.dev/browser-level-image-lazy-loading and I think that's not something we need at the moment.
But I tested on https://stacker.news/items/178769 (with "click to load external images" turned off) which has a height of 10,000 pixels and all images were loaded nonetheless in Brave.
|
Agreed. Premature |
TODO:
FIXME:
|
Only thing left to do is to fix the fullscreen CSS (if we don't want to ship as is): Screenshots from desktop and mobile which show the misalignment of the link to the original (bottom left corner): I wanted to have the link aligned with the image. So on desktop it's too far to the left and on mobile, it's too far below the image. |
Collected metrics using this patch: commit 2357be4754ace7611cba91804a19e95f0e9ea97e
Author: ekzyis <[email protected]>
Date: Tue Sep 26 12:12:31 2023 +0200
Collect URL metrics during imgproxy processing
diff --git a/lib/url.js b/lib/url.js
index 14f7a6f..c32f3be 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -1,7 +1,7 @@
export function ensureProtocol (value) {
if (!value) return value
value = value.trim()
- if (!/^([a-z0-9]+:\/\/|mailto:)/.test(value)) {
+ if (!/^([a-z0-9]+:\/\/|(mailto|data):)/.test(value)) {
value = 'http://' + value
}
return value
diff --git a/worker/imgproxy.js b/worker/imgproxy.js
index 4ca45c6..5e83555 100644
--- a/worker/imgproxy.js
+++ b/worker/imgproxy.js
@@ -1,5 +1,6 @@
import { createHmac } from 'node:crypto'
import { extractUrls } from '../lib/md.js'
+import { ensureProtocol } from '../lib/url.js'
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
(process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
@@ -13,30 +14,7 @@ const IMGPROXY_SALT = process.env.IMGPROXY_SALT
const IMGPROXY_KEY = process.env.IMGPROXY_KEY
const cache = new Map()
-
-const knownPositives = [
- /\.(jpe?g|png|gif|webp|avif)$/,
- /^https:\/\/i\.postimg\.cc\//,
- /^https:\/\/i\.imgflip\.com\//,
- /^https:\/\/i\.imgur\.com\//,
- /^https:\/\/pbs\.twimg\.com\//,
- /^https:\/\/www\.zapread\.com\/i\//,
- /^https:\/\/substackcdn\.com\/image/,
-]
-const knownNegatives = [
- /^https:\/\/(twitter\.com|x\.com|nitter\.(net|it|at))\/\w+\/status/,
- /^https:\/\/postimg\.cc/,
- /^https:\/\/imgur\.com/,
- /^https:\/\/youtu\.be/,
- /^https:\/\/(www\.)?youtube\.com/,
- /^mailto:/,
- /^https:\/\/stacker\.news\/items/,
- /^https:\/\/news\.ycombinator\.com\/(item|user)\?id=/,
- /^https:\/\/\w+\.substack.com/,
- /^http:\/\/\w+\.onion/,
- /^http:\/\/nitter\.priv\.loki/,
- /^http:\/\/\w+\.b32\.i2p/,
-]
+export const urlMetrics = {}
function decodeOriginalUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/')
@@ -131,11 +109,54 @@ async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {})
return response
}
+function countMetric(url, metric) {
+ let k1, k2, m1, m2
+ try {
+ const u = new URL(ensureProtocol(url))
+ const path = u.pathname.split('/').slice(0,-1).join('/')
+ k1 = u.host
+ k2 = u.host + path
+ m1 = urlMetrics[k1] || {}
+ m2 = urlMetrics[k2] || {}
+ } catch(err) {
+ const m = urlMetrics[url] || {}
+ urlMetrics[url] = Object.assign(m, { err: (m.err || 0) + 1})
+ return
+ }
+ if (metric === 'TOTAL') {
+ urlMetrics[k1] = Object.assign(m1, { total: (m1.total || 0) + 1})
+ urlMetrics[k2] = Object.assign(m2, { total: (m2.total || 0) + 1})
+ }
+ else if (metric === 'HEAD_ERR') {
+ urlMetrics[k1] = Object.assign(m1, { headErr: (m1.headErr || 0) + 1})
+ urlMetrics[k2] = Object.assign(m2, { err: (m2.err || 0) + 1})
+ }
+ else if (metric === 'HEAD_TRUE') {
+ urlMetrics[k1] = Object.assign(m1, { headTrue: (m1.headTrue || 0) + 1})
+ urlMetrics[k2] = Object.assign(m2, { err: (m2.err || 0) + 1})
+ }
+ else if (metric === 'HEAD_FALSE') {
+ urlMetrics[k1] = Object.assign(m1, { headFalse: (m1.headFalse || 0) + 1})
+ urlMetrics[k2] = Object.assign(m2, { err: (m2.err || 0) + 1})
+ }
+ else if (metric === 'GET_ERR') {
+ urlMetrics[k1] = Object.assign(m1, { getErr: (m1.getErr || 0) + 1})
+ urlMetrics[k2] = Object.assign(m2, { err: (m2.err || 0) + 1})
+ }
+ else if (metric === 'GET_TRUE') {
+ urlMetrics[k1] = Object.assign(m1, { getTrue: (m1.getTrue || 0) + 1})
+ urlMetrics[k2] = Object.assign(m2, { err: (m2.err || 0) + 1})
+ }
+ else if (metric === 'GET_FALSE') {
+ urlMetrics[k1] = Object.assign(m1, { getFalse: (m1.getFalse || 0) + 1})
+ urlMetrics[k2] = Object.assign(m2, { err: (m2.err || 0) + 1})
+ }
+}
+
const isImageURL = async url => {
if (cache.has(url)) return cache.get(url)
- if (knownPositives.some(regexp => regexp.test(url))) return true
- if (knownNegatives.some(regexp => regexp.test(url))) return false
+ countMetric(url, 'TOTAL')
let isImage
@@ -146,6 +167,7 @@ const isImageURL = async url => {
const buf = await res.blob()
isImage = buf.type.startsWith('image/')
} catch (err) {
+ countMetric(url, 'HEAD_ERR')
console.log(url, err)
}
@@ -153,8 +175,10 @@ const isImageURL = async url => {
// However, negatives may be false negatives
if (isImage) {
cache.set(url, true)
+ countMetric(url, 'HEAD_TRUE')
return true
}
+ countMetric(url, 'HEAD_FALSE')
// if not known yet, run GET request with longer timeout
try {
@@ -162,8 +186,10 @@ const isImageURL = async url => {
const buf = await res.blob()
isImage = buf.type.startsWith('image/')
} catch (err) {
+ countMetric(url, 'GET_ERR')
console.log(url, err)
}
+ countMetric(url, isImage ? 'GET_TRUE' : 'GET_FALSE')
cache.set(url, isImage)
return isImage Log: https://files.ekzyis.com/public/sn/imgproxy_processing.txt Table with hit count: hit means that link was found to be an image
|
prisma/migrations/20230920192620_item_imgproxy_urls/migration.sql
Outdated
Show resolved
Hide resolved
prisma/migrations/20230920192620_item_imgproxy_urls/migration.sql
Outdated
Show resolved
Hide resolved
* moved imgproxy code into worker: - item table now has JSONB column `imgproxyUrls` - worker parses `text` and `url` and check whichs URLs are images - for every image URL found, we sign multiple proxy URLs for use with `srcset` (responsive images) * we longer no replace image URLs - code is backwards compatible by decoding original URL for imgproxy URLs * improved image detection: - fallback to GET if HEAD failed or returned negative - this shouldn't be a problem since this is done inside the worker with timeouts * improved UI/UX: there are placeholder images now if original URLs should be loaded automatically: - placeholder if image is still processing - placeholder if there was an error loading the image from our image proxy * moved image code in frontend to components/image.js
Depends on #500
Fixes #451
TODO:
imgproxyUrls IS NULL
)open original
/open in new tab
in fullscreen modeimage upload (maybe in next PR?)in next PRvideos:
if "click to load external images" is enabled, there will be a placeholder for unprocessed images. on click, it loads the original image: https://files.ekzyis.com/public/sn/images_v2_processing_click_to_load.mp4
if "click to load external images" is not enabled, it will automatically load the original url if images are not processed yet (
imgproxyUrls
is falsy): https://files.ekzyis.com/public/sn/images_v2_processing_automatically_load_original.mp4image processing is done in worker + responsive images: https://files.ekzyis.com/public/sn/images_v2_processing_in_worker.mp4
(note: it will always load the best resolution if it was already cached)
on imgproxy errors, it will show "click to load image" (if privacy setting enabled) or automatically load the original image (if privacy setting not enabled): https://files.ekzyis.com/public/sn/images_v2_imgproxy_error.mp4
TODO:
sign URLs in the database path for these known image hosting services to improve UX (no processing delay)decided not to since that would only be partial processing which breaks frontend assumptions about when items have been processed by the worker