appshot-cli 0.6.0 → 0.8.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 (41) hide show
  1. package/README.md +266 -9
  2. package/dist/cli.js +6 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/build.js +1 -1
  5. package/dist/commands/build.js.map +1 -1
  6. package/dist/commands/frame.d.ts +3 -0
  7. package/dist/commands/frame.d.ts.map +1 -0
  8. package/dist/commands/frame.js +251 -0
  9. package/dist/commands/frame.js.map +1 -0
  10. package/dist/commands/style.d.ts.map +1 -1
  11. package/dist/commands/style.js +122 -1
  12. package/dist/commands/style.js.map +1 -1
  13. package/dist/core/compose.d.ts +25 -0
  14. package/dist/core/compose.d.ts.map +1 -1
  15. package/dist/core/compose.js +411 -47
  16. package/dist/core/compose.js.map +1 -1
  17. package/dist/core/devices.d.ts +5 -0
  18. package/dist/core/devices.d.ts.map +1 -1
  19. package/dist/core/devices.js +43 -1
  20. package/dist/core/devices.js.map +1 -1
  21. package/dist/core/files.d.ts +1 -1
  22. package/dist/core/files.d.ts.map +1 -1
  23. package/dist/core/files.js +15 -3
  24. package/dist/core/files.js.map +1 -1
  25. package/dist/services/doctor.js +1 -1
  26. package/dist/services/fonts.d.ts.map +1 -1
  27. package/dist/services/fonts.js +2 -0
  28. package/dist/services/fonts.js.map +1 -1
  29. package/dist/types.d.ts +14 -2
  30. package/dist/types.d.ts.map +1 -1
  31. package/fonts/FiraCode/FiraCode-Bold.ttf +0 -0
  32. package/fonts/FiraCode/FiraCode-LICENSE.txt +93 -0
  33. package/fonts/FiraCode/FiraCode-Light.ttf +0 -0
  34. package/fonts/FiraCode/FiraCode-Regular.ttf +0 -0
  35. package/fonts/FiraCode/FiraCode-SemiBold.ttf +0 -0
  36. package/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf +0 -0
  37. package/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.ttf +0 -0
  38. package/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf +0 -0
  39. package/fonts/JetBrainsMono/JetBrainsMono-LICENSE.txt +93 -0
  40. package/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf +0 -0
  41. package/package.json +1 -1
