33 * Pika marketing asset generator.
44 *
55 * Usage:
6- * npx tsx build/marketing/generate.ts --version <version> [--capture] [--web] [--figma] [--all]
6+ * npx tsx build/marketing/generate.ts --version <version> [--app <path>] [-- capture] [--web] [--figma] [--all]
77 *
88 * Flags:
99 * --version Required. Version string, e.g. 1.3.0 or 1.2.0-beta1.
10- * --capture Launch the exported Pika.app, capture all shots, quit.
10+ * --app Optional. Path to Pika.app. Overrides auto-detection.
11+ * --capture Launch Pika.app, capture all shots, quit.
1112 * --web Composite source PNGs into website JPGs (@2x + @1x).
1213 * --figma Copy shadow-trimmed PNGs to figma/ output folder.
1314 * --all Run capture, web, and figma in sequence.
1415 *
15- * Pika.app is auto-detected from :
16- * pika-releases/Production Exports/Pika * (v<version>)/Pika.app
17- *
18- * If no export is found, falls back to whatever Pika is registered system-wide.
16+ * Pika.app resolution order :
17+ * 1. --app <path> if provided
18+ * 2. pika-releases/Production Exports/Pika * (v<version>)/Pika.app
19+ * 3. Whatever Pika is registered system-wide (fallback)
1920 *
2021 * Output: pika-releases/Marketing/v<version>/
2122 */
@@ -41,6 +42,7 @@ const RELEASES_DIR = join(WORKSPACE_ROOT, "pika-releases", "Marketing");
4142// ---------------------------------------------------------------------------
4243
4344interface Shot {
45+ window ?: "about" | "help" | "preferences" | "splash" ;
4446 file : string ;
4547 fg : string ;
4648 bg : string ;
@@ -105,7 +107,7 @@ function findExportedApp(ver: string): string | undefined {
105107 return existsSync ( appPath ) ? appPath : undefined ;
106108}
107109
108- const pikaApp = findExportedApp ( version ) ;
110+ const pikaApp = arg ( "--app" ) ?? findExportedApp ( version ) ;
109111
110112// ---------------------------------------------------------------------------
111113// Capture
@@ -117,6 +119,9 @@ async function runCaptureStep(): Promise<void> {
117119
118120 if ( pikaApp ) {
119121 console . log ( ` App: ${ pikaApp } ` ) ;
122+ console . log ( " Quitting any existing Pika…" ) ;
123+ spawnSync ( "pkill" , [ "-x" , "Pika" ] , { stdio : "pipe" } ) ;
124+ await sleep ( 500 ) ;
120125 console . log ( " Launching Pika…" ) ;
121126 spawnSync ( "open" , [ "-a" , pikaApp ] , { stdio : "inherit" } ) ;
122127 // Allow Pika to fully initialise before sending URL triggers
@@ -130,7 +135,7 @@ async function runCaptureStep(): Promise<void> {
130135
131136 for ( const shot of manifest . shots ) {
132137 const outputPath = join ( SOURCE_DIR , shot . file ) ;
133- const cmd = `"${ captureScript } " "${ outputPath } " "${ shot . fg } " "${ shot . bg } " "${ shot . appearance } " "${ shot . history } "` ;
138+ const cmd = `"${ captureScript } " "${ outputPath } " "${ shot . fg } " "${ shot . bg } " "${ shot . appearance } " "${ shot . history } " " ${ shot . window ?? "main" } " ` ;
134139 console . log ( ` ${ shot . file } ` ) ;
135140 execSync ( cmd , { stdio : "inherit" , env } ) ;
136141 }
@@ -149,15 +154,22 @@ function sleep(ms: number): Promise<void> {
149154// Web composite
150155// ---------------------------------------------------------------------------
151156
157+ // Canvas dimensions match the website @2x asset size.
158+ // screencapture gives 960x600px windows (480x300pt at @2x Retina).
159+ // Windows are placed at full captured size on the @2x canvas.
160+ // @1x output is a 50% resize of the @2x canvas.
152161const CANVAS_WIDTH = 2380 ;
153162const CANVAS_HEIGHT = 838 ;
154163
155- // Layout: left (x=-30, y=54), center (x=893, y=0), right (x=1820, y=54)
164+ // Three-window fan layout on the @2x canvas (2380x838).
165+ // Center window is in front (composited last), side windows partially off-screen.
166+ // Draw order: left → right → center (so center appears on top).
156167const WINDOW_POSITIONS = [
157- { left : - 30 , top : 54 } ,
158- { left : 893 , top : 0 } ,
159- { left : 1820 , top : 54 } ,
168+ { left : - 50 , top : 160 } , // left (index 0 in manifest.web)
169+ { left : 710 , top : 80 } , // center (index 1)
170+ { left : 1470 , top : 160 } , // right (index 2)
160171] ;
172+ const COMPOSITE_ORDER = [ 0 , 2 , 1 ] ; // draw left and right first, center last
161173
162174const BACKGROUNDS : Record < "dark" | "light" , { r : number ; g : number ; b : number } > = {
163175 dark : { r : 26 , g : 26 , b : 26 } ,
@@ -168,29 +180,32 @@ async function compositeWeb(mode: "dark" | "light"): Promise<void> {
168180 const files = manifest . web [ mode ] ;
169181 const bg = BACKGROUNDS [ mode ] ;
170182
171- const trimmed = await Promise . all (
172- files . map ( ( f ) =>
173- sharp ( join ( SOURCE_DIR , f ) )
174- . trim ( )
175- . toBuffer ( { resolveWithObject : true } )
176- )
177- ) ;
178-
179- const composites = trimmed . map ( ( { data } , i ) => ( {
180- input : data ,
181- left : Math . max ( 0 , WINDOW_POSITIONS [ i ] . left ) ,
182- top : WINDOW_POSITIONS [ i ] . top ,
183- } ) ) ;
183+ // Build composites in COMPOSITE_ORDER so center window renders on top.
184+ const composites : Parameters < ReturnType < typeof sharp > [ "composite" ] > [ 0 ] = [ ] ;
185+ for ( const idx of COMPOSITE_ORDER ) {
186+ const file = join ( SOURCE_DIR , files [ idx ] ) ;
187+ let left = WINDOW_POSITIONS [ idx ] . left ;
188+ const top = WINDOW_POSITIONS [ idx ] . top ;
189+
190+ let input : Buffer ;
191+ if ( left < 0 ) {
192+ // Crop off the portion that would be off-screen to the left.
193+ const meta = await sharp ( file ) . metadata ( ) ;
194+ input = await sharp ( file )
195+ . extract ( { left : - left , top : 0 , width : ( meta . width ?? 960 ) + left , height : meta . height ?? 600 } )
196+ . toBuffer ( ) ;
197+ left = 0 ;
198+ } else {
199+ input = await sharp ( file ) . toBuffer ( ) ;
200+ }
201+
202+ composites . push ( { input, left, top } ) ;
203+ }
184204
185205 mkdirSync ( OUTPUT_DIR , { recursive : true } ) ;
186206
187207 const composited = await sharp ( {
188- create : {
189- width : CANVAS_WIDTH ,
190- height : CANVAS_HEIGHT ,
191- channels : 3 ,
192- background : bg ,
193- } ,
208+ create : { width : CANVAS_WIDTH , height : CANVAS_HEIGHT , channels : 3 , background : bg } ,
194209 } )
195210 . composite ( composites )
196211 . jpeg ( { quality : 95 } )
@@ -219,14 +234,30 @@ async function runWebStep(): Promise<void> {
219234// Figma export
220235// ---------------------------------------------------------------------------
221236
237+ // Remove macOS window shadow from a PNG.
238+ // screencapture includes the shadow as semi-transparent pixels outside the window frame.
239+ // sharp's default .trim() only removes fully-transparent pixels (alpha === 0), leaving
240+ // residual shadow. This function first zeros out any pixel with alpha < 200, then trims.
241+ async function removeShadow ( src : string ) : Promise < Buffer > {
242+ const { data, info } = await sharp ( src ) . ensureAlpha ( ) . raw ( ) . toBuffer ( { resolveWithObject : true } ) ;
243+ const { width, height, channels } = info ;
244+ for ( let i = 0 ; i < data . length ; i += channels ) {
245+ if ( data [ i + 3 ] < 200 ) {
246+ data [ i ] = 0 ; data [ i + 1 ] = 0 ; data [ i + 2 ] = 0 ; data [ i + 3 ] = 0 ;
247+ }
248+ }
249+ return sharp ( Buffer . from ( data ) , { raw : { width, height, channels } } ) . trim ( ) . png ( ) . toBuffer ( ) ;
250+ }
251+
222252async function runFigmaStep ( ) : Promise < void > {
223253 console . log ( "\n── Figma exports ────────────────────────────────" ) ;
224254 mkdirSync ( FIGMA_DIR , { recursive : true } ) ;
225255
226256 for ( const [ key , file ] of Object . entries ( manifest . figma ) ) {
227257 const src = join ( SOURCE_DIR , file ) ;
228258 const dest = join ( FIGMA_DIR , `${ key } .png` ) ;
229- await sharp ( src ) . trim ( ) . toFile ( dest ) ;
259+ const buf = await removeShadow ( src ) ;
260+ await sharp ( buf ) . toFile ( dest ) ;
230261 console . log ( ` ${ key } .png ← ${ file } ` ) ;
231262 }
232263}
0 commit comments