@@ -6,38 +6,164 @@ import sharp from "sharp";
66const projectRoot = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , ".." ) ;
77const sourcePath = path . join (
88 projectRoot ,
9- "assets-src/hero/compbio-research-atlas-light-master .png" ,
9+ "assets-src/hero/compbio-research-atlas-chroma-source .png" ,
1010) ;
1111const outputDirectory = path . join ( projectRoot , "public/hero" ) ;
1212const outputWidths = [ 800 , 1600 ] ;
1313
14- const isGoldPixel = ( red , green , blue ) =>
15- red > 125
16- && red > blue * 1.18
17- && green > blue * 1.08
18- && red > green * 1.015 ;
14+ const clamp = ( value , minimum = 0 , maximum = 1 ) =>
15+ Math . max ( minimum , Math . min ( maximum , value ) ) ;
1916
20- const clearSafeMargin = async ( input ) => {
17+ const smoothstep = ( start , end , value ) => {
18+ const amount = clamp ( ( value - start ) / ( end - start ) ) ;
19+ return amount * amount * ( 3 - 2 * amount ) ;
20+ } ;
21+
22+ const getBackgroundModel = ( data , info ) => {
23+ const rows = Array . from ( { length : info . height } , ( ) => [ 0 , 0 , 0 , 0 ] ) ;
24+ const columns = Array . from ( { length : info . width } , ( ) => [ 0 , 0 , 0 , 0 ] ) ;
25+ const global = [ 0 , 0 , 0 , 0 ] ;
26+
27+ for ( let y = 0 ; y < info . height ; y += 1 ) {
28+ for ( let x = 0 ; x < info . width ; x += 1 ) {
29+ const index = ( y * info . width + x ) * info . channels ;
30+ const red = data [ index ] ;
31+ const green = data [ index + 1 ] ;
32+ const blue = data [ index + 2 ] ;
33+ const screenStrength = ( red + blue ) / 2 - green ;
34+
35+ if ( screenStrength <= 120 || Math . abs ( red - blue ) >= 90 ) continue ;
36+
37+ for ( let channel = 0 ; channel < 3 ; channel += 1 ) {
38+ rows [ y ] [ channel ] += data [ index + channel ] ;
39+ columns [ x ] [ channel ] += data [ index + channel ] ;
40+ global [ channel ] += data [ index + channel ] ;
41+ }
42+
43+ rows [ y ] [ 3 ] += 1 ;
44+ columns [ x ] [ 3 ] += 1 ;
45+ global [ 3 ] += 1 ;
46+ }
47+ }
48+
49+ const globalMean = global
50+ . slice ( 0 , 3 )
51+ . map ( ( value ) => value / global [ 3 ] ) ;
52+
53+ const fillMeans = ( values ) =>
54+ values . map ( ( entry ) =>
55+ entry [ 3 ]
56+ ? entry . slice ( 0 , 3 ) . map ( ( value ) => value / entry [ 3 ] )
57+ : globalMean ) ;
58+
59+ const smooth = ( values , radius ) =>
60+ values . map ( ( _ , index ) => {
61+ const result = [ 0 , 0 , 0 ] ;
62+ let samples = 0 ;
63+
64+ for (
65+ let sample = Math . max ( 0 , index - radius ) ;
66+ sample <= Math . min ( values . length - 1 , index + radius ) ;
67+ sample += 1
68+ ) {
69+ for ( let channel = 0 ; channel < 3 ; channel += 1 ) {
70+ result [ channel ] += values [ sample ] [ channel ] ;
71+ }
72+ samples += 1 ;
73+ }
74+
75+ return result . map ( ( value ) => value / samples ) ;
76+ } ) ;
77+
78+ return {
79+ columns : smooth ( fillMeans ( columns ) , 14 ) ,
80+ global : globalMean ,
81+ rows : smooth ( fillMeans ( rows ) , 10 ) ,
82+ } ;
83+ } ;
84+
85+ const keyChromaSource = async ( input ) => {
2186 const { data, info } = await sharp ( input )
22- . ensureAlpha ( )
2387 . raw ( )
2488 . toBuffer ( { resolveWithObject : true } ) ;
89+ const background = getBackgroundModel ( data , info ) ;
90+ const output = Buffer . alloc ( info . width * info . height * 4 ) ;
91+ const safeMargin = Math . round ( Math . min ( info . width , info . height ) * 0.055 ) ;
2592
2693 for ( let y = 0 ; y < info . height ; y += 1 ) {
2794 for ( let x = 0 ; x < info . width ; x += 1 ) {
28- if ( x >= 100 && x < info . width - 100 && y >= 100 && y < info . height - 120 ) {
95+ const sourceIndex = ( y * info . width + x ) * info . channels ;
96+ const outputIndex = ( y * info . width + x ) * 4 ;
97+
98+ if (
99+ x < safeMargin
100+ || x >= info . width - safeMargin
101+ || y < safeMargin
102+ || y >= info . height - safeMargin
103+ ) {
104+ output [ outputIndex ] = 0 ;
105+ output [ outputIndex + 1 ] = 0 ;
106+ output [ outputIndex + 2 ] = 0 ;
107+ output [ outputIndex + 3 ] = 0 ;
108+ continue ;
109+ }
110+
111+ const backdrop = [ 0 , 1 , 2 ] . map ( ( channel ) =>
112+ clamp (
113+ background . rows [ y ] [ channel ]
114+ + background . columns [ x ] [ channel ]
115+ - background . global [ channel ] ,
116+ 0 ,
117+ 255 ,
118+ ) ) ;
119+ const red = data [ sourceIndex ] ;
120+ const green = data [ sourceIndex + 1 ] ;
121+ const blue = data [ sourceIndex + 2 ] ;
122+ const screenStrength = ( red + blue ) / 2 - green ;
123+ const backgroundStrength =
124+ ( backdrop [ 0 ] + backdrop [ 2 ] ) / 2 - backdrop [ 1 ] ;
125+ const colorDistance = Math . hypot (
126+ red - backdrop [ 0 ] ,
127+ green - backdrop [ 1 ] ,
128+ blue - backdrop [ 2 ] ,
129+ ) ;
130+
131+ let alpha = 1 - clamp ( screenStrength / Math . max ( 1 , backgroundStrength ) ) ;
132+
133+ if ( colorDistance < 4 ) alpha = 0 ;
134+ alpha = clamp ( ( alpha - 0.018 ) / 0.982 ) ;
135+
136+ if ( alpha < 0.035 ) {
137+ output [ outputIndex ] = 0 ;
138+ output [ outputIndex + 1 ] = 0 ;
139+ output [ outputIndex + 2 ] = 0 ;
140+ output [ outputIndex + 3 ] = 0 ;
29141 continue ;
30142 }
31143
32- const index = ( y * info . width + x ) * 4 ;
33- data [ index ] = 0 ;
34- data [ index + 1 ] = 0 ;
35- data [ index + 2 ] = 0 ;
36- data [ index + 3 ] = 0 ;
144+ const recovered = [ 0 , 1 , 2 ] . map ( ( channel ) =>
145+ clamp (
146+ ( data [ sourceIndex + channel ] - ( 1 - alpha ) * backdrop [ channel ] )
147+ / alpha ,
148+ 0 ,
149+ 255 ,
150+ ) ) ;
151+ const magentaSpill = Math . max (
152+ 0 ,
153+ Math . min ( recovered [ 0 ] , recovered [ 2 ] ) - recovered [ 1 ] - 4 ,
154+ ) ;
155+
156+ recovered [ 0 ] -= magentaSpill ;
157+ recovered [ 2 ] -= magentaSpill ;
158+
159+ for ( let channel = 0 ; channel < 3 ; channel += 1 ) {
160+ output [ outputIndex + channel ] = Math . round ( recovered [ channel ] ) ;
161+ }
162+ output [ outputIndex + 3 ] = Math . round ( alpha * 255 ) ;
37163 }
38164 }
39165
40- return sharp ( data , {
166+ return sharp ( output , {
41167 raw : {
42168 width : info . width ,
43169 height : info . height ,
@@ -48,9 +174,8 @@ const clearSafeMargin = async (input) => {
48174 . toBuffer ( ) ;
49175} ;
50176
51- const buildDarkVariant = async ( input ) => {
177+ const buildLightVariant = async ( input ) => {
52178 const { data, info } = await sharp ( input )
53- . ensureAlpha ( )
54179 . raw ( )
55180 . toBuffer ( { resolveWithObject : true } ) ;
56181
@@ -62,26 +187,28 @@ const buildDarkVariant = async (input) => {
62187
63188 if ( alpha === 0 ) continue ;
64189
65- if ( isGoldPixel ( red , green , blue ) ) {
66- data [ index ] = Math . min ( 255 , Math . round ( red * 1.02 ) ) ;
67- data [ index + 1 ] = Math . min ( 255 , Math . round ( green * 1.03 ) ) ;
68- data [ index + 2 ] = Math . min ( 255 , Math . round ( blue * 0.96 ) ) ;
69- continue ;
70- }
71-
72- const luminance = Math . max (
73- 0 ,
74- Math . min ( 1 , ( 0.2126 * red + 0.7152 * green + 0.0722 * blue ) / 255 ) ,
190+ const maximum = Math . max ( red , green , blue ) ;
191+ const minimum = Math . min ( red , green , blue ) ;
192+ const saturation = maximum ? ( maximum - minimum ) / maximum : 0 ;
193+ const goldWeight =
194+ smoothstep ( 0.1 , 0.45 , ( red - blue ) / 255 )
195+ * smoothstep ( 0.08 , 0.35 , saturation )
196+ * smoothstep ( 80 , 170 , red ) ;
197+ const luminance = clamp (
198+ ( 0.2126 * red + 0.7152 * green + 0.0722 * blue ) / 255 ,
75199 ) ;
76- const pearl = [ 247 , 243 , 232 ] ;
77- const iceBlue = [ 139 , 164 , 194 ] ;
200+ const tone = smoothstep ( 0.32 , 0.94 , luminance ) ;
201+ const navy = [ 12 , 52 , 101 ] ;
202+ const steel = [ 200 , 214 , 229 ] ;
203+ const neutral = navy . map ( ( channel , channelIndex ) =>
204+ channel * ( 1 - tone ) + steel [ channelIndex ] * tone ) ;
78205
79- data [ index ] = Math . round ( pearl [ 0 ] * ( 1 - luminance ) + iceBlue [ 0 ] * luminance ) ;
206+ data [ index ] = Math . round ( neutral [ 0 ] * ( 1 - goldWeight ) + red * goldWeight ) ;
80207 data [ index + 1 ] = Math . round (
81- pearl [ 1 ] * ( 1 - luminance ) + iceBlue [ 1 ] * luminance ,
208+ neutral [ 1 ] * ( 1 - goldWeight ) + green * goldWeight ,
82209 ) ;
83210 data [ index + 2 ] = Math . round (
84- pearl [ 2 ] * ( 1 - luminance ) + iceBlue [ 2 ] * luminance ,
211+ neutral [ 2 ] * ( 1 - goldWeight ) + blue * goldWeight ,
85212 ) ;
86213 }
87214
@@ -107,7 +234,6 @@ const writeVariant = async (variant, input, width) => {
107234 . resize ( width , height , {
108235 fit : "fill" ,
109236 kernel : sharp . kernel . lanczos3 ,
110- withoutEnlargement : true ,
111237 } )
112238 . webp ( {
113239 quality : 90 ,
@@ -120,25 +246,24 @@ const writeVariant = async (variant, input, width) => {
120246 console . log ( `Wrote ${ path . relative ( projectRoot , outputPath ) } ` ) ;
121247} ;
122248
123- const sourceMaster = await sharp ( sourcePath ) . png ( ) . toBuffer ( ) ;
124- const metadata = await sharp ( sourceMaster ) . metadata ( ) ;
249+ const metadata = await sharp ( sourcePath ) . metadata ( ) ;
125250
126251if (
127- metadata . width !== 1600
128- || metadata . height !== 1200
129- || metadata . hasAlpha !== true
252+ ! metadata . width
253+ || ! metadata . height
254+ || Math . abs ( metadata . width / metadata . height - 4 / 3 ) > 0.001
130255) {
131256 throw new Error (
132- "Hero master must be a 1600x1200 PNG with a transparent alpha channel ." ,
257+ "Hero chroma source must be a 4:3 PNG." ,
133258 ) ;
134259}
135260
136261await mkdir ( outputDirectory , { recursive : true } ) ;
137262
138- const source = await clearSafeMargin ( sourceMaster ) ;
263+ const darkSource = await keyChromaSource ( sourcePath ) ;
139264const variants = {
140- light : source ,
141- dark : await buildDarkVariant ( source ) ,
265+ light : await buildLightVariant ( darkSource ) ,
266+ dark : darkSource ,
142267} ;
143268
144269for ( const [ variant , input ] of Object . entries ( variants ) ) {
0 commit comments