appshot-cli 0.8.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +533 -33
  2. package/dist/cli.js +19 -5
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/build.d.ts.map +1 -1
  5. package/dist/commands/build.js +16 -2
  6. package/dist/commands/build.js.map +1 -1
  7. package/dist/commands/init.js +2 -2
  8. package/dist/commands/preset.d.ts +5 -0
  9. package/dist/commands/preset.d.ts.map +1 -0
  10. package/dist/commands/preset.js +191 -0
  11. package/dist/commands/preset.js.map +1 -0
  12. package/dist/commands/quickstart.d.ts +3 -0
  13. package/dist/commands/quickstart.d.ts.map +1 -0
  14. package/dist/commands/quickstart.js +326 -0
  15. package/dist/commands/quickstart.js.map +1 -0
  16. package/dist/commands/style.d.ts.map +1 -1
  17. package/dist/commands/style.js +98 -0
  18. package/dist/commands/style.js.map +1 -1
  19. package/dist/commands/template.d.ts +3 -0
  20. package/dist/commands/template.d.ts.map +1 -0
  21. package/dist/commands/template.js +399 -0
  22. package/dist/commands/template.js.map +1 -0
  23. package/dist/core/compose.d.ts +16 -0
  24. package/dist/core/compose.d.ts.map +1 -1
  25. package/dist/core/compose.js +342 -83
  26. package/dist/core/compose.js.map +1 -1
  27. package/dist/services/doctor.js +1 -1
  28. package/dist/templates/registry.d.ts +73 -0
  29. package/dist/templates/registry.d.ts.map +1 -0
  30. package/dist/templates/registry.js +724 -0
  31. package/dist/templates/registry.js.map +1 -0
  32. package/dist/types.d.ts +15 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/utils/validation.d.ts +36 -0
  35. package/dist/utils/validation.d.ts.map +1 -0
  36. package/dist/utils/validation.js +143 -0
  37. package/dist/utils/validation.js.map +1 -0
  38. package/package.json +3 -2
