Skip to content

Commit 0648d76

Browse files
Improve web composite shadow rendering in marketing generator
- Hoist shadow constants (SHADOW_BLUR, SHADOW_OFFSET_Y, SHADOW_OPACITY) to module scope so WINDOW_POSITIONS can derive insets from them - Add SHADOW_EDGE_PAD = SHADOW_BLUR * 2 and use it for left/right window positions so outer shadows fade to the canvas edge rather than clipping - Fix Gaussian blur by switching to extend()+separate blur pass on the greyscale alpha channel, avoiding Sharp's premultiplied-alpha hard-edge bug - Use blurInfo.channels stride when building the RGBA shadow buffer so the correct channel byte is read regardless of Sharp's output encoding - Pad all four sides with 2× SHADOW_BLUR (bottom adds SHADOW_OFFSET_Y) so the Gaussian has room to fade below ~14% before hitting the canvas edge - Apply directional padding: extra bottom space only where shadow falls Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3834a2d commit 0648d76

1 file changed

Lines changed: 93 additions & 11 deletions

File tree

build/marketing/generate.ts

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,22 @@ function sleep(ms: number): Promise<void> {
161161
const CANVAS_WIDTH = 2380;
162162
const CANVAS_HEIGHT = 838;
163163

164+
// Drop shadow parameters — declared here so WINDOW_POSITIONS can reference them.
165+
const SHADOW_OFFSET_Y = 10; // downward shift in px
166+
const SHADOW_BLUR = 10; // gaussian sigma
167+
const SHADOW_OPACITY = 0.15; // 0–1
168+
164169
// Three-window fan layout on the @2x canvas (2380x838).
165170
// Center window is in front (composited last), side windows partially off-screen.
166171
// Draw order: left → right → center (so center appears on top).
172+
// Positions refer to the visual window corner (shadow padding is added internally).
173+
// Side windows are inset by SHADOW_BLUR * 2 so the outer shadow fades to the canvas edge
174+
// rather than clipping. Center window remains horizontally centred on the canvas.
175+
const SHADOW_EDGE_PAD = SHADOW_BLUR * 2;
167176
const WINDOW_POSITIONS = [
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)
177+
{ left: SHADOW_EDGE_PAD, top: 160 }, // left
178+
{ left: 710, top: 80 }, // center — centred on 2380px canvas
179+
{ left: CANVAS_WIDTH - 960 - SHADOW_EDGE_PAD, top: 160 }, // right
171180
];
172181
const COMPOSITE_ORDER = [0, 2, 1]; // draw left and right first, center last
173182

@@ -176,27 +185,100 @@ const BACKGROUNDS: Record<"dark" | "light", { r: number; g: number; b: number }>
176185
light: { r: 255, g: 255, b: 255 },
177186
};
178187

