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.
- package/README.md +266 -9
- package/dist/cli.js +6 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/build.js +1 -1
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/frame.d.ts +3 -0
- package/dist/commands/frame.d.ts.map +1 -0
- package/dist/commands/frame.js +251 -0
- package/dist/commands/frame.js.map +1 -0
- package/dist/commands/style.d.ts.map +1 -1
- package/dist/commands/style.js +122 -1
- package/dist/commands/style.js.map +1 -1
- package/dist/core/compose.d.ts +25 -0
- package/dist/core/compose.d.ts.map +1 -1
- package/dist/core/compose.js +411 -47
- package/dist/core/compose.js.map +1 -1
- package/dist/core/devices.d.ts +5 -0
- package/dist/core/devices.d.ts.map +1 -1
- package/dist/core/devices.js +43 -1
- package/dist/core/devices.js.map +1 -1
- package/dist/core/files.d.ts +1 -1
- package/dist/core/files.d.ts.map +1 -1
- package/dist/core/files.js +15 -3
- package/dist/core/files.js.map +1 -1
- package/dist/services/doctor.js +1 -1
- package/dist/services/fonts.d.ts.map +1 -1
- package/dist/services/fonts.js +2 -0
- package/dist/services/fonts.js.map +1 -1
- package/dist/types.d.ts +14 -2
- package/dist/types.d.ts.map +1 -1
- package/fonts/FiraCode/FiraCode-Bold.ttf +0 -0
- package/fonts/FiraCode/FiraCode-LICENSE.txt +93 -0
- package/fonts/FiraCode/FiraCode-Light.ttf +0 -0
- package/fonts/FiraCode/FiraCode-Regular.ttf +0 -0
- package/fonts/FiraCode/FiraCode-SemiBold.ttf +0 -0
- package/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf +0 -0
- package/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.ttf +0 -0
- package/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf +0 -0
- package/fonts/JetBrainsMono/JetBrainsMono-LICENSE.txt +93 -0
- package/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf +0 -0
- package/package.json +1 -1
package/dist/core/compose.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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 (
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
deviceTop =
|
|
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
|
-
//
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
*/
|