@@ -47,20 +47,39 @@ function escapeXml(text) {
47
47
  /**
48
48
  * Generate SVG with text, background, and border support
49
49
  */
50
- function generateCaptionSVG(lines, width, height, fontSize, fontFamily, fontStyle, fontWeight, textColor, lineHeight, captionConfig) {
51
- const backgroundConfig = captionConfig.background;
52
- const borderConfig = captionConfig.border;
50
+ function generateCaptionSVG(lines, width, height, fontSize, fontFamily, fontStyle, fontWeight, textColor, lineHeight, captionConfig, deviceConfig = {}) {
51
+ const backgroundConfig = deviceConfig.captionBackground || captionConfig.background;
52
+ const borderConfig = deviceConfig.captionBorder || captionConfig.border;
53
+ const verticalAlign = captionConfig.box?.verticalAlign || 'center';
54
+ const align = captionConfig.align || 'center';
53
55
  // Calculate text positioning
54
56
  const totalTextHeight = lines.length * fontSize * lineHeight;
55
- const startY = (height - totalTextHeight) / 2 + fontSize;
57
+ const bgPadding = backgroundConfig?.padding || 20;
58
+ const startY = verticalAlign === 'top'
59
+ ? Math.max(fontSize, bgPadding + fontSize)
60
+ : (height - totalTextHeight) / 2 + fontSize;
61
+ // Horizontal alignment
62
+ const sideMargin = backgroundConfig?.sideMargin ?? 30;
63
+ const leftX = sideMargin + bgPadding;
64
+ const rightX = width - (sideMargin + bgPadding);
65
+ const centerX = Math.floor(width / 2);
66
+ let textAnchor = 'middle';
67
+ let textX = centerX;
68
+ if (align === 'left') {
69
+ textAnchor = 'start';
70
+ textX = leftX;
71
+ }
72
+ else if (align === 'right') {
73
+ textAnchor = 'end';
74
+ textX = rightX;
75
+ }
56
76
  // SVG elements array
57
77
  const svgElements = [];
58
78
  // Add background rectangle if configured
59
79
  if (backgroundConfig?.color) {
60
- const bgPadding = backgroundConfig.padding || 20;
61
- const bgOpacity = backgroundConfig.opacity || 0.8;
80
+ const bgOpacity = backgroundConfig.opacity !== undefined ? backgroundConfig.opacity : 0.8;
62
81
  // Use full width minus margins for uniform appearance
63
- const sideMargin = 30; // Margin from edges
82
+ const sideMargin = backgroundConfig.sideMargin ?? 30; // Margin from edges
64
83
  const bgWidth = width - (sideMargin * 2);
65
84
  const bgHeight = totalTextHeight + bgPadding * 2;
66
85
  const bgX = sideMargin;
@@ -75,7 +94,7 @@ function generateCaptionSVG(lines, width, height, fontSize, fontFamily, fontStyl
75
94
  const borderWidth = borderConfig.width;
76
95
  const borderRadius = borderConfig.radius || 12;
77
96
  // Use full width minus margins for uniform appearance
78
- const sideMargin = 30; // Margin from edges
97
+ const sideMargin = backgroundConfig?.sideMargin ?? 30; // Margin from edges
79
98
  const rectWidth = width - (sideMargin * 2);
80
99
  const rectHeight = totalTextHeight + bgPadding * 2;
81
100
  const rectX = sideMargin;
@@ -86,13 +105,13 @@ function generateCaptionSVG(lines, width, height, fontSize, fontFamily, fontStyl
86
105
  // Add text elements
87
106
  const textElements = lines.map((line, index) => {
88
107
  const y = startY + (index * fontSize * lineHeight);
89
- return `<text x="${width / 2}" y="${y}" ` +
108
+ return `<text x="${textX}" y="${y}" ` +
90
109
  `font-family="${fontFamily}" ` +
91
110
  `font-size="${fontSize}" ` +
92
111
  `font-style="${fontStyle}" ` +
93
112
  `font-weight="${fontWeight}" ` +
94
113
  `fill="${textColor}" ` +
95
- `text-anchor="middle">${escapeXml(line)}</text>`;
114
+ `text-anchor="${textAnchor}">${escapeXml(line)}</text>`;
96
115
  });
97
116
  svgElements.push(...textElements);
98
117
  return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
@@ -108,18 +127,30 @@ export async function composeAppStoreScreenshot(options) {
108
127
  // Check for device-specific override first
109
128
  const captionPosition = deviceConfig.captionPosition || captionConfig.position || 'above';
110
129
  const partialFrame = deviceConfig.partialFrame || false;
111
- const frameOffset = deviceConfig.frameOffset || 25; // Default 25% cut off
112
- const framePosition = deviceConfig.framePosition || 'center';
130
+ const frameOffset = deviceConfig.frameOffset !== undefined ? deviceConfig.frameOffset : 25; // Default 25% cut off
131
+ const framePosition = deviceConfig.framePosition !== undefined ? deviceConfig.framePosition : 'center';
113
132
  const deviceFrameScale = deviceConfig.frameScale;
133
+ const boxCfg = deviceConfig.captionBox || captionConfig.box || {};
134
+ const marginTop = boxCfg.marginTop || 0;
135
+ const marginBottom = boxCfg.marginBottom || 0;
136
+ const sideMarginDbg = captionConfig.background?.sideMargin ?? 30;
137
+ if (verbose) {
138
+ console.log(pc.dim(' Layout settings:'));
139
+ console.log(pc.dim(` Caption position: ${captionPosition}`));
140
+ console.log(pc.dim(` Frame position: ${String(framePosition)}`));
141
+ console.log(pc.dim(` Frame scale: ${deviceFrameScale ?? '(auto)'}`));
142
+ console.log(pc.dim(` Margins: top=${marginTop}px, bottom=${marginBottom}px, side=${sideMarginDbg}px`));
143
+ }
114
144
  // Calculate dimensions based on output
115
145
  // Pre-calculate device dimensions for caption height calculation
116
146
  let targetDeviceHeight = 0;
117
147
  let deviceTop = 0;
148
+ let croppedPixels = 0; // Track cropped pixels for partial frames
118
149
  if (frame && frameMetadata) {
119
150
  const originalFrameHeight = frameMetadata.frameHeight;
120
151
  const availableHeight = Math.max(100, outputHeight - 100); // Temporary estimate
121
152
  const scaleY = availableHeight / originalFrameHeight;
122
- const scale = deviceConfig.frameScale ? scaleY * deviceConfig.frameScale : scaleY * 0.9;
153
+ const scale = deviceConfig.frameScale !== undefined ? scaleY * deviceConfig.frameScale : scaleY * 0.9;
123
154
  targetDeviceHeight = Math.floor(originalFrameHeight * scale);
124
155
  // Calculate preliminary device position
125
156
  if (typeof framePosition === 'number') {
@@ -139,6 +170,10 @@ export async function composeAppStoreScreenshot(options) {
139
170
  // Calculate caption height based on position
140
171
  let captionHeight = 0;
141
172
  let captionLines = [];
173
+ const backgroundCfg = deviceConfig.captionBackground || captionConfig.background || {};
174
+ const sideMargin = backgroundCfg.sideMargin ?? 30;
175
+ const boxPadding = backgroundCfg.padding ?? 20;
176
+ const wrapWidth = Math.max(50, outputWidth - (sideMargin * 2) - (boxPadding * 2));
142
177
  if ((captionPosition === 'above' || captionPosition === 'below') && caption) {
143
178
  const isWatch = outputWidth < 500;
144
179
  const captionFontSize = deviceConfig.captionSize || captionConfig.fontsize;
@@ -158,7 +193,7 @@ export async function composeAppStoreScreenshot(options) {
158
193
  // Use smaller font size for watch (36px max)
159
194
  const watchFontSize = Math.min(36, captionFontSize);
160
195
  // Use wrapText which now accounts for watch padding - allow 3 lines for watch
161
- captionLines = wrapText(caption, outputWidth, watchFontSize, 3);
196
+ captionLines = wrapText(caption, wrapWidth, watchFontSize, 3);
162
197
  if (verbose) {
163
198
  console.log(pc.dim(` Watch mode: font reduced to ${watchFontSize}px`));
164
199
  console.log(pc.dim(` Wrap width: ${outputWidth}px (with padding)`));
@@ -166,9 +201,22 @@ export async function composeAppStoreScreenshot(options) {
166
201
  }
167
202
  else if (autoSize) {
168
203
  // Use adaptive caption height
169
- const result = calculateAdaptiveCaptionHeight(caption, captionFontSize, outputWidth, outputHeight, deviceTop, targetDeviceHeight, framePosition);
204
+ const result = calculateAdaptiveCaptionHeight(caption, captionFontSize, wrapWidth, outputHeight, deviceTop, targetDeviceHeight, framePosition);
170
205
  captionHeight = result.height;
171
206
  captionLines = result.lines;
207
+ // Respect min/max height if provided (helps verticalAlign be visible)
208
+ const minH = captionBoxConfig.minHeight;
209
+ const maxH = captionBoxConfig.maxHeight;
210
+ if (typeof minH === 'number')
211
+ captionHeight = Math.max(minH, captionHeight);
212
+ if (typeof maxH === 'number')
213
+ captionHeight = Math.min(maxH, captionHeight);
214
+ // Ensure the SVG canvas can fully contain the background rect
215
+ const computedLineHeight = (captionConfig.box && captionConfig.box.lineHeight) || 1.4;
216
+ const minSvgHeight = Math.ceil((captionLines.length * captionFontSize * computedLineHeight) + (boxPadding * 2));
217
+ if (captionHeight < minSvgHeight) {
218
+ captionHeight = minSvgHeight;
219
+ }
172
220
  if (verbose) {
173
221
  console.log(pc.dim(` Adaptive height: ${captionHeight}px`));
174
222
  }
@@ -176,7 +224,7 @@ export async function composeAppStoreScreenshot(options) {
176
224
  else {
177
225
  // Use fixed height with text wrapping
178
226
  const maxLines = captionBoxConfig.maxLines || 3;
179
- captionLines = wrapText(caption, outputWidth, captionFontSize, maxLines);
227
+ captionLines = wrapText(caption, wrapWidth, captionFontSize, maxLines);
180
228
  const lineHeight = captionBoxConfig.lineHeight || 1.4;
181
229
  const textHeight = captionLines.length * captionFontSize * lineHeight;
182
230
  captionHeight = captionConfig.paddingTop + textHeight + (captionConfig.paddingBottom || 60);
@@ -187,6 +235,12 @@ export async function composeAppStoreScreenshot(options) {
187
235
  if (captionBoxConfig.maxHeight) {
188
236
  captionHeight = Math.min(captionBoxConfig.maxHeight, captionHeight);
189
237
  }
238
+ // Ensure the SVG canvas can fully contain the background rect
239
+ const computedLineHeight = (captionConfig.box && captionConfig.box.lineHeight) || 1.4;
240
+ const minSvgHeight = Math.ceil((captionLines.length * captionFontSize * computedLineHeight) + (boxPadding * 2));
241
+ if (captionHeight < minSvgHeight) {
242
+ captionHeight = minSvgHeight;
243
+ }
190
244
  if (verbose) {
191
245
  console.log(pc.dim(` Max lines: ${maxLines}`));
192
246
  console.log(pc.dim(` Line height: ${lineHeight}`));
@@ -195,7 +249,7 @@ export async function composeAppStoreScreenshot(options) {
195
249
  }
196
250
  if (verbose && captionLines.length > 0) {
197
251
  console.log(pc.dim(` Lines wrapped: ${captionLines.length}`));
198
- console.log(pc.dim(` Wrap width: ${outputWidth - 80}px`)); // Assuming 40px padding each side
252
+ console.log(pc.dim(` Wrap width: ${wrapWidth}px`));
199
253
  }
200
254
  }
201
255
  // Calculate total canvas dimensions (should be output dimensions)
@@ -245,6 +299,7 @@ export async function composeAppStoreScreenshot(options) {
245
299
  // Get caption box config
246
300
  const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
247
301
  const lineHeight = captionBoxConfig.lineHeight || 1.4;
302
+ const topMargin = captionBoxConfig.marginTop || 0;
248
303
  if (captionLines.length === 0) {
249
304
  // Fallback if no lines were calculated
250
305
  captionLines = [caption];
@@ -264,15 +319,40 @@ export async function composeAppStoreScreenshot(options) {
264
319
  console.log(pc.dim(` Style: ${fontStyle}, Weight: ${fontWeight}`));
265
320
  }
266
321
  }
267
- svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, captionConfig.color, lineHeight, captionConfig);
322
+ // Ensure 'below' captions anchor their background box at the top of the
323
+ // reserved area so the box does not extend upward into the device area.
324
+ // If a verticalAlign is already provided, respect it; otherwise default to 'top'.
325
+ const adjustedCaptionConfig = {
326
+ ...captionConfig,
327
+ box: {
328
+ ...(captionConfig.box || {}),
329
+ verticalAlign: (captionConfig.box && captionConfig.box.verticalAlign) ? captionConfig.box.verticalAlign : 'top'
330
+ }
331
+ };
332
+ svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, adjustedCaptionConfig.color, lineHeight, adjustedCaptionConfig, deviceConfig);
268
333
  const captionImage = await sharp(Buffer.from(svgText))
269
334
  .png()
270
335
  .toBuffer();
271
336
  composites.push({
272
337
  input: captionImage,
273
- top: 0,
338
+ top: Math.max(0, topMargin),
274
339
  left: 0
275
340
  });
341
+ if (options.onDebug) {
342
+ const bm = (deviceConfig.captionBox || captionConfig.box || {}).marginBottom || 0;
343
+ options.onDebug({
344
+ mode: 'above',
345
+ framePosition,
346
+ frameScale: deviceFrameScale,
347
+ deviceTop,
348
+ deviceBottom: deviceTop + targetDeviceHeight,
349
+ deviceHeight: targetDeviceHeight,
350
+ captionTop: Math.max(0, topMargin),
351
+ captionHeight,
352
+ marginTop: topMargin,
353
+ marginBottom: bm
354
+ });
355
+ }
276
356
  }
277
357
  catch {
278
358
  // If text rendering fails, just add transparent area
@@ -311,19 +391,25 @@ export async function composeAppStoreScreenshot(options) {
311
391
  let availableHeight;
312
392
  // For 'above' positioning, reduce available height by caption
313
393
  // For 'below' positioning, we'll adjust positioning later but calculate scale normally
394
+ const boxConfig = deviceConfig.captionBox || captionConfig.box || {};
395
+ const topMargin = boxConfig.marginTop || 0;
396
+ const bottomMargin = boxConfig.marginBottom || 0;
314
397
  if (captionPosition === 'above') {
315
- availableHeight = Math.max(100, outputHeight - captionHeight);
398
+ availableHeight = Math.max(100, outputHeight - captionHeight - topMargin - bottomMargin);
316
399
  }
317
400
  else if (captionPosition === 'below') {
318
- availableHeight = Math.max(100, outputHeight - captionHeight);
401
+ availableHeight = Math.max(100, outputHeight - captionHeight - bottomMargin);
319
402
  }
320
403
  else {
321
404
  // 'overlay' positioning doesn't reduce available space
322
- availableHeight = outputHeight;
405
+ availableHeight = outputHeight - bottomMargin;
323
406
  }
324
- // If frameScale is explicitly set, use total output height for consistent sizing
407
+ // Respect explicit frameScale by basing scale on the full output height.
408
+ // This keeps device size under user control and avoids unexpected shrinking
409
+ // when switching caption modes. Caption placement logic will adapt if the
410
+ // device intrudes into reserved areas.
325
411
  if (deviceFrameScale !== undefined) {
326
- availableHeight = outputHeight; // Use full height for consistent scale
412
+ availableHeight = outputHeight;
327
413
  }
328
414
  // Calculate scale to fit within available space while maintaining aspect ratio
329
415
  const scaleX = availableWidth / originalFrameWidth;
@@ -458,9 +544,11 @@ export async function composeAppStoreScreenshot(options) {
458
544
  // If partial frame, crop the bottom
459
545
  if (partialFrame) {
460
546
  const cropHeight = Math.floor(originalFrameHeight * (1 - frameOffset / 100));
547
+ croppedPixels = Math.floor((originalFrameHeight - cropHeight) * scale); // Scaled cropped amount
461
548
  if (verbose) {
462
549
  console.log(pc.dim(` Partial frame: cropping ${frameOffset}% from bottom`));
463
550
  console.log(pc.dim(` Crop height: ${cropHeight}px`));
551
+ console.log(pc.dim(` Cropped pixels (scaled): ${croppedPixels}px`));
464
552
  }
465
553
  try {
466
554
  deviceComposite = await sharp(deviceComposite)
@@ -496,76 +584,182 @@ export async function composeAppStoreScreenshot(options) {
496
584
  }
497
585
  }
498
586
  // Recalculate position with actual caption height
587
+ let availableSpace = 0; // Declare here for wider scope
499
588
  if (captionPosition === 'above') {
500
589
  // Caption above: position device below caption area
501
590
  if (typeof framePosition === 'number') {
502
591
  // Custom position as percentage from top (0-100)
503
- const availableSpace = canvasHeight - captionHeight - targetDeviceHeight;
504
- deviceTop = captionHeight + Math.floor(availableSpace * (framePosition / 100));
592
+ const boxConfig = deviceConfig.captionBox || captionConfig.box || {};
593
+ const topMargin = boxConfig.marginTop || 0;
594
+ const bottomMargin = boxConfig.marginBottom || 0;
595
+ availableSpace = canvasHeight - topMargin - captionHeight - bottomMargin - targetDeviceHeight;
596
+ // Fix for watch: if there's no available space, adjust positioning to work
597
+ if (availableSpace < 0 && frameMetadata.deviceType === 'watch') {
598
+ // For watch with negative space, position relative to full canvas minus device height
599
+ // This allows the watch to overlap with the caption area when needed
600
+ const totalAvailable = canvasHeight - bottomMargin - targetDeviceHeight;
601
+ if (verbose) {
602
+ console.log(pc.dim(' Watch positioning fix applied (overlap mode)'));
603
+ console.log(pc.dim(` Original available: ${availableSpace}px`));
604
+ console.log(pc.dim(` Using total available: ${totalAvailable}px`));
605
+ }
606
+ // Position from top of canvas, allowing overlap with caption
607
+ deviceTop = Math.floor(totalAvailable * (framePosition / 100));
608
+ }
609
+ else {
610
+ // Normal positioning for other devices or when space is available
611
+ deviceTop = topMargin + captionHeight + Math.floor(availableSpace * (framePosition / 100));
612
+ if (verbose) {
613
+ console.log(pc.dim(' Frame positioning (above mode):'));
614
+ console.log(pc.dim(` Canvas height: ${canvasHeight}px`));
615
+ console.log(pc.dim(` Caption height: ${captionHeight}px`));
616
+ console.log(pc.dim(` Device height: ${targetDeviceHeight}px`));
617
+ console.log(pc.dim(` Top margin: ${topMargin}px`));
618
+ console.log(pc.dim(` Bottom margin: ${bottomMargin}px`));
619
+ console.log(pc.dim(` Available space: ${availableSpace}px`));
620
+ console.log(pc.dim(` Frame position %: ${framePosition}`));
621
+ console.log(pc.dim(` Calculated deviceTop: ${deviceTop}px`));
622
+ }
623
+ }
624
+ // Debug logging for watch positioning issue
625
+ if (verbose && frameMetadata.deviceType === 'watch') {
626
+ console.log(pc.dim(' Watch positioning debug:'));
627
+ console.log(pc.dim(` Canvas height: ${canvasHeight}px`));
628
+ console.log(pc.dim(` Caption height: ${captionHeight}px`));
629
+ console.log(pc.dim(` Target device height: ${targetDeviceHeight}px`));
630
+ console.log(pc.dim(` Available space: ${availableSpace}px`));
631
+ console.log(pc.dim(` Device top: ${deviceTop}px`));
632
+ }
505
633
  }
506
634
  else if (framePosition === 'top') {
507
- deviceTop = captionHeight;
635
+ const boxConfig = deviceConfig.captionBox || captionConfig.box || {};
636
+ const topMargin = boxConfig.marginTop || 0;
637
+ deviceTop = topMargin + captionHeight;
508
638
  }
509
639
  else if (framePosition === 'bottom') {
510
- deviceTop = canvasHeight - targetDeviceHeight;
640
+ const boxConfig = deviceConfig.captionBox || captionConfig.box || {};
641
+ const bottomMargin = boxConfig.marginBottom || 0;
642
+ deviceTop = canvasHeight - bottomMargin - targetDeviceHeight;
511
643
  }
512
644
  else if (framePosition === 'center') {
513
645
  // Default centered positioning
514
- const availableSpace = canvasHeight - captionHeight;
515
- deviceTop = captionHeight + Math.floor((availableSpace - targetDeviceHeight) / 2);
646
+ const boxConfig = deviceConfig.captionBox || captionConfig.box || {};
647
+ const topMargin = boxConfig.marginTop || 0;
648
+ const bottomMargin = boxConfig.marginBottom || 0;
649
+ const availableSpace2 = canvasHeight - topMargin - captionHeight - bottomMargin;
650
+ deviceTop = topMargin + captionHeight + Math.floor((availableSpace2 - targetDeviceHeight) / 2);
516
651
  }
517
652
  else {
518
- // Default to centered
519
- deviceTop = captionHeight;
653
+ const boxConfig = deviceConfig.captionBox || captionConfig.box || {};
654
+ const topMargin = boxConfig.marginTop || 0;
655
+ deviceTop = topMargin + captionHeight;
520
656
  }
521
657
  // Ensure device doesn't go off canvas
522
- deviceTop = Math.floor(Math.max(captionHeight, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
658
+ const boxConfig2 = deviceConfig.captionBox || captionConfig.box || {};
659
+ const bottomMargin2 = boxConfig2.marginBottom || 0;
660
+ // For watch with negative available space, allow overlap with caption
661
+ if (frameMetadata.deviceType === 'watch' && typeof framePosition === 'number' && availableSpace < 0) {
662
+ // Only clamp to canvas bounds, not caption height
663
+ // With partialFrame, allow device to extend beyond canvas by the cropped amount
664
+ const bottomAdjustment = partialFrame ? croppedPixels : 0;
665
+ deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, canvasHeight - bottomMargin2 - targetDeviceHeight + bottomAdjustment)));
666
+ }
667
+ else {
668
+ // Normal clamping for other devices
669
+ const topMargin2 = (deviceConfig.captionBox || captionConfig.box || {}).marginTop || 0;
670
+ // With partialFrame, allow device to extend beyond canvas by the cropped amount
671
+ const bottomAdjustment = partialFrame ? croppedPixels : 0;
672
+ deviceTop = Math.floor(Math.max(topMargin2 + captionHeight, Math.min(deviceTop, canvasHeight - bottomMargin2 - targetDeviceHeight + bottomAdjustment)));
673
+ }
523
674
  }
524
675
  else if (captionPosition === 'below') {
525
676
  // Caption below: position device in upper area, leaving space for caption at bottom
526
- const deviceAreaHeight = canvasHeight - captionHeight;
677
+ const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
678
+ // Apply a sensible default gap when marginTop is undefined to meet user expectations
679
+ // of a small clearance between device and caption. If marginTop is explicitly set,
680
+ // it wins. Otherwise we default to max(12px, half border width).
681
+ const borderWidthForGap = (deviceConfig.captionBorder || captionConfig.border)?.width || 0;
682
+ const defaultBelowGap = Math.max(12, Math.round(borderWidthForGap / 2));
683
+ const marginTop = (captionBoxConfig.marginTop !== undefined)
684
+ ? captionBoxConfig.marginTop
685
+ : defaultBelowGap;
686
+ const marginBottom = captionBoxConfig.marginBottom || 0;
687
+ // Calculate ideal positions
688
+ // Device area is everything except caption, marginTop gap, and marginBottom
689
+ const deviceAreaHeight = canvasHeight - captionHeight - marginTop - marginBottom;
690
+ const spaceWithMargins = deviceAreaHeight - targetDeviceHeight;
691
+ const maxOverlapSlack = (canvasHeight - captionHeight) - targetDeviceHeight;
692
+ const effectiveAvailableSpace = spaceWithMargins >= 0
693
+ ? spaceWithMargins
694
+ : Math.max(0, maxOverlapSlack);
527
695
  if (typeof framePosition === 'number') {
528
696
  // Custom position as percentage within device area
529
- const availableSpace = deviceAreaHeight - targetDeviceHeight;
530
- deviceTop = Math.floor(availableSpace * (framePosition / 100));
697
+ deviceTop = Math.floor(effectiveAvailableSpace * (framePosition / 100));
531
698
  }
532
699
  else if (framePosition === 'top') {
533
700
  deviceTop = 0;
534
701
  }
535
702
  else if (framePosition === 'bottom') {
536
- deviceTop = deviceAreaHeight - targetDeviceHeight;
703
+ deviceTop = effectiveAvailableSpace;
537
704
  }
538
705
  else if (framePosition === 'center') {
539
706
  // Default centered positioning in device area
540
- deviceTop = Math.floor((deviceAreaHeight - targetDeviceHeight) / 2);
707
+ deviceTop = Math.floor(effectiveAvailableSpace / 2);
541
708
  }
542
709
  else {
543
710
  // Default to centered in device area
544
- deviceTop = Math.floor((deviceAreaHeight - targetDeviceHeight) / 2);
711
+ deviceTop = Math.floor(effectiveAvailableSpace / 2);
545
712
  }
546
- // Ensure device doesn't go off canvas or into caption area
547
- deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, deviceAreaHeight - targetDeviceHeight)));
713
+ // Ensure device doesn't go off canvas
714
+ // With partialFrame, allow device to extend beyond canvas by the cropped amount
715
+ const bottomAdjustmentBelow = partialFrame ? croppedPixels : 0;
716
+ // Clamp device position to available space (allowing overlap slack when needed)
717
+ const maxDeviceTop = effectiveAvailableSpace + bottomAdjustmentBelow;
718
+ deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, maxDeviceTop)));
548
719
  }
549
720
  else {
550
- // 'overlay' positioning: position device normally without caption area considerations
721
+ // 'overlay' positioning: position device using the full canvas height.
722
+ // Per layout invariants, overlay is bottom-anchored by the caption box;
723
+ // top margin should not influence device placement. Respect explicit
724
+ // bottom spacing only.
725
+ const marginTop = 0; // ignore top margin for overlay
726
+ const marginBottom = (deviceConfig.captionBox || captionConfig.box || {}).marginBottom || 0;
551
727
  if (typeof framePosition === 'number') {
552
- const availableSpace = canvasHeight - targetDeviceHeight;
553
- deviceTop = Math.floor(availableSpace * (framePosition / 100));
728
+ // Custom position as percentage within available space (accounting for margins)
729
+ const availableSpace = canvasHeight - marginTop - marginBottom - targetDeviceHeight;
730
+ deviceTop = marginTop + Math.floor(availableSpace * (framePosition / 100));
731
+ if (verbose) {
732
+ console.log(pc.dim(' Frame positioning (overlay mode):'));
733
+ console.log(pc.dim(` Canvas height: ${canvasHeight}px`));
734
+ console.log(pc.dim(` Device height: ${targetDeviceHeight}px`));
735
+ console.log(pc.dim(` Top margin: ${marginTop}px`));
736
+ console.log(pc.dim(` Bottom margin: ${marginBottom}px`));
737
+ console.log(pc.dim(` Available space: ${availableSpace}px`));
738
+ console.log(pc.dim(` Frame position %: ${framePosition}`));
739
+ console.log(pc.dim(` Calculated deviceTop: ${deviceTop}px`));
740
+ }
554
741
  }
555
742
  else if (framePosition === 'top') {
556
- deviceTop = 0;
743
+ deviceTop = marginTop;
557
744
  }
558
745
  else if (framePosition === 'bottom') {
559
- deviceTop = canvasHeight - targetDeviceHeight;
746
+ // With partialFrame, allow device to extend beyond canvas by the cropped amount
747
+ const bottomAdjustment = partialFrame ? croppedPixels : 0;
748
+ deviceTop = canvasHeight - marginBottom - targetDeviceHeight + bottomAdjustment;
560
749
  }
561
750
  else if (framePosition === 'center') {
562
- deviceTop = Math.floor((canvasHeight - targetDeviceHeight) / 2);
751
+ const availableSpace = canvasHeight - marginTop - marginBottom - targetDeviceHeight;
752
+ deviceTop = marginTop + Math.floor(availableSpace / 2);
563
753
  }
564
754
  else {
565
- deviceTop = Math.floor((canvasHeight - targetDeviceHeight) / 2);
755
+ // Default to center
756
+ const availableSpace = canvasHeight - marginTop - marginBottom - targetDeviceHeight;
757
+ deviceTop = marginTop + Math.floor(availableSpace / 2);
566
758
  }
567
759
  // Ensure device doesn't go off canvas
568
- deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
760
+ // With partialFrame, allow device to extend beyond canvas by the cropped amount
761
+ const bottomAdjustmentOverlay = partialFrame ? croppedPixels : 0;
762
+ deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, canvasHeight - marginBottom - targetDeviceHeight + bottomAdjustmentOverlay)));
569
763
  }
570
764
  const deviceLeft = Math.floor((canvasWidth - targetDeviceWidth) / 2);
571
765
  if (verbose) {
@@ -640,6 +834,12 @@ export async function composeAppStoreScreenshot(options) {
640
834
  // Get caption box config
641
835
  const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
642
836
  const lineHeight = captionBoxConfig.lineHeight || 1.4;
837
+ const borderWidthForGap = (deviceConfig.captionBorder || captionConfig.border)?.width || 0;
838
+ const defaultBelowGap = Math.max(12, Math.round(borderWidthForGap / 2));
839
+ const marginTop = (captionBoxConfig.marginTop !== undefined)
840
+ ? captionBoxConfig.marginTop
841
+ : defaultBelowGap;
842
+ const bottomMargin = captionBoxConfig.marginBottom || 0;
643
843
  if (captionLines.length === 0) {
644
844
  // Fallback if no lines were calculated
645
845
  captionLines = [caption];
@@ -658,17 +858,40 @@ export async function composeAppStoreScreenshot(options) {
658
858
  console.log(pc.dim(` Style: ${fontStyle}, Weight: ${fontWeight}`));
659
859
  }
660
860
  }
661
- svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, captionConfig.color, lineHeight, captionConfig);
861
+ svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, captionConfig.color, lineHeight, captionConfig, deviceConfig);
662
862
  const captionImage = await sharp(Buffer.from(svgText))
663
863
  .png()
664
864
  .toBuffer();
665
- // Position caption at bottom of canvas
666
- const captionTop = canvasHeight - captionHeight;
865
+ // Caption positioning for 'below' mode
866
+ // Compute two anchors:
867
+ // 1) bottom-anchored top of the reserved caption area
868
+ // 2) just-below-device top (device bottom + marginTop)
869
+ // Use whichever is lower-bound safe (max), so the caption stays at the
870
+ // bottom under normal conditions, and moves down only if the device
871
+ // intrudes into the reserved area.
872
+ const deviceBottom = deviceTop + targetDeviceHeight; // includes watch bands
873
+ const bottomAnchorTop = canvasHeight - captionHeight - bottomMargin;
874
+ const justBelowDeviceTop = deviceBottom + marginTop;
875
+ const captionTop = Math.max(bottomAnchorTop, justBelowDeviceTop);
667
876
  composites.push({
668
877
  input: captionImage,
669
878
  top: captionTop,
670
879
  left: 0
671
880
  });
881
+ if (options.onDebug) {
882
+ options.onDebug({
883
+ mode: 'below',
884
+ framePosition,
885
+ frameScale: deviceFrameScale,
886
+ deviceTop,
887
+ deviceBottom,
888
+ deviceHeight: targetDeviceHeight,
889
+ captionTop,
890
+ captionHeight,
891
+ marginTop,
892
+ marginBottom
893
+ });
894
+ }
672
895
  }
673
896
  catch {
674
897
  // If text rendering fails, just add transparent area at bottom
@@ -707,16 +930,13 @@ export async function composeAppStoreScreenshot(options) {
707
930
  }
708
931
  else {
709
932
  // Simple fallback wrapping for overlay
710
- const maxCharsPerLine = Math.floor(outputWidth / (fontSize * 0.6));
711
- if (caption.length > maxCharsPerLine) {
712
- overlayLines = wrapText(caption, outputWidth, fontSize, 2); // Max 2 lines for overlay
713
- }
714
- else {
715
- overlayLines = [caption];
716
- }
933
+ overlayLines = wrapText(caption, wrapWidth, fontSize, 2); // Max 2 lines for overlay
717
934
  }
718
- // Position overlay text based on padding settings
719
- const textY = captionConfig.paddingTop + fontSize;
935
+ // Position overlay text at bottom of canvas for overlay mode
936
+ // Use marginBottom from captionBox if available, otherwise use paddingBottom
937
+ const captionBoxConfig2 = deviceConfig.captionBox || captionConfig.box || {};
938
+ const marginBottom = captionBoxConfig2.marginBottom;
939
+ const totalTextHeight = overlayLines.length * fontSize * lineHeight;
720
940
  // Use device-specific font if available, otherwise use global caption font
721
941
  const fontToUse = deviceConfig.captionFont || captionConfig.font;
722
942
  // Parse font name for style and weight
@@ -724,54 +944,78 @@ export async function composeAppStoreScreenshot(options) {
724
944
  const fontFamily = getFontStack(parsedFont.family);
725
945
  const fontStyle = parsedFont.style || 'normal';
726
946
  const fontWeight = parsedFont.weight === 'bold' ? '700' : '400';
727
- if (verbose) {
728
- console.log(pc.dim(' Overlay caption:'));
729
- console.log(pc.dim(` Font: ${fontToUse} → ${fontFamily}`));
730
- console.log(pc.dim(` Position: ${textY}px from top`));
731
- }
947
+ // verbose logging deferred until after positioning is computed
732
948
  // For overlay, we need custom SVG generation with background/border support
733
949
  const svgElements = [];
734
- // Add background rectangle if configured
735
- const backgroundConfig = captionConfig.background;
736
- const borderConfig = captionConfig.border;
950
+ // Use device-specific background/border settings if available, otherwise use global
951
+ const backgroundConfig = deviceConfig.captionBackground || captionConfig.background;
952
+ const borderConfig = deviceConfig.captionBorder || captionConfig.border;
953
+ // Anchor overlay by the OUTER BOX bottom (including padding and border stroke)
954
+ const bgPadding = backgroundConfig?.padding ?? 20;
955
+ const strokeWidth = borderConfig?.width ?? 0; // stroke is centered on rect; half extends outward
956
+ const bottomSpacing = (marginBottom ?? captionConfig.paddingBottom ?? 60); // respect explicit 0
957
+ const boxHeight = totalTextHeight + (bgPadding * 2);
958
+ const rectBottom = canvasHeight - bottomSpacing - (strokeWidth > 0 ? strokeWidth / 2 : 0);
959
+ const rectY = rectBottom - boxHeight; // top of the fill/border rects
960
+ const textY = rectY + bgPadding + fontSize; // first line baseline
737
961
  if (backgroundConfig?.color) {
738
- const bgPadding = backgroundConfig.padding || 20;
739
- const bgOpacity = backgroundConfig.opacity || 0.8;
962
+ const bgOpacity = backgroundConfig.opacity !== undefined ? backgroundConfig.opacity : 0.8;
740
963
  // Use full width minus margins for uniform appearance
741
- const totalTextHeight = overlayLines.length * fontSize * lineHeight;
742
- const sideMargin = 30; // Margin from edges
964
+ const sideMargin = backgroundConfig.sideMargin ?? 30; // Margin from edges
743
965
  const bgWidth = canvasWidth - (sideMargin * 2);
744
- const bgHeight = totalTextHeight + bgPadding * 2;
966
+ const bgHeight = boxHeight;
745
967
  const bgX = sideMargin;
746
- const bgY = textY - fontSize - bgPadding;
968
+ const bgY = rectY;
747
969
  const bgRadius = borderConfig?.radius || 12; // Default to 12px for better visibility
748
970
  svgElements.push(`<rect x="${bgX}" y="${bgY}" width="${bgWidth}" height="${bgHeight}" ` +
749
971
  `fill="${backgroundConfig.color}" opacity="${bgOpacity}" rx="${bgRadius}"/>`);
750
972
  }
751
973
  // Add border rectangle if configured
752
974
  if (borderConfig?.color && borderConfig?.width) {
753
- const bgPadding = backgroundConfig?.padding || 20;
754
975
  const borderWidth = borderConfig.width;
755
976
  const borderRadius = borderConfig.radius || 12;
756
- const totalTextHeight = overlayLines.length * fontSize * lineHeight;
757
- const sideMargin = 30; // Margin from edges
977
+ const sideMargin = backgroundConfig?.sideMargin ?? 30; // Margin from edges
758
978
  const rectWidth = canvasWidth - (sideMargin * 2);
759
- const rectHeight = totalTextHeight + bgPadding * 2;
979
+ const rectHeight = boxHeight;
760
980
  const rectX = sideMargin;
761
- const rectY = textY - fontSize - bgPadding;
762
- svgElements.push(`<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" ` +
981
+ const rectY2 = rectY;
982
+ svgElements.push(`<rect x="${rectX}" y="${rectY2}" width="${rectWidth}" height="${rectHeight}" ` +
763
983
  `fill="none" stroke="${borderConfig.color}" stroke-width="${borderWidth}" rx="${borderRadius}"/>`);
764
984
  }
985
+ // Now that we know final positions, emit verbose details
986
+ if (verbose) {
987
+ console.log(pc.dim(' Overlay caption:'));
988
+ console.log(pc.dim(` Font: ${fontToUse} → ${fontFamily}`));
989
+ console.log(pc.dim(` Rect: y=${rectY}, h=${boxHeight}, bottom gap=${bottomSpacing}`));
990
+ console.log(pc.dim(` Baseline Y: ${textY}`));
991
+ }
992
+ // Horizontal alignment for overlay
993
+ const align = captionConfig.align || 'center';
994
+ const sideMargin = backgroundConfig?.sideMargin ?? 30;
995
+ const pad = bgPadding;
996
+ const leftX = sideMargin + pad;
997
+ const rightX = canvasWidth - (sideMargin + pad);
998
+ const centerX = Math.floor(canvasWidth / 2);
999
+ let textAnchor = 'middle';
1000
+ let textX = centerX;
1001
+ if (align === 'left') {
1002
+ textAnchor = 'start';
1003
+ textX = leftX;
1004
+ }
1005
+ else if (align === 'right') {
1006
+ textAnchor = 'end';
1007
+ textX = rightX;
1008
+ }
765
1009
  // Add text elements
766
1010
  const textElements = overlayLines.map((line, index) => {
767
1011
  const y = textY + (index * fontSize * lineHeight);
768
- return `<text x="${canvasWidth / 2}" y="${y}" ` +
1012
+ return `<text x="${textX}" y="${y}" ` +
769
1013
  `font-family="${fontFamily}" ` +
770
1014
  `font-size="${fontSize}" ` +
771
1015
  `font-style="${fontStyle}" ` +
772
1016
  `font-weight="${fontWeight}" ` +
773
1017
  `fill="${captionConfig.color}" ` +
774
- `text-anchor="middle">${escapeXml(line)}</text>`;
1018
+ `text-anchor="${textAnchor}">${escapeXml(line)}</text>`;
775
1019
  });
776
1020
  svgElements.push(...textElements);
777
1021
  const svgText = `<svg width="${canvasWidth}" height="${canvasHeight}" xmlns="http://www.w3.org/2000/svg">
@@ -787,6 +1031,21 @@ export async function composeAppStoreScreenshot(options) {
787
1031
  left: 0,
788
1032
  blend: 'over'
789
1033
  });
1034
+ if (options.onDebug) {
1035
+ options.onDebug({
1036
+ mode: 'overlay',
1037
+ framePosition,
1038
+ frameScale: deviceFrameScale,
1039
+ deviceTop,
1040
+ deviceBottom: deviceTop + targetDeviceHeight,
1041
+ deviceHeight: targetDeviceHeight,
1042
+ captionTop: rectY,
1043
+ captionHeight: boxHeight,
1044
+ bottomSpacing,
1045
+ rectY,
1046
+ rectBottom
1047
+ });
1048
+ }
790
1049
  }
791
1050
  catch {
792
1051
  console.log('[INFO] Overlay caption rendering failed, skipping caption');