@@ -43,6 +43,61 @@ function escapeXml(text) {
43
43
  .replace(/"/g, '"')
44
44
  .replace(/'/g, ''');
45
45
  }
46
+ /**
47
+ * Generate SVG with text, background, and border support
48
+ */
49
+ function generateCaptionSVG(lines, width, height, fontSize, fontFamily, fontStyle, fontWeight, textColor, lineHeight, captionConfig) {
50
+ const backgroundConfig = captionConfig.background;
51
+ const borderConfig = captionConfig.border;
52
+ // Calculate text positioning
53
+ const totalTextHeight = lines.length * fontSize * lineHeight;
54
+ const startY = (height - totalTextHeight) / 2 + fontSize;
55
+ // SVG elements array
56
+ const svgElements = [];
57
+ // Add background rectangle if configured
58
+ if (backgroundConfig?.color) {
59
+ const bgPadding = backgroundConfig.padding || 20;
60
+ const bgOpacity = backgroundConfig.opacity || 0.8;
61
+ // Use full width minus margins for uniform appearance
62
+ const sideMargin = 30; // Margin from edges
63
+ const bgWidth = width - (sideMargin * 2);
64
+ const bgHeight = totalTextHeight + bgPadding * 2;
65
+ const bgX = sideMargin;
66
+ const bgY = startY - fontSize - bgPadding;
67
+ const bgRadius = borderConfig?.radius || 12; // Default to 12px for better visibility
68
+ svgElements.push(`<rect x="${bgX}" y="${bgY}" width="${bgWidth}" height="${bgHeight}" ` +
69
+ `fill="${backgroundConfig.color}" opacity="${bgOpacity}" rx="${bgRadius}"/>`);
70
+ }
71
+ // Add border rectangle if configured
72
+ if (borderConfig?.color && borderConfig?.width) {
73
+ const bgPadding = backgroundConfig?.padding || 20;
74
+ const borderWidth = borderConfig.width;
75
+ const borderRadius = borderConfig.radius || 12;
76
+ // Use full width minus margins for uniform appearance
77
+ const sideMargin = 30; // Margin from edges
78
+ const rectWidth = width - (sideMargin * 2);
79
+ const rectHeight = totalTextHeight + bgPadding * 2;
80
+ const rectX = sideMargin;
81
+ const rectY = startY - fontSize - bgPadding;
82
+ svgElements.push(`<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" ` +
83
+ `fill="none" stroke="${borderConfig.color}" stroke-width="${borderWidth}" rx="${borderRadius}"/>`);
84
+ }
85
+ // Add text elements
86
+ const textElements = lines.map((line, index) => {
87
+ const y = startY + (index * fontSize * lineHeight);
88
+ return `<text x="${width / 2}" y="${y}" ` +
89
+ `font-family="${fontFamily}" ` +
90
+ `font-size="${fontSize}" ` +
91
+ `font-style="${fontStyle}" ` +
92
+ `font-weight="${fontWeight}" ` +
93
+ `fill="${textColor}" ` +
94
+ `text-anchor="middle">${escapeXml(line)}</text>`;
95
+ });
96
+ svgElements.push(...textElements);
97
+ return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
98
+ ${svgElements.join('\n ')}
99
+ </svg>`;
100
+ }
46
101
  /**
47
102
  * Compose a complete App Store screenshot with gradient, caption, and framed device
48
103
  */
@@ -80,10 +135,10 @@ export async function composeAppStoreScreenshot(options) {
80
135
  deviceTop = Math.floor((outputHeight - targetDeviceHeight) / 2);
81
136
  }
82
137
  }
83
- // Calculate caption height if positioned above
138
+ // Calculate caption height based on position
84
139
  let captionHeight = 0;
85
140
  let captionLines = [];
86
- if (captionPosition === 'above' && caption) {
141
+ if ((captionPosition === 'above' || captionPosition === 'below') && caption) {
87
142
  const isWatch = outputWidth < 500;
88
143
  const captionFontSize = deviceConfig.captionSize || captionConfig.fontsize;
89
144
  // Get caption box config (device-specific or global)
@@ -165,10 +220,6 @@ export async function composeAppStoreScreenshot(options) {
165
220
  // Fallback if no lines were calculated
166
221
  captionLines = [caption];
167
222
  }
168
- // Calculate vertical positioning for centered text block
169
- const totalTextHeight = captionLines.length * fontSize * lineHeight;
170
- const startY = (captionHeight - totalTextHeight) / 2 + fontSize;
171
- // Create SVG with multiple text lines
172
223
  // Use device-specific font if available, otherwise use global caption font
173
224
  const fontToUse = deviceConfig.captionFont || captionConfig.font;
174
225
  // Parse font name for style and weight
@@ -184,19 +235,7 @@ export async function composeAppStoreScreenshot(options) {
184
235
  console.log(pc.dim(` Style: ${fontStyle}, Weight: ${fontWeight}`));
185
236
  }
186
237
  }
187
- const textElements = captionLines.map((line, index) => {
188
- const y = startY + (index * fontSize * lineHeight);
189
- return `<text x="${canvasWidth / 2}" y="${y}"
190
- font-family="${fontFamily}"
191
- font-size="${fontSize}"
192
- font-style="${fontStyle}"
193
- font-weight="${fontWeight}"
194
- fill="${captionConfig.color}"
195
- text-anchor="middle">${escapeXml(line)}</text>`;
196
- }).join('\n');
197
- svgText = `<svg width="${canvasWidth}" height="${captionHeight}" xmlns="http://www.w3.org/2000/svg">
198
- ${textElements}
199
- </svg>`;
238
+ svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, captionConfig.color, lineHeight, captionConfig);
200
239
  const captionImage = await sharp(Buffer.from(svgText))
201
240
  .png()
202
241
  .toBuffer();
@@ -236,7 +275,19 @@ export async function composeAppStoreScreenshot(options) {
236
275
  const originalFrameHeight = frameMetadata.frameHeight;
237
276
  // Calculate available space for the device
238
277
  const availableWidth = outputWidth;
239
- let availableHeight = Math.max(100, outputHeight - captionHeight); // Default: account for caption
278
+ let availableHeight;
279
+ // For 'above' positioning, reduce available height by caption
280
+ // For 'below' positioning, we'll adjust positioning later but calculate scale normally
281
+ if (captionPosition === 'above') {
282
+ availableHeight = Math.max(100, outputHeight - captionHeight);
283
+ }
284
+ else if (captionPosition === 'below') {
285
+ availableHeight = Math.max(100, outputHeight - captionHeight);
286
+ }
287
+ else {
288
+ // 'overlay' positioning doesn't reduce available space
289
+ availableHeight = outputHeight;
290
+ }
240
291
  // If frameScale is explicitly set, use total output height for consistent sizing
241
292
  if (deviceFrameScale !== undefined) {
242
293
  availableHeight = outputHeight; // Use full height for consistent scale
@@ -412,34 +463,94 @@ export async function composeAppStoreScreenshot(options) {
412
463
  }
413
464
  }
414
465
  // Recalculate position with actual caption height
415
- if (typeof framePosition === 'number') {
416
- // Custom position as percentage from top (0-100)
417
- const availableSpace = canvasHeight - captionHeight - targetDeviceHeight;
418
- deviceTop = captionHeight + Math.floor(availableSpace * (framePosition / 100));
419
- }
420
- else if (framePosition === 'top') {
421
- deviceTop = captionHeight;
422
- }
423
- else if (framePosition === 'bottom') {
424
- deviceTop = canvasHeight - targetDeviceHeight;
466
+ if (captionPosition === 'above') {
467
+ // Caption above: position device below caption area
468
+ if (typeof framePosition === 'number') {
469
+ // Custom position as percentage from top (0-100)
470
+ const availableSpace = canvasHeight - captionHeight - targetDeviceHeight;
471
+ deviceTop = captionHeight + Math.floor(availableSpace * (framePosition / 100));
472
+ }
473
+ else if (framePosition === 'top') {
474
+ deviceTop = captionHeight;
475
+ }
476
+ else if (framePosition === 'bottom') {
477
+ deviceTop = canvasHeight - targetDeviceHeight;
478
+ }
479
+ else if (framePosition === 'center') {
480
+ // Default centered positioning
481
+ if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
482
+ // Special watch positioning (unless explicitly overridden)
483
+ deviceTop = canvasHeight - Math.floor(targetDeviceHeight * 0.75) - 25;
484
+ }
485
+ else {
486
+ const availableSpace = canvasHeight - captionHeight;
487
+ deviceTop = captionHeight + Math.floor((availableSpace - targetDeviceHeight) / 2);
488
+ }
489
+ }
490
+ else {
491
+ // Default to centered
492
+ deviceTop = captionHeight;
493
+ }
494
+ // Ensure device doesn't go off canvas
495
+ deviceTop = Math.floor(Math.max(captionHeight, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
425
496
  }
426
- else if (framePosition === 'center') {
427
- // Default centered positioning
428
- if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
429
- // Special watch positioning (unless explicitly overridden)
430
- deviceTop = canvasHeight - Math.floor(targetDeviceHeight * 0.75) - 25;
497
+ else if (captionPosition === 'below') {
498
+ // Caption below: position device in upper area, leaving space for caption at bottom
499
+ const deviceAreaHeight = canvasHeight - captionHeight;
500
+ if (typeof framePosition === 'number') {
501
+ // Custom position as percentage within device area
502
+ const availableSpace = deviceAreaHeight - targetDeviceHeight;
503
+ deviceTop = Math.floor(availableSpace * (framePosition / 100));
504
+ }
505
+ else if (framePosition === 'top') {
506
+ deviceTop = 0;
507
+ }
508
+ else if (framePosition === 'bottom') {
509
+ deviceTop = deviceAreaHeight - targetDeviceHeight;
510
+ }
511
+ else if (framePosition === 'center') {
512
+ // Default centered positioning in device area
513
+ if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
514
+ // Special watch positioning (unless explicitly overridden)
515
+ deviceTop = Math.max(0, deviceAreaHeight - Math.floor(targetDeviceHeight * 0.75) - 25);
516
+ }
517
+ else {
518
+ deviceTop = Math.floor((deviceAreaHeight - targetDeviceHeight) / 2);
519
+ }
431
520
  }
432
521
  else {
433
- const availableSpace = canvasHeight - captionHeight;
434
- deviceTop = captionHeight + Math.floor((availableSpace - targetDeviceHeight) / 2);
522
+ // Default to centered in device area
523
+ deviceTop = Math.floor((deviceAreaHeight - targetDeviceHeight) / 2);
435
524
  }
525
+ // Ensure device doesn't go off canvas or into caption area
526
+ deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, deviceAreaHeight - targetDeviceHeight)));
436
527
  }
437
528
  else {
438
- // Default to centered
439
- deviceTop = captionHeight;
529
+ // 'overlay' positioning: position device normally without caption area considerations
530
+ if (typeof framePosition === 'number') {
531
+ const availableSpace = canvasHeight - targetDeviceHeight;
532
+ deviceTop = Math.floor(availableSpace * (framePosition / 100));
533
+ }
534
+ else if (framePosition === 'top') {
535
+ deviceTop = 0;
536
+ }
537
+ else if (framePosition === 'bottom') {
538
+ deviceTop = canvasHeight - targetDeviceHeight;
539
+ }
540
+ else if (framePosition === 'center') {
541
+ if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
542
+ deviceTop = canvasHeight - Math.floor(targetDeviceHeight * 0.75) - 25;
543
+ }
544
+ else {
545
+ deviceTop = Math.floor((canvasHeight - targetDeviceHeight) / 2);
546
+ }
547
+ }
548
+ else {
549
+ deviceTop = Math.floor((canvasHeight - targetDeviceHeight) / 2);
550
+ }
551
+ // Ensure device doesn't go off canvas
552
+ deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
440
553
  }
441
- // Ensure device doesn't go off canvas
442
- deviceTop = Math.floor(Math.max(captionHeight, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
443
554
  const deviceLeft = Math.floor((canvasWidth - targetDeviceWidth) / 2);
444
555
  if (verbose) {
445
556
  console.log(pc.dim(` Position: ${framePosition} → top: ${deviceTop}px, left: ${deviceLeft}px`));
@@ -458,7 +569,21 @@ export async function composeAppStoreScreenshot(options) {
458
569
  const screenshotHeight = screenshotMeta.height || outputHeight;
459
570
  // Calculate available space for the screenshot
460
571
  const availableWidth = outputWidth;
461
- const availableHeight = outputHeight - captionHeight;
572
+ let availableHeight;
573
+ let deviceTop;
574
+ if (captionPosition === 'above') {
575
+ availableHeight = outputHeight - captionHeight;
576
+ deviceTop = Math.floor(captionHeight);
577
+ }
578
+ else if (captionPosition === 'below') {
579
+ availableHeight = outputHeight - captionHeight;
580
+ deviceTop = 0; // Start from top when caption is below
581
+ }
582
+ else {
583
+ // 'overlay' positioning
584
+ availableHeight = outputHeight;
585
+ deviceTop = 0;
586
+ }
462
587
  // Calculate scale to fit within available space while maintaining aspect ratio
463
588
  const scaleX = availableWidth / screenshotWidth;
464
589
  const scaleY = availableHeight / screenshotHeight;
@@ -475,8 +600,11 @@ export async function composeAppStoreScreenshot(options) {
475
600
  })
476
601
  .toBuffer();
477
602
  }
603
+ // For 'below' positioning, center the screenshot in the available device area
604
+ if (captionPosition === 'below') {
605
+ deviceTop = Math.floor((availableHeight - targetHeight) / 2);
606
+ }
478
607
  // Center the screenshot horizontally
479
- const deviceTop = Math.floor(captionHeight);
480
608
  const deviceLeft = Math.floor((canvasWidth - targetWidth) / 2);
481
609
  composites.push({
482
610
  input: resizedScreenshot,
@@ -484,11 +612,169 @@ export async function composeAppStoreScreenshot(options) {
484
612
  left: Math.max(0, deviceLeft)
485
613
  });
486
614
  }
487
- // Add overlay caption if specified (legacy support)
488
- // Note: Caption rendering requires librsvg to be installed
615
+ // Add caption if positioned below the device
616
+ if (caption && captionPosition === 'below') {
617
+ try {
618
+ // Create simple SVG text
619
+ const isWatch = outputWidth < 500;
620
+ // Use device-specific caption size if provided
621
+ const baseFontSize = deviceConfig.captionSize || captionConfig.fontsize;
622
+ const fontSize = isWatch ? Math.min(36, baseFontSize) : baseFontSize; // Smaller font for watch
623
+ let svgText;
624
+ // Get caption box config
625
+ const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
626
+ const lineHeight = captionBoxConfig.lineHeight || 1.4;
627
+ if (captionLines.length === 0) {
628
+ // Fallback if no lines were calculated
629
+ captionLines = [caption];
630
+ }
631
+ // Use device-specific font if available, otherwise use global caption font
632
+ const fontToUse = deviceConfig.captionFont || captionConfig.font;
633
+ // Parse font name for style and weight
634
+ const parsedFont = parseFontName(fontToUse);
635
+ const fontFamily = getFontStack(parsedFont.family);
636
+ const fontStyle = parsedFont.style || 'normal';
637
+ const fontWeight = parsedFont.weight === 'bold' ? '700' : '400';
638
+ if (verbose) {
639
+ console.log(pc.dim(' Caption below device:'));
640
+ console.log(pc.dim(` Font: ${fontToUse} → ${fontFamily}`));
641
+ if (parsedFont.style || parsedFont.weight) {
642
+ console.log(pc.dim(` Style: ${fontStyle}, Weight: ${fontWeight}`));
643
+ }
644
+ }
645
+ svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, captionConfig.color, lineHeight, captionConfig);
646
+ const captionImage = await sharp(Buffer.from(svgText))
647
+ .png()
648
+ .toBuffer();
649
+ // Position caption at bottom of canvas
650
+ const captionTop = canvasHeight - captionHeight;
651
+ composites.push({
652
+ input: captionImage,
653
+ top: captionTop,
654
+ left: 0
655
+ });
656
+ }
657
+ catch {
658
+ // If text rendering fails, just add transparent area at bottom
659
+ console.log('[INFO] Bottom caption rendering failed, reserving space');
660
+ const captionArea = await sharp({
661
+ create: {
662
+ width: canvasWidth,
663
+ height: captionHeight,
664
+ channels: 4,
665
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
666
+ }
667
+ })
668
+ .png()
669
+ .toBuffer();
670
+ composites.push({
671
+ input: captionArea,
672
+ top: canvasHeight - captionHeight,
673
+ left: 0
674
+ });
675
+ }
676
+ }
677
+ // Add overlay caption if specified
489
678
  if (caption && captionPosition === 'overlay') {
490
- // Skip caption rendering for now - would require librsvg
491
- // TODO: Implement pure bitmap text rendering in future version
679
+ try {
680
+ // Create simple SVG text for overlay
681
+ const isWatch = outputWidth < 500;
682
+ const baseFontSize = deviceConfig.captionSize || captionConfig.fontsize;
683
+ const fontSize = isWatch ? Math.min(36, baseFontSize) : baseFontSize;
684
+ // Get caption box config
685
+ const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
686
+ const lineHeight = captionBoxConfig.lineHeight || 1.4;
687
+ // For overlay, use simple single line or basic wrapping
688
+ let overlayLines = [];
689
+ if (captionLines.length > 0) {
690
+ overlayLines = captionLines;
691
+ }
692
+ else {
693
+ // Simple fallback wrapping for overlay
694
+ const maxCharsPerLine = Math.floor(outputWidth / (fontSize * 0.6));
695
+ if (caption.length > maxCharsPerLine) {
696
+ overlayLines = wrapText(caption, outputWidth, fontSize, 2); // Max 2 lines for overlay
697
+ }
698
+ else {
699
+ overlayLines = [caption];
700
+ }
701
+ }
702
+ // Position overlay text based on padding settings
703
+ const textY = captionConfig.paddingTop + fontSize;
704
+ // Use device-specific font if available, otherwise use global caption font
705
+ const fontToUse = deviceConfig.captionFont || captionConfig.font;
706
+ // Parse font name for style and weight
707
+ const parsedFont = parseFontName(fontToUse);
708
+ const fontFamily = getFontStack(parsedFont.family);
709
+ const fontStyle = parsedFont.style || 'normal';
710
+ const fontWeight = parsedFont.weight === 'bold' ? '700' : '400';
711
+ if (verbose) {
712
+ console.log(pc.dim(' Overlay caption:'));
713
+ console.log(pc.dim(` Font: ${fontToUse} → ${fontFamily}`));
714
+ console.log(pc.dim(` Position: ${textY}px from top`));
715
+ }
716
+ // For overlay, we need custom SVG generation with background/border support
717
+ const svgElements = [];
718
+ // Add background rectangle if configured
719
+ const backgroundConfig = captionConfig.background;
720
+ const borderConfig = captionConfig.border;
721
+ if (backgroundConfig?.color) {
722
+ const bgPadding = backgroundConfig.padding || 20;
723
+ const bgOpacity = backgroundConfig.opacity || 0.8;
724
+ // Use full width minus margins for uniform appearance
725
+ const totalTextHeight = overlayLines.length * fontSize * lineHeight;
726
+ const sideMargin = 30; // Margin from edges
727
+ const bgWidth = canvasWidth - (sideMargin * 2);
728
+ const bgHeight = totalTextHeight + bgPadding * 2;
729
+ const bgX = sideMargin;
730
+ const bgY = textY - fontSize - bgPadding;
731
+ const bgRadius = borderConfig?.radius || 12; // Default to 12px for better visibility
732
+ svgElements.push(`<rect x="${bgX}" y="${bgY}" width="${bgWidth}" height="${bgHeight}" ` +
733
+ `fill="${backgroundConfig.color}" opacity="${bgOpacity}" rx="${bgRadius}"/>`);
734
+ }
735
+ // Add border rectangle if configured
736
+ if (borderConfig?.color && borderConfig?.width) {
737
+ const bgPadding = backgroundConfig?.padding || 20;
738
+ const borderWidth = borderConfig.width;
739
+ const borderRadius = borderConfig.radius || 12;
740
+ const totalTextHeight = overlayLines.length * fontSize * lineHeight;
741
+ const sideMargin = 30; // Margin from edges
742
+ const rectWidth = canvasWidth - (sideMargin * 2);
743
+ const rectHeight = totalTextHeight + bgPadding * 2;
744
+ const rectX = sideMargin;
745
+ const rectY = textY - fontSize - bgPadding;
746
+ svgElements.push(`<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" ` +
747
+ `fill="none" stroke="${borderConfig.color}" stroke-width="${borderWidth}" rx="${borderRadius}"/>`);
748
+ }
749
+ // Add text elements
750
+ const textElements = overlayLines.map((line, index) => {
751
+ const y = textY + (index * fontSize * lineHeight);
752
+ return `<text x="${canvasWidth / 2}" y="${y}" ` +
753
+ `font-family="${fontFamily}" ` +
754
+ `font-size="${fontSize}" ` +
755
+ `font-style="${fontStyle}" ` +
756
+ `font-weight="${fontWeight}" ` +
757
+ `fill="${captionConfig.color}" ` +
758
+ `text-anchor="middle">${escapeXml(line)}</text>`;
759
+ });
760
+ svgElements.push(...textElements);
761
+ const svgText = `<svg width="${canvasWidth}" height="${canvasHeight}" xmlns="http://www.w3.org/2000/svg">
762
+ ${svgElements.join('\n ')}
763
+ </svg>`;
764
+ const overlayImage = await sharp(Buffer.from(svgText))
765
+ .png()
766
+ .toBuffer();
767
+ // Add overlay caption on top of everything
768
+ composites.push({
769
+ input: overlayImage,
770
+ top: 0,
771
+ left: 0,
772
+ blend: 'over'
773
+ });
774
+ }
775
+ catch {
776
+ console.log('[INFO] Overlay caption rendering failed, skipping caption');
777
+ }
492
778
  }
