feat: Added support for thumb re upload

This commit is contained in:
florian 2024-07-07 15:43:17 +02:00
parent 694cb4cc49
commit 27e9eda2b0
11 changed files with 297 additions and 111 deletions

BIN
bun.lockb

Binary file not shown.

13
package-lock.json generated
View File

@ -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",

View File

@ -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": {

View File

@ -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<HTMLCanvasElement>(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 <canvas ref={canvasRef} width={width} height={height} {...props} />;
}

View File

@ -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<string | undefined>();
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 (
<>
<div className=" bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row">
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row">
{fileEventData.m?.startsWith('video/') && (
<>
{thumbnailRequestEventId &&
@ -82,7 +120,7 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
<a
key={`link${i + 1}`}
href={`#item${i + 1}`}
onClick={() => setFileEventData(ed => ({ ...ed, thumbnail: t }))}
onClick={() => setSelectedThumbnail(t)}
className={'btn btn-xs ' + (t == fileEventData.thumbnail ? 'btn-primary' : '')}
>{`${i + 1}`}</a>
))}
@ -95,9 +133,9 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
))}
</>
)}
{fileEventData.m?.startsWith('audio/') && fileEventData.thumbnail && (
{isAudio && fileEventData.thumbnail && (
<div className="w-2/6">
<img src={fileEventData.thumbnail} className="w-full" />
<img src={fileEventData.thumbnail || selectedThumbnail} className="w-full" />
</div>
)}
@ -111,31 +149,44 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
</div>
)}
<div className="grid gap-4 w-4/6" style={{ gridTemplateColumns: '1fr 30em' }}>
{fileEventData.title && (
{(isAudio || isVideo) && (
<>
<span className="font-bold">Title</span>
<span>{fileEventData.title}</span>
<input
type="text"
className="input input-primary"
value={fileEventData.title}
onChange={e => setFileEventData(ed => ({ ...ed, title: e.target.value }))}
></input>
</>
)}
{fileEventData.artist && (
{isAudio && (
<>
<span className="font-bold">Artist</span>
<span>{fileEventData.artist}</span>
</>
)}
{fileEventData.album && (
{isAudio && (
<>
<span className="font-bold">Album</span>
<span>{fileEventData.album}</span>
</>
)}
{fileEventData.year && (
{isAudio && (
<>
<span className="font-bold">Year</span>
<span>{fileEventData.year}</span>
</>
)}
<span className="font-bold">Summary / Description</span>
<textarea
value={fileEventData.content}
onChange={e => setFileEventData(ed => ({ ...ed, content: e.target.value }))}
className="textarea textarea-primary"
placeholder="Caption"
></textarea>
<span className="font-bold">Type</span>
<span>{fileEventData.m}</span>
@ -148,13 +199,6 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
<span className="font-bold">File size</span>
<span>{fileEventData.size ? formatFileSize(fileEventData.size) : 'unknown'}</span>
<span className="font-bold">Content / Description</span>
<textarea
value={fileEventData.content}
onChange={e => setFileEventData(ed => ({ ...ed, content: e.target.value }))}
className="textarea"
placeholder="Caption"
></textarea>
<span className="font-bold">URL</span>
<div className="">
{fileEventData.url.map((text, i) => (
@ -172,7 +216,12 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
<div className="flex gap-2">
<button
className="btn btn-primary"
onClick={async () => setJsonOutput(await publishFileEvent(fileEventData))}
onClick={async () => {
if (!data.thumbnail) {
await publishSelectedThumbnailToOwnServer();
}
setJsonOutput(await publishFileEvent(fileEventData));
}}
>
Create File Event
</button>
@ -184,7 +233,12 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
</button>
<button
className="btn btn-primary"
onClick={async () => setJsonOutput(await publishVideoEvent(fileEventData))}
onClick={async () => {
if (!data.thumbnail) {
await publishSelectedThumbnailToOwnServer();
}
setJsonOutput(await publishVideoEvent(fileEventData));
}}
>
Create Video Event
</button>

View File

@ -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<string> => {
// 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<string> => {
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]);
}

View File

@ -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<BlobDescriptor>(`${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]: {

View File

@ -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<ImageSize> => {
const img = new Image();
const objectUrl = URL.createObjectURL(imageFile);
const promise = new Promise<ImageSize>((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;

50
src/utils/blur.ts Normal file
View File

@ -0,0 +1,50 @@
import { encode } from 'blurhash';
const loadImage = async (src: string): Promise<HTMLImageElement> =>
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<string | ArrayBuffer | null> {
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);
}
}
}

18
src/utils/image.ts Normal file
View File

@ -0,0 +1,18 @@
type ImageSize = {
width: number;
height: number;
};
export const getImageSize = async (imageFile: File): Promise<ImageSize> => {
const img = new Image();
const objectUrl = URL.createObjectURL(imageFile);
const promise = new Promise<ImageSize>((resolve, reject) => {
img.onload = () => {
resolve({ width: img.width, height: img.height });
URL.revokeObjectURL(objectUrl);
};
img.onerror = () => reject();
});
img.src = objectUrl;
return promise;
};

48
src/utils/transfer.ts Normal file
View File

@ -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<SignedEvent>,
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<BlobDescriptor>(`${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<SignedEvent>,
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
): Promise<BlobDescriptor> => {
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);
};