diff --git a/deno.json b/deno.json index 0de69b95..ef4ea803 100644 --- a/deno.json +++ b/deno.json @@ -41,6 +41,7 @@ "@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2", "@hono/hono": "jsr:@hono/hono@^4.4.6", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", + "@jcayzac/image-information": "npm:@jcayzac/image-information@1.1.1", "@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1", "@negrel/webpush": "jsr:@negrel/webpush@^0.3.0", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", @@ -59,6 +60,7 @@ "@std/json": "jsr:@std/json@^0.223.0", "@std/media-types": "jsr:@std/media-types@^0.224.1", "@std/streams": "jsr:@std/streams@^0.223.0", + "blurhash": "npm:blurhash@2.0.5", "comlink": "npm:comlink@^4.4.1", "comlink-async-generator": "npm:comlink-async-generator@^0.0.1", "commander": "npm:commander@12.1.0", diff --git a/deno.lock b/deno.lock index c0e4711c..50d01408 100644 --- a/deno.lock +++ b/deno.lock @@ -80,12 +80,14 @@ "jsr:@std/streams@0.223": "0.223.0", "npm:@electric-sql/pglite@~0.2.8": "0.2.8", "npm:@isaacs/ttlcache@^1.4.1": "1.4.1", + "npm:@jcayzac/image-information@1.1.1": "1.1.1", "npm:@noble/hashes@^1.4.0": "1.4.0", "npm:@noble/secp256k1@2": "2.1.0", "npm:@scure/base@^1.1.6": "1.1.6", "npm:@scure/bip32@^1.4.0": "1.4.0", "npm:@scure/bip39@^1.3.0": "1.3.0", "npm:@types/node@*": "18.16.19", + "npm:blurhash@2.0.5": "2.0.5", "npm:comlink-async-generator@*": "0.0.1", "npm:comlink-async-generator@^0.0.1": "0.0.1", "npm:comlink@^4.4.1": "4.4.1", @@ -658,6 +660,12 @@ "@isaacs/ttlcache@1.4.1": { "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==" }, + "@jcayzac/image-information@1.1.1": { + "integrity": "sha512-WXM5RTu3tTuAPXPx4ytbywjqTxnjBUWM1sY+7A9UPqPwQbM9V3jkY/rzo1OJ6VYSpogFdK4cyc+o9FZFZLW+nw==", + "dependencies": [ + "image-size" + ] + }, "@noble/ciphers@0.5.3": { "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==" }, @@ -769,6 +777,9 @@ "bintrees@1.0.2": { "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" }, + "blurhash@2.0.5": { + "integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==" + }, "braces@3.0.2": { "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "dependencies": [ @@ -987,6 +998,15 @@ "safer-buffer" ] }, + "image-size@1.1.1": { + "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", + "dependencies": [ + "queue" + ] + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, "is-fullwidth-code-point@4.0.0": { "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==" }, @@ -1260,6 +1280,12 @@ "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, + "queue@6.0.2": { + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": [ + "inherits" + ] + }, "restore-cursor@4.0.0": { "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", "dependencies": [ @@ -2104,12 +2130,14 @@ "jsr:@std/streams@0.223", "npm:@electric-sql/pglite@~0.2.8", "npm:@isaacs/ttlcache@^1.4.1", + "npm:@jcayzac/image-information@1.1.1", "npm:@noble/secp256k1@2", "npm:@scure/base@^1.1.6", "npm:comlink-async-generator@^0.0.1", "npm:comlink@^4.4.1", "npm:commander@12.1.0", "npm:entities@^4.5.0", + "npm:fast-blurhash@1.1.4", "npm:fast-stable-stringify@1", "npm:formdata-helper@0.3", "npm:hono-rate-limiter@0.3", diff --git a/src/uploaders/IPFSUploader.ts b/src/uploaders/IPFSUploader.ts index 7bf5165b..75f082e3 100644 --- a/src/uploaders/IPFSUploader.ts +++ b/src/uploaders/IPFSUploader.ts @@ -1,5 +1,11 @@ import { NUploader } from '@nostrify/nostrify'; import { z } from 'zod'; +import { probe } from '@jcayzac/image-information'; +import { Stickynotes } from '@soapbox/stickynotes'; +import { encode } from 'blurhash'; +import { encodeHex } from '@std/encoding/hex'; + +const console = new Stickynotes('ditto:ipfs:uploader'); export interface IPFSUploaderOpts { baseUrl: string; @@ -7,6 +13,19 @@ export interface IPFSUploaderOpts { fetch?: typeof fetch; } +function toByteArray(f: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('loadend', (m) => { + if (m?.target?.result instanceof ArrayBuffer) { + resolve(new Uint8Array(m.target.result)); + } else reject('Error loading file: readAsArrayBufferFailed'); + }); + reader.addEventListener('error', (e) => reject(e)); + reader.readAsArrayBuffer(f); + }); +} + /** * IPFS uploader. It expects an IPFS node up and running. * It will try to connect to `http://localhost:5001` by default, @@ -36,13 +55,29 @@ export class IPFSUploader implements NUploader { }); const { Hash: cid } = IPFSUploader.schema().parse(await response.json()); - - return [ + const tags: [['url', string], ...string[][]] = [ ['url', new URL(`/ipfs/${cid}`, this.baseUrl).toString()], ['m', file.type], ['cid', cid], ['size', file.size.toString()], ]; + + try { + const buffer = await toByteArray(file); + const hash = await crypto.subtle.digest('SHA-256', buffer).then(encodeHex); + tags.push(['x', hash], ['ox', hash]); + const metadata = probe(buffer); + if (metadata) { + // sane default from https://github.com/woltapp/blurhash readme + const blurhash = encode(new Uint8ClampedArray(buffer), metadata.width, metadata.height, 4, 4); + tags.push(['blurhash', blurhash]); + tags.push(['dim', `${metadata.width}x${metadata.height}`]); + } + } catch (e) { + console.error(`Error parsing ipfs metadata: ${e}`); + } + + return tags; } async delete(cid: string, opts?: { signal?: AbortSignal }): Promise {