Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Audio cover generator #445

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
99 changes: 99 additions & 0 deletions src/components/form/AudioCoverMetadataOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<Blob> => {
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<MetadataOverlayProps> = ({
title,
artist,
mimeType,
style
}) => {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
width={618}
height={382}
style={{ ...style, pointerEvents: 'none' }}
/>
);
};

export default MetadataOverlay;
172 changes: 156 additions & 16 deletions src/components/form/FormFields.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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':
Expand Down Expand Up @@ -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 } }) => (
<Upload
ref={ref}
name={name}
file={value?.file}
label={field.label}
placeHolder={value ? value?.file?.name : field.placeHolder}
className={styles.field}
onChange={onChange}
allowedTypesLabel={ALLOWED_COVER_FILETYPES_LABEL}
>
{error && <FieldError error={error.message} />}
</Upload>
)}
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 && (
<Button
className={styles['visualizer-image-download-button']}
onClick={() => setShowVisualizer(true)}
>
Generate Audio Visualization
</Button>
)}

{isAudioMimeType && showVisualizer && (
<div className={styles.field}>
<p>Generated Image (Save and Upload Manually)</p>
<div ref={visualizerRef} style={containerStyle}>
<div style={visualizerContainerStyle}>
<AudioVisualizer
blob={audioBlob}
width={618}
height={382}
backgroundColor="black"
waveColor="white"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
/>
<MetadataOverlay
title={watch('title') || 'Untitled'}
artist={getArtistText(userInfo, address)}
mimeType={fileValue?.artifact?.mimeType}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1,
}}
/>
</div>
</div>
<Button
className={styles['visualizer-image-download-button']}
onClick={handleSaveImage}
>
Download As File
</Button>
</div>
)}

<Upload
ref={ref}
name={name}
file={value?.file}
label={field.label}
placeHolder={value ? value?.file?.name : field.placeHolder}
className={styles.field}
onChange={onChange}
allowedTypesLabel={ALLOWED_COVER_FILETYPES_LABEL}
>
{error && <FieldError error={error.message} />}
</Upload>
</>
)
}}
/>
)

Expand Down
5 changes: 2 additions & 3 deletions src/components/form/MintForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
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() {
Expand Down Expand Up @@ -74,7 +73,7 @@
} else {
setPreview(undefined)
}
}, [isTypedArt, typedinput, isMonoType])

Check warning on line 76 in src/components/form/MintForm.jsx

View workflow job for this annotation

GitHub Actions / build (18)

React Hook useEffect has a missing dependency: 'generateCoverImagePreview'. Either include it or remove the dependency array

const generateCoverImagePreview = async (inputText) => {
try {
Expand Down Expand Up @@ -102,8 +101,8 @@
} 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)
Expand Down
12 changes: 11 additions & 1 deletion src/components/form/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
}

.field {
margin: 0;
margin: 0.382em 0 0 0;
}

.typed_field {
Expand Down Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions src/components/upload/index.module.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.container {
margin-top: 10px;
margin-bottom: 10px;

label,
Expand Down
3 changes: 1 addition & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -65,7 +65,6 @@ export const ALLOWED_FILETYPES_LABEL = Object.entries(MIMETYPE)
![
'ZIP1',
'ZIP2',
'OGA',
'OGV',
'BMP',
'TIFF',
Expand Down
Loading
Loading