493
779
  // Composite everything onto the gradient
494
780
  const result = await sharp(gradient)
@@ -497,6 +783,84 @@ export async function composeAppStoreScreenshot(options) {
497
783
  .toBuffer();
498
784
  return result;
499
785
  }
786
+ /**
787
+ * Compose a framed device with a fully transparent background.
788
+ * No gradient or captions are applied; output is frame-sized.
789
+ */
790
+ export async function composeFrameOnly(options) {
791
+ const { screenshot, frame, frameMetadata, outputFormat = 'png', jpegQuality = 92, verbose = false } = options;
792
+ if (!frame || !frameMetadata) {
793
+ throw new Error('composeFrameOnly requires a frame buffer and frame metadata');
794
+ }
795
+ const { frameWidth, frameHeight, screenRect } = frameMetadata;
796
+ if (verbose) {
797
+ console.log(pc.dim('Framing with transparent background:'));
798
+ if (frameMetadata.displayName || frameMetadata.name) {
799
+ console.log(pc.dim(` Frame: ${frameMetadata.displayName || frameMetadata.name}`));
800
+ }
801
+ console.log(pc.dim(` Frame size: ${frameWidth}x${frameHeight}`));
802
+ console.log(pc.dim(` Screen rect: ${screenRect.width}x${screenRect.height} @ (${screenRect.x}, ${screenRect.y})`));
803
+ }
804
+ // Prepare screenshot to fit the frame's screen area
805
+ let resizedScreenshot = await sharp(screenshot)
806
+ .resize(screenRect.width, screenRect.height, { fit: 'fill' })
807
+ .toBuffer();
808
+ // Apply mask if available; otherwise add rounded corners for iPhone heuristically
809
+ let maskApplied = false;
810
+ if (frameMetadata.maskPath) {
811
+ try {
812
+ const maskBuffer = await fs.readFile(frameMetadata.maskPath);
813
+ const resizedMask = await sharp(maskBuffer)
814
+ .resize(screenRect.width, screenRect.height, { fit: 'fill' })
815
+ .toBuffer();
816
+ const screenshotRgb = await sharp(resizedScreenshot).removeAlpha().toBuffer();
817
+ const maskAlpha = await sharp(resizedMask).extractChannel('red').toBuffer();
818
+ resizedScreenshot = await sharp(screenshotRgb).joinChannel(maskAlpha).png().toBuffer();
819
+ maskApplied = true;
820
+ }
821
+ catch (err) {
822
+ if (verbose) {
823
+ console.warn(pc.dim(` Mask load failed, falling back to rounded corners (if applicable): ${err instanceof Error ? err.message : String(err)}`));
824
+ }
825
+ }
826
+ }
827
+ if (!maskApplied && frameMetadata.deviceType === 'iphone') {
828
+ // Heuristic rounded corners for iPhone if mask missing
829
+ let cornerRadius;
830
+ const frameName = frameMetadata.displayName?.toLowerCase() || frameMetadata.name?.toLowerCase() || '';
831
+ if (frameName.includes('16 pro') || frameName.includes('15 pro') || frameName.includes('14 pro')) {
832
+ cornerRadius = Math.floor(screenRect.width * 0.12);
833
+ }
834
+ else if (frameName.includes('se') || frameName.includes('8')) {
835
+ cornerRadius = 0;
836
+ }
837
+ else {
838
+ cornerRadius = Math.floor(screenRect.width * 0.10);
839
+ }
840
+ if (cornerRadius > 0) {
841
+ resizedScreenshot = await applyRoundedCorners(resizedScreenshot, screenRect.width, screenRect.height, cornerRadius);
842
+ }
843
+ }
844
+ // Base transparent canvas sized to the frame
845
+ const base = sharp({
846
+ create: {
847
+ width: frameWidth,
848
+ height: frameHeight,
849
+ channels: 4,
850
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
851
+ }
852
+ });
853
+ // Composite screenshot at screen coordinates, then overlay frame image
854
+ let composed = base.composite([
855
+ { input: resizedScreenshot, top: screenRect.y, left: screenRect.x },
856
+ { input: frame, top: 0, left: 0 }
857
+ ]);
858
+ if (outputFormat === 'png') {
859
+ return composed.png().toBuffer();
860
+ }
861
+ // JPEG cannot store alpha; flatten on white
862
+ return composed.flatten({ background: '#ffffff' }).jpeg({ quality: jpegQuality }).toBuffer();
863
+ }
500
864
  /**
501
865
  * Create SVG for caption text
502
866
  */