11/**
2- * Regenerates the two README hero images .
2+ * Regenerates the four README screenshots: one per language, per appearance .
33 *
4- * The renderer is bundled and run for real against `mock.js`, once per language and
5- * theme, then the light and dark shots of the same language are composited into one
6- * window split along the diagonal. Nothing here mocks the interface itself, so an image
7- * that no longer matches the app means the app changed, not the screenshot script.
4+ * The renderer is bundled and run for real against `mock.js`, then each shot is drawn
5+ * into a macOS-shaped window with its own shadow and border. Nothing here mocks the
6+ * interface itself, so an image that no longer matches the app means the app changed,
7+ * not the screenshot script.
8+ *
9+ * The READMEs pick between light and dark with `<picture>`, so the screenshot follows
10+ * whichever appearance the reader is browsing GitHub in.
811 *
912 * Usage: `node scripts/screenshots/capture.mjs` (see ./README.md for the prerequisites).
1013 */
@@ -14,6 +17,7 @@ import { mkdir, stat, writeFile } from 'node:fs/promises'
1417import { createServer } from 'node:http'
1518import { dirname , extname , join , normalize , resolve } from 'node:path'
1619import { fileURLToPath } from 'node:url'
20+ import { deflateSync , inflateSync } from 'node:zlib'
1721import * as esbuild from 'esbuild'
1822
1923const HERE = dirname ( fileURLToPath ( import . meta. url ) )
@@ -26,6 +30,50 @@ const WIDTH = 1180
2630const HEIGHT = 800
2731const SCALE = 2
2832
33+ /**
34+ * Canvas encodes its PNGs for speed, which costs about half the file again on an image
35+ * this size. Re-deflating the pixels it produced is lossless and gets that back.
36+ */
37+ function recompress ( png ) {
38+ const chunks = [ ]
39+ const parts = [ ]
40+ for ( let at = 8 ; at < png . length ; ) {
41+ const length = png . readUInt32BE ( at )
42+ const type = png . toString ( 'ascii' , at + 4 , at + 8 )
43+ const body = png . subarray ( at + 8 , at + 8 + length )
44+ if ( type === 'IDAT' ) parts . push ( body )
45+ else if ( type !== 'IEND' ) chunks . push ( { type, body } )
46+ at += length + 12
47+ }
48+
49+ const deflated = deflateSync ( inflateSync ( Buffer . concat ( parts ) ) , { level : 9 } )
50+ const framed = ( { type, body } ) => {
51+ const chunk = Buffer . alloc ( body . length + 12 )
52+ chunk . writeUInt32BE ( body . length , 0 )
53+ chunk . write ( type , 4 , 'ascii' )
54+ body . copy ( chunk , 8 )
55+ chunk . writeInt32BE ( crc ( chunk . subarray ( 4 , body . length + 8 ) ) , body . length + 8 )
56+ return chunk
57+ }
58+ return Buffer . concat ( [
59+ png . subarray ( 0 , 8 ) ,
60+ ...chunks . map ( framed ) ,
61+ framed ( { type : 'IDAT' , body : deflated } ) ,
62+ framed ( { type : 'IEND' , body : Buffer . alloc ( 0 ) } )
63+ ] )
64+ }
65+
66+ const CRC_TABLE = Array . from ( { length : 256 } , ( _ , index ) => {
67+ let value = index
68+ for ( let bit = 0 ; bit < 8 ; bit += 1 ) value = value & 1 ? 0xedb88320 ^ ( value >>> 1 ) : value >>> 1
69+ return value >>> 0
70+ } )
71+ const crc = ( bytes ) => {
72+ let value = 0xffffffff
73+ for ( const byte of bytes ) value = CRC_TABLE [ ( value ^ byte ) & 0xff ] ^ ( value >>> 8 )
74+ return ( value ^ 0xffffffff ) | 0
75+ }
76+
2977const playwright = await import ( 'playwright' ) . catch ( ( ) => {
3078 throw new Error ( 'playwright is not installed — see scripts/screenshots/README.md' )
3179} )
@@ -76,103 +124,76 @@ for (const language of ['zh', 'en']) {
76124 }
77125}
78126
79- // Compositing happens in the page so the shots stay same-origin and the canvas untainted.
127+ // Framing happens in the page so the shots stay same-origin and the canvas untainted.
80128const compositor = await context . newPage ( )
81129await compositor . goto ( `${ origin } ${ page_ } ` )
82130
83131await mkdir ( OUT , { recursive : true } )
84132for ( const language of [ 'zh' , 'en' ] ) {
85- const dataURL = await compositor . evaluate ( async ( { light, dark, width, height, scale } ) => {
86- const load = ( src ) => new Promise ( ( done , fail ) => {
87- const image = new Image ( )
88- image . onload = ( ) => done ( image )
89- image . onerror = fail
90- image . src = src
91- } )
92- const [ lightShot , darkShot ] = await Promise . all ( [ load ( light ) , load ( dark ) ] )
93-
94- const pad = 56 * scale
95- const w = width * scale
96- const h = height * scale
97- const radius = 12 * scale
98- const canvas = document . createElement ( 'canvas' )
99- canvas . width = w + pad * 2
100- canvas . height = h + pad * 2
101- const ctx = canvas . getContext ( '2d' )
102- const window_ = ( inset = 0 ) => {
103- const [ x , y , width_ , height_ , r ] = [ pad + inset , pad + inset , w - inset * 2 , h - inset * 2 , radius ]
104- ctx . beginPath ( )
105- ctx . moveTo ( x + r , y )
106- ctx . arcTo ( x + width_ , y , x + width_ , y + height_ , r )
107- ctx . arcTo ( x + width_ , y + height_ , x , y + height_ , r )
108- ctx . arcTo ( x , y + height_ , x , y , r )
109- ctx . arcTo ( x , y , x + width_ , y , r )
110- ctx . closePath ( )
111- }
112-
113- // The page behind the image is transparent, so the drop shadow has to read on both
114- // GitHub themes rather than sit on an assumed white background.
115- ctx . save ( )
116- ctx . shadowColor = 'rgba(12, 14, 28, 0.34)'
117- ctx . shadowBlur = 44 * scale
118- ctx . shadowOffsetY = 16 * scale
119- window_ ( )
120- ctx . fill ( )
121- ctx . restore ( )
122-
123- ctx . save ( )
124- window_ ( )
125- ctx . clip ( )
126- ctx . drawImage ( lightShot , pad , pad , w , h )
127- // Dark takes the half below the diagonal running top-left → bottom-right.
128- ctx . save ( )
129- ctx . beginPath ( )
130- ctx . moveTo ( pad + w , pad )
131- ctx . lineTo ( pad + w , pad + h )
132- ctx . lineTo ( pad , pad + h )
133- ctx . closePath ( )
134- ctx . clip ( )
135- ctx . drawImage ( darkShot , pad , pad , w , h )
136- ctx . restore ( )
137-
138- const seam = ctx . createLinearGradient ( pad + w , pad , pad , pad + h )
139- seam . addColorStop ( 0 , 'rgba(255, 255, 255, 0.16)' )
140- seam . addColorStop ( 0.5 , 'rgba(255, 255, 255, 0.62)' )
141- seam . addColorStop ( 1 , 'rgba(255, 255, 255, 0.16)' )
142- ctx . strokeStyle = seam
143- ctx . lineWidth = 1.6 * scale
144- ctx . beginPath ( )
145- ctx . moveTo ( pad + w , pad )
146- ctx . lineTo ( pad , pad + h )
147- ctx . stroke ( )
148-
149- // The window keeps its traffic lights: the sidebar already reserves the room.
150- ; [ '#ff5f57' , '#febc2e' , '#28c840' ] . forEach ( ( color , index ) => {
151- ctx . beginPath ( )
152- ctx . arc ( pad + ( 25 + index * 20 ) * scale , pad + 27 * scale , 6 * scale , 0 , Math . PI * 2 )
153- ctx . fillStyle = color
154- ctx . fill ( )
133+ for ( const theme of [ 'light' , 'dark' ] ) {
134+ const dataURL = await compositor . evaluate ( async ( { shot, dark, width, height, scale } ) => {
135+ const image = await new Promise ( ( done , fail ) => {
136+ const element = new Image ( )
137+ element . onload = ( ) => done ( element )
138+ element . onerror = fail
139+ element . src = shot
140+ } )
141+
142+ const pad = 12 * scale
143+ const w = width * scale
144+ const h = height * scale
145+ const radius = 12 * scale
146+ const canvas = document . createElement ( 'canvas' )
147+ canvas . width = w + pad * 2
148+ canvas . height = h + pad * 2
149+ const ctx = canvas . getContext ( '2d' )
150+ const window_ = ( inset = 0 ) => {
151+ const [ x , y , width_ , height_ , r ] = [ pad + inset , pad + inset , w - inset * 2 , h - inset * 2 , radius ]
152+ ctx . beginPath ( )
153+ ctx . moveTo ( x + r , y )
154+ ctx . arcTo ( x + width_ , y , x + width_ , y + height_ , r )
155+ ctx . arcTo ( x + width_ , y + height_ , x , y + height_ , r )
156+ ctx . arcTo ( x , y + height_ , x , y , r )
157+ ctx . arcTo ( x , y , x + width_ , y , r )
158+ ctx . closePath ( )
159+ }
160+
161+ // No drop shadow: a soft gradient across a transparent canvas is the one thing a
162+ // PNG cannot compress, and it doubled the file for an effect GitHub's own page
163+ // never shows around anything else.
164+ ctx . save ( )
165+ window_ ( )
166+ ctx . clip ( )
167+ ctx . drawImage ( image , pad , pad , w , h )
168+
169+ // The window keeps its traffic lights: the sidebar already reserves the room.
170+ ; [ '#ff5f57' , '#febc2e' , '#28c840' ] . forEach ( ( color , index ) => {
171+ ctx . beginPath ( )
172+ ctx . arc ( pad + ( 25 + index * 20 ) * scale , pad + 27 * scale , 6 * scale , 0 , Math . PI * 2 )
173+ ctx . fillStyle = color
174+ ctx . fill ( )
175+ } )
176+ ctx . restore ( )
177+
178+ ctx . strokeStyle = dark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(20, 22, 40, 0.16)'
179+ ctx . lineWidth = scale
180+ window_ ( 0.5 )
181+ ctx . stroke ( )
182+
183+ return canvas . toDataURL ( 'image/png' )
184+ } , {
185+ shot : `${ origin } /scripts/screenshots/build/shots/${ language } -${ theme } .png` ,
186+ dark : theme === 'dark' ,
187+ width : WIDTH ,
188+ height : HEIGHT ,
189+ scale : SCALE
155190 } )
156- ctx . restore ( )
157-
158- ctx . strokeStyle = 'rgba(20, 22, 40, 0.16)'
159- ctx . lineWidth = scale
160- window_ ( 0.5 )
161- ctx . stroke ( )
162-
163- return canvas . toDataURL ( 'image/png' )
164- } , {
165- light : `${ origin } /scripts/screenshots/build/shots/${ language } -light.png` ,
166- dark : `${ origin } /scripts/screenshots/build/shots/${ language } -dark.png` ,
167- width : WIDTH ,
168- height : HEIGHT ,
169- scale : SCALE
170- } )
171-
172- const file = join ( OUT , `overview-${ language } .png` )
173- const bytes = Buffer . from ( dataURL . slice ( dataURL . indexOf ( ',' ) + 1 ) , 'base64' )
174- await writeFile ( file , bytes )
175- console . log ( `wrote ${ file } (${ Math . round ( bytes . length / 1024 ) } KiB)` )
191+
192+ const file = join ( OUT , `overview-${ language } -${ theme } .png` )
193+ const bytes = recompress ( Buffer . from ( dataURL . slice ( dataURL . indexOf ( ',' ) + 1 ) , 'base64' ) )
194+ await writeFile ( file , bytes )
195+ console . log ( `wrote ${ file } (${ Math . round ( bytes . length / 1024 ) } KiB)` )
196+ }
176197}
177198
178199await browser . close ( )
0 commit comments