color palette extractor

This commit is contained in:
2023-11-02 20:45:29 +01:00
parent 8d4cc4696c
commit 5c04672b0d
6 changed files with 204 additions and 16 deletions

View File

@@ -43,6 +43,10 @@ app.get( '/showcase.js', ( req, res ) => {
res.sendFile( path.join( __dirname + '/client/showcase.js' ) ); res.sendFile( path.join( __dirname + '/client/showcase.js' ) );
} ); } );
app.get( '/colorPaletteExtractor.js', ( req, res ) => {
res.sendFile( path.join( __dirname + '/client/colorPaletteExtractor.js' ) );
} );
app.get( '/showcase.css', ( req, res ) => { app.get( '/showcase.css', ( req, res ) => {
res.sendFile( path.join( __dirname + '/client/showcase.css' ) ); res.sendFile( path.join( __dirname + '/client/showcase.css' ) );
} ); } );

View File

@@ -0,0 +1,145 @@
// https://github.com/zygisS22/color-palette-extraction/blob/master/index.js
const buildRgb = ( imageData ) => {
const rgbValues = [];
// note that we are loopin every 4!
// for every Red, Green, Blue and Alpha
for (let i = 0; i < imageData.length; i += 4) {
const rgb = {
r: imageData[ i ],
g: imageData[ i + 1 ],
b: imageData[ i + 2 ],
};
rgbValues.push(rgb);
}
return rgbValues;
};
// returns what color channel has the biggest difference
const findBiggestColorRange = (rgbValues) => {
/**
* Min is initialized to the maximum value posible
* from there we procced to find the minimum value for that color channel
*
* Max is initialized to the minimum value posible
* from there we procced to fin the maximum value for that color channel
*/
let rMin = Number.MAX_VALUE;
let gMin = Number.MAX_VALUE;
let bMin = Number.MAX_VALUE;
let rMax = Number.MIN_VALUE;
let gMax = Number.MIN_VALUE;
let bMax = Number.MIN_VALUE;
rgbValues.forEach((pixel) => {
rMin = Math.min(rMin, pixel.r);
gMin = Math.min(gMin, pixel.g);
bMin = Math.min(bMin, pixel.b);
rMax = Math.max(rMax, pixel.r);
gMax = Math.max(gMax, pixel.g);
bMax = Math.max(bMax, pixel.b);
});
const rRange = rMax - rMin;
const gRange = gMax - gMin;
const bRange = bMax - bMin;
// determine which color has the biggest difference
const biggestRange = Math.max(rRange, gRange, bRange);
if (biggestRange === rRange) {
return 'r';
} else if (biggestRange === gRange) {
return 'g';
} else {
return 'b';
}
};
/**
* Median cut implementation
* can be found here -> https://en.wikipedia.org/wiki/Median_cut
*/
const quantization = ( rgbValues, depth ) => {
const MAX_DEPTH = 4;
// Base case
if ( depth === MAX_DEPTH || rgbValues.length === 0 ) {
const color = rgbValues.reduce(
( prev, curr ) => {
prev.r += curr.r;
prev.g += curr.g;
prev.b += curr.b;
return prev;
},
{
r: 0,
g: 0,
b: 0,
}
);
color.r = Math.round( color.r / rgbValues.length );
color.g = Math.round( color.g / rgbValues.length );
color.b = Math.round( color.b / rgbValues.length );
return [color];
}
/**
* Recursively do the following:
* 1. Find the pixel channel (red,green or blue) with biggest difference/range
* 2. Order by this channel
* 3. Divide in half the rgb colors list
* 4. Repeat process again, until desired depth or base case
*/
const componentToSortBy = findBiggestColorRange( rgbValues );
rgbValues.sort( ( p1, p2 ) => {
return p1[ componentToSortBy ] - p2[ componentToSortBy ];
} );
const mid = rgbValues.length / 2;
return [
...quantization( rgbValues.slice( 0, mid ), depth + 1 ),
...quantization( rgbValues.slice( mid + 1 ), depth + 1 ),
];
};
const getColourPalette = ( imageURL ) => {
return new Promise( ( resolve, reject ) => {
// Set the canvas size to be the same as of the uploaded image
let image = new Image();
image.src = imageURL;
const canvas = document.getElementById( 'canvas' );
setTimeout( () => {
canvas.width = image.width ?? 500;
canvas.height = image.height ?? 500;
const ctx = canvas.getContext( '2d' );
ctx.drawImage(image, 0, 0);
/**
* getImageData returns an array full of RGBA values
* each pixel consists of four values: the red value of the colour, the green, the blue and the alpha
* (transparency). For array value consistency reasons,
* the alpha is not from 0 to 1 like it is in the RGBA of CSS, but from 0 to 255.
*/
const imageData = ctx.getImageData( 0, 0, canvas.width, canvas.height );
// Convert the image data to RGB values so its much simpler
const rgbArray = buildRgb( imageData.data );
/**
* Color quantization
* A process that reduces the number of colors used in an image
* while trying to visually maintin the original image as much as possible
*/
resolve( quantization( rgbArray, 0 ) );
}, 1000 );
} );
};