188+
// Add a consistent programmatic drop shadow to a window image.
189+
// Returns the image buffer with shadow included, plus the uniform padding added on each side.
190+
//
191+
// Why grayscale for the blur: Sharp uses premultiplied alpha internally for gaussblur.
192+
// Blurring pure black (0,0,0) on transparent (0,0,0,0) gives identical premultiplied
193+
// values (0,0,0) in both regions — the Gaussian has nothing to spread, producing a
194+
// hard edge. Extracting the alpha channel as single-channel greyscale and blurring that
195+
// directly avoids premultiplied alpha entirely, giving a proper Gaussian gradient.
196+
async function withDropShadow(buf: Buffer): Promise<{ image: Buffer; padLeft: number; padTop: number }> {
197+
198+
const meta = await sharp(buf).metadata();
199+
const w = meta.width ?? 960;
200+
const h = meta.height ?? 600;
201+
202+
// All sides need 2× SHADOW_BLUR so the Gaussian (σ=SHADOW_BLUR) doesn't clip at the canvas edge
203+
// (at 1σ the shadow is still ~61% intensity; at 2σ it's ~14%, negligible).
204+
// Bottom gets an extra SHADOW_OFFSET_Y since the shadow is shifted down.
205+
const padLeft = SHADOW_BLUR * 2;
206+
const padTop = SHADOW_BLUR * 2;
207+
const padRight = SHADOW_BLUR * 2;
208+
const padBottom = SHADOW_BLUR * 2 + SHADOW_OFFSET_Y;
209+
const totalW = w + padLeft + padRight;
210+
const totalH = h + padTop + padBottom;
211+
212+
// 1. Extract the window's alpha channel as single-channel greyscale.
213+
const windowAlpha = await sharp(buf).extractChannel(3).toBuffer();
214+
215+
// 2. Extend the alpha mask with black padding (shadow shifted down), then blur.
216+
// Using extend() on the greyscale image directly and blurring in a second step
217+
// avoids RGB canvas compositing quirks and pipeline ordering issues.
218+
const alphaPadded = await sharp(windowAlpha)
219+
.extend({
220+
top: padTop + SHADOW_OFFSET_Y, // extra offset pushes shadow down in the blur canvas
221+
bottom: SHADOW_BLUR * 2,
222+
left: padLeft,
223+
right: padRight,
224+
background: { r: 0, g: 0, b: 0 },
225+
})
226+
.png()
227+
.toBuffer();
228+
229+
const { data: blurredGrey, info: blurInfo } = await sharp(alphaPadded)
230+
.blur(SHADOW_BLUR)
231+
.raw()
232+
.toBuffer({ resolveWithObject: true });
233+
234+
// 3. Build RGBA shadow: R=G=B=0 (black), A = blurred grey × SHADOW_OPACITY.
235+
// Use blurInfo.channels to stride correctly — Sharp may output 1, 2, or 3 channels
236+
// depending on how it encoded the padded PNG; always take the first channel.
237+
const ch = blurInfo.channels;
238+
const shadowData = Buffer.alloc(totalW * totalH * 4, 0);
239+
for (let i = 0; i < totalW * totalH; i++) {
240+
shadowData[i * 4 + 3] = Math.round((blurredGrey[i * ch] as number) * SHADOW_OPACITY);
241+
}
242+
const shadow = await sharp(shadowData, { raw: { width: totalW, height: totalH, channels: 4 } })
243+
.png()
244+
.toBuffer();
245+
246+
// 4. Composite the clean window over the shadow at the padded position.
247+
const result = await sharp(shadow)
248+
.composite([{ input: buf, left: padLeft, top: padTop }])
249+
.png()
250+
.toBuffer();
251+
252+
return { image: result, padLeft, padTop };
253+
}
254+
179255
async function compositeWeb(mode: "dark" | "light"): Promise<void> {
180256
const files = manifest.web[mode];
181257
const bg = BACKGROUNDS[mode];
182258

183259
// Build composites in COMPOSITE_ORDER so center window renders on top.
184260
const composites: Parameters<ReturnType<typeof sharp>["composite"]>[0] = [];
185261
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;
262+
const src = join(SOURCE_DIR, files[idx]);
263+
264+
// Remove macOS-captured shadow, then apply a consistent programmatic shadow.
265+
const cleaned = await removeShadow(src);
266+
const { image: shadowed, padLeft, padTop } = await withDropShadow(cleaned);
267+
268+
// Adjust position so the window chrome lands at WINDOW_POSITIONS[idx].
269+
let left = WINDOW_POSITIONS[idx].left - padLeft;
270+
const top = WINDOW_POSITIONS[idx].top - padTop;
189271

190272
let input: Buffer;
191273
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 })
274+
// Crop the hidden left portion (shadow padding + off-screen shift).
275+
const meta = await sharp(shadowed).metadata();
276+
input = await sharp(shadowed)
277+
.extract({ left: -left, top: 0, width: (meta.width ?? 0) + left, height: meta.height ?? 0 })
196278
.toBuffer();
197279
left = 0;
198280
} else {
199-
input = await sharp(file).toBuffer();
281+
input = shadowed;
200282
}
201283

202284
composites.push({ input, left, top });

0 commit comments

Comments
 (0)