diff --git a/package-lock.json b/package-lock.json index f73254594..a4e699deb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "markdown-to-jsx": "^7.1.9", "mime-types": "^2.1.35", "react": "^18.2.0", + "react-audio-visualize": "^1.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.43.1", "react-infinite-scroller": "^1.2.6", @@ -18600,6 +18601,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-audio-visualize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-audio-visualize/-/react-audio-visualize-1.2.0.tgz", + "integrity": "sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ==", + "peerDependencies": { + "react": ">=16.2.0", + "react-dom": ">=16.2.0" + } + }, "node_modules/react-colorful": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", diff --git a/package.json b/package.json index f6178185a..7f36c78eb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "markdown-to-jsx": "^7.1.9", "mime-types": "^2.1.35", "react": "^18.2.0", + "react-audio-visualize": "^1.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.43.1", "react-infinite-scroller": "^1.2.6", diff --git a/src/components/form/AudioCoverMetadataOverlay.tsx b/src/components/form/AudioCoverMetadataOverlay.tsx new file mode 100644 index 000000000..05f97a8e0 --- /dev/null +++ b/src/components/form/AudioCoverMetadataOverlay.tsx @@ -0,0 +1,99 @@ +import { HEN_CONTRACT_FA2 } from '@constants'; +import React, { useEffect, useRef } from 'react'; + +export const combineVisualizerWithMetadata = async ( + visualizerRef: HTMLDivElement, + fileValue: { + artifact?: { + name?: string; + size?: number; + mimeType?: string; + }; + } +): Promise => { + return new Promise((resolve, reject) => { + try { + // Create a new canvas for the final image + const finalCanvas = document.createElement('canvas'); + finalCanvas.width = 618; // Match the AudioVisualizer dimensions + finalCanvas.height = 382; + + const ctx = finalCanvas.getContext('2d'); + if (!ctx) throw new Error('Could not get canvas context'); + + // Fill with black background + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height); + + // Get both canvas elements + const visualizerCanvas = visualizerRef.querySelector('canvas') as HTMLCanvasElement | null; + const metadataCanvas = visualizerRef.querySelector('canvas:last-child') as HTMLCanvasElement | null; + + if (!visualizerCanvas || !metadataCanvas) { + throw new Error('Could not find canvas elements'); + } + + // First draw the visualizer + ctx.drawImage(visualizerCanvas, 0, 0); + + // Then draw the metadata canvas on top + ctx.drawImage(metadataCanvas, 0, 0); + + finalCanvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Failed to create blob')); + }, 'image/png'); + } catch (error) { + reject(error); + } + }); +}; + +interface MetadataOverlayProps { + title: string; + artist: string; + mimeType: string; + style?: React.CSSProperties; +} + +const MetadataOverlay: React.FC = ({ + title, + artist, + mimeType, + style +}) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.fillStyle = 'white'; + ctx.font = '16px monospace'; + ctx.globalAlpha = 0.8; + + // Draw metadata + ctx.fillText(`Title: ${title}`, 15, 25); + ctx.fillText(`Wallet: ${artist}`, 15, 45); + ctx.fillText(`${new Date().toISOString()}`, 15, 65); + ctx.fillText(`${mimeType}`, 15, 85); + ctx.fillText(`Mint Contract: ${HEN_CONTRACT_FA2} (HEN/TEIA)`, 15, 370); + + }, [title, artist, mimeType]); + + return ( + + ); +}; + +export default MetadataOverlay; \ No newline at end of file diff --git a/src/components/form/FormFields.jsx b/src/components/form/FormFields.jsx index 6932fcc63..3f8052a38 100644 --- a/src/components/form/FormFields.jsx +++ b/src/components/form/FormFields.jsx @@ -2,15 +2,23 @@ import { Checkbox, Input, Textarea } from '@atoms/input' import { Line } from '@atoms/line' import Select from '@atoms/select/Base' import styles from '@style' -import { memo } from 'react' +import { memo, useState, useRef } from 'react' import { Upload } from '@components/upload/index' +import { AudioVisualizer } from 'react-audio-visualize' import { ALLOWED_FILETYPES_LABEL, ALLOWED_COVER_FILETYPES_LABEL, } from '@constants' -import { Controller } from 'react-hook-form' +import { Controller, useFormContext } from 'react-hook-form' import classNames from 'classnames' import CustomCopyrightForm from './CustomCopyrightForm' +import { processAudioForVisualizer } from '@utils/mint' +import { Button } from '@atoms/button' +import MetadataOverlay, { + combineVisualizerWithMetadata, +} from './AudioCoverMetadataOverlay' +import { useUserStore } from '@context/userStore' +import { shallow } from 'zustand/shallow' const FieldError = memo(({ error, text }) => { const classes = classNames({ @@ -25,6 +33,26 @@ const FieldError = memo(({ error, text }) => { */ export const FormFields = ({ value, field, error, register, control }) => { const name = field.name + const { watch } = useFormContext() + const [address, userInfo] = useUserStore( + (st) => [st.address, st.userInfo], + shallow + ) + const [showVisualizer, setShowVisualizer] = useState(false) + const visualizerRef = useRef(null) + const AUDIO_MIME_TYPES = [ + 'audio/mpeg', + 'audio/wav', + 'audio/flac', + 'audio/x-flac', + 'audio/ogg', + ] + const getArtistText = (userInfo, address) => { + if (userInfo?.name) { + return `${userInfo.name} (${address})` + } + return address + } switch (field.type) { case 'text': @@ -138,20 +166,132 @@ export const FormFields = ({ value, field, error, register, control }) => { defaultValue={field.defaultValue} name={name} rules={field.rules} - render={({ field: { onChange, value, name, ref } }) => ( - - {error && } - - )} + render={({ field: { onChange, value, name, ref } }) => { + const fileValue = watch(value) + + const isAudioMimeType = + fileValue && + AUDIO_MIME_TYPES.includes(fileValue.artifact?.mimeType) + const audioBlob = processAudioForVisualizer( + fileValue?.artifact.reader + ) + + const containerStyle = { + position: 'relative', + width: '100%', + maxWidth: '618px', + height: '382px', + maxHeight: '382px', + backgroundColor: 'black', + overflow: 'hidden', + } + + const visualizerContainerStyle = { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + } + + const handleSaveImage = async () => { + if (!visualizerRef.current) return + + try { + const finalBlob = await combineVisualizerWithMetadata( + visualizerRef.current, + fileValue + ) + + const url = URL.createObjectURL(finalBlob) + const a = document.createElement('a') + a.href = url + a.download = `${fileValue.artifact?.name || 'audio'}_cover.png` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + const newFile = new File( + [finalBlob], + `${fileValue.artifact?.name || 'audio'}_cover.png`, + { type: 'image/png' } + ) + onChange({ file: newFile }) + } catch (error) { + console.error('Failed to save image:', error) + } + } + + return ( + <> + {isAudioMimeType && !showVisualizer && ( + + )} + + {isAudioMimeType && showVisualizer && ( +
+

Generated Image (Save and Upload Manually)

+
+
+ + +
+
+ +
+ )} + + + {error && } + + + ) + }} /> ) diff --git a/src/components/form/MintForm.jsx b/src/components/form/MintForm.jsx index 4660ff32a..f66b7d357 100644 --- a/src/components/form/MintForm.jsx +++ b/src/components/form/MintForm.jsx @@ -17,7 +17,6 @@ import { generateTypedArtCoverImage, } from '@utils/mint' import { AUTO_GENERATE_COVER_MIMETYPES } from '@constants' -import { Midi } from '@tonejs/midi' import { processMidiCover } from '@utils/midi' export default function MintForm() { @@ -102,8 +101,8 @@ export default function MintForm() { } else if (data.typedinput) { data = await processTypedInput(data) } else if ( - data.artifact.mimeType == 'audio/midi' || - data.artifact.mimeType == 'audio/mid' + data.artifact.mimeType === 'audio/midi' || + data.artifact.mimeType === 'audio/mid' ) { // generate midi cover and set as data.object data = await processMidiCover(data) diff --git a/src/components/form/index.module.scss b/src/components/form/index.module.scss index 50ff1203a..6bfa8ee80 100644 --- a/src/components/form/index.module.scss +++ b/src/components/form/index.module.scss @@ -8,7 +8,7 @@ } .field { - margin: 0; + margin: 0.382em 0 0 0; } .typed_field { @@ -46,3 +46,13 @@ .modalInfoIcon:hover { color: var(--highlight-color); } + +.visualizer-image-download-button { + border: 1px solid white !important; + padding: 5px; + margin: 5px; +} + +.visualizer-image-download-button:hover { + text-decoration: underline; +} diff --git a/src/components/upload/index.module.scss b/src/components/upload/index.module.scss index 0d55e7ad0..2e3b0a978 100644 --- a/src/components/upload/index.module.scss +++ b/src/components/upload/index.module.scss @@ -1,4 +1,5 @@ .container { + margin-top: 10px; margin-bottom: 10px; label, diff --git a/src/constants.ts b/src/constants.ts index d32f7a809..4978aaa07 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,7 +35,7 @@ export const MIMETYPE: { [key: string]: string } = { MID: 'audio/mid', MP3: 'audio/mpeg', MP4: 'video/mp4', - OGA: 'audio/ogg', + OGG: 'audio/ogg', OGV: 'video/ogg', PDF: 'application/pdf', PNG: 'image/png', @@ -65,7 +65,6 @@ export const ALLOWED_FILETYPES_LABEL = Object.entries(MIMETYPE) ![ 'ZIP1', 'ZIP2', - 'OGA', 'OGV', 'BMP', 'TIFF', diff --git a/src/utils/mint.ts b/src/utils/mint.ts index fd6e566d0..383f37f62 100644 --- a/src/utils/mint.ts +++ b/src/utils/mint.ts @@ -357,3 +357,22 @@ export const convertFileToFileForm = async ( format, } } + +/** + * Processes an audio data URI into a blob for AudioVisualizer + * @param {string} reader - The data URI from the artifact reader + * @returns {Blob} The processed audio blob + */ + +export const processAudioForVisualizer = (reader: string) => { + const rawBase64 = reader.split(',')[1] || reader; + const byteString = atob(rawBase64); + const arrayBuffer = new ArrayBuffer(byteString.length); + const uint8Array = new Uint8Array(arrayBuffer); + + for (let i = 0; i < byteString.length; i++) { + uint8Array[i] = byteString.charCodeAt(i); + } + + return new Blob([arrayBuffer], { type: 'audio/mpeg' }); +}; \ No newline at end of file