From 6d11464a355d6371704dfe5a0afdf119d47933cb Mon Sep 17 00:00:00 2001 From: ryangtanaka Date: Sun, 12 Jan 2025 13:28:52 -0800 Subject: [PATCH] Audio cover generator (#445) * waveform image generator --------- Co-authored-by: Yannick Goossens --- package-lock.json | 10 + package.json | 1 + src/atoms/modal/index.module.scss | 1 + .../form/AudioCoverMetadataOverlay.tsx | 99 ++++++++++ src/components/form/CustomCopyrightForm.jsx | 15 +- src/components/form/Form.jsx | 16 +- src/components/form/FormFields.jsx | 185 ++++++++++++++++-- src/components/form/MintForm.jsx | 5 +- src/components/form/copyrightmodaltext.ts | 2 +- src/components/form/index.module.scss | 12 +- src/components/media-types/index.tsx | 1 + src/components/media-types/unknown/index.jsx | 39 +++- src/components/upload/index.module.scss | 1 + src/constants.ts | 11 +- src/utils/mint.ts | 19 ++ 15 files changed, 380 insertions(+), 37 deletions(-) create mode 100644 src/components/form/AudioCoverMetadataOverlay.tsx 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/atoms/modal/index.module.scss b/src/atoms/modal/index.module.scss index a5e202c27..37111798f 100644 --- a/src/atoms/modal/index.module.scss +++ b/src/atoms/modal/index.module.scss @@ -15,6 +15,7 @@ } .modalContent { + position: absolute; top: 0; background-color: var(--background-color); padding: 20px; 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/CustomCopyrightForm.jsx b/src/components/form/CustomCopyrightForm.jsx index 924df10a0..8f6b92c65 100644 --- a/src/components/form/CustomCopyrightForm.jsx +++ b/src/components/form/CustomCopyrightForm.jsx @@ -19,7 +19,7 @@ const initialClauses = { exclusiveRights: 'none', // Options are 'none', 'majority', 'superMajority' retainCreatorRights: true, // When exclusive rights conditions are met, does the Creator retain their rights to their own work? releasePublicDomain: false, - requireAttribution: false, + requireAttribution: true, rightsAreTransferable: true, expirationDate: '', expirationDateExists: false, @@ -165,10 +165,12 @@ export const ClausesDescriptions = ({ clauses }) => { ) } -function CustomCopyrightForm({ onChange, value }) { +function CustomCopyrightForm({ onChange, value, defaultValue }) { const { watch } = useFormContext() const { license, minterName, address } = useOutletContext() - const [clauses, setClauses] = useState(initialClauses) + const [clauses, setClauses] = useState( + defaultValue?.clauses || initialClauses + ) const [generatedDocument, setGeneratedDocument] = useState( 'No Permissions Chosen' ) @@ -598,7 +600,8 @@ Unless stated otherwise (in this Agreement itself), this Agreement remains effec @@ -619,8 +622,9 @@ Unless stated otherwise (in this Agreement itself), this Agreement remains effec

{clauseLabels.expirationDate}

handleChange(e, 'expirationDate')} + defaultValue={defaultValue?.clauses?.expirationDate} className={styles.field} /> @@ -634,6 +638,7 @@ Unless stated otherwise (in this Agreement itself), this Agreement remains effec value={clauses?.addendum || ''} onChange={handleInputChange} placeholder="Add additional notes, clauses, restrictions, scopes, etc." + defaultValue={defaultValue?.clauses?.addendum} className={styles.field} /> diff --git a/src/components/form/Form.jsx b/src/components/form/Form.jsx index b447d8be7..7ebfba6fa 100644 --- a/src/components/form/Form.jsx +++ b/src/components/form/Form.jsx @@ -1,8 +1,9 @@ import Button from '@atoms/button/Button' import { get } from 'lodash' -import { memo } from 'react' +import { memo, useEffect } from 'react' import { useFormContext } from 'react-hook-form' import { FormFields } from './FormFields' +import { useMintStore } from '@context/mintStore' function Form({ fields, defaultValues, children, onSubmit, onReset }) { const { @@ -14,6 +15,14 @@ function Form({ fields, defaultValues, children, onSubmit, onReset }) { formState: { errors }, } = useFormContext() + // Watch all form values and update mintStore + const formValues = watch() + useEffect(() => { + if (Object.keys(formValues).length > 0) { + useMintStore.setState(formValues) + } + }, [formValues]) + return (
{fields.map((f) => { @@ -24,7 +33,10 @@ function Form({ fields, defaultValues, children, onSubmit, onReset }) { register={register} value={value} control={control} - field={f} + field={{ + ...f, + defaultValue: defaultValues?.[f.name], + }} error={get(errors, f.name)} /> ) diff --git a/src/components/form/FormFields.jsx b/src/components/form/FormFields.jsx index 6932fcc63..0c26de485 100644 --- a/src/components/form/FormFields.jsx +++ b/src/components/form/FormFields.jsx @@ -2,15 +2,24 @@ 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, + AUDIO_MIME_TYPES, } 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 +34,21 @@ 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 [audioBlob, setAudioBlob] = useState(null) + const visualizerRef = useRef(null) + + const getArtistText = (userInfo, address) => { + if (userInfo?.name) { + return `${userInfo.name} (${address})` + } + return address + } switch (field.type) { case 'text': @@ -35,6 +59,7 @@ export const FormFields = ({ value, field, error, register, control }) => { name={name} type={field.type} label={field.label} + defaultValue={field.defaultValue} placeholder={field.placeholder} {...register(name, field.rules)} > @@ -48,6 +73,7 @@ export const FormFields = ({ value, field, error, register, control }) => { className={styles.field} label={field.label} placeholder={field.placeholder} + defaultValue={field.defaultValue} {...register(name, field.rules)} > @@ -60,6 +86,7 @@ export const FormFields = ({ value, field, error, register, control }) => { className={styles.typed_field} label={field.label} placeholder={field.placeholder} + defaultValue={field.defaultValue} {...register(name, field.rules)} > @@ -83,6 +110,7 @@ export const FormFields = ({ value, field, error, register, control }) => { search={field.type === 'select-search'} label={field.label} placeholder={field.placeholder} + defaultValue={field.defaultValue} onChange={onChange} /> )} @@ -100,6 +128,7 @@ export const FormFields = ({ value, field, error, register, control }) => { ref={ref} className={styles.field} label={field.label} + defaultValue={field.defaultValue} checked={value} onCheck={(v) => onChange(v)} /> @@ -138,20 +167,138 @@ 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 handleShowVisualizer = () => { + if (!showVisualizer && fileValue?.artifact?.reader) { + const blob = processAudioForVisualizer( + fileValue.artifact.reader + ) + // Store the processed blob on the fileValue object itself + setAudioBlob(blob) + } + setShowVisualizer(true) + } + + const containerStyle = { + position: 'relative', + width: '100%', + maxWidth: '618px', + aspectRatio: '618/382', + 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 && } + + + ) + }} /> ) @@ -163,7 +310,11 @@ export const FormFields = ({ value, field, error, register, control }) => { defaultValue={field.defaultValue} rules={field.rules} render={({ field: { onChange, value } }) => ( - + )} /> ) 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/copyrightmodaltext.ts b/src/components/form/copyrightmodaltext.ts index 4558d0fc8..4efa7d134 100644 --- a/src/components/form/copyrightmodaltext.ts +++ b/src/components/form/copyrightmodaltext.ts @@ -61,7 +61,7 @@ export const copyrightModalText = { releasePublicDomain: `

This clause allows the work to be released to the public domain, effectively removing all copyright restrictions associated with it.

By releasing the work to the public domain, the creator permits anyone to use, modify, and distribute the work without needing to seek permission or pay royalties. This can be a strategic decision for creators who wish to maximize the work's exposure and accessibility.

-

Public domain release is often irrevocable, meaning once a work is released to the public domain, the creator cannot reclaim the rights. Therefore, it's crucial for Creators to carefully consider the implications before making such a decision, especially since it can be easily referenced on the blockchain itself. ("Announcements" made on social media or other privately-owned platforms often do not constitute

+

Public domain release is often irrevocable, meaning once a work is released to the public domain, the creator cannot reclaim the rights. Therefore, it's crucial for Creators to carefully consider the implications before making such a decision, especially since it can be easily referenced on the blockchain itself. ("Announcements" made on social media or other privately-owned platforms may or may not be admissible as a declaration so it may be risky to assume so.

This option can be particularly beneficial for educational, cultural, or philanthropic purposes, contributing to the broader public good by freely sharing creative works.

`, requireAttribution: ` 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/media-types/index.tsx b/src/components/media-types/index.tsx index 98858e099..6b6ad03b7 100644 --- a/src/components/media-types/index.tsx +++ b/src/components/media-types/index.tsx @@ -152,6 +152,7 @@ export const RenderMediaType = ({ ) /* AUDIO */ case MIMETYPE.MP3: + case MIMETYPE.OGG: case MIMETYPE.OGA: case MIMETYPE.FLAC: case MIMETYPE.WAV: diff --git a/src/components/media-types/unknown/index.jsx b/src/components/media-types/unknown/index.jsx index 076304d20..b8f19a0ed 100644 --- a/src/components/media-types/unknown/index.jsx +++ b/src/components/media-types/unknown/index.jsx @@ -1,12 +1,39 @@ -import styles from '@style' +import { useEffect, useState } from 'react' export const UnknownComponent = ({ mimeType }) => { - /* const [queue, updateQueue] = useState() - updateQueue(await axios.post(import.meta.env.VITE_GRAPHQL_STATUS).then(res => res.data)) - */ + const [processingState, setProcessingState] = useState('checking') + + useEffect(() => { + // If we have a mimeType that's known (like PNG), but we're still hitting this component, + // it likely means we're in a metadata processing state + const isKnownMimeType = mimeType?.toLowerCase().includes('png') + + if (isKnownMimeType) { + setProcessingState('processing') + } else { + setProcessingState('unknown') + } + }, [mimeType]) + return ( -
-
Metadata on queue
+
+
+ {processingState === 'processing' ? ( +
+

Metadata processing...

+

+ Please wait while we process your file +

+
+ ) : ( +
+

Unknown file type: {mimeType}

+

+ This file type is not supported +

+
+ )} +
) } 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..0da6f0041 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', @@ -52,6 +52,14 @@ export const MIMETYPE: { [key: string]: string } = { TXT: 'text/plain', } +export const AUDIO_MIME_TYPES = [ + 'audio/mpeg', + 'audio/wav', + 'audio/flac', + 'audio/x-flac', + 'audio/ogg', +] + export const ALLOWED_MIMETYPES = Object.keys(MIMETYPE) .map((k) => MIMETYPE[k]) // disabling GLTF from new updates, @@ -65,7 +73,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