diff --git a/bun.lockb b/bun.lockb index 8f256dd..4e8dfcd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package-lock.json b/package-lock.json index fdc6770..d89ae07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "add": "^2.0.6", "axios": "^1.7.2", "blossom-client-sdk": "^0.9.0", + "compress.js": "^2.1.2", "dayjs": "^1.11.11", "id3js": "^2.1.1", "lodash": "^4.17.21", @@ -29,6 +30,7 @@ }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.35.6", + "@types/compress.js": "^1.1.3", "@types/lodash": "^4.17.4", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -1215,6 +1217,12 @@ "react": "^18 || ^19" } }, + "node_modules/@types/compress.js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/compress.js/-/compress.js-1.1.3.tgz", + "integrity": "sha512-3uflodXzp7qcfxCwbkZ0KrqwgLIXzR/0IHoJvLfWmFXCDrB85aVSZFElsVkAm8ng227oFpNLFT4yWnm0Ozfhfw==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -1998,6 +2006,11 @@ "node": ">= 6" } }, + "node_modules/compress.js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/compress.js/-/compress.js-2.1.2.tgz", + "integrity": "sha512-DBb6M4wwe0rRAPeiKQ8HJrWuocVppUw9Qte4rEXiDrc5X3TrzeRKLzpvSE9oZ0Nd4HTXSSFphj3/XWwuptkQqw==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index aa8b71f..2b691c8 100644 --- a/package.json +++ b/package.json @@ -12,43 +12,44 @@ "analyze": "vite-bundle-visualizer" }, "dependencies": { - "@heroicons/react": "^2.1.3", + "@heroicons/react": "^2.1.4", "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "^2.8.2", "@nostr-dev-kit/ndk-cache-dexie": "^2.4.2", - "@tanstack/react-query": "^5.39.0", - "@tanstack/react-query-devtools": "^5.39.0", + "@tanstack/react-query": "^5.50.1", + "@tanstack/react-query-devtools": "^5.50.1", "add": "^2.0.6", "axios": "^1.7.2", "blossom-client-sdk": "^0.9.0", + "blurhash": "^2.0.5", "dayjs": "^1.11.11", "id3js": "^2.1.1", "lodash": "^4.17.21", - "nostr-tools": "^2.5.2", - "p-limit": "^5.0.0", + "nostr-tools": "^2.7.0", + "p-limit": "^6.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-pdf": "^8.0.2", - "react-router-dom": "^6.23.1" + "react-pdf": "^9.1.0", + "react-router-dom": "^6.24.1" }, "devDependencies": { - "@tanstack/eslint-plugin-query": "^5.35.6", - "@types/lodash": "^4.17.4", + "@tanstack/eslint-plugin-query": "^5.50.1", + "@types/lodash": "^4.17.6", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^7.10.0", - "@typescript-eslint/parser": "^7.10.0", + "@typescript-eslint/eslint-plugin": "^7.15.0", + "@typescript-eslint/parser": "^7.15.0", "@vitejs/plugin-react-swc": "^3.7.0", "autoprefixer": "^10.4.19", "daisyui": "latest", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", - "postcss": "^8.4.38", - "prettier": "^3.2.5", - "tailwindcss": "^3.4.3", - "typescript": "^5.4.5", - "vite": "^5.2.11", + "postcss": "^8.4.39", + "prettier": "^3.3.2", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3", + "vite": "^5.3.3", "vite-bundle-visualizer": "^1.2.1" }, "optionalDependencies": { diff --git a/src/components/BlurImage.tsx b/src/components/BlurImage.tsx new file mode 100644 index 0000000..614faa4 --- /dev/null +++ b/src/components/BlurImage.tsx @@ -0,0 +1,29 @@ +import { decode } from 'blurhash'; +import { useEffect, useRef } from 'react'; + +type BlurhashImageProps = { + blurhash: string; + width: number; + height: number; + alt: string; +}; + +export function BlurhashImage({ blurhash, width, height, ...props }: BlurhashImageProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + + const pixels = decode(blurhash, width, height); + if (ctx) { + const imageData = ctx.createImageData(width, height); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + } + } + }, [blurhash, width, height]); + + return ; +} diff --git a/src/components/FileEventEditor/FileEventEditor.tsx b/src/components/FileEventEditor/FileEventEditor.tsx index f317ac6..c46c454 100644 --- a/src/components/FileEventEditor/FileEventEditor.tsx +++ b/src/components/FileEventEditor/FileEventEditor.tsx @@ -3,19 +3,23 @@ import { formatFileSize } from '../../utils/utils'; import { fetchId3Tag } from '../../utils/id3'; import useVideoThumbnailDvm from './dvm'; import { usePublishing } from './usePublishing'; +import { BlobDescriptor } from 'blossom-client-sdk'; +import { transferBlob } from '../../utils/transfer'; +import { useNDK } from '../../utils/ndk'; export type FileEventData = { originalFile: File; content: string; url: string[]; + width?: number; + height?: number; dim?: string; x: string; m?: string; size: number; thumbnails?: string[]; thumbnail?: string; - //summary: string; - //alt: string; + blurHash?: string; artist?: string; title?: string; @@ -24,13 +28,19 @@ export type FileEventData = { }; const FileEventEditor = ({ data }: { data: FileEventData }) => { + const { signEventTemplate } = useNDK(); const [fileEventData, setFileEventData] = useState(data); + const [selectedThumbnail, setSelectedThumbnail] = useState(); + const { createDvmThumbnailRequest, thumbnailRequestEventId } = useVideoThumbnailDvm(setFileEventData); const { publishAudioEvent, publishFileEvent, publishVideoEvent } = usePublishing(); const [jsonOutput, setJsonOutput] = useState(''); + const isAudio = fileEventData.m?.startsWith('audio/'); + const isVideo = fileEventData.m?.startsWith('video/'); + useEffect(() => { - if (fileEventData.m?.startsWith('video/') && fileEventData.thumbnails == undefined) { + if (isVideo && fileEventData.thumbnails == undefined) { createDvmThumbnailRequest(fileEventData); } if ( @@ -62,9 +72,37 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => { } }, [fileEventData]); + function extractProtocolAndDomain(url: string): string | null { + const regex = /^(https?:\/\/[^/]+)/; + const match = url.match(regex); + return match ? match[0] : null; + } + + const publishSelectedThumbnailToOwnServer = async () => { + const servers = data.url.map(extractProtocolAndDomain); + + // upload selected thumbnail to the same blossom servers as the video + let uploadedThumbnails: BlobDescriptor[] = []; + if (selectedThumbnail) { + uploadedThumbnails = ( + await Promise.all( + servers.map(s => { + if (s && selectedThumbnail) return transferBlob(selectedThumbnail, s, signEventTemplate); + }) + ) + ).filter(t => t !== undefined) as BlobDescriptor[]; + } + + if (uploadedThumbnails.length > 0) { + data.thumbnail = uploadedThumbnails[0].url; // TODO do we need multiple thumbsnails?? or server URLs? + } + }; + + + // TODO add tags editor return ( <> -
+
{fileEventData.m?.startsWith('video/') && ( <> {thumbnailRequestEventId && @@ -82,7 +120,7 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => { setFileEventData(ed => ({ ...ed, thumbnail: t }))} + onClick={() => setSelectedThumbnail(t)} className={'btn btn-xs ' + (t == fileEventData.thumbnail ? 'btn-primary' : '')} >{`${i + 1}`} ))} @@ -95,9 +133,9 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => { ))} )} - {fileEventData.m?.startsWith('audio/') && fileEventData.thumbnail && ( + {isAudio && fileEventData.thumbnail && (
- +
)} @@ -111,31 +149,44 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
)}
- {fileEventData.title && ( + {(isAudio || isVideo) && ( <> Title - {fileEventData.title} + setFileEventData(ed => ({ ...ed, title: e.target.value }))} + > )} - {fileEventData.artist && ( + {isAudio && ( <> Artist {fileEventData.artist} )} - {fileEventData.album && ( + {isAudio && ( <> Album {fileEventData.album} )} - {fileEventData.year && ( + {isAudio && ( <> Year {fileEventData.year} )} + Summary / Description + + Type {fileEventData.m} @@ -148,13 +199,6 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => { File size {fileEventData.size ? formatFileSize(fileEventData.size) : 'unknown'} - Content / Description - URL
{fileEventData.url.map((text, i) => ( @@ -172,7 +216,12 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
@@ -184,7 +233,12 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => { diff --git a/src/components/FileEventEditor/usePublishing.ts b/src/components/FileEventEditor/usePublishing.ts index d85d432..29f6abb 100644 --- a/src/components/FileEventEditor/usePublishing.ts +++ b/src/components/FileEventEditor/usePublishing.ts @@ -3,26 +3,24 @@ import dayjs from 'dayjs'; import { FileEventData } from './FileEventEditor'; import { uniq } from 'lodash'; import { useNDK } from '../../utils/ndk'; +import { KIND_AUDIO, KIND_FILE_META, KIND_VIDEO_HORIZONTAL, KIND_VIDEO_VERTICAL } from '../../utils/useFileMetaEvents'; export const usePublishing = () => { const { ndk, user } = useNDK(); const publishFileEvent = async (data: FileEventData): Promise => { - // TODO REupload selected video thumbnail from DVM - + // TODO where to put video title? const e: NostrEvent = { created_at: dayjs().unix(), content: data.content, - tags: [ - ...uniq(data.url).map(du => ['url', du]), - ['x', data.x], - //['summary', data.summary], - //['alt', data.alt], - ], - kind: 1063, + tags: [...uniq(data.url).map(du => ['url', du]), ['x', data.x], ['summary', data.content]], + kind: KIND_FILE_META, pubkey: user?.pubkey || '', }; + if (data.title) { + e.tags.push(['alt', `${data.title}`]); + } if (data.size) { e.tags.push(['size', `${data.size}`]); } @@ -32,8 +30,10 @@ export const usePublishing = () => { if (data.m) { e.tags.push(['m', data.m]); } + if (data.blurHash) { + e.tags.push(['blurhash', data.blurHash]); + } if (data.thumbnail) { - // TODO upload thumbnail to own storage e.tags.push(['thumb', data.thumbnail]); e.tags.push(['image', data.thumbnail]); } @@ -55,7 +55,7 @@ export const usePublishing = () => { ['x', data.x], ...uniq(data.url).map(du => ['imeta', `url ${du}`, `m ${data.m}`]), ], - kind: 31337, // TODO vertical video event based on dim?! + kind: KIND_AUDIO, pubkey: user?.pubkey || '', }; @@ -81,6 +81,8 @@ export const usePublishing = () => { }; const publishVideoEvent = async (data: FileEventData): Promise => { + const videoIsHorizontal = data.width == undefined || data.height == undefined || data.width > data.height; + const e: NostrEvent = { created_at: dayjs().unix(), content: data.content, @@ -88,14 +90,16 @@ export const usePublishing = () => { ['d', data.x], ['x', data.x], ['url', data.url[0]], - ['title', data.content], - // ['summary', data.], TODO add summary + ['summary', data.content], ['published_at', `${dayjs().unix()}`], ['client', 'bouquet'], ], - kind: 31337, + kind: videoIsHorizontal ? KIND_VIDEO_HORIZONTAL : KIND_VIDEO_VERTICAL, pubkey: user?.pubkey || '', }; + if (data.title) { + e.tags.push(['title', data.title]); + } if (data.size) { e.tags.push(['size', `${data.size}`]); } @@ -106,7 +110,6 @@ export const usePublishing = () => { e.tags.push(['m', data.m]); } if (data.thumbnail) { - // TODO upload to own blossom instance e.tags.push(['thumb', data.thumbnail]); e.tags.push(['preview', data.thumbnail]); } diff --git a/src/pages/Transfer.tsx b/src/pages/Transfer.tsx index 02f5010..ebfa6b9 100644 --- a/src/pages/Transfer.tsx +++ b/src/pages/Transfer.tsx @@ -8,15 +8,15 @@ import { import { ServerList } from '../components/ServerList/ServerList'; import { useServerInfo } from '../utils/useServerInfo'; import { useMemo, useState } from 'react'; -import { BlobDescriptor, BlossomClient, SignedEvent } from 'blossom-client-sdk'; +import { BlobDescriptor } from 'blossom-client-sdk'; import { useNDK } from '../utils/ndk'; import { useQueryClient } from '@tanstack/react-query'; import { formatFileSize } from '../utils/utils'; import BlobList from '../components/BlobList/BlobList'; import './Transfer.css'; import { useNavigate, useParams } from 'react-router-dom'; -import axios, { AxiosProgressEvent } from 'axios'; import ProgressBar from '../components/ProgressBar/ProgressBar'; +import { downloadBlob, uploadBlob } from '../utils/transfer'; type TransferStatus = { [key: string]: { @@ -31,6 +31,9 @@ type TransferStatus = { }; export const Transfer = () => { + // TODO add transfer for single files + // TODO add support for mirror command (fallback to upload) + const { source } = useParams(); const [transferSource, setTransferSource] = useState(source); const navigate = useNavigate(); @@ -60,38 +63,6 @@ export const Transfer = () => { return []; }, [serverInfo, transferSource, transferTarget]); - const uploadBlob = async ( - server: string, - file: File, - auth?: SignedEvent, - onUploadProgress?: (progressEvent: AxiosProgressEvent) => void - ) => { - const headers = { - Accept: 'application/json', - 'Content-Type': file.type, - }; - - const res = await axios.put(`${server}/upload`, file, { - headers: auth ? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(auth) } : headers, - onUploadProgress, - }); - - return res.data; - }; - - const downloadBlob = async ( - server: string, - sha256: string, - onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void - ) => { - const response = await axios.get(`${server}/${sha256}`, { - responseType: 'blob', - onDownloadProgress, - }); - - return response.data; - }; - const performTransfer = async (sourceServer: string, targetServer: string, blobs: BlobDescriptor[]) => { setTransferLog({}); setTransferCancelled(false); @@ -111,7 +82,7 @@ export const Transfer = () => { }, })); - const data = await downloadBlob(serverInfo[sourceServer].url, b.sha256, progressEvent => { + const result = await downloadBlob(`${serverInfo[sourceServer].url}/${b.sha256}`, progressEvent => { setTransferLog(ts => ({ ...ts, [b.sha256]: { @@ -136,12 +107,11 @@ export const Transfer = () => { throw e; }); - if (!data) continue; + if (!result) continue; - const file = new File([data], b.sha256, { type: b.type, lastModified: b.created }); - const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob'); + const file = new File([result.data], b.sha256, { type: b.type, lastModified: b.created }); - await uploadBlob(serverInfo[targetServer].url, file, uploadAuth, progressEvent => { + await uploadBlob(serverInfo[targetServer].url, file, signEventTemplate, progressEvent => { setTransferLog(ts => ({ ...ts, [b.sha256]: { diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index b4f5c7b..b39cc89 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -13,6 +13,8 @@ import FileEventEditor, { FileEventData } from '../components/FileEventEditor/Fi import pLimit from 'p-limit'; import { Server, useUserServers } from '../utils/useUserServers'; import { resizeImage } from '../utils/resize'; +import { getImageSize } from '../utils/image'; +import { getBlurhashFromFile } from '../utils/blur'; type TransferStats = { enabled: boolean; @@ -79,25 +81,6 @@ function Upload() { // const [resizeImages, setResizeImages] = useState(false); // const [publishToNostr, setPublishToNostr] = useState(false); - type ImageSize = { - width: number; - height: number; - }; - - const getImageSize = async (imageFile: File): Promise => { - const img = new Image(); - const objectUrl = URL.createObjectURL(imageFile); - const promise = new Promise((resolve, reject) => { - img.onload = () => { - resolve({ width: img.width, height: img.height }); - URL.revokeObjectURL(objectUrl); - }; - img.onerror = () => reject(); - }); - img.src = objectUrl; - return promise; - }; - async function uploadBlob( server: string, file: File, @@ -147,11 +130,28 @@ function Upload() { } as FileEventData; if (file.type.startsWith('image/')) { const dimensions = await getImageSize(file); - data = { ...data, dim: `${dimensions.width}x${dimensions.height}` }; + data = { + ...data, + width: dimensions.width, + height: dimensions.height, + dim: `${dimensions.width}x${dimensions.height}`, + }; + + // TODO maybe combine fileSize and Hash! + const blur = await getBlurhashFromFile(file); + if (blur) { + data = { + ...data, + blurHash: blur, + }; + } } fileDimensions[file.name] = data; } + // TODO icon to cancel upload + // TODO detect if the file already exists? if we have the hash?? + const startTransfer = async (server: Server, primary: boolean) => { const serverUrl = serverInfo[server.name].url; let serverTransferred = 0; diff --git a/src/utils/blur.ts b/src/utils/blur.ts new file mode 100644 index 0000000..ee2b583 --- /dev/null +++ b/src/utils/blur.ts @@ -0,0 +1,50 @@ +import { encode } from 'blurhash'; + +const loadImage = async (src: string): Promise => + new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (...args) => reject(args); + img.src = src; + }); + +const getImageData = (image: HTMLImageElement) => { + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d'); + if (context) { + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, image.width, image.height); + } +}; + +function getFileDataURL(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + + reader.onload = function (event) { + if (event.target) { + const dataURL = event.target.result; + resolve(dataURL); + } + reject(); + }; + + reader.onerror = function (error) { + reject(error); + }; + }); +} + +export async function getBlurhashFromFile(file: File) { + const imageUrl = await getFileDataURL(file); + if (imageUrl) { + const image = await loadImage(imageUrl?.toString()); + const imageData = getImageData(image); + if (imageData) { + return encode(imageData.data, imageData.width, imageData.height, 4, 3); + } + } +} diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..98dad68 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,18 @@ +type ImageSize = { + width: number; + height: number; +}; + +export const getImageSize = async (imageFile: File): Promise => { + const img = new Image(); + const objectUrl = URL.createObjectURL(imageFile); + const promise = new Promise((resolve, reject) => { + img.onload = () => { + resolve({ width: img.width, height: img.height }); + URL.revokeObjectURL(objectUrl); + }; + img.onerror = () => reject(); + }); + img.src = objectUrl; + return promise; +}; diff --git a/src/utils/transfer.ts b/src/utils/transfer.ts new file mode 100644 index 0000000..da763a5 --- /dev/null +++ b/src/utils/transfer.ts @@ -0,0 +1,48 @@ +import axios, { AxiosProgressEvent } from 'axios'; +import { BlobDescriptor, BlossomClient, EventTemplate, SignedEvent } from 'blossom-client-sdk'; + +export const uploadBlob = async ( + server: string, + file: File, + signEventTemplate: (template: EventTemplate) => Promise, + onUploadProgress?: (progressEvent: AxiosProgressEvent) => void +) => { + const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob'); + + const headers = { + Accept: 'application/json', + 'Content-Type': file.type, + }; + + const res = await axios.put(`${server}/upload`, file, { + headers: uploadAuth ? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(uploadAuth) } : headers, + onUploadProgress, + }); + + return res.data; +}; + +export const downloadBlob = async (url: string, onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void) => { + const response = await axios.get(url, { + responseType: 'blob', + onDownloadProgress, + }); + + return { data: response.data, type: response.headers['Content-Type']?.toString() }; +}; + +export const transferBlob = async ( + sourceUrl: string, + targetServer: string, + signEventTemplate: (template: EventTemplate) => Promise, + onUploadProgress?: (progressEvent: AxiosProgressEvent) => void +): Promise => { + console.log({ sourceUrl, targetServer }); + const result = await downloadBlob(sourceUrl, onUploadProgress); + + const fileName = sourceUrl.replace(/.*\//, ''); + + const file = new File([result.data], fileName, { type: result.type, lastModified: new Date().getTime() }); + + return await uploadBlob(targetServer, file, signEventTemplate, onUploadProgress); +};