View File

@@ -170,4 +170,22 @@ body {
object-position: center; object-position: center;
font-size: 5vw !important; font-size: 5vw !important;
user-select: none; user-select: none;
}
.current-song {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 55vh;
width: 100%;
}
.fancy-view-song-art {
height: 30vh;
width: 30vh;
object-fit: cover;
object-position: center;
margin-bottom: 20px;
font-size: 30vh !important;
} }

View File

@@ -11,32 +11,41 @@
</head> </head>
<body> <body>
<div class="content" id="app"> <div class="content" id="app">
<h1>Ok</h1> <div v-if="hasLoaded" style="width: 100%">
<div class="song-list-wrapper" v-if="hasLoaded"> <div class="current-song">
<div v-for="song in songQueue" class="song-list"> <span class="material-symbols-outlined fancy-view-song-art" v-if="!playingSong.hasCoverArt">music_note</span>
<span class="material-symbols-outlined song-image" v-if="!song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying )">music_note</span> <img v-else :src="'/getSongCover?filename=' + playingSong.filename" class="fancy-view-song-art">
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying )" :src="'/getSongCover?filename=' + song.filename" class="song-image"> <h1>{{ playingSong.title }}</h1>
<div v-if="playingSong.filename === song.filename && isPlaying" class="playing-symbols"> <p>{{ playingSong.artist }}</p>
<div class="playing-symbols-wrapper"> </div>
<div class="playing-bar" id="bar-1"></div> <div class="song-list-wrapper">
<div class="playing-bar" id="bar-2"></div> <div v-for="song in songQueue" class="song-list">
<div class="playing-bar" id="bar-3"></div> <span class="material-symbols-outlined song-image" v-if="!song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying )">music_note</span>
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying )" :src="'/getSongCover?filename=' + song.filename" class="song-image">
<div v-if="playingSong.filename === song.filename && isPlaying" class="playing-symbols">
<div class="playing-symbols-wrapper">
<div class="playing-bar" id="bar-1"></div>
<div class="playing-bar" id="bar-2"></div>
<div class="playing-bar" id="bar-3"></div>
</div>
</div>
<span class="material-symbols-outlined pause-icon" v-if="!isPlaying && playingSong.filename === song.filename">pause</span>
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div> </div>
</div> </div>
<span class="material-symbols-outlined pause-icon" v-if="!isPlaying && playingSong.filename === song.filename">pause</span> <!-- <img :src="" alt=""> -->
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div>
</div> </div>
<!-- <img :src="" alt=""> -->
</div> </div>
<div v-else> <div v-else>
<h1>Loading...</h1> <h1>Loading...</h1>
</div> </div>
<canvas id="canvas"></canvas>
</div> </div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="/colorPaletteExtractor.js"></script>
<script src="/showcase.js"></script> <script src="/showcase.js"></script>
</body> </body>
</html> </html>

View File

@@ -10,6 +10,7 @@ createApp( {
isPlaying: false, isPlaying: false,
pos: 0, pos: 0,
queuePos: 0, queuePos: 0,
colourPalette: [],
}; };
}, },
computed: { computed: {
@@ -40,6 +41,11 @@ createApp( {
this.songs = data.data.songQueue ?? []; this.songs = data.data.songQueue ?? [];
this.pos = data.data.pos ?? 0; this.pos = data.data.pos ?? 0;
this.queuePos = data.data.queuePos ?? 0; this.queuePos = data.data.queuePos ?? 0;
getColourPalette( '/getSongCover?filename=' + data.data.playingSong.filename ).then( palette => {
this.colourPalette = palette;
} ).catch( () => {
this.colourPalette = [ { 'r': 255, 'g': 0, 'b': 0 }, { 'r': 0, 'g': 255, 'b': 0 }, { 'r': 0, 'g': 0, 'b': 255 } ]
} );
} else if ( data.type === 'pos' ) { } else if ( data.type === 'pos' ) {
this.pos = data.data; this.pos = data.data;
} else if ( data.type === 'isPlaying' ) { } else if ( data.type === 'isPlaying' ) {
@@ -48,6 +54,11 @@ createApp( {
this.songs = data.data; this.songs = data.data;
} else if ( data.type === 'playingSong' ) { } else if ( data.type === 'playingSong' ) {
this.playingSong = data.data; this.playingSong = data.data;
getColourPalette( '/getSongCover?filename=' + data.data.filename ).then( palette => {
this.colourPalette = palette;
} ).catch( () => {
this.colourPalette = [ { 'r': 255, 'g': 0, 'b': 0 }, { 'r': 0, 'g': 255, 'b': 0 }, { 'r': 0, 'g': 0, 'b': 255 } ]
} );
} else if ( data.type === 'queuePos' ) { } else if ( data.type === 'queuePos' ) {
this.queuePos = data.data; this.queuePos = data.data;
} }

View File

@@ -80,6 +80,7 @@
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 40vh;
} }
.controls { .controls {