medias-fakerator 1.0.0-dra

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 (37) hide show
  1. package/assets/fonts/Chirp-Regular.ttf +0 -0
  2. package/assets/fonts/Noto-Bold.ttf +0 -0
  3. package/assets/fonts/Noto-Emoji.ttf +0 -0
  4. package/assets/fonts/Noto-Regular.ttf +0 -0
  5. package/assets/fonts/SfProDisplay.ttf +0 -0
  6. package/assets/images/android/background-call.jpg +0 -0
  7. package/assets/images/google/google-lyrics.jpg +0 -0
  8. package/assets/images/google/google-search.jpg +0 -0
  9. package/assets/images/instagram/fakestory.png +0 -0
  10. package/assets/images/instagram/instagram-verified.png +0 -0
  11. package/assets/images/iphone/background-call.jpg +0 -0
  12. package/assets/images/iphone/background.jpg +0 -0
  13. package/assets/images/iphone/battery.png +0 -0
  14. package/assets/images/iphone/copy.png +0 -0
  15. package/assets/images/iphone/delete.png +0 -0
  16. package/assets/images/iphone/forward.png +0 -0
  17. package/assets/images/iphone/pin.png +0 -0
  18. package/assets/images/iphone/plus.png +0 -0
  19. package/assets/images/iphone/reply.png +0 -0
  20. package/assets/images/iphone/report.png +0 -0
  21. package/assets/images/iphone/signal.png +0 -0
  22. package/assets/images/iphone/star.png +0 -0
  23. package/assets/images/iphone/wifi.png +0 -0
  24. package/assets/images/pattern_02.png +0 -0
  25. package/assets/images/tiktok/tiktok-verified.png +0 -0
  26. package/assets/images/tweet/like.png +0 -0
  27. package/assets/images/tweet/other.png +0 -0
  28. package/assets/images/tweet/reply.png +0 -0
  29. package/assets/images/tweet/retweet.png +0 -0
  30. package/assets/images/tweet/share.png +0 -0
  31. package/assets/images/tweet/twitter-verified.png +0 -0
  32. package/assets/images/youtube/youtube-verified.png +0 -0
  33. package/index.js +39 -0
  34. package/lib/generators.js +3220 -0
  35. package/lib/quote-generator.js +1621 -0
  36. package/lib/utils.js +166 -0
  37. package/package.json +24 -0
@@ -0,0 +1,3220 @@
1
+ const fs = require("fs-extra");
2
+ const path = require("path");
3
+ const sharp = require("sharp");
4
+ const emojiImageByBrandPromise = require("emoji-cache");
5
+ const ffmpeg = require("fluent-ffmpeg");
6
+ const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
7
+ const { createCanvas, loadImage, registerFont } = require("canvas");
8
+ const {
9
+ parseTextToSegments,
10
+ rebuildLinesFromSegments,
11
+ getContrastColor,
12
+ } = require("./utils.js");
13
+ registerFont(path.join(__dirname, "../assets/fonts/SfProDisplay.ttf"), {
14
+ family: "SfProDisplay",
15
+ });
16
+ registerFont(path.join(__dirname, "../assets/fonts/Noto-Bold.ttf"), {
17
+ family: "NotoBold",
18
+ });
19
+ registerFont(path.join(__dirname, "../assets/fonts/Noto-Regular.ttf"), {
20
+ family: "NotoRegular",
21
+ });
22
+ registerFont(path.join(__dirname, "../assets/fonts/Chirp-Regular.ttf"), {
23
+ family: "ChirpRegular",
24
+ });
25
+ ffmpeg.setFfmpegPath(ffmpegInstaller.path);
26
+
27
+ function drawRoundRect(ctx, x, y, width, height, radius) {
28
+ ctx.beginPath();
29
+ ctx.moveTo(x + radius, y);
30
+ ctx.lineTo(x + width - radius, y);
31
+ ctx.arcTo(x + width, y, x + width, y + radius, radius);
32
+ ctx.lineTo(x + width, y + height - radius);
33
+ ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
34
+ ctx.lineTo(x + radius, y + height);
35
+ ctx.arcTo(x, y + height, x, y + height - radius, radius);
36
+ ctx.lineTo(x, y + radius);
37
+ ctx.arcTo(x, y, x + radius, y, radius);
38
+ ctx.closePath();
39
+ }
40
+ function formatStatNumber(value) {
41
+ if (value === null || value === undefined) return "";
42
+
43
+ const num =
44
+ typeof value === "number"
45
+ ? value
46
+ : parseFloat(String(value).replace(/,/g, ""));
47
+
48
+ if (!isFinite(num)) return String(value);
49
+
50
+ if (num >= 1_000_000_000) {
51
+ return (num / 1_000_000_000).toFixed(1).replace(/\.0$/, "") + "B";
52
+ }
53
+ if (num >= 1_000_000) {
54
+ return (num / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
55
+ }
56
+ if (num >= 1_000) {
57
+ return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
58
+ }
59
+
60
+ return num.toString();
61
+ }
62
+
63
+ function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
64
+ if (!text) return y + lineHeight;
65
+
66
+ const words = text.split(" ");
67
+ let line = "";
68
+
69
+ for (let i = 0; i < words.length; i++) {
70
+ const testLine = line + words[i] + " ";
71
+ const metrics = ctx.measureText(testLine);
72
+
73
+ if (metrics.width > maxWidth && line !== "") {
74
+ ctx.fillText(line, x, y);
75
+ line = words[i] + " ";
76
+ y += lineHeight;
77
+ } else {
78
+ line = testLine;
79
+ }
80
+ }
81
+
82
+ ctx.fillText(line, x, y);
83
+ return y + lineHeight;
84
+ }
85
+
86
+ function isHighlighted(highlightList, segmentContent) {
87
+ if (
88
+ !segmentContent ||
89
+ typeof segmentContent !== "string" ||
90
+ !highlightList ||
91
+ highlightList.length === 0
92
+ )
93
+ return false;
94
+ const cleanFormatting = (str) => {
95
+ if (str.startsWith("```") && str.endsWith("```")) return str.slice(3, -3);
96
+ if (
97
+ (str.startsWith("*_") && str.endsWith("_*")) ||
98
+ (str.startsWith("_*") && str.endsWith("*_"))
99
+ )
100
+ return str.slice(2, -2);
101
+ if (
102
+ (str.startsWith("*") && str.endsWith("*")) ||
103
+ (str.startsWith("_") && str.endsWith("_")) ||
104
+ (str.startsWith("~") && str.endsWith("~"))
105
+ )
106
+ return str.slice(1, -1);
107
+ return str;
108
+ };
109
+ const contentLower = segmentContent.toLowerCase();
110
+ for (const rawHighlightWord of highlightList) {
111
+ const cleanedHighlightWord =
112
+ cleanFormatting(rawHighlightWord).toLowerCase();
113
+ if (cleanedHighlightWord === contentLower) {
114
+ return true;
115
+ }
116
+ }
117
+ return false;
118
+ }
119
+
120
+ function wrapText(ctx, text, maxWidth) {
121
+ const words = text.split(" ");
122
+ const lines = [];
123
+ let currentLine = words[0];
124
+
125
+ for (let i = 1; i < words.length; i++) {
126
+ const word = words[i];
127
+ const width = ctx.measureText(currentLine + " " + word).width;
128
+ if (width < maxWidth) {
129
+ currentLine += " " + word;
130
+ } else {
131
+ lines.push(currentLine);
132
+ currentLine = word;
133
+ }
134
+ }
135
+ lines.push(currentLine);
136
+ return lines;
137
+ }
138
+
139
+ async function generateFakeStory(options = {}) {
140
+ const allEmojiImages = await emojiImageByBrandPromise;
141
+ const emojiCache = allEmojiImages["apple"] || {};
142
+ const {
143
+ caption,
144
+ username,
145
+ profilePicBuffer,
146
+ backgroundPath = path.join(
147
+ __dirname,
148
+ "..",
149
+ "assets",
150
+ "images",
151
+ "instagram",
152
+ "fakestory.png"
153
+ ),
154
+ } = options;
155
+ if (!caption || !username || !profilePicBuffer)
156
+ throw new Error("Caption, username, dan profilePicBuffer wajib diisi.");
157
+ const bg = await loadImage(backgroundPath);
158
+ const pp = await loadImage(profilePicBuffer);
159
+ const canvas = createCanvas(bg.width, bg.height);
160
+ const ctx = canvas.getContext("2d");
161
+ ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);
162
+ const ppX = 40;
163
+ const ppY = 250;
164
+ const ppSize = 70;
165
+ ctx.save();
166
+ ctx.beginPath();
167
+ ctx.arc(ppX + ppSize / 2, ppY + ppSize / 2, ppSize / 2, 0, Math.PI * 2);
168
+ ctx.closePath();
169
+ ctx.clip();
170
+ ctx.drawImage(pp, ppX, ppY, ppSize, ppSize);
171
+ ctx.restore();
172
+ ctx.font = "28px Arial";
173
+ ctx.fillStyle = "#FFFFFF";
174
+ ctx.textAlign = "left";
175
+ ctx.textBaseline = "middle";
176
+ const usernameX = ppX + ppSize + 15;
177
+ const usernameY = ppY + ppSize / 2;
178
+ ctx.fillText(username, usernameX, usernameY);
179
+ const padding = 60;
180
+ const maxWidth = canvas.width - padding * 2;
181
+ const maxHeight = 500;
182
+ let fontSize = 42;
183
+ let finalLines = [];
184
+ let lineHeight = 0;
185
+ let finalFontSize = 0;
186
+ while (fontSize > 10) {
187
+ ctx.font = `${fontSize}px Arial`;
188
+ const segments = parseTextToSegments(caption, ctx, fontSize);
189
+ const lines = rebuildLinesFromSegments(segments, maxWidth, ctx, fontSize);
190
+ let isTooWide = false;
191
+ for (const line of lines) {
192
+ const lineWidth = line.reduce((sum, seg) => sum + seg.width, 0);
193
+ if (lineWidth > maxWidth) {
194
+ isTooWide = true;
195
+ break;
196
+ }
197
+ }
198
+ const currentLineHeight = fontSize * 1.3;
199
+ const totalHeight = lines.length * currentLineHeight;
200
+ if (totalHeight <= maxHeight && !isTooWide) {
201
+ finalLines = lines;
202
+ lineHeight = currentLineHeight;
203
+ finalFontSize = fontSize;
204
+ break;
205
+ }
206
+ fontSize -= 2;
207
+ }
208
+ ctx.textBaseline = "top";
209
+ const captionX = canvas.width / 2;
210
+ const totalTextHeight = finalLines.length * lineHeight;
211
+ const startY = canvas.height / 2 - totalTextHeight / 2 + 50;
212
+ ctx.strokeStyle = "#FFFFFF";
213
+ ctx.lineWidth = Math.max(2, finalFontSize / 15);
214
+ for (let i = 0; i < finalLines.length; i++) {
215
+ const line = finalLines[i];
216
+ const totalLineWidth = line.reduce((sum, seg) => sum + seg.width, 0);
217
+ let currentX = captionX - totalLineWidth / 2;
218
+ const currentLineY = startY + i * lineHeight;
219
+ for (const segment of line) {
220
+ ctx.fillStyle = "#FFFFFF";
221
+ switch (segment.type) {
222
+ case "bold":
223
+ ctx.font = `bold ${finalFontSize}px Arial`;
224
+ ctx.fillText(segment.content, currentX, currentLineY);
225
+ break;
226
+ case "italic":
227
+ ctx.font = `italic ${finalFontSize}px Arial`;
228
+ ctx.fillText(segment.content, currentX, currentLineY);
229
+ break;
230
+ case "bolditalic":
231
+ ctx.font = `italic bold ${finalFontSize}px Arial`;
232
+ ctx.fillText(segment.content, currentX, currentLineY);
233
+ break;
234
+ case "monospace":
235
+ ctx.font = `${finalFontSize}px 'Courier New', monospace`;
236
+ ctx.fillText(segment.content, currentX, currentLineY);
237
+ break;
238
+ case "strikethrough":
239
+ ctx.font = `${finalFontSize}px Arial`;
240
+ ctx.fillText(segment.content, currentX, currentLineY);
241
+ const strikeY = currentLineY + finalFontSize / 2;
242
+ ctx.beginPath();
243
+ ctx.moveTo(currentX, strikeY);
244
+ ctx.lineTo(currentX + segment.width, strikeY);
245
+ ctx.stroke();
246
+ break;
247
+ case "emoji":
248
+ const emojiImg = await loadImage(
249
+ Buffer.from(emojiCache[segment.content], "base64")
250
+ );
251
+ const emojiDrawY = currentLineY + (lineHeight - finalFontSize) / 2;
252
+ ctx.drawImage(
253
+ emojiImg,
254
+ currentX,
255
+ emojiDrawY,
256
+ finalFontSize,
257
+ finalFontSize
258
+ );
259
+ break;
260
+ case "text":
261
+ case "whitespace":
262
+ default:
263
+ ctx.font = `${finalFontSize}px Arial`;
264
+ ctx.fillText(segment.content, currentX, currentLineY);
265
+ break;
266
+ }
267
+ currentX += segment.width;
268
+ }
269
+ }
270
+ return canvas.toBuffer("image/png");
271
+ }
272
+ async function generateFakeTweet(options = {}) {
273
+ const allEmojiImages = await emojiImageByBrandPromise;
274
+ const emojiCache = allEmojiImages["apple"] || {};
275
+
276
+ const {
277
+ comment = "Ini adalah contoh tweet *tebal* dan _miring_.\nJuga ~salah~ dan ```monospace```. ✨",
278
+ user = { displayName: "Pengembang Handal", username: "js_master" },
279
+ avatarUrl = path.join(__dirname, "..", "assets", "fotobot.jpeg"),
280
+ verified = false,
281
+ backgroundColor = "#15202b",
282
+ font = { name: "Chirp", path: null },
283
+
284
+ mediaUrl = null,
285
+ mediaType = "image",
286
+ videoDuration = "0:10",
287
+
288
+ stats = {
289
+ replies: 0,
290
+ reposts: 0,
291
+ quotes: 0,
292
+ likes: 0,
293
+ bookmarks: 0,
294
+ views: null,
295
+ },
296
+
297
+ timestamp = "2:13 PM · 14 Oct 25",
298
+ } = options;
299
+
300
+ try {
301
+ const textColor = getContrastColor(backgroundColor);
302
+ const mutedColor = textColor === "#FFFFFF" ? "#8493a2" : "#536471";
303
+ const lineColor = textColor === "#FFFFFF" ? "#38444d" : "#cfd9de";
304
+
305
+ if (font.path && fs.existsSync(font.path)) {
306
+ registerFont(font.path, { family: font.name });
307
+ } else {
308
+ const defaultFontPath = path.join(
309
+ __dirname,
310
+ "..",
311
+ "assets",
312
+ "fonts",
313
+ "Chirp-Regular.ttf"
314
+ );
315
+ if (fs.existsSync(defaultFontPath)) {
316
+ registerFont(defaultFontPath, { family: "Chirp" });
317
+ }
318
+ }
319
+
320
+ const tempCtx = createCanvas(1, 1).getContext("2d");
321
+ const commentFontSize = 25;
322
+ const commentLineHeight = 40;
323
+ const commentMaxWidth = 800;
324
+ tempCtx.font = `${commentFontSize}px "${font.name}"`;
325
+
326
+ const segments = parseTextToSegments(comment, tempCtx, commentFontSize);
327
+ const commentLines = rebuildLinesFromSegments(
328
+ segments,
329
+ commentMaxWidth,
330
+ tempCtx,
331
+ commentFontSize
332
+ );
333
+
334
+ const totalCommentHeight = commentLines.length * commentLineHeight;
335
+
336
+ let mediaHeight = 0;
337
+ let mediaImage = null;
338
+ const mediaMarginTop = 20;
339
+ const mediaMarginBottom = 20;
340
+ const mediaPadding = 30;
341
+ const canvasWidth = 968;
342
+ const mediaWidth = canvasWidth - mediaPadding * 2;
343
+
344
+ if (mediaUrl) {
345
+ try {
346
+ mediaImage = await loadImage(mediaUrl);
347
+
348
+ const aspectRatio = mediaImage.height / mediaImage.width;
349
+ const calculatedHeight = mediaWidth * aspectRatio;
350
+
351
+ mediaHeight = Math.max(300, Math.min(900, calculatedHeight));
352
+ } catch (err) {
353
+ console.error("Gagal memuat media:", err.message);
354
+ }
355
+ }
356
+
357
+ const baseHeight = 240;
358
+ const statsHeight = 80;
359
+ const footerHeight = 88;
360
+ const totalMediaHeight = mediaImage
361
+ ? mediaMarginTop + mediaHeight + mediaMarginBottom
362
+ : 0;
363
+
364
+ const finalHeight =
365
+ baseHeight +
366
+ totalCommentHeight +
367
+ totalMediaHeight +
368
+ statsHeight +
369
+ footerHeight;
370
+
371
+ const canvas = createCanvas(canvasWidth, finalHeight);
372
+ const ctx = canvas.getContext("2d");
373
+
374
+ ctx.fillStyle = backgroundColor;
375
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
376
+ ctx.textBaseline = "alphabetic";
377
+
378
+ ctx.save();
379
+ ctx.beginPath();
380
+ ctx.arc(80, 75, 40, 0, Math.PI * 2);
381
+ ctx.closePath();
382
+ ctx.clip();
383
+ const avatarImg = await loadImage(avatarUrl);
384
+ ctx.drawImage(avatarImg, 40, 35, 80, 80);
385
+ ctx.restore();
386
+
387
+ ctx.fillStyle = textColor;
388
+ ctx.textAlign = "left";
389
+ ctx.font = `bold 25px "${font.name}"`;
390
+ ctx.fillText(user.displayName, 140, 70);
391
+
392
+ if (verified) {
393
+ const textLength = ctx.measureText(user.displayName).width;
394
+ const verifiedIcon = await loadImage(
395
+ path.join(
396
+ __dirname,
397
+ "..",
398
+ "assets",
399
+ "images",
400
+ "tweet",
401
+ "twitter-verified.png"
402
+ )
403
+ );
404
+ ctx.drawImage(verifiedIcon, textLength + 150, 48, 30, 30);
405
+ }
406
+
407
+ ctx.font = `20px "${font.name}"`;
408
+ ctx.fillStyle = mutedColor;
409
+ ctx.fillText("@" + user.username, 140, 100);
410
+
411
+ try {
412
+ const otherIcon = await loadImage(
413
+ path.join(__dirname, "..", "assets", "images", "tweet", "other.png")
414
+ );
415
+ ctx.drawImage(otherIcon, 900, 40, 35, 35);
416
+ } catch (err) {
417
+ ctx.fillStyle = mutedColor;
418
+ ctx.font = "bold 30px Arial";
419
+ ctx.fillText("⋯", 900, 60);
420
+ }
421
+
422
+ const commentStartX = 40;
423
+ const commentStartY = 130;
424
+ ctx.textBaseline = "middle";
425
+ ctx.strokeStyle = textColor;
426
+ ctx.lineWidth = 2;
427
+
428
+ for (let i = 0; i < commentLines.length; i++) {
429
+ const line = commentLines[i];
430
+ const lineCenterY =
431
+ commentStartY + i * commentLineHeight + commentLineHeight / 2;
432
+ let currentX = commentStartX;
433
+
434
+ for (const segment of line) {
435
+ ctx.fillStyle = textColor;
436
+ switch (segment.type) {
437
+ case "bold":
438
+ ctx.font = `bold ${commentFontSize}px Arial`;
439
+ ctx.fillText(segment.content, currentX, lineCenterY);
440
+ break;
441
+ case "italic":
442
+ ctx.font = `italic ${commentFontSize}px Arial`;
443
+ ctx.fillText(segment.content, currentX, lineCenterY);
444
+ break;
445
+ case "bolditalic":
446
+ ctx.font = `italic bold ${commentFontSize}px Arial`;
447
+ ctx.fillText(segment.content, currentX, lineCenterY);
448
+ break;
449
+ case "monospace":
450
+ ctx.font = `${commentFontSize}px 'Courier New', monospace`;
451
+ ctx.fillText(segment.content, currentX, lineCenterY);
452
+ break;
453
+ case "strikethrough":
454
+ ctx.font = `${commentFontSize}px Arial`;
455
+ ctx.fillText(segment.content, currentX, lineCenterY);
456
+ const strikeY = lineCenterY;
457
+ ctx.beginPath();
458
+ ctx.moveTo(currentX, strikeY);
459
+ ctx.lineTo(currentX + segment.width, strikeY);
460
+ ctx.stroke();
461
+ break;
462
+ case "emoji":
463
+ const emojiImg = await loadImage(
464
+ Buffer.from(emojiCache[segment.content], "base64")
465
+ );
466
+ const emojiY = lineCenterY - commentFontSize / 2;
467
+ ctx.drawImage(
468
+ emojiImg,
469
+ currentX,
470
+ emojiY,
471
+ commentFontSize,
472
+ commentFontSize
473
+ );
474
+ break;
475
+ case "text":
476
+ case "whitespace":
477
+ default:
478
+ ctx.font = `${commentFontSize}px Arial`;
479
+ ctx.fillText(segment.content, currentX, lineCenterY);
480
+ break;
481
+ }
482
+ currentX += segment.width;
483
+ }
484
+ }
485
+
486
+ let currentY = commentStartY + totalCommentHeight;
487
+ if (mediaImage) {
488
+ currentY += mediaMarginTop;
489
+
490
+ const mediaX = mediaPadding;
491
+ const borderRadius = 16;
492
+
493
+ ctx.save();
494
+ ctx.beginPath();
495
+ ctx.moveTo(mediaX + borderRadius, currentY);
496
+ ctx.lineTo(mediaX + mediaWidth - borderRadius, currentY);
497
+ ctx.quadraticCurveTo(
498
+ mediaX + mediaWidth,
499
+ currentY,
500
+ mediaX + mediaWidth,
501
+ currentY + borderRadius
502
+ );
503
+ ctx.lineTo(mediaX + mediaWidth, currentY + mediaHeight - borderRadius);
504
+ ctx.quadraticCurveTo(
505
+ mediaX + mediaWidth,
506
+ currentY + mediaHeight,
507
+ mediaX + mediaWidth - borderRadius,
508
+ currentY + mediaHeight
509
+ );
510
+ ctx.lineTo(mediaX + borderRadius, currentY + mediaHeight);
511
+ ctx.quadraticCurveTo(
512
+ mediaX,
513
+ currentY + mediaHeight,
514
+ mediaX,
515
+ currentY + mediaHeight - borderRadius
516
+ );
517
+ ctx.lineTo(mediaX, currentY + borderRadius);
518
+ ctx.quadraticCurveTo(mediaX, currentY, mediaX + borderRadius, currentY);
519
+ ctx.closePath();
520
+ ctx.clip();
521
+
522
+ ctx.drawImage(mediaImage, mediaX, currentY, mediaWidth, mediaHeight);
523
+ ctx.restore();
524
+
525
+ if (mediaType === "video") {
526
+ const muteIconSize = 40;
527
+ const muteIconPadding = 12;
528
+ const muteX = mediaX + mediaWidth - muteIconSize - muteIconPadding;
529
+ const muteY = currentY + mediaHeight - muteIconSize - muteIconPadding;
530
+
531
+ ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
532
+ ctx.beginPath();
533
+ ctx.arc(
534
+ muteX + muteIconSize / 2,
535
+ muteY + muteIconSize / 2,
536
+ muteIconSize / 2,
537
+ 0,
538
+ Math.PI * 2
539
+ );
540
+ ctx.fill();
541
+
542
+ ctx.fillStyle = "#FFFFFF";
543
+ ctx.font = "20px Arial";
544
+ ctx.textAlign = "center";
545
+ ctx.textBaseline = "middle";
546
+ ctx.fillText("🔇", muteX + muteIconSize / 2, muteY + muteIconSize / 2);
547
+
548
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
549
+ ctx.font = "bold 16px Arial";
550
+ const durationWidth = ctx.measureText(videoDuration).width + 16;
551
+ const durationX = mediaX + 12;
552
+ const durationY = currentY + mediaHeight - 32;
553
+
554
+ ctx.beginPath();
555
+ ctx.roundRect(durationX, durationY, durationWidth, 24, 4);
556
+ ctx.fill();
557
+
558
+ ctx.fillStyle = "#FFFFFF";
559
+ ctx.textAlign = "left";
560
+ ctx.textBaseline = "middle";
561
+ ctx.fillText(videoDuration, durationX + 8, durationY + 12);
562
+ }
563
+
564
+ currentY += mediaHeight + mediaMarginBottom;
565
+ }
566
+
567
+ ctx.textAlign = "left";
568
+ ctx.textBaseline = "alphabetic";
569
+ ctx.font = `22px "${font.name}"`;
570
+ ctx.fillStyle = mutedColor;
571
+
572
+ let timestampText = timestamp;
573
+ if (stats.views) {
574
+ timestampText += ` · ${stats.views} Views`;
575
+ }
576
+ ctx.fillText(timestampText, 40, currentY + 20);
577
+ currentY += 50;
578
+
579
+ ctx.strokeStyle = lineColor;
580
+ ctx.lineWidth = 1;
581
+ ctx.beginPath();
582
+ ctx.moveTo(35, currentY - 20);
583
+ ctx.lineTo(canvas.width - 35, currentY - 20);
584
+ ctx.stroke();
585
+ currentY += 25;
586
+
587
+ const statsArray = [];
588
+ if (stats.reposts > 0 || stats.quotes > 0) {
589
+ const totalReposts = stats.reposts + (stats.quotes || 0);
590
+ statsArray.push({
591
+ label: "Reposts",
592
+ value: formatStatNumber(totalReposts),
593
+ });
594
+ }
595
+ if (stats.quotes > 0) {
596
+ statsArray.push({
597
+ label: "Quotes",
598
+ value: formatStatNumber(stats.quotes),
599
+ });
600
+ }
601
+ if (stats.likes > 0) {
602
+ statsArray.push({ label: "Likes", value: formatStatNumber(stats.likes) });
603
+ }
604
+ if (stats.bookmarks > 0) {
605
+ statsArray.push({
606
+ label: "Bookmarks",
607
+ value: formatStatNumber(stats.bookmarks),
608
+ });
609
+ }
610
+
611
+ let statsX = 35;
612
+ ctx.font = `bold 22px "${font.name}"`;
613
+ for (let i = 0; i < statsArray.length; i++) {
614
+ const stat = statsArray[i];
615
+
616
+ ctx.fillStyle = textColor;
617
+ ctx.fillText(stat.value, statsX, currentY);
618
+ const valueWidth = ctx.measureText(stat.value).width;
619
+
620
+ ctx.font = `22px "${font.name}"`;
621
+ ctx.fillStyle = mutedColor;
622
+ ctx.fillText(" " + stat.label, statsX + valueWidth, currentY);
623
+ const labelWidth = ctx.measureText(" " + stat.label).width;
624
+
625
+ statsX += valueWidth + labelWidth + 30;
626
+ ctx.font = `bold 22px "${font.name}"`;
627
+ }
628
+ currentY += 50;
629
+
630
+ ctx.strokeStyle = lineColor;
631
+ ctx.lineWidth = 1;
632
+ ctx.beginPath();
633
+ ctx.moveTo(35, currentY - 20);
634
+ ctx.lineTo(canvas.width - 35, currentY - 20);
635
+ ctx.stroke();
636
+ currentY += 20;
637
+
638
+ try {
639
+ const iconPath = (iconName) =>
640
+ path.join(__dirname, "..", "assets", "images", "tweet", iconName);
641
+
642
+ const iconY = currentY;
643
+ const iconSize = 45;
644
+ const icons = ["reply.png", "retweet.png", "like.png", "share.png"];
645
+ const iconCount = icons.length;
646
+
647
+ const padding = 100;
648
+ const totalWidth = canvas.width - padding * 2;
649
+
650
+ const iconSpacing = totalWidth / (iconCount - 1);
651
+
652
+ const startX = padding;
653
+
654
+ for (let i = 0; i < iconCount; i++) {
655
+ const x = startX + iconSpacing * i - iconSize / 2;
656
+ ctx.drawImage(
657
+ await loadImage(iconPath(icons[i])),
658
+ x,
659
+ iconY,
660
+ iconSize,
661
+ iconSize
662
+ );
663
+ }
664
+ } catch (err) {
665
+ console.error("Gagal memuat ikon tweet:", err.message);
666
+ }
667
+
668
+ return canvas.toBuffer("image/png");
669
+ } catch (error) {
670
+ console.error("Gagal membuat kartu Tweet:", error);
671
+ return null;
672
+ }
673
+ }
674
+ async function generateFakeChatIphone(options = {}) {
675
+ const allEmojiImages = await emojiImageByBrandPromise;
676
+ const emojiCache = allEmojiImages["apple"] || {};
677
+ const {
678
+ text = "Ini adalah *contoh* IQC.\nMendukung _semua_ format ~Markdown~. ✨",
679
+ imageUrl = null,
680
+ chatTime = "11:02",
681
+ statusBarTime = "17:01",
682
+ bubbleColor = "#272a2f",
683
+ menuColor = "#272a2f",
684
+ textColor = "#FFFFFF",
685
+ fontName = "Arial",
686
+ side = "left",
687
+ } = options;
688
+
689
+ try {
690
+ const canvasWidth = 1320;
691
+ const canvasHeight = 2868;
692
+ const canvas = createCanvas(canvasWidth, canvasHeight);
693
+ const ctx = canvas.getContext("2d");
694
+
695
+ const iconPath = (iconName) =>
696
+ path.join(__dirname, "..", "assets", "images", "iphone", iconName);
697
+
698
+ const backgroundImage = await loadImage(iconPath("background.jpg"));
699
+ ctx.drawImage(backgroundImage, 0, 0, canvasWidth, canvasHeight);
700
+
701
+ ctx.fillStyle = textColor;
702
+ ctx.font = `bold 50px "${fontName}"`;
703
+ ctx.textBaseline = "middle";
704
+ ctx.textAlign = "left";
705
+ ctx.fillText(statusBarTime, 40, 80);
706
+ const statusIconY = 60;
707
+ const statusIconSize = 55;
708
+ const rightMargin = 40;
709
+ const iconSpacing = 20;
710
+ let currentX_status = canvasWidth - rightMargin - statusIconSize;
711
+ ctx.drawImage(
712
+ await loadImage(iconPath("battery.png")),
713
+ currentX_status,
714
+ statusIconY - 10,
715
+ statusIconSize,
716
+ statusIconSize * 1.5
717
+ );
718
+ currentX_status -= statusIconSize + iconSpacing;
719
+ ctx.drawImage(
720
+ await loadImage(iconPath("wifi.png")),
721
+ currentX_status,
722
+ statusIconY,
723
+ statusIconSize,
724
+ statusIconSize
725
+ );
726
+ currentX_status -= statusIconSize + iconSpacing;
727
+ ctx.drawImage(
728
+ await loadImage(iconPath("signal.png")),
729
+ currentX_status,
730
+ statusIconY,
731
+ statusIconSize,
732
+ statusIconSize
733
+ );
734
+
735
+ const startX = 40;
736
+ const bubbleSpacing = 20;
737
+ const emojiString = "👍❤️😂😮😢🙏🤔";
738
+ const emojiFontSize = 65;
739
+ const plusIconSize = 115;
740
+ const emojiPadding = 15;
741
+
742
+ ctx.font = `${emojiFontSize}px "${fontName}"`;
743
+ const emojiSegments = parseTextToSegments(emojiString, ctx, emojiFontSize);
744
+ let emojiContentWidth =
745
+ emojiSegments.reduce((sum, seg) => sum + seg.width + 20, 0) +
746
+ plusIconSize +
747
+ 20;
748
+ const bubble1Width = emojiContentWidth + emojiPadding * 2 - 20;
749
+ const bubble1Height = 110;
750
+
751
+ let messageImage = null;
752
+ let imageWidth = 0;
753
+ let imageHeight = 0;
754
+ const maxImageWidth = 900;
755
+ const maxImageHeight = 900;
756
+ const minImageWidth = 350;
757
+
758
+ if (imageUrl) {
759
+ try {
760
+ messageImage = await loadImage(imageUrl);
761
+ const aspectRatio = messageImage.width / messageImage.height;
762
+
763
+ imageWidth = messageImage.width;
764
+ imageHeight = messageImage.height;
765
+
766
+ if (imageWidth > maxImageWidth || imageHeight > maxImageHeight) {
767
+ if (aspectRatio > 1) {
768
+ imageWidth = Math.min(imageWidth, maxImageWidth);
769
+ imageHeight = imageWidth / aspectRatio;
770
+
771
+ if (imageHeight > maxImageHeight) {
772
+ imageHeight = maxImageHeight;
773
+ imageWidth = imageHeight * aspectRatio;
774
+ }
775
+ } else {
776
+ imageHeight = Math.min(imageHeight, maxImageHeight);
777
+ imageWidth = imageHeight * aspectRatio;
778
+
779
+ if (imageWidth > maxImageWidth) {
780
+ imageWidth = maxImageWidth;
781
+ imageHeight = imageWidth / aspectRatio;
782
+ }
783
+ }
784
+ }
785
+
786
+ if (imageWidth < minImageWidth) {
787
+ imageWidth = minImageWidth;
788
+ imageHeight = imageWidth / aspectRatio;
789
+ }
790
+ } catch (err) {
791
+ console.error("Gagal memuat gambar:", err);
792
+ messageImage = null;
793
+ }
794
+ }
795
+
796
+ const textFontSize = 52;
797
+ const textLineHeight = textFontSize * 1.4;
798
+ const textMaxWidth = canvasWidth - startX - 40;
799
+ const bubblePadding = 40;
800
+ const imagePadding = 20;
801
+
802
+ ctx.font = `bold ${textFontSize}px "${fontName}"`;
803
+ const textSegments = parseTextToSegments(text, ctx, textFontSize);
804
+ const textLines = rebuildLinesFromSegments(
805
+ textSegments,
806
+ textMaxWidth - bubblePadding * 2,
807
+ ctx,
808
+ textFontSize
809
+ );
810
+
811
+ let bubble2Width;
812
+ let bubble2Height;
813
+
814
+ let textContentWidth;
815
+ if (textLines.length === 1) {
816
+ textContentWidth =
817
+ textLines[0].reduce((sum, seg) => sum + seg.width, 0) +
818
+ bubblePadding * 2;
819
+ } else {
820
+ textContentWidth = Math.min(textMaxWidth, canvasWidth * 0.7);
821
+ }
822
+
823
+ if (messageImage) {
824
+ const minBubbleWidth = imageWidth + imagePadding * 2;
825
+ bubble2Width = Math.max(textContentWidth, minBubbleWidth);
826
+
827
+ const textHeight = textLines.length * textLineHeight;
828
+ bubble2Height =
829
+ imageHeight + imagePadding + textHeight + bubblePadding * 2;
830
+ } else {
831
+ bubble2Width = textContentWidth;
832
+ bubble2Height = textLines.length * textLineHeight + bubblePadding * 2;
833
+ }
834
+
835
+ const menuItems = [
836
+ { text: "Reply", icon: "reply.png" },
837
+ { text: "Forward", icon: "forward.png" },
838
+ { text: "Copy", icon: "copy.png" },
839
+ { text: "Star", icon: "star.png" },
840
+ { text: "Pin", icon: "pin.png" },
841
+ { text: "Report", icon: "report.png" },
842
+ { text: "Delete", icon: "delete.png", color: "#ff453a" },
843
+ ];
844
+ const menuItemHeight = 110;
845
+ const bubble3Width = (canvasWidth * 4) / 9 - startX;
846
+ const bubble3Height = menuItems.length * menuItemHeight;
847
+
848
+ const sequentialBlockHeight = bubble1Height + bubbleSpacing + bubble2Height;
849
+ const centeredY = (canvasHeight - sequentialBlockHeight) / 2;
850
+ const verticalOffset = -300;
851
+ const topLimitY = 200;
852
+ const startY = Math.max(centeredY + verticalOffset, topLimitY);
853
+ const bubble1Y = startY;
854
+ const bubble2Y = bubble1Y + bubble1Height + bubbleSpacing;
855
+
856
+ let bubble3Y;
857
+ const bottomLimitY = canvasHeight - 100;
858
+ const normal_bubble3Y = bubble2Y + bubble2Height + bubbleSpacing;
859
+ bubble3Y =
860
+ normal_bubble3Y + bubble3Height >= bottomLimitY
861
+ ? bottomLimitY - bubble3Height
862
+ : normal_bubble3Y;
863
+
864
+ const getBubbleX = (width) =>
865
+ side === "left" ? startX : canvasWidth - startX - width;
866
+
867
+ const finalBubbleColor = side === "right" ? "#015347" : bubbleColor;
868
+
869
+ ctx.fillStyle = menuColor;
870
+ drawRoundRect(
871
+ ctx,
872
+ getBubbleX(bubble1Width),
873
+ bubble1Y,
874
+ bubble1Width,
875
+ bubble1Height,
876
+ 50
877
+ );
878
+ ctx.fill();
879
+ let currentEmojiX = getBubbleX(bubble1Width) + emojiPadding;
880
+ for (const segment of emojiSegments) {
881
+ const emojiImg = await loadImage(
882
+ Buffer.from(emojiCache[segment.content], "base64")
883
+ );
884
+ ctx.drawImage(
885
+ emojiImg,
886
+ currentEmojiX,
887
+ bubble1Y + (bubble1Height - emojiFontSize) / 2,
888
+ emojiFontSize,
889
+ emojiFontSize
890
+ );
891
+ currentEmojiX += segment.width + 20;
892
+ }
893
+ ctx.drawImage(
894
+ await loadImage(iconPath("plus.png")),
895
+ currentEmojiX,
896
+ bubble1Y + (bubble1Height - plusIconSize) / 2,
897
+ plusIconSize,
898
+ plusIconSize
899
+ );
900
+
901
+ ctx.fillStyle = finalBubbleColor;
902
+ drawRoundRect(
903
+ ctx,
904
+ getBubbleX(bubble2Width),
905
+ bubble2Y,
906
+ bubble2Width,
907
+ bubble2Height,
908
+ 45
909
+ );
910
+ ctx.fill();
911
+
912
+ let contentStartY = bubble2Y + bubblePadding;
913
+
914
+ if (messageImage) {
915
+ ctx.save();
916
+
917
+ const availableWidth = bubble2Width - imagePadding * 2;
918
+ const imgX =
919
+ getBubbleX(bubble2Width) +
920
+ imagePadding +
921
+ (availableWidth - imageWidth) / 2;
922
+ const imgY = contentStartY;
923
+
924
+ ctx.beginPath();
925
+ ctx.moveTo(imgX + 30, imgY);
926
+ ctx.lineTo(imgX + imageWidth - 30, imgY);
927
+ ctx.quadraticCurveTo(
928
+ imgX + imageWidth,
929
+ imgY,
930
+ imgX + imageWidth,
931
+ imgY + 30
932
+ );
933
+ ctx.lineTo(imgX + imageWidth, imgY + imageHeight - 30);
934
+ ctx.quadraticCurveTo(
935
+ imgX + imageWidth,
936
+ imgY + imageHeight,
937
+ imgX + imageWidth - 30,
938
+ imgY + imageHeight
939
+ );
940
+ ctx.lineTo(imgX + 30, imgY + imageHeight);
941
+ ctx.quadraticCurveTo(
942
+ imgX,
943
+ imgY + imageHeight,
944
+ imgX,
945
+ imgY + imageHeight - 30
946
+ );
947
+ ctx.lineTo(imgX, imgY + 30);
948
+ ctx.quadraticCurveTo(imgX, imgY, imgX + 30, imgY);
949
+ ctx.closePath();
950
+ ctx.clip();
951
+
952
+ ctx.drawImage(messageImage, imgX, imgY, imageWidth, imageHeight);
953
+ ctx.restore();
954
+
955
+ contentStartY += imageHeight + imagePadding;
956
+ }
957
+
958
+ ctx.strokeStyle = textColor;
959
+ ctx.lineWidth = 3;
960
+ for (let i = 0; i < textLines.length; i++) {
961
+ const line = textLines[i];
962
+ let currentTextX = getBubbleX(bubble2Width) + bubblePadding;
963
+ const lineY = contentStartY + i * textLineHeight;
964
+ ctx.textBaseline = "top";
965
+ for (const segment of line) {
966
+ ctx.fillStyle = textColor;
967
+ switch (segment.type) {
968
+ case "bold":
969
+ ctx.font = `bold ${textFontSize}px "${fontName}"`;
970
+ ctx.fillText(segment.content, currentTextX, lineY);
971
+ break;
972
+ case "italic":
973
+ ctx.font = `italic ${textFontSize}px "${fontName}"`;
974
+ ctx.fillText(segment.content, currentTextX, lineY);
975
+ break;
976
+ case "bolditalic":
977
+ ctx.font = `italic bold ${textFontSize}px "${fontName}"`;
978
+ ctx.fillText(segment.content, currentTextX, lineY);
979
+ break;
980
+ case "monospace":
981
+ ctx.font = `${textFontSize}px 'Courier New', monospace`;
982
+ ctx.fillText(segment.content, currentTextX, lineY);
983
+ break;
984
+ case "strikethrough":
985
+ ctx.font = `${textFontSize}px "${fontName}"`;
986
+ ctx.fillText(segment.content, currentTextX, lineY);
987
+ const strikeY = lineY + textFontSize / 2;
988
+ ctx.beginPath();
989
+ ctx.moveTo(currentTextX, strikeY);
990
+ ctx.lineTo(currentTextX + segment.width, strikeY);
991
+ ctx.stroke();
992
+ break;
993
+ case "emoji":
994
+ const emojiImg = await loadImage(
995
+ Buffer.from(emojiCache[segment.content], "base64")
996
+ );
997
+ ctx.drawImage(
998
+ emojiImg,
999
+ currentTextX,
1000
+ lineY,
1001
+ textFontSize,
1002
+ textFontSize
1003
+ );
1004
+ break;
1005
+ case "text":
1006
+ case "whitespace":
1007
+ default:
1008
+ ctx.font = `${textFontSize}px "${fontName}"`;
1009
+ ctx.fillText(segment.content, currentTextX, lineY);
1010
+ break;
1011
+ }
1012
+ currentTextX += segment.width;
1013
+ }
1014
+ }
1015
+
1016
+ ctx.fillStyle = "#a0a0a0";
1017
+ ctx.font = `34px "${fontName}"`;
1018
+ ctx.textAlign = "right";
1019
+ ctx.textBaseline = "bottom";
1020
+ ctx.fillText(
1021
+ chatTime,
1022
+ getBubbleX(bubble2Width) + bubble2Width - bubblePadding / 1.5,
1023
+ bubble2Y + bubble2Height - bubblePadding / 8
1024
+ );
1025
+
1026
+ ctx.fillStyle = "rgba(39, 42, 47, 0.85)";
1027
+ drawRoundRect(
1028
+ ctx,
1029
+ getBubbleX(bubble3Width),
1030
+ bubble3Y,
1031
+ bubble3Width,
1032
+ bubble3Height,
1033
+ 40
1034
+ );
1035
+ ctx.fill();
1036
+
1037
+ for (let i = 0; i < menuItems.length; i++) {
1038
+ const item = menuItems[i];
1039
+ const itemY = bubble3Y + i * menuItemHeight;
1040
+ ctx.fillStyle = item.color || textColor;
1041
+ ctx.font = `50px "${fontName}"`;
1042
+ ctx.textAlign = "left";
1043
+ ctx.textBaseline = "middle";
1044
+ ctx.fillText(
1045
+ item.text,
1046
+ getBubbleX(bubble3Width) + 40,
1047
+ itemY + menuItemHeight / 2
1048
+ );
1049
+ const itemIcon = await loadImage(iconPath(item.icon));
1050
+ const iconSize = 55;
1051
+ const iconX = getBubbleX(bubble3Width) + bubble3Width - 40 - iconSize;
1052
+ ctx.drawImage(
1053
+ itemIcon,
1054
+ iconX,
1055
+ itemY + (menuItemHeight - iconSize) / 2,
1056
+ iconSize,
1057
+ iconSize
1058
+ );
1059
+ if (i < menuItems.length - 1) {
1060
+ const lineY = itemY + menuItemHeight;
1061
+ ctx.strokeStyle = "#555";
1062
+ ctx.lineWidth = 2;
1063
+ ctx.beginPath();
1064
+ ctx.moveTo(getBubbleX(bubble3Width) + 40, lineY);
1065
+ ctx.lineTo(getBubbleX(bubble3Width) + bubble3Width - 40, lineY);
1066
+ ctx.stroke();
1067
+ }
1068
+ }
1069
+
1070
+ const indicatorWidth = 450;
1071
+ const indicatorHeight = 15;
1072
+ const indicatorX = (canvasWidth - indicatorWidth) / 2;
1073
+ const indicatorY = canvasHeight - indicatorHeight - 20;
1074
+ ctx.fillStyle = "#FFFFFF";
1075
+ drawRoundRect(
1076
+ ctx,
1077
+ indicatorX,
1078
+ indicatorY,
1079
+ indicatorWidth,
1080
+ indicatorHeight,
1081
+ indicatorHeight / 2
1082
+ );
1083
+ ctx.fill();
1084
+
1085
+ return canvas.toBuffer("image/png");
1086
+ } catch (error) {
1087
+ console.error("Gagal membuat gambar quote:", error);
1088
+ return null;
1089
+ }
1090
+ }
1091
+
1092
+ async function generateFakeCallIphone(options = {}) {
1093
+ const {
1094
+ name = "Bubub",
1095
+ duration = "02:20:19",
1096
+ statusBarTime = "19:51",
1097
+ profileImageUrl = null,
1098
+ textColor = "#FFFFFF",
1099
+ fontName = "Arial",
1100
+ } = options;
1101
+ try {
1102
+ const canvasWidth = 731;
1103
+ const canvasHeight = 1280;
1104
+ const canvas = createCanvas(canvasWidth, canvasHeight);
1105
+ const ctx = canvas.getContext("2d");
1106
+ const iconPath = (iconName) =>
1107
+ path.join(__dirname, "..", "assets", "images", "iphone", iconName);
1108
+
1109
+ const backgroundImage = await loadImage(iconPath("background-call.jpg"));
1110
+ ctx.drawImage(backgroundImage, 0, 0, canvasWidth, canvasHeight);
1111
+
1112
+ const iconSize = 25;
1113
+ const iconY = 0;
1114
+ const leftMargin = 20;
1115
+
1116
+ ctx.drawImage(
1117
+ await loadImage(iconPath("signal.png")),
1118
+ leftMargin,
1119
+ iconY,
1120
+ iconSize,
1121
+ iconSize
1122
+ );
1123
+
1124
+ ctx.drawImage(
1125
+ await loadImage(iconPath("wifi.png")),
1126
+ leftMargin + iconSize + 5,
1127
+ iconY,
1128
+ iconSize,
1129
+ iconSize
1130
+ );
1131
+
1132
+ ctx.fillStyle = textColor;
1133
+ ctx.font = `bold 26px "${fontName}"`;
1134
+ ctx.textAlign = "center";
1135
+ ctx.textBaseline = "middle";
1136
+ ctx.fillText(statusBarTime, canvasWidth / 2, 14);
1137
+
1138
+ const batteryWidth = 40;
1139
+ const batteryHeight = 40;
1140
+ const rightMargin = 20;
1141
+ ctx.drawImage(
1142
+ await loadImage(iconPath("battery.png")),
1143
+ canvasWidth - rightMargin - batteryWidth,
1144
+ iconY - 5,
1145
+ batteryWidth,
1146
+ batteryHeight
1147
+ );
1148
+
1149
+ const nameY = 95;
1150
+ ctx.fillStyle = textColor;
1151
+ ctx.font = `bold 40px Arial`;
1152
+ ctx.textAlign = "center";
1153
+ ctx.fillText(name, canvasWidth / 2, nameY);
1154
+
1155
+ const timerY = 150;
1156
+ ctx.fillStyle = "#CCCCCC";
1157
+ ctx.font = `25px "${fontName}"`;
1158
+ ctx.textAlign = "center";
1159
+ ctx.fillText(duration, canvasWidth / 2, timerY);
1160
+
1161
+ const profileSize = 400;
1162
+ const profileX = (canvasWidth - profileSize) / 2;
1163
+ const profileY = 380;
1164
+
1165
+ ctx.save();
1166
+ ctx.beginPath();
1167
+ ctx.arc(
1168
+ profileX + profileSize / 2,
1169
+ profileY + profileSize / 2,
1170
+ profileSize / 2,
1171
+ 0,
1172
+ Math.PI * 2
1173
+ );
1174
+ ctx.closePath();
1175
+
1176
+ if (profileImageUrl) {
1177
+ try {
1178
+ const profileImage = await loadImage(profileImageUrl);
1179
+
1180
+ const imgRatio = profileImage.width / profileImage.height;
1181
+ const frameRatio = 1; // lingkaran → kotak
1182
+
1183
+ let srcX = 0,
1184
+ srcY = 0,
1185
+ srcW = profileImage.width,
1186
+ srcH = profileImage.height;
1187
+
1188
+ if (imgRatio > frameRatio) {
1189
+ // gambar lebih lebar → crop kiri kanan
1190
+ srcW = profileImage.height;
1191
+ srcX = (profileImage.width - srcW) / 2;
1192
+ } else {
1193
+ // gambar lebih tinggi → crop atas bawah
1194
+ srcH = profileImage.width;
1195
+ srcY = (profileImage.height - srcH) / 2;
1196
+ }
1197
+
1198
+ ctx.clip();
1199
+ ctx.drawImage(
1200
+ profileImage,
1201
+ srcX,
1202
+ srcY,
1203
+ srcW,
1204
+ srcH,
1205
+ profileX,
1206
+ profileY,
1207
+ profileSize,
1208
+ profileSize
1209
+ );
1210
+ } catch (err) {
1211
+ console.error("Gagal memuat gambar profil:", err);
1212
+
1213
+ ctx.fillStyle = "#8B8B8B";
1214
+ ctx.fill();
1215
+
1216
+ ctx.restore();
1217
+ ctx.save();
1218
+ ctx.fillStyle = "#FFFFFF";
1219
+
1220
+ const iconCenterX = profileX + profileSize / 2;
1221
+ const iconCenterY = profileY + profileSize / 2;
1222
+
1223
+ ctx.beginPath();
1224
+ ctx.arc(iconCenterX, iconCenterY - 40, 60, 0, Math.PI * 2);
1225
+ ctx.fill();
1226
+
1227
+ ctx.beginPath();
1228
+ ctx.arc(iconCenterX, iconCenterY + 80, 110, 0, Math.PI * 2);
1229
+ ctx.fill();
1230
+ }
1231
+ } else {
1232
+ ctx.fillStyle = "#8B8B8B";
1233
+ ctx.fill();
1234
+
1235
+ ctx.restore();
1236
+ ctx.save();
1237
+ ctx.fillStyle = "#FFFFFF";
1238
+ const iconCenterX = profileX + profileSize / 2;
1239
+ const iconCenterY = profileY + profileSize / 2;
1240
+
1241
+ ctx.beginPath();
1242
+ ctx.arc(iconCenterX, iconCenterY - 40, 60, 0, Math.PI * 2);
1243
+ ctx.fill();
1244
+
1245
+ ctx.beginPath();
1246
+ ctx.arc(iconCenterX, iconCenterY + 80, 110, 0, Math.PI * 2);
1247
+ ctx.fill();
1248
+ }
1249
+ ctx.restore();
1250
+
1251
+ const indicatorWidth = 250;
1252
+ const indicatorHeight = 10;
1253
+ const indicatorX = (canvasWidth - indicatorWidth) / 2;
1254
+ const indicatorY = canvasHeight - indicatorHeight - 10;
1255
+ ctx.fillStyle = "#FFFFFF";
1256
+ drawRoundRect(
1257
+ ctx,
1258
+ indicatorX,
1259
+ indicatorY,
1260
+ indicatorWidth,
1261
+ indicatorHeight,
1262
+ indicatorHeight / 2
1263
+ );
1264
+ ctx.fill();
1265
+
1266
+ return canvas.toBuffer("image/png");
1267
+ } catch (error) {
1268
+ console.error("Gagal membuat gambar fake call:", error);
1269
+ return null;
1270
+ }
1271
+ }
1272
+
1273
+ async function generateFakeCallAndroid(options = {}) {
1274
+ const {
1275
+ name = "Bubub",
1276
+ duration = "02:20:19",
1277
+ statusBarTime = "19:51",
1278
+ profileImageUrl = null,
1279
+ textColor = "#FFFFFF",
1280
+ fontName = "Arial",
1281
+ } = options;
1282
+ try {
1283
+ const canvasWidth = 560;
1284
+ const canvasHeight = 1280;
1285
+ const canvas = createCanvas(canvasWidth, canvasHeight);
1286
+ const ctx = canvas.getContext("2d");
1287
+ const iconPath = (iconName) =>
1288
+ path.join(__dirname, "..", "assets", "images", "android", iconName);
1289
+
1290
+ const backgroundImage = await loadImage(iconPath("background-call.jpg"));
1291
+ ctx.drawImage(backgroundImage, 0, 0, canvasWidth, canvasHeight);
1292
+
1293
+ ctx.fillStyle = textColor;
1294
+ ctx.font = `20px "${fontName}"`;
1295
+ ctx.textAlign = "left";
1296
+ ctx.textBaseline = "middle";
1297
+ ctx.fillText(statusBarTime, 15, 14);
1298
+
1299
+ const nameY = 75;
1300
+ ctx.fillStyle = textColor;
1301
+ ctx.font = `bold 30px Arial`;
1302
+ ctx.textAlign = "center";
1303
+ ctx.fillText(name, canvasWidth / 2, nameY);
1304
+
1305
+ const timerY = 110;
1306
+ ctx.fillStyle = "#CCCCCC";
1307
+ ctx.font = `15px "${fontName}"`;
1308
+ ctx.textAlign = "center";
1309
+ ctx.fillText(duration, canvasWidth / 2, timerY);
1310
+
1311
+ const profileSize = 250;
1312
+ const profileX = (canvasWidth - profileSize) / 2;
1313
+ const profileY = 450;
1314
+
1315
+ ctx.save();
1316
+ ctx.beginPath();
1317
+ ctx.arc(
1318
+ profileX + profileSize / 2,
1319
+ profileY + profileSize / 2,
1320
+ profileSize / 2,
1321
+ 0,
1322
+ Math.PI * 2
1323
+ );
1324
+ ctx.closePath();
1325
+
1326
+ if (profileImageUrl) {
1327
+ try {
1328
+ const profileImage = await loadImage(profileImageUrl);
1329
+
1330
+ const imgRatio = profileImage.width / profileImage.height;
1331
+ const frameRatio = 1; // lingkaran → kotak
1332
+
1333
+ let srcX = 0,
1334
+ srcY = 0,
1335
+ srcW = profileImage.width,
1336
+ srcH = profileImage.height;
1337
+
1338
+ if (imgRatio > frameRatio) {
1339
+ // gambar lebih lebar → crop kiri kanan
1340
+ srcW = profileImage.height;
1341
+ srcX = (profileImage.width - srcW) / 2;
1342
+ } else {
1343
+ // gambar lebih tinggi → crop atas bawah
1344
+ srcH = profileImage.width;
1345
+ srcY = (profileImage.height - srcH) / 2;
1346
+ }
1347
+
1348
+ ctx.clip();
1349
+ ctx.drawImage(
1350
+ profileImage,
1351
+ srcX,
1352
+ srcY,
1353
+ srcW,
1354
+ srcH,
1355
+ profileX,
1356
+ profileY,
1357
+ profileSize,
1358
+ profileSize
1359
+ );
1360
+ } catch (err) {
1361
+ console.error("Gagal memuat gambar profil:", err);
1362
+
1363
+ ctx.fillStyle = "#8B8B8B";
1364
+ ctx.fill();
1365
+
1366
+ ctx.restore();
1367
+ ctx.save();
1368
+ ctx.fillStyle = "#FFFFFF";
1369
+
1370
+ const iconCenterX = profileX + profileSize / 2;
1371
+ const iconCenterY = profileY + profileSize / 2;
1372
+
1373
+ ctx.beginPath();
1374
+ ctx.arc(iconCenterX, iconCenterY - 25, 38, 0, Math.PI * 2);
1375
+ ctx.fill();
1376
+
1377
+ ctx.beginPath();
1378
+ ctx.arc(iconCenterX, iconCenterY + 50, 69, 0, Math.PI * 2);
1379
+ ctx.fill();
1380
+ }
1381
+ } else {
1382
+ ctx.fillStyle = "#8B8B8B";
1383
+ ctx.fill();
1384
+
1385
+ ctx.restore();
1386
+ ctx.save();
1387
+ ctx.fillStyle = "#FFFFFF";
1388
+ const iconCenterX = profileX + profileSize / 2;
1389
+ const iconCenterY = profileY + profileSize / 2;
1390
+
1391
+ ctx.beginPath();
1392
+ ctx.arc(iconCenterX, iconCenterY - 25, 38, 0, Math.PI * 2);
1393
+ ctx.fill();
1394
+
1395
+ ctx.beginPath();
1396
+ ctx.arc(iconCenterX, iconCenterY + 50, 69, 0, Math.PI * 2);
1397
+ ctx.fill();
1398
+ }
1399
+ ctx.restore();
1400
+
1401
+ return canvas.toBuffer("image/png");
1402
+ } catch (error) {
1403
+ console.error("Gagal membuat gambar fake call:", error);
1404
+ return null;
1405
+ }
1406
+ }
1407
+
1408
+ async function generateInstagramProfile(options = {}) {
1409
+ const {
1410
+ username = "w.Iruka",
1411
+ displayName = "w.Iruka イルカ",
1412
+ note = "What's new?",
1413
+ posts = "1",
1414
+ followers = "399",
1415
+ following = "29",
1416
+ bio = "Nothing special, never stop flying... GET OUT!!!",
1417
+ verified = true,
1418
+ profileImageUrl = null,
1419
+ bgColor = "#000000",
1420
+ textColor = "#FFFFFF",
1421
+ secondaryTextColor = "#A8A8A8",
1422
+ } = options;
1423
+
1424
+ try {
1425
+ const baseHeight = 530;
1426
+ const canvasWidth = 1080;
1427
+ const tempCanvas = createCanvas(canvasWidth, baseHeight);
1428
+ const tempCtx = tempCanvas.getContext("2d");
1429
+
1430
+ tempCtx.font = '26px "SfProDisplay", sans-serif';
1431
+ const bioLines = wrapText(tempCtx, bio, 1010);
1432
+ const bioLineHeight = 38;
1433
+ const bioHeight = bioLines.length * bioLineHeight;
1434
+
1435
+ const bottomPadding = 60;
1436
+ const canvasHeight = 430 + bioHeight + bottomPadding;
1437
+
1438
+ const canvas = createCanvas(canvasWidth, canvasHeight);
1439
+ const ctx = canvas.getContext("2d");
1440
+
1441
+ ctx.fillStyle = bgColor;
1442
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
1443
+
1444
+ const headerCenterY = 100;
1445
+
1446
+ ctx.strokeStyle = textColor;
1447
+ ctx.lineWidth = 4;
1448
+ ctx.lineCap = "round";
1449
+
1450
+ const plusCenterX = 45;
1451
+ const plusHalfSize = 18;
1452
+
1453
+ ctx.beginPath();
1454
+ ctx.moveTo(plusCenterX - plusHalfSize, headerCenterY - 8);
1455
+ ctx.lineTo(plusCenterX + plusHalfSize, headerCenterY - 8);
1456
+ ctx.stroke();
1457
+
1458
+ ctx.beginPath();
1459
+ ctx.moveTo(plusCenterX, headerCenterY - plusHalfSize - 8);
1460
+ ctx.lineTo(plusCenterX, headerCenterY + plusHalfSize - 8);
1461
+ ctx.stroke();
1462
+
1463
+ ctx.fillStyle = textColor;
1464
+ ctx.strokeStyle = textColor;
1465
+ ctx.lineWidth = 1;
1466
+ ctx.font = '42px "SfProDisplay", sans-serif';
1467
+ ctx.textAlign = "left";
1468
+
1469
+ const usernameMetrics = ctx.measureText(username);
1470
+ const usernameWidth = usernameMetrics.width;
1471
+ const ascent = usernameMetrics.actualBoundingBoxAscent || 0;
1472
+ const descent = usernameMetrics.actualBoundingBoxDescent || 0;
1473
+ const centerOffset = (descent - ascent) / 2;
1474
+
1475
+ const dropSize = 10;
1476
+ const dropdownWidth = dropSize * 2;
1477
+ const gapUsernameToDropdown = 15;
1478
+
1479
+ const groupWidth = usernameWidth + gapUsernameToDropdown + dropdownWidth;
1480
+ const groupStartX = (canvasWidth - groupWidth) / 2;
1481
+
1482
+ const usernameX = groupStartX;
1483
+ const dropdownCenterX =
1484
+ usernameX + usernameWidth + gapUsernameToDropdown + dropSize;
1485
+ const usernameBaselineY = headerCenterY - centerOffset;
1486
+
1487
+ ctx.fillText(username, usernameX, usernameBaselineY - 11);
1488
+ ctx.strokeText(username, usernameX, usernameBaselineY - 11);
1489
+
1490
+ ctx.strokeStyle = textColor;
1491
+ ctx.lineWidth = 5;
1492
+ ctx.lineCap = "round";
1493
+
1494
+ ctx.beginPath();
1495
+ ctx.moveTo(dropdownCenterX - dropSize, headerCenterY - dropSize / 2 - 8);
1496
+ ctx.lineTo(dropdownCenterX, headerCenterY + dropSize / 2 - 8);
1497
+ ctx.lineTo(dropdownCenterX + dropSize, headerCenterY - dropSize / 2 - 8);
1498
+ ctx.stroke();
1499
+
1500
+ const menuX = 1000;
1501
+ const menuYStart = headerCenterY - 25;
1502
+
1503
+ ctx.lineWidth = 4;
1504
+ for (let i = 0; i < 3; i++) {
1505
+ ctx.beginPath();
1506
+ ctx.moveTo(menuX, menuYStart + i * 16);
1507
+ ctx.lineTo(menuX + 45, menuYStart + i * 16);
1508
+ ctx.stroke();
1509
+ }
1510
+
1511
+ const profileSize = 160;
1512
+ const profileX = 80;
1513
+ const profileY = 165;
1514
+
1515
+ ctx.save();
1516
+ ctx.beginPath();
1517
+ ctx.arc(
1518
+ profileX + profileSize / 2,
1519
+ profileY + profileSize / 2,
1520
+ profileSize / 2,
1521
+ 0,
1522
+ Math.PI * 2
1523
+ );
1524
+ ctx.closePath();
1525
+ ctx.clip();
1526
+
1527
+ if (profileImageUrl) {
1528
+ const profileImage = await loadImage(profileImageUrl);
1529
+
1530
+ const imgRatio = profileImage.width / profileImage.height;
1531
+ const frameRatio = 1; // avatar IG selalu kotak
1532
+
1533
+ let srcX = 0,
1534
+ srcY = 0,
1535
+ srcW = profileImage.width,
1536
+ srcH = profileImage.height;
1537
+
1538
+ if (imgRatio > frameRatio) {
1539
+ // gambar lebih lebar → crop kiri kanan
1540
+ srcW = profileImage.height;
1541
+ srcX = (profileImage.width - srcW) / 2;
1542
+ } else {
1543
+ // gambar lebih tinggi → crop atas bawah
1544
+ srcH = profileImage.width;
1545
+ srcY = (profileImage.height - srcH) / 2;
1546
+ }
1547
+
1548
+ ctx.drawImage(
1549
+ profileImage,
1550
+ srcX,
1551
+ srcY,
1552
+ srcW,
1553
+ srcH,
1554
+ profileX,
1555
+ profileY,
1556
+ profileSize,
1557
+ profileSize
1558
+ );
1559
+ } else {
1560
+ ctx.fillStyle = "#262626";
1561
+ ctx.fillRect(profileX, profileY, profileSize, profileSize);
1562
+ }
1563
+ ctx.restore();
1564
+
1565
+ const plusButtonX = profileX + profileSize - 15;
1566
+ const plusButtonY = profileY + profileSize - 15;
1567
+
1568
+ ctx.fillStyle = "#FFFFFF";
1569
+ ctx.beginPath();
1570
+ ctx.arc(plusButtonX, plusButtonY, 25, 0, Math.PI * 2);
1571
+ ctx.fill();
1572
+
1573
+ ctx.strokeStyle = bgColor;
1574
+ ctx.lineWidth = 4;
1575
+ ctx.stroke();
1576
+
1577
+ ctx.strokeStyle = "#000000";
1578
+ ctx.lineWidth = 3.5;
1579
+ ctx.beginPath();
1580
+ ctx.moveTo(plusButtonX - 10, plusButtonY);
1581
+ ctx.lineTo(plusButtonX + 10, plusButtonY);
1582
+ ctx.stroke();
1583
+ ctx.beginPath();
1584
+ ctx.moveTo(plusButtonX, plusButtonY - 10);
1585
+ ctx.lineTo(plusButtonX, plusButtonY + 10);
1586
+ ctx.stroke();
1587
+
1588
+ if (note) {
1589
+ const noteX = 80;
1590
+ const noteY = 140;
1591
+ ctx.font = '22px "SfProDisplay", sans-serif';
1592
+ const noteTextWidth = ctx.measureText(note).width;
1593
+ const notePadding = 20;
1594
+ const noteWidth = noteTextWidth + notePadding * 2;
1595
+ const noteHeight = 42;
1596
+ const noteRadius = 21;
1597
+
1598
+ ctx.fillStyle = "#262626";
1599
+ ctx.beginPath();
1600
+ ctx.moveTo(noteX + noteRadius, noteY);
1601
+ ctx.lineTo(noteX + noteWidth - noteRadius, noteY);
1602
+ ctx.quadraticCurveTo(
1603
+ noteX + noteWidth,
1604
+ noteY,
1605
+ noteX + noteWidth,
1606
+ noteY + noteRadius
1607
+ );
1608
+ ctx.lineTo(noteX + noteWidth, noteY + noteHeight - noteRadius);
1609
+ ctx.quadraticCurveTo(
1610
+ noteX + noteWidth,
1611
+ noteY + noteHeight,
1612
+ noteX + noteWidth - noteRadius,
1613
+ noteY + noteHeight
1614
+ );
1615
+ ctx.lineTo(noteX + noteRadius, noteY + noteHeight);
1616
+ ctx.quadraticCurveTo(
1617
+ noteX,
1618
+ noteY + noteHeight,
1619
+ noteX,
1620
+ noteY + noteHeight - noteRadius
1621
+ );
1622
+ ctx.lineTo(noteX, noteY + noteRadius);
1623
+ ctx.quadraticCurveTo(noteX, noteY, noteX + noteRadius, noteY);
1624
+ ctx.closePath();
1625
+ ctx.fill();
1626
+
1627
+ ctx.fillStyle = secondaryTextColor;
1628
+ ctx.textAlign = "left";
1629
+ ctx.textBaseline = "middle";
1630
+ ctx.fillText(note, noteX + notePadding, noteY + noteHeight / 2);
1631
+ }
1632
+
1633
+ ctx.textAlign = "left";
1634
+ ctx.fillStyle = textColor;
1635
+ ctx.strokeStyle = textColor;
1636
+ ctx.lineWidth = 1;
1637
+ ctx.font = 'bold 32px "SfProDisplay", sans-serif';
1638
+ const nameX = 330;
1639
+ const nameY = 190;
1640
+ ctx.fillText(displayName, nameX, nameY);
1641
+ ctx.strokeText(displayName, nameX, nameY);
1642
+
1643
+ if (verified) {
1644
+ const textLength = ctx.measureText(displayName).width;
1645
+ const verifiedIcon = await loadImage(
1646
+ path.join(
1647
+ __dirname,
1648
+ "..",
1649
+ "assets",
1650
+ "images",
1651
+ "instagram",
1652
+ "instagram-verified.png"
1653
+ )
1654
+ );
1655
+ const iconSize = 30;
1656
+ const badgeX = nameX + textLength + 15;
1657
+ const badgeY = nameY - iconSize + 15;
1658
+ ctx.drawImage(verifiedIcon, badgeX, badgeY, iconSize, iconSize);
1659
+ }
1660
+
1661
+ const statsY = 265;
1662
+ const statsX1 = 330;
1663
+ const statsX2 = 550;
1664
+ const statsX3 = 800;
1665
+
1666
+ ctx.fillStyle = textColor;
1667
+ ctx.strokeStyle = textColor;
1668
+ ctx.lineWidth = 1;
1669
+ ctx.font = 'bold 32px "SfProDisplay", sans-serif';
1670
+ ctx.textBaseline = "alphabetic";
1671
+
1672
+ const postsText = formatStatNumber(posts);
1673
+ const followersText = formatStatNumber(followers);
1674
+ const followingText = formatStatNumber(following);
1675
+
1676
+ ctx.fillText(postsText, statsX1, statsY);
1677
+ ctx.strokeText(postsText, statsX1, statsY);
1678
+
1679
+ ctx.fillText(followersText, statsX2, statsY);
1680
+ ctx.strokeText(followersText, statsX2, statsY);
1681
+
1682
+ ctx.fillText(followingText, statsX3, statsY);
1683
+ ctx.strokeText(followingText, statsX3, statsY);
1684
+
1685
+ ctx.font = '24px "SfProDisplay", sans-serif';
1686
+ ctx.fillText("posts", statsX1, statsY + 40);
1687
+ ctx.fillText("followers", statsX2, statsY + 40);
1688
+ ctx.fillText("following", statsX3, statsY + 40);
1689
+
1690
+ let currentY = 430;
1691
+ ctx.fillStyle = textColor;
1692
+ ctx.font = '26px "SfProDisplay", sans-serif';
1693
+ ctx.textAlign = "left";
1694
+
1695
+ bioLines.forEach((line) => {
1696
+ ctx.fillText(line, 35, currentY);
1697
+ currentY += bioLineHeight;
1698
+ });
1699
+
1700
+ return canvas.toBuffer("image/png");
1701
+ } catch (error) {
1702
+ console.error("Gagal membuat Instagram profile:", error);
1703
+ return null;
1704
+ }
1705
+ }
1706
+
1707
+ async function generateTiktokProfile(options = {}) {
1708
+ const {
1709
+ username = "w.Iruka",
1710
+ displayName = "w.Iruka イルカ",
1711
+ posts = "1",
1712
+ followers = "399",
1713
+ following = "29",
1714
+ bio = "Nothing special, never stop flying... GET OUT!!!",
1715
+ verified = true,
1716
+ profileImageUrl = null,
1717
+ bgColor = "#000000",
1718
+ textColor = "#FFFFFF",
1719
+ secondaryTextColor = "#626262",
1720
+ } = options;
1721
+
1722
+ try {
1723
+ const canvasWidth = 1080;
1724
+
1725
+ const tempCanvas = createCanvas(canvasWidth, 530);
1726
+ const tempCtx = tempCanvas.getContext("2d");
1727
+ tempCtx.font = '26px "NotoRegular", sans-serif';
1728
+
1729
+ const bioLines = wrapText(tempCtx, bio, 1010);
1730
+ const bioLineHeight = 38;
1731
+ const bioHeight = bioLines.length * bioLineHeight;
1732
+
1733
+ const profileSize = 160;
1734
+ const topBottomPadding = 60;
1735
+
1736
+ const displayNameFontSize = 32;
1737
+ const usernameFontSize = 26;
1738
+ const statsNumberFontSize = 32;
1739
+ const statsLabelFontSize = 24;
1740
+
1741
+ const gapProfileToDisplayName = 25;
1742
+ const gapDisplayNameToUsername = 10;
1743
+ const gapUsernameToStats = 35;
1744
+ const gapStatsToBio = 60;
1745
+ const gapNumberToLabel = 10;
1746
+
1747
+ const statsBlockHeight =
1748
+ statsNumberFontSize + gapNumberToLabel + statsLabelFontSize;
1749
+
1750
+ const contentHeight =
1751
+ profileSize +
1752
+ gapProfileToDisplayName +
1753
+ displayNameFontSize +
1754
+ gapDisplayNameToUsername +
1755
+ usernameFontSize +
1756
+ gapUsernameToStats +
1757
+ statsBlockHeight +
1758
+ gapStatsToBio +
1759
+ bioHeight;
1760
+
1761
+ const canvasHeight = contentHeight + topBottomPadding * 2;
1762
+
1763
+ const canvas = createCanvas(canvasWidth, canvasHeight);
1764
+ const ctx = canvas.getContext("2d");
1765
+
1766
+ ctx.fillStyle = bgColor;
1767
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
1768
+
1769
+ const centerX = canvasWidth / 2;
1770
+ let currentY = topBottomPadding;
1771
+
1772
+ const profileX = centerX - profileSize / 2;
1773
+ const profileY = currentY;
1774
+
1775
+ ctx.save();
1776
+ ctx.beginPath();
1777
+ ctx.arc(
1778
+ profileX + profileSize / 2,
1779
+ profileY + profileSize / 2,
1780
+ profileSize / 2,
1781
+ 0,
1782
+ Math.PI * 2
1783
+ );
1784
+ ctx.closePath();
1785
+ ctx.clip();
1786
+
1787
+ if (profileImageUrl) {
1788
+ const profileImage = await loadImage(profileImageUrl);
1789
+
1790
+ const imgRatio = profileImage.width / profileImage.height;
1791
+ const frameRatio = 1; // avatar selalu kotak
1792
+
1793
+ let srcX = 0,
1794
+ srcY = 0,
1795
+ srcW = profileImage.width,
1796
+ srcH = profileImage.height;
1797
+
1798
+ if (imgRatio > frameRatio) {
1799
+ // gambar lebih lebar → crop kiri kanan
1800
+ srcW = profileImage.height;
1801
+ srcX = (profileImage.width - srcW) / 2;
1802
+ } else {
1803
+ // gambar lebih tinggi → crop atas bawah
1804
+ srcH = profileImage.width;
1805
+ srcY = (profileImage.height - srcH) / 2;
1806
+ }
1807
+
1808
+ ctx.drawImage(
1809
+ profileImage,
1810
+ srcX,
1811
+ srcY,
1812
+ srcW,
1813
+ srcH,
1814
+ profileX,
1815
+ profileY,
1816
+ profileSize,
1817
+ profileSize
1818
+ );
1819
+ } else {
1820
+ ctx.fillStyle = "#262626";
1821
+ ctx.fillRect(profileX, profileY, profileSize, profileSize);
1822
+ }
1823
+ ctx.restore();
1824
+
1825
+ currentY += profileSize + gapProfileToDisplayName;
1826
+
1827
+ ctx.textAlign = "center";
1828
+ ctx.textBaseline = "alphabetic";
1829
+ ctx.fillStyle = textColor;
1830
+ ctx.strokeStyle = textColor;
1831
+ ctx.lineWidth = 1;
1832
+ ctx.font = `${displayNameFontSize}px "NotoRegular", sans-serif`;
1833
+
1834
+ const nameY = currentY + displayNameFontSize;
1835
+ ctx.fillText(displayName, centerX, nameY);
1836
+ ctx.strokeText(displayName, centerX, nameY);
1837
+
1838
+ currentY = nameY + gapDisplayNameToUsername;
1839
+
1840
+ ctx.strokeStyle = secondaryTextColor;
1841
+ ctx.lineWidth = 1;
1842
+ ctx.font = `${usernameFontSize}px "NotoRegular", sans-serif`;
1843
+ ctx.fillStyle = secondaryTextColor;
1844
+
1845
+ const usernameText = `@${username}`;
1846
+ const usernameTextWidth = ctx.measureText(usernameText).width;
1847
+
1848
+ const usernameY = currentY + usernameFontSize;
1849
+
1850
+ ctx.fillText(usernameText, centerX - 10, usernameY);
1851
+ ctx.strokeText(usernameText, centerX - 10, usernameY);
1852
+
1853
+ if (verified) {
1854
+ const verifiedIcon = await loadImage(
1855
+ path.join(
1856
+ __dirname,
1857
+ "..",
1858
+ "assets",
1859
+ "images",
1860
+ "tiktok",
1861
+ "tiktok-verified.png"
1862
+ )
1863
+ );
1864
+ const iconSize = 20;
1865
+
1866
+ const badgeX = centerX + usernameTextWidth / 2;
1867
+ const badgeY = usernameY - iconSize + 3;
1868
+
1869
+ ctx.drawImage(verifiedIcon, badgeX, badgeY, iconSize, iconSize);
1870
+ }
1871
+
1872
+ currentY = usernameY + gapUsernameToStats;
1873
+
1874
+ ctx.fillStyle = textColor;
1875
+ ctx.strokeStyle = textColor;
1876
+ ctx.lineWidth = 1;
1877
+ ctx.font = `bold ${statsNumberFontSize}px "NotoRegular", sans-serif`;
1878
+
1879
+ const statsNumbersY = currentY + statsNumberFontSize;
1880
+ const statsSpacing = 200;
1881
+
1882
+ const postsX = centerX - statsSpacing;
1883
+ const followersX = centerX;
1884
+ const followingX = centerX + statsSpacing;
1885
+
1886
+ const postsText = formatStatNumber(posts);
1887
+ const followersText = formatStatNumber(followers);
1888
+ const followingText = formatStatNumber(following);
1889
+
1890
+ ctx.fillText(postsText, postsX, statsNumbersY);
1891
+ ctx.strokeText(postsText, postsX, statsNumbersY);
1892
+
1893
+ ctx.fillText(followersText, followersX, statsNumbersY);
1894
+ ctx.strokeText(followersText, followersX, statsNumbersY);
1895
+
1896
+ ctx.fillText(followingText, followingX, statsNumbersY);
1897
+ ctx.strokeText(followingText, followingX, statsNumbersY);
1898
+
1899
+ ctx.fillStyle = secondaryTextColor;
1900
+ ctx.font = `${statsLabelFontSize}px "NotoRegular", sans-serif`;
1901
+ const statsLabelsY = statsNumbersY + gapNumberToLabel + statsLabelFontSize;
1902
+
1903
+ ctx.fillText("Following", postsX, statsLabelsY);
1904
+ ctx.fillText("Followers", followersX, statsLabelsY);
1905
+ ctx.fillText("Likes", followingX, statsLabelsY);
1906
+
1907
+ currentY = statsLabelsY + gapStatsToBio;
1908
+
1909
+ ctx.fillStyle = textColor;
1910
+ ctx.font = '26px "NotoRegular", sans-serif';
1911
+ ctx.textAlign = "center";
1912
+
1913
+ let bioY = currentY;
1914
+ bioLines.forEach((line) => {
1915
+ ctx.fillText(line, centerX, bioY);
1916
+ bioY += bioLineHeight;
1917
+ });
1918
+
1919
+ return canvas.toBuffer("image/png");
1920
+ } catch (error) {
1921
+ console.error("Gagal membuat TikTok profile:", error);
1922
+ return null;
1923
+ }
1924
+ }
1925
+
1926
+ async function generateTwitterProfile(options = {}) {
1927
+ const {
1928
+ username = "ExynAwA",
1929
+ displayName = "Exyn AwA",
1930
+ bio = "Nothing special, never stop flying...",
1931
+ followers = 0,
1932
+ following = 0,
1933
+ born = "January 1, 2000",
1934
+ joined = "January 2025",
1935
+ verified = false,
1936
+ profileImageUrl = null,
1937
+ bannerImageUrl = null,
1938
+ } = options;
1939
+
1940
+ const width = 1080;
1941
+ const bannerHeight = 300;
1942
+ const paddingX = 60;
1943
+
1944
+ const tempCanvas = createCanvas(width, 10);
1945
+ const tempCtx = tempCanvas.getContext("2d");
1946
+ tempCtx.font = '30px "SfProDisplay", sans-serif';
1947
+
1948
+ const bioLines = wrapText(tempCtx, bio, width - paddingX * 2);
1949
+ const bioHeight = bioLines.length * 40;
1950
+
1951
+ const canvasHeight = bannerHeight + 120 + 260 + bioHeight + 60;
1952
+
1953
+ const canvas = createCanvas(width, canvasHeight);
1954
+ const ctx = canvas.getContext("2d");
1955
+
1956
+ if (bannerImageUrl) {
1957
+ const banner = await loadImage(bannerImageUrl);
1958
+
1959
+ const imgRatio = banner.width / banner.height;
1960
+ const frameRatio = width / bannerHeight;
1961
+
1962
+ let srcX = 0,
1963
+ srcY = 0,
1964
+ srcW = banner.width,
1965
+ srcH = banner.height;
1966
+
1967
+ if (imgRatio > frameRatio) {
1968
+ // banner lebih lebar → crop kiri kanan
1969
+ srcW = banner.height * frameRatio;
1970
+ srcX = (banner.width - srcW) / 2;
1971
+ } else {
1972
+ // banner lebih tinggi → crop atas bawah
1973
+ srcH = banner.width / frameRatio;
1974
+ srcY = (banner.height - srcH) / 2;
1975
+ }
1976
+
1977
+ ctx.drawImage(banner, srcX, srcY, srcW, srcH, 0, 0, width, bannerHeight);
1978
+ } else {
1979
+ ctx.fillStyle = "#1DA1F2";
1980
+ ctx.fillRect(0, 0, width, bannerHeight);
1981
+ }
1982
+
1983
+ ctx.fillStyle = "#000000";
1984
+ ctx.fillRect(0, bannerHeight, width, canvasHeight - bannerHeight);
1985
+
1986
+ const avatarSize = 170;
1987
+ const avatarX = paddingX;
1988
+ const avatarY = bannerHeight - avatarSize / 2;
1989
+ const avatarCenterX = avatarX + avatarSize / 2;
1990
+ const avatarCenterY = avatarY + avatarSize / 2;
1991
+
1992
+ ctx.beginPath();
1993
+ ctx.arc(avatarCenterX, avatarCenterY, avatarSize / 2 + 6, 0, Math.PI * 2);
1994
+ ctx.fillStyle = "#000000";
1995
+ ctx.fill();
1996
+
1997
+ ctx.save();
1998
+ ctx.beginPath();
1999
+ ctx.arc(avatarCenterX, avatarCenterY, avatarSize / 2, 0, Math.PI * 2);
2000
+ ctx.clip();
2001
+
2002
+ if (profileImageUrl) {
2003
+ const avatar = await loadImage(profileImageUrl);
2004
+
2005
+ const imgRatio = avatar.width / avatar.height;
2006
+ const frameRatio = 1; // kotak
2007
+
2008
+ let srcX = 0,
2009
+ srcY = 0,
2010
+ srcW = avatar.width,
2011
+ srcH = avatar.height;
2012
+
2013
+ if (imgRatio > frameRatio) {
2014
+ // lebih lebar → crop kiri kanan
2015
+ srcW = avatar.height;
2016
+ srcX = (avatar.width - srcW) / 2;
2017
+ } else {
2018
+ // lebih tinggi → crop atas bawah
2019
+ srcH = avatar.width;
2020
+ srcY = (avatar.height - srcH) / 2;
2021
+ }
2022
+
2023
+ ctx.drawImage(
2024
+ avatar,
2025
+ srcX,
2026
+ srcY,
2027
+ srcW,
2028
+ srcH,
2029
+ avatarX,
2030
+ avatarY,
2031
+ avatarSize,
2032
+ avatarSize
2033
+ );
2034
+ } else {
2035
+ ctx.fillStyle = "#2F3336";
2036
+ ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize);
2037
+ }
2038
+ ctx.restore();
2039
+
2040
+ let y = avatarY + avatarSize + 60;
2041
+
2042
+ ctx.strokeStyle = "#FFFFFF";
2043
+ ctx.lineWidth = 1.5;
2044
+ ctx.font = 'bold 42px "SfProDisplay", sans-serif';
2045
+ ctx.fillStyle = "#FFFFFF";
2046
+ ctx.fillText(displayName, avatarX, y);
2047
+ ctx.strokeText(displayName, avatarX, y);
2048
+
2049
+ const nameWidth = ctx.measureText(displayName).width;
2050
+
2051
+ if (verified) {
2052
+ const badge = await loadImage(
2053
+ path.join(__dirname, "../assets/images/tweet/twitter-verified.png")
2054
+ );
2055
+ ctx.drawImage(badge, avatarX + nameWidth + 14, y - 32, 32, 32);
2056
+ }
2057
+
2058
+ y += 46;
2059
+ ctx.font = '28px "SfProDisplay", sans-serif';
2060
+ ctx.fillStyle = "#8899A6";
2061
+ ctx.fillText("@" + username, avatarX, y);
2062
+
2063
+ y += 44;
2064
+ ctx.font = '26px "SfProDisplay", sans-serif';
2065
+ ctx.fillText(`🎂 Born ${born}`, avatarX, y);
2066
+ ctx.fillText(`📅 Joined ${joined}`, avatarX + 340, y);
2067
+
2068
+ y += 56;
2069
+
2070
+ const followingText = formatStatNumber(following);
2071
+ const followersText = formatStatNumber(followers);
2072
+
2073
+ ctx.textBaseline = "alphabetic";
2074
+
2075
+ ctx.strokeStyle = "#FFFFFF";
2076
+ ctx.lineWidth = 1;
2077
+ ctx.font = 'bold 30px "SfProDisplay", sans-serif';
2078
+ ctx.fillStyle = "#FFFFFF";
2079
+ ctx.fillText(followingText, avatarX, y);
2080
+ ctx.strokeText(followingText, avatarX, y);
2081
+
2082
+ const followingWidth = ctx.measureText(followingText).width;
2083
+
2084
+ ctx.font = '26px "SfProDisplay", sans-serif';
2085
+ ctx.fillStyle = "#8899A6";
2086
+ ctx.fillText(" Following", avatarX + followingWidth + 6, y);
2087
+
2088
+ const followingBlockWidth =
2089
+ followingWidth + ctx.measureText(" Following").width;
2090
+
2091
+ const blockGap = 28;
2092
+
2093
+ ctx.strokeStyle = "#FFFFFF";
2094
+ ctx.lineWidth = 1;
2095
+ ctx.font = 'bold 30px "SfProDisplay", sans-serif';
2096
+ ctx.fillStyle = "#FFFFFF";
2097
+ ctx.fillText(followersText, avatarX + followingBlockWidth + blockGap, y);
2098
+ ctx.strokeText(followersText, avatarX + followingBlockWidth + blockGap, y);
2099
+
2100
+ const followersWidth = ctx.measureText(followersText).width;
2101
+
2102
+ ctx.font = '26px "SfProDisplay", sans-serif';
2103
+ ctx.fillStyle = "#8899A6";
2104
+ ctx.fillText(
2105
+ " Followers",
2106
+ avatarX + followingBlockWidth + blockGap + followersWidth + 6,
2107
+ y
2108
+ );
2109
+
2110
+ y += 60;
2111
+ ctx.font = '30px "SfProDisplay", sans-serif';
2112
+ ctx.fillStyle = "#FFFFFF";
2113
+
2114
+ bioLines.forEach((line) => {
2115
+ ctx.fillText(line, avatarX, y);
2116
+ y += 40;
2117
+ });
2118
+
2119
+ return canvas.toBuffer("image/png");
2120
+ }
2121
+
2122
+ async function generateYtProfile(options = {}) {
2123
+ const {
2124
+ username = "Miawaug",
2125
+ displayName = "MiawAug",
2126
+ subscribers = 24800000,
2127
+ videos = 4600,
2128
+ bio = "Disini Kita Akan Seru Seruan bermain game PC, Mobile, dan Console Hehe...",
2129
+ verified = true,
2130
+ profileImageUrl = null,
2131
+ bannerImageUrl = null,
2132
+ } = options;
2133
+
2134
+ const width = 1080;
2135
+ const padding = 48;
2136
+
2137
+ const bannerHeight = 320;
2138
+ const bannerRadius = 24;
2139
+
2140
+ const tempCanvas = createCanvas(width, 10);
2141
+ const tempCtx = tempCanvas.getContext("2d");
2142
+ tempCtx.font = '26px "NotoRegular", sans-serif';
2143
+
2144
+ const bioLines = wrapText(tempCtx, bio, width - padding * 2);
2145
+ const bioHeight = bioLines.length * 34;
2146
+
2147
+ const contentHeight =
2148
+ padding + bannerHeight + padding + 180 + 32 + bioHeight + 60;
2149
+
2150
+ const canvas = createCanvas(width, contentHeight);
2151
+ const ctx = canvas.getContext("2d");
2152
+
2153
+ ctx.fillStyle = "#0F0F0F";
2154
+ ctx.fillRect(0, 0, width, contentHeight);
2155
+
2156
+ const bannerX = padding;
2157
+ const bannerY = padding;
2158
+ const bannerW = width - padding * 2;
2159
+
2160
+ ctx.save();
2161
+ ctx.beginPath();
2162
+ ctx.moveTo(bannerX + bannerRadius, bannerY);
2163
+ ctx.lineTo(bannerX + bannerW - bannerRadius, bannerY);
2164
+ ctx.quadraticCurveTo(
2165
+ bannerX + bannerW,
2166
+ bannerY,
2167
+ bannerX + bannerW,
2168
+ bannerY + bannerRadius
2169
+ );
2170
+ ctx.lineTo(bannerX + bannerW, bannerY + bannerHeight - bannerRadius);
2171
+ ctx.quadraticCurveTo(
2172
+ bannerX + bannerW,
2173
+ bannerY + bannerHeight,
2174
+ bannerX + bannerW - bannerRadius,
2175
+ bannerY + bannerHeight
2176
+ );
2177
+ ctx.lineTo(bannerX + bannerRadius, bannerY + bannerHeight);
2178
+ ctx.quadraticCurveTo(
2179
+ bannerX,
2180
+ bannerY + bannerHeight,
2181
+ bannerX,
2182
+ bannerY + bannerHeight - bannerRadius
2183
+ );
2184
+ ctx.lineTo(bannerX, bannerY + bannerRadius);
2185
+ ctx.quadraticCurveTo(bannerX, bannerY, bannerX + bannerRadius, bannerY);
2186
+ ctx.closePath();
2187
+ ctx.clip();
2188
+
2189
+ if (bannerImageUrl) {
2190
+ const banner = await loadImage(bannerImageUrl);
2191
+
2192
+ const imgRatio = banner.width / banner.height;
2193
+ const frameRatio = bannerW / bannerHeight;
2194
+
2195
+ let srcX = 0,
2196
+ srcY = 0,
2197
+ srcW = banner.width,
2198
+ srcH = banner.height;
2199
+
2200
+ if (imgRatio > frameRatio) {
2201
+ // gambar lebih lebar → crop kiri kanan
2202
+ srcW = banner.height * frameRatio;
2203
+ srcX = (banner.width - srcW) / 2;
2204
+ } else {
2205
+ // gambar lebih tinggi → crop atas bawah
2206
+ srcH = banner.width / frameRatio;
2207
+ srcY = (banner.height - srcH) / 2;
2208
+ }
2209
+
2210
+ ctx.drawImage(
2211
+ banner,
2212
+ srcX,
2213
+ srcY,
2214
+ srcW,
2215
+ srcH,
2216
+ bannerX,
2217
+ bannerY,
2218
+ bannerW,
2219
+ bannerHeight
2220
+ );
2221
+ } else {
2222
+ ctx.fillStyle = "#202020";
2223
+ ctx.fillRect(bannerX, bannerY, bannerW, bannerHeight);
2224
+ }
2225
+ ctx.restore();
2226
+
2227
+ let y = bannerY + bannerHeight + padding;
2228
+
2229
+ const avatarSize = 140;
2230
+ const avatarX = padding;
2231
+ const avatarY = y;
2232
+
2233
+ ctx.save();
2234
+ ctx.beginPath();
2235
+ ctx.arc(
2236
+ avatarX + avatarSize / 2,
2237
+ avatarY + avatarSize / 2,
2238
+ avatarSize / 2,
2239
+ 0,
2240
+ Math.PI * 2
2241
+ );
2242
+ ctx.clip();
2243
+
2244
+ if (profileImageUrl) {
2245
+ const avatar = await loadImage(profileImageUrl);
2246
+
2247
+ const imgRatio = avatar.width / avatar.height;
2248
+ const frameRatio = 1; // kotak
2249
+
2250
+ let srcX = 0,
2251
+ srcY = 0,
2252
+ srcW = avatar.width,
2253
+ srcH = avatar.height;
2254
+
2255
+ if (imgRatio > frameRatio) {
2256
+ // lebih lebar → crop kiri kanan
2257
+ srcW = avatar.height;
2258
+ srcX = (avatar.width - srcW) / 2;
2259
+ } else {
2260
+ // lebih tinggi → crop atas bawah
2261
+ srcH = avatar.width;
2262
+ srcY = (avatar.height - srcH) / 2;
2263
+ }
2264
+
2265
+ ctx.drawImage(
2266
+ avatar,
2267
+ srcX,
2268
+ srcY,
2269
+ srcW,
2270
+ srcH,
2271
+ avatarX,
2272
+ avatarY,
2273
+ avatarSize,
2274
+ avatarSize
2275
+ );
2276
+ } else {
2277
+ ctx.fillStyle = "#303030";
2278
+ ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize);
2279
+ }
2280
+ ctx.restore();
2281
+
2282
+ const textX = avatarX + avatarSize + 24;
2283
+
2284
+ const nameFont = 32;
2285
+ const smallFont = 22;
2286
+ const lineGap = 10;
2287
+
2288
+ const textBlockHeight = nameFont + lineGap + smallFont + lineGap + smallFont;
2289
+
2290
+ let textY = avatarY + avatarSize / 2 - textBlockHeight / 2 + nameFont;
2291
+
2292
+ ctx.strokeStyle = "#FFFFFF";
2293
+ ctx.lineWidth = 1.5;
2294
+ ctx.font = `bold ${nameFont}px "NotoRegular", sans-serif`;
2295
+ ctx.fillStyle = "#FFFFFF";
2296
+ ctx.fillText(displayName, textX, textY);
2297
+ ctx.strokeText(displayName, textX, textY);
2298
+
2299
+ const nameWidth = ctx.measureText(displayName).width;
2300
+
2301
+ if (verified) {
2302
+ const badge = await loadImage(
2303
+ path.join(__dirname, "../assets/images/youtube/youtube-verified.png")
2304
+ );
2305
+ ctx.drawImage(badge, textX + nameWidth + 8, textY - 22, 22, 22);
2306
+ }
2307
+
2308
+ textY += nameFont + lineGap;
2309
+ ctx.font = `${smallFont}px "NotoRegular", sans-serif`;
2310
+ ctx.fillStyle = "#FFFFFF";
2311
+ ctx.fillText("@" + username, textX, textY - 10);
2312
+
2313
+ textY += smallFont + lineGap;
2314
+ ctx.fillStyle = "#AAAAAA";
2315
+ ctx.fillText(
2316
+ `${formatStatNumber(subscribers)} subscribers • ${formatStatNumber(
2317
+ videos
2318
+ )} videos`,
2319
+ textX,
2320
+ textY - 10
2321
+ );
2322
+
2323
+ let bioY = avatarY + avatarSize + 40;
2324
+ ctx.font = '26px "NotoRegular", sans-serif';
2325
+ ctx.fillStyle = "#AAAAAA";
2326
+
2327
+ bioLines.forEach((line) => {
2328
+ ctx.fillText(line, padding, bioY);
2329
+ bioY += 34;
2330
+ });
2331
+
2332
+ return canvas.toBuffer("image/png");
2333
+ }
2334
+
2335
+ async function generateGoogleSearch(options = {}) {
2336
+ const {
2337
+ query = "Contoh Pencarian",
2338
+ description = "Ini adalah teks deskripsi dummy untuk keperluan demo UI Google Search. Konten ini aman dan tidak berasal dari sumber berhak cipta.",
2339
+ imageUrl = null,
2340
+ source = "Demo Source",
2341
+ } = options;
2342
+
2343
+ const width = 956;
2344
+ const height = 1100;
2345
+
2346
+ const canvas = createCanvas(width, height);
2347
+ const ctx = canvas.getContext("2d");
2348
+
2349
+ // ===== BACKGROUND TEMPLATE =====
2350
+ const bg = await loadImage(
2351
+ path.join(__dirname, "../assets/images/google/google-search.jpg")
2352
+ );
2353
+ ctx.drawImage(bg, 0, 0, width, height);
2354
+
2355
+ ctx.fillStyle = "#B0B0B0";
2356
+ ctx.font = 'bold 30px "SfProDisplay", sans-serif';
2357
+ ctx.textAlign = "left";
2358
+ ctx.fillText(query, width / 7 - 5, height / 8 - 15 * 4);
2359
+
2360
+ // ===== QUERY TITLE =====
2361
+ ctx.strokeStyle = "#FFFFFF";
2362
+ ctx.lineWidth = 1.5;
2363
+ ctx.fillStyle = "#FFFFFF";
2364
+ ctx.font = 'bold 34px "SfProDisplay", sans-serif';
2365
+ ctx.textAlign = "left";
2366
+ ctx.fillText(query, 48, height / 4 + 15);
2367
+ ctx.strokeText(query, 48, height / 4 + 15);
2368
+
2369
+ // ===== IMAGE CARD =====
2370
+ const imgX = 36;
2371
+ const imgY = height / 3 - 25;
2372
+ const imgW = width - 72;
2373
+ const imgH = 460;
2374
+ const radius = 25;
2375
+
2376
+ // 1️⃣ FRAME BACKGROUND
2377
+ ctx.fillStyle = "#1E1E1E";
2378
+ ctx.beginPath();
2379
+ ctx.moveTo(imgX + radius, imgY);
2380
+ ctx.lineTo(imgX + imgW - radius, imgY);
2381
+ ctx.quadraticCurveTo(imgX + imgW, imgY, imgX + imgW, imgY + radius);
2382
+ ctx.lineTo(imgX + imgW, imgY + imgH - radius);
2383
+ ctx.quadraticCurveTo(
2384
+ imgX + imgW,
2385
+ imgY + imgH,
2386
+ imgX + imgW - radius,
2387
+ imgY + imgH
2388
+ );
2389
+ ctx.lineTo(imgX + radius, imgY + imgH);
2390
+ ctx.quadraticCurveTo(imgX, imgY + imgH, imgX, imgY + imgH - radius);
2391
+ ctx.lineTo(imgX, imgY + radius);
2392
+ ctx.quadraticCurveTo(imgX, imgY, imgX + radius, imgY);
2393
+ ctx.closePath();
2394
+ ctx.fill();
2395
+
2396
+ // 2️⃣ CLIP ROUND
2397
+ ctx.save();
2398
+ ctx.beginPath();
2399
+ ctx.moveTo(imgX + radius, imgY);
2400
+ ctx.lineTo(imgX + imgW - radius, imgY);
2401
+ ctx.quadraticCurveTo(imgX + imgW, imgY, imgX + imgW, imgY + radius);
2402
+ ctx.lineTo(imgX + imgW, imgY + imgH - radius);
2403
+ ctx.quadraticCurveTo(
2404
+ imgX + imgW,
2405
+ imgY + imgH,
2406
+ imgX + imgW - radius,
2407
+ imgY + imgH
2408
+ );
2409
+ ctx.lineTo(imgX + radius, imgY + imgH);
2410
+ ctx.quadraticCurveTo(imgX, imgY + imgH, imgX, imgY + imgH - radius);
2411
+ ctx.lineTo(imgX, imgY + radius);
2412
+ ctx.quadraticCurveTo(imgX, imgY, imgX + radius, imgY);
2413
+ ctx.closePath();
2414
+ ctx.clip();
2415
+
2416
+ // 3️⃣ CENTER CROP (OBJECT-FIT: COVER)
2417
+ if (imageUrl) {
2418
+ const img = await loadImage(imageUrl);
2419
+
2420
+ const imgRatio = img.width / img.height;
2421
+ const frameRatio = imgW / imgH;
2422
+
2423
+ let srcX = 0,
2424
+ srcY = 0,
2425
+ srcW = img.width,
2426
+ srcH = img.height;
2427
+
2428
+ if (imgRatio > frameRatio) {
2429
+ // gambar lebih lebar → crop kiri kanan
2430
+ srcW = img.height * frameRatio;
2431
+ srcX = (img.width - srcW) / 2;
2432
+ } else {
2433
+ // gambar lebih tinggi → crop atas bawah
2434
+ srcH = img.width / frameRatio;
2435
+ srcY = (img.height - srcH) / 2;
2436
+ }
2437
+
2438
+ ctx.drawImage(img, srcX, srcY, srcW, srcH, imgX, imgY, imgW, imgH);
2439
+ }
2440
+
2441
+ ctx.restore();
2442
+
2443
+ // ===== IMAGE SOURCE OVERLAY =====
2444
+ ctx.fillStyle = "rgba(0,0,0,0.55)";
2445
+ ctx.fillRect(imgX + 18, imgY + imgH - 44, 260, 32);
2446
+
2447
+ ctx.fillStyle = "#FFFFFF";
2448
+ ctx.font = '20px "SfProDisplay", sans-serif';
2449
+ ctx.fillText(`Sumber: ${source}`, imgX + 28, imgY + imgH - 22);
2450
+
2451
+ // ===== DESCRIPTION =====
2452
+ ctx.font = '26px "SfProDisplay", sans-serif';
2453
+ ctx.fillStyle = "#E0E0E0";
2454
+
2455
+ const textX = 48;
2456
+ const textY = imgY + imgH + 56;
2457
+ const maxWidth = width - 96;
2458
+ const lineHeight = 38;
2459
+
2460
+ let line = "";
2461
+ let y = textY;
2462
+ const words = description.split(" ");
2463
+
2464
+ for (let i = 0; i < words.length; i++) {
2465
+ const testLine = line + words[i] + " ";
2466
+ if (ctx.measureText(testLine).width > maxWidth) {
2467
+ ctx.fillText(line, textX, y);
2468
+ line = words[i] + " ";
2469
+ y += lineHeight;
2470
+ } else {
2471
+ line = testLine;
2472
+ }
2473
+ }
2474
+ ctx.fillText(line, textX, y);
2475
+
2476
+ return canvas.toBuffer("image/png");
2477
+ }
2478
+
2479
+ async function generateGoogleLyrics(options = {}) {
2480
+ const {
2481
+ song = "Langkah Kecil",
2482
+ artist = "Demo Artist",
2483
+ lyrics = [
2484
+ "Ini hanya contoh lirik",
2485
+ "Bukan lagu yang sebenarnya",
2486
+ "Digunakan untuk demo UI",
2487
+ "Agar aman dari hak cipta",
2488
+ "Setiap baris hanya placeholder",
2489
+ "Bebas dipakai kapan saja",
2490
+ "",
2491
+ "Ini hanya contoh lirik",
2492
+ "Bukan lagu yang sebenarnya",
2493
+ ],
2494
+ years = "2009",
2495
+ thumbnail = null,
2496
+ } = options;
2497
+
2498
+ const canvasWidth = 1080;
2499
+ const canvasHeight = 981;
2500
+ const canvas = createCanvas(canvasWidth, canvasHeight);
2501
+ const ctx = canvas.getContext("2d");
2502
+
2503
+ // ===== BACKGROUND =====
2504
+ const backgroundImage = await loadImage(
2505
+ path.join(__dirname, "../assets/images/google/google-lyrics.jpg")
2506
+ );
2507
+ ctx.drawImage(backgroundImage, 0, 0, canvasWidth, canvasHeight);
2508
+
2509
+ ctx.fillStyle = "#B0B0B0";
2510
+ ctx.font = 'bold 30px "SfProDisplay", sans-serif';
2511
+ ctx.textAlign = "left";
2512
+ ctx.fillText(song, canvasWidth / 7 - 6, canvasHeight / 8 - 40);
2513
+
2514
+ // ===== TITLE =====
2515
+ ctx.strokeStyle = "#FFFFFF";
2516
+ ctx.lineWidth = 1.5;
2517
+ ctx.fillStyle = "#FFFFFF";
2518
+ ctx.font = 'bold 40px "SfProDisplay", sans-serif';
2519
+ ctx.textAlign = "left";
2520
+ ctx.fillText(song, canvasWidth / 10 - 10 * 5, canvasHeight / 8 + 100 * 2);
2521
+ ctx.strokeText(song, canvasWidth / 10 - 10 * 5, canvasHeight / 8 + 100 * 2);
2522
+
2523
+ // ===== ARTIST =====
2524
+ ctx.fillStyle = "#B0B0B0";
2525
+ ctx.font = '26px "SfProDisplay", sans-serif';
2526
+ ctx.fillText(
2527
+ `${artist} • ${years}`,
2528
+ canvasWidth / 10 - 10 * 5,
2529
+ canvasHeight / 8 + 80 * 3
2530
+ );
2531
+
2532
+ // ===== LYRICS PREVIEW =====
2533
+ ctx.textAlign = "left";
2534
+ ctx.fillStyle = "#B0B0B0";
2535
+ ctx.font = '28px "SfProDisplay", sans-serif';
2536
+
2537
+ let startY = canvasHeight / 8 + 108 * 4;
2538
+ let y = startY;
2539
+ const lineHeight = 42;
2540
+ const maxWidth = canvasWidth - (canvasWidth / 10 - 10 * 5) - 64;
2541
+
2542
+ // OPTIONAL: limit total rendered lines (aman)
2543
+ const maxRenderedLines = 12;
2544
+ let renderedLines = 0;
2545
+
2546
+ for (const line of lyrics) {
2547
+ if (renderedLines >= maxRenderedLines) break;
2548
+
2549
+ const prevY = y;
2550
+ y = drawWrappedText(
2551
+ ctx,
2552
+ line,
2553
+ canvasWidth / 10 - 10 * 5,
2554
+ y,
2555
+ maxWidth,
2556
+ lineHeight
2557
+ );
2558
+
2559
+ // hitung berapa baris yang dipakai
2560
+ renderedLines += Math.round((y - prevY) / lineHeight);
2561
+ }
2562
+
2563
+ // ===== THUMBNAIL (RECTANGLE + ROUNDED, INLINE) =====
2564
+ // ===== THUMBNAIL (RECTANGLE + ROUNDED + CENTER CROP) =====
2565
+ if (thumbnail) {
2566
+ const thumb = await loadImage(thumbnail);
2567
+
2568
+ const thumbWidth = 185;
2569
+ const thumbHeight = 129;
2570
+ const radius = 16;
2571
+
2572
+ const x = canvasWidth - thumbWidth - 25;
2573
+ const yThumb = canvasHeight / 8 + 100 * 2 - 51;
2574
+
2575
+ // === hitung center crop ===
2576
+ const imgRatio = thumb.width / thumb.height;
2577
+ const frameRatio = thumbWidth / thumbHeight;
2578
+
2579
+ let srcX = 0,
2580
+ srcY = 0,
2581
+ srcW = thumb.width,
2582
+ srcH = thumb.height;
2583
+
2584
+ if (imgRatio > frameRatio) {
2585
+ // gambar lebih lebar → crop kiri kanan
2586
+ srcW = thumb.height * frameRatio;
2587
+ srcX = (thumb.width - srcW) / 2;
2588
+ } else {
2589
+ // gambar lebih tinggi → crop atas bawah
2590
+ srcH = thumb.width / frameRatio;
2591
+ srcY = (thumb.height - srcH) / 2;
2592
+ }
2593
+
2594
+ ctx.save();
2595
+ ctx.beginPath();
2596
+
2597
+ ctx.moveTo(x + radius, yThumb);
2598
+ ctx.lineTo(x + thumbWidth - radius, yThumb);
2599
+ ctx.quadraticCurveTo(
2600
+ x + thumbWidth,
2601
+ yThumb,
2602
+ x + thumbWidth,
2603
+ yThumb + radius
2604
+ );
2605
+
2606
+ ctx.lineTo(x + thumbWidth, yThumb + thumbHeight - radius);
2607
+ ctx.quadraticCurveTo(
2608
+ x + thumbWidth,
2609
+ yThumb + thumbHeight,
2610
+ x + thumbWidth - radius,
2611
+ yThumb + thumbHeight
2612
+ );
2613
+
2614
+ ctx.lineTo(x + radius, yThumb + thumbHeight);
2615
+ ctx.quadraticCurveTo(
2616
+ x,
2617
+ yThumb + thumbHeight,
2618
+ x,
2619
+ yThumb + thumbHeight - radius
2620
+ );
2621
+
2622
+ ctx.lineTo(x, yThumb + radius);
2623
+ ctx.quadraticCurveTo(x, yThumb, x + radius, yThumb);
2624
+
2625
+ ctx.closePath();
2626
+ ctx.clip();
2627
+
2628
+ ctx.drawImage(
2629
+ thumb,
2630
+ srcX,
2631
+ srcY,
2632
+ srcW,
2633
+ srcH,
2634
+ x,
2635
+ yThumb,
2636
+ thumbWidth,
2637
+ thumbHeight
2638
+ );
2639
+
2640
+ ctx.restore();
2641
+ }
2642
+
2643
+ return canvas.toBuffer("image/png");
2644
+ }
2645
+
2646
+ async function generateMeme(baseImagePath, topText, bottomText, options = {}) {
2647
+ let img = await loadImage(baseImagePath);
2648
+ let targetWidth = img.width;
2649
+ let targetHeight = img.height;
2650
+ if (options.square) {
2651
+ const size = Math.min(img.width, img.height);
2652
+ const offsetX = (img.width - size) / 2;
2653
+ const offsetY = (img.height - size) / 2;
2654
+ const squareCanvas = createCanvas(size, size);
2655
+ const squareCtx = squareCanvas.getContext("2d");
2656
+ squareCtx.drawImage(img, offsetX, offsetY, size, size, 0, 0, size, size);
2657
+ img = await loadImage(squareCanvas.toBuffer());
2658
+ targetWidth = targetHeight = size;
2659
+ }
2660
+ const canvas = createCanvas(targetWidth, targetHeight);
2661
+ const ctx = canvas.getContext("2d");
2662
+ ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
2663
+ const fontSize = options.fontSize || 65;
2664
+ const maxWidth = canvas.width - 20;
2665
+ const allEmojiImages = await emojiImageByBrandPromise;
2666
+ const emojiCache = allEmojiImages["apple"] || {};
2667
+ async function renderTextWithEmoji(text, y, fromBottom = false) {
2668
+ const segments = parseTextToSegments(text, ctx, fontSize);
2669
+ const lines = rebuildLinesFromSegments(segments, maxWidth, ctx, fontSize);
2670
+ const lineHeight = fontSize + 5;
2671
+ let totalHeight = lines.length * lineHeight;
2672
+ let currentY;
2673
+ if (fromBottom) {
2674
+ currentY = canvas.height - y - totalHeight + fontSize;
2675
+ } else {
2676
+ currentY = y;
2677
+ }
2678
+ for (const line of lines) {
2679
+ let currentX = (canvas.width - line.reduce((a, s) => a + s.width, 0)) / 2;
2680
+ for (const segment of line) {
2681
+ if (segment.type === "emoji") {
2682
+ const base64 = emojiCache[segment.content];
2683
+ if (base64) {
2684
+ const emojiImg = await loadImage(Buffer.from(base64, "base64"));
2685
+ const emojiYOffset = fontSize * 0.75;
2686
+ ctx.drawImage(
2687
+ emojiImg,
2688
+ currentX,
2689
+ currentY - emojiYOffset,
2690
+ fontSize,
2691
+ fontSize
2692
+ );
2693
+ } else {
2694
+ ctx.fillStyle = "white";
2695
+ ctx.fillText(segment.content, currentX, currentY);
2696
+ }
2697
+ } else {
2698
+ ctx.font = `${fontSize}px "SfProDisplay"`;
2699
+ ctx.fillStyle = "white";
2700
+ ctx.strokeStyle = "black";
2701
+ ctx.lineWidth = 4;
2702
+ ctx.strokeText(segment.content, currentX, currentY);
2703
+ ctx.fillText(segment.content, currentX, currentY);
2704
+ }
2705
+ currentX += segment.width;
2706
+ }
2707
+ currentY += lineHeight;
2708
+ }
2709
+ }
2710
+ await renderTextWithEmoji(
2711
+ topText,
2712
+ options.topPadding || fontSize + 40,
2713
+ false
2714
+ );
2715
+ await renderTextWithEmoji(bottomText, options.bottomPadding || 50, true);
2716
+ return canvas.toBuffer();
2717
+ }
2718
+ async function generateQuote(baseImagePath, text, options = {}) {
2719
+ const img = await loadImage(baseImagePath);
2720
+ const canvas = createCanvas(img.width, img.height);
2721
+ const ctx = canvas.getContext("2d");
2722
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
2723
+ const fontSize = options.fontSize || 42;
2724
+ const startX = options.startX || 80;
2725
+ const maxWidth = options.maxWidth || canvas.width * 0.7;
2726
+ const lineSpacing = options.lineSpacing || Math.floor(fontSize * 0.5);
2727
+ const gap = options.gap || Math.floor(fontSize * 0.5);
2728
+ ctx.font = `bold ${fontSize}px Arial`;
2729
+ function wrapText(text) {
2730
+ const words = text.split(" ");
2731
+ let lines = [];
2732
+ let currentLine = words[0];
2733
+ for (let i = 1; i < words.length; i++) {
2734
+ const word = words[i];
2735
+ const width = ctx.measureText(currentLine + " " + word).width;
2736
+ if (width < maxWidth) {
2737
+ currentLine += " " + word;
2738
+ } else {
2739
+ lines.push(currentLine);
2740
+ currentLine = word;
2741
+ }
2742
+ }
2743
+ lines.push(currentLine);
2744
+ return lines;
2745
+ }
2746
+ const lines = wrapText(text);
2747
+ let y = canvas.height * 0.5 - canvas.height * 0.15;
2748
+ const quoteFontSize = Math.floor(fontSize * 1.4);
2749
+ const boxSize = quoteFontSize * 0.8;
2750
+ ctx.fillStyle = "#00B894";
2751
+ ctx.fillRect(startX, y - fontSize - boxSize - gap, boxSize, boxSize);
2752
+ ctx.fillStyle = "white";
2753
+ ctx.font = `bold ${quoteFontSize}px Arial`;
2754
+ ctx.textBaseline = "middle";
2755
+ ctx.textAlign = "center";
2756
+ ctx.fillText("❝", startX + boxSize / 2, y - fontSize - boxSize / 2);
2757
+ ctx.font = `bold ${fontSize}px Arial`;
2758
+ ctx.textBaseline = "alphabetic";
2759
+ ctx.textAlign = "left";
2760
+ for (const line of lines) {
2761
+ const lineWidth = ctx.measureText(line).width;
2762
+ const paddingX = Math.floor(fontSize * 0.5);
2763
+ const paddingY = Math.floor(fontSize * 0.25);
2764
+ ctx.fillStyle = "white";
2765
+ ctx.fillRect(
2766
+ startX - paddingX / 2,
2767
+ y - fontSize,
2768
+ lineWidth + paddingX,
2769
+ fontSize + paddingY
2770
+ );
2771
+ ctx.fillStyle = "black";
2772
+ ctx.fillText(line, startX, y);
2773
+ y += fontSize + lineSpacing;
2774
+ }
2775
+ return canvas.toBuffer();
2776
+ }
2777
+ function generateAnimatedBratVid(tempFrameDir, outputPath) {
2778
+ return new Promise((resolve, reject) => {
2779
+ try {
2780
+ if (typeof tempFrameDir !== "string" || typeof outputPath !== "string")
2781
+ throw new TypeError("Directory and path must be strings");
2782
+ if (!fs.existsSync(tempFrameDir))
2783
+ throw new Error(`Temporary frame directory not found: ${tempFrameDir}`);
2784
+ ffmpeg()
2785
+ .input(path.join(tempFrameDir, "frame_%d.png"))
2786
+ .inputOptions("-framerate", "1.5")
2787
+ .outputOptions(
2788
+ "-vf",
2789
+ "scale=512:512:force_original_aspect_ratio=decrease,pad=512:512:(ow-iw)/2:(oh-ih)/2"
2790
+ )
2791
+ .output(outputPath)
2792
+ .videoCodec("libwebp")
2793
+ .outputOptions("-loop", "0", "-q:v", "80", "-preset", "default", "-an")
2794
+ .on("end", resolve)
2795
+ .on("error", reject)
2796
+ .run();
2797
+ } catch (error) {
2798
+ reject(error);
2799
+ }
2800
+ });
2801
+ }
2802
+
2803
+ async function bratVidGenerator(
2804
+ text,
2805
+ width,
2806
+ height,
2807
+ bgColor = "#FFFFFF",
2808
+ textColor = "#000000",
2809
+ highlightWords = []
2810
+ ) {
2811
+ try {
2812
+ if (typeof text !== "string" || text.trim().length === 0)
2813
+ throw new Error("Text must be a non-empty string");
2814
+ if (!Array.isArray(highlightWords))
2815
+ throw new TypeError("highlightWords must be an array.");
2816
+ if (
2817
+ !Number.isInteger(width) ||
2818
+ !Number.isInteger(height) ||
2819
+ width <= 0 ||
2820
+ height <= 0
2821
+ )
2822
+ throw new Error("Width and height must be positive integers");
2823
+ if (!/^#[0-9A-F]{6}$/i.test(bgColor) || !/^#[0-9A-F]{6}$/i.test(textColor))
2824
+ throw new Error("Colors must be in hex format (#RRGGBB)");
2825
+ const allEmojiImages = await emojiImageByBrandPromise;
2826
+ const emojiCache = allEmojiImages["apple"] || {};
2827
+ const padding = 20;
2828
+ const availableWidth = width - padding * 2;
2829
+ const tempCanvas = createCanvas(1, 1);
2830
+ const tempCtx = tempCanvas.getContext("2d");
2831
+ if (!tempCtx) throw new Error("Failed to create canvas context");
2832
+ const tokens = text.match(/\S+|\n/g) || [];
2833
+ if (tokens.length === 0)
2834
+ throw new Error("No valid content tokens found in the text");
2835
+ let frames = [];
2836
+ const recalculateSegmentWidths = (segments, fontSize, ctx) => {
2837
+ return segments.map((seg) => {
2838
+ let newWidth = seg.width;
2839
+ switch (seg.type) {
2840
+ case "bold":
2841
+ ctx.font = `bold ${fontSize}px Arial`;
2842
+ newWidth = ctx.measureText(seg.content).width;
2843
+ break;
2844
+ case "italic":
2845
+ ctx.font = `italic ${fontSize}px Arial`;
2846
+ newWidth = ctx.measureText(seg.content).width;
2847
+ break;
2848
+ case "bolditalic":
2849
+ ctx.font = `bold italic ${fontSize}px Arial`;
2850
+ newWidth = ctx.measureText(seg.content).width;
2851
+ break;
2852
+ case "monospace":
2853
+ ctx.font = `${fontSize}px 'Courier New', monospace`;
2854
+ newWidth = ctx.measureText(seg.content).width;
2855
+ break;
2856
+ case "strikethrough":
2857
+ case "text":
2858
+ ctx.font = `${fontSize}px Arial`;
2859
+ newWidth = ctx.measureText(seg.content).width;
2860
+ break;
2861
+ case "emoji":
2862
+ newWidth = fontSize * 1.2;
2863
+ break;
2864
+ }
2865
+ return { ...seg, width: newWidth };
2866
+ });
2867
+ };
2868
+ const renderSegment = async (ctx, segment, x, y, fontSize, lineHeight) => {
2869
+ ctx.fillStyle = highlightWords.includes(segment.content)
2870
+ ? "red"
2871
+ : textColor;
2872
+ switch (segment.type) {
2873
+ case "bold":
2874
+ ctx.font = `bold ${fontSize}px Arial`;
2875
+ break;
2876
+ case "italic":
2877
+ ctx.font = `italic ${fontSize}px Arial`;
2878
+ break;
2879
+ case "bolditalic":
2880
+ ctx.font = `bold italic ${fontSize}px Arial`;
2881
+ break;
2882
+ case "monospace":
2883
+ ctx.font = `${fontSize}px 'Courier New', monospace`;
2884
+ break;
2885
+ default:
2886
+ ctx.font = `${fontSize}px Arial`;
2887
+ break;
2888
+ }
2889
+ if (segment.type === "emoji") {
2890
+ const emojiSize = fontSize * 1.2;
2891
+ const emojiY = y + (lineHeight - emojiSize) / 2;
2892
+ if (!emojiCache[segment.content])
2893
+ throw new Error(`Emoji ${segment.content} tidak ditemukan`);
2894
+ const emojiImg = await loadImage(
2895
+ Buffer.from(emojiCache[segment.content], "base64")
2896
+ );
2897
+ ctx.drawImage(emojiImg, x, emojiY, emojiSize, emojiSize);
2898
+ } else {
2899
+ ctx.fillText(segment.content, x, y);
2900
+ if (segment.type === "strikethrough") {
2901
+ ctx.strokeStyle = ctx.fillStyle;
2902
+ ctx.lineWidth = Math.max(1, fontSize / 15);
2903
+ const lineY = y + lineHeight / 2.1;
2904
+ ctx.beginPath();
2905
+ ctx.moveTo(x, lineY);
2906
+ ctx.lineTo(x + segment.width, lineY);
2907
+ ctx.stroke();
2908
+ }
2909
+ }
2910
+ };
2911
+ for (let i = 1; i <= tokens.length; i++) {
2912
+ const frameTokens = tokens.slice(0, i);
2913
+ const currentText = frameTokens
2914
+ .join(" ")
2915
+ .replace(/ \n /g, "\n")
2916
+ .replace(/\n /g, "\n")
2917
+ .replace(/ \n/g, "\n");
2918
+ if (currentText.trim() === "") continue;
2919
+ let fontSize = 200;
2920
+ let finalLines = [];
2921
+ let lineHeight = 0;
2922
+ const lineHeightMultiplier = 1.3;
2923
+ while (fontSize > 10) {
2924
+ let currentRenderLines = [];
2925
+ const textLines = currentText.split("\n");
2926
+ for (const singleLineText of textLines) {
2927
+ if (singleLineText === "") {
2928
+ currentRenderLines.push([]);
2929
+ continue;
2930
+ }
2931
+ let segments = parseTextToSegments(singleLineText, tempCtx, fontSize);
2932
+ let segmentsForSizing = recalculateSegmentWidths(
2933
+ segments,
2934
+ fontSize,
2935
+ tempCtx
2936
+ );
2937
+ let wrappedLines = rebuildLinesFromSegments(
2938
+ segmentsForSizing,
2939
+ availableWidth,
2940
+ tempCtx,
2941
+ fontSize
2942
+ );
2943
+ currentRenderLines.push(...wrappedLines);
2944
+ }
2945
+ const currentLineHeight = fontSize * lineHeightMultiplier;
2946
+ const totalTextHeight = currentRenderLines.length * currentLineHeight;
2947
+ if (totalTextHeight <= height - padding * 2) {
2948
+ finalLines = currentRenderLines;
2949
+ lineHeight = currentLineHeight;
2950
+ break;
2951
+ }
2952
+ fontSize -= 2;
2953
+ }
2954
+ const canvas = createCanvas(width, height);
2955
+ const ctx = canvas.getContext("2d");
2956
+ if (!ctx) throw new Error("Failed to create canvas context");
2957
+ ctx.fillStyle = bgColor;
2958
+ ctx.fillRect(0, 0, width, height);
2959
+ ctx.textBaseline = "top";
2960
+ const totalTextBlockHeight = finalLines.length * lineHeight;
2961
+ const startY = (height - totalTextBlockHeight) / 2;
2962
+ for (let j = 0; j < finalLines.length; j++) {
2963
+ const line = finalLines[j];
2964
+ const positionY = startY + j * lineHeight;
2965
+ const contentSegments = line.filter((seg) => seg.type !== "whitespace");
2966
+ if (contentSegments.length <= 1) {
2967
+ let positionX = padding;
2968
+ for (const segment of line) {
2969
+ await renderSegment(
2970
+ ctx,
2971
+ segment,
2972
+ positionX,
2973
+ positionY,
2974
+ fontSize,
2975
+ lineHeight
2976
+ );
2977
+ positionX += segment.width;
2978
+ }
2979
+ } else {
2980
+ const totalContentWidth = contentSegments.reduce(
2981
+ (sum, seg) => sum + seg.width,
2982
+ 0
2983
+ );
2984
+ const spaceBetween =
2985
+ (availableWidth - totalContentWidth) / (contentSegments.length - 1);
2986
+ let positionX = padding;
2987
+ for (let k = 0; k < contentSegments.length; k++) {
2988
+ const segment = contentSegments[k];
2989
+ await renderSegment(
2990
+ ctx,
2991
+ segment,
2992
+ positionX,
2993
+ positionY,
2994
+ fontSize,
2995
+ lineHeight
2996
+ );
2997
+ positionX += segment.width;
2998
+ if (k < contentSegments.length - 1) {
2999
+ positionX += spaceBetween;
3000
+ }
3001
+ }
3002
+ }
3003
+ }
3004
+ const buffer = canvas.toBuffer("image/png");
3005
+ const blurredBuffer = await sharp(buffer).blur(3).toBuffer();
3006
+ frames.push(blurredBuffer);
3007
+ }
3008
+ return frames;
3009
+ } catch (error) {
3010
+ throw error;
3011
+ }
3012
+ }
3013
+ async function bratGenerator(teks, highlightWords = []) {
3014
+ try {
3015
+ if (typeof teks !== "string" || teks.trim().length === 0)
3016
+ throw new Error("Teks tidak boleh kosong.");
3017
+ if (!Array.isArray(highlightWords))
3018
+ throw new TypeError("highlightWords harus berupa array.");
3019
+ const allEmojiImages = await emojiImageByBrandPromise;
3020
+ const emojiCache = allEmojiImages["apple"] || {};
3021
+ let width = 512,
3022
+ height = 512,
3023
+ margin = 8,
3024
+ verticalPadding = 8;
3025
+ const canvas = createCanvas(width, height);
3026
+ const ctx = canvas.getContext("2d");
3027
+ if (!ctx) throw new Error("Gagal membuat konteks kanvas.");
3028
+ ctx.fillStyle = "white";
3029
+ ctx.fillRect(0, 0, width, height);
3030
+ ctx.textAlign = "left";
3031
+ ctx.textBaseline = "top";
3032
+ let fontSize = 200;
3033
+ let lineHeightMultiplier = 1.3;
3034
+ const availableWidth = width - 2 * margin;
3035
+ let finalLines = [];
3036
+ let finalFontSize = 0;
3037
+ let lineHeight = 0;
3038
+ const wordCount = (
3039
+ teks.trim().match(/(\p{L}|\p{N}|\p{Emoji_Presentation})+/gu) || []
3040
+ ).length;
3041
+ let lastKnownGoodSolution = null;
3042
+ while (fontSize > 10) {
3043
+ let currentRenderLines = [];
3044
+ const textLines = teks.split("\n");
3045
+ for (const singleLineText of textLines) {
3046
+ if (singleLineText === "") {
3047
+ currentRenderLines.push([]);
3048
+ continue;
3049
+ }
3050
+ let segments = parseTextToSegments(singleLineText, ctx, fontSize);
3051
+ let wrappedLines = rebuildLinesFromSegments(
3052
+ segments,
3053
+ availableWidth,
3054
+ ctx,
3055
+ fontSize
3056
+ );
3057
+ currentRenderLines.push(...wrappedLines);
3058
+ }
3059
+ if (
3060
+ currentRenderLines.length === 1 &&
3061
+ currentRenderLines[0].filter((seg) => seg.type !== "whitespace")
3062
+ .length === 2 &&
3063
+ currentRenderLines[0].some((seg) => seg.type === "text") &&
3064
+ currentRenderLines[0].some((seg) => seg.type === "emoji")
3065
+ ) {
3066
+ const textSeg = currentRenderLines[0].find(
3067
+ (seg) => seg.type === "text"
3068
+ );
3069
+ const emojiSeg = currentRenderLines[0].find(
3070
+ (seg) => seg.type === "emoji"
3071
+ );
3072
+ currentRenderLines = [[textSeg], [emojiSeg]];
3073
+ }
3074
+ const currentLineHeight = fontSize * lineHeightMultiplier;
3075
+ const totalTextHeight = currentRenderLines.length * currentLineHeight;
3076
+ if (totalTextHeight <= height - 2 * verticalPadding) {
3077
+ lastKnownGoodSolution = {
3078
+ lines: currentRenderLines,
3079
+ fontSize: fontSize,
3080
+ lineHeight: currentLineHeight,
3081
+ };
3082
+ if (wordCount === 4) {
3083
+ if (currentRenderLines.length === 2) {
3084
+ finalLines = currentRenderLines;
3085
+ finalFontSize = fontSize;
3086
+ lineHeight = currentLineHeight;
3087
+ break;
3088
+ }
3089
+ } else {
3090
+ finalLines = currentRenderLines;
3091
+ finalFontSize = fontSize;
3092
+ lineHeight = currentLineHeight;
3093
+ break;
3094
+ }
3095
+ }
3096
+ fontSize -= 2;
3097
+ }
3098
+ if (finalLines.length === 0 && lastKnownGoodSolution) {
3099
+ finalLines = lastKnownGoodSolution.lines;
3100
+ finalFontSize = lastKnownGoodSolution.fontSize;
3101
+ lineHeight = lastKnownGoodSolution.lineHeight;
3102
+ }
3103
+ if (
3104
+ finalLines.length === 1 &&
3105
+ finalLines[0].length === 1 &&
3106
+ finalLines[0][0].type === "text"
3107
+ ) {
3108
+ const theOnlyWord = finalLines[0][0].content;
3109
+ const heightBasedSize =
3110
+ (height - 2 * verticalPadding) / lineHeightMultiplier;
3111
+ ctx.font = `200px Arial`;
3112
+ const referenceWidth = ctx.measureText(theOnlyWord).width;
3113
+ const widthBasedSize = (availableWidth / referenceWidth) * 200;
3114
+ finalFontSize = Math.floor(Math.min(heightBasedSize, widthBasedSize));
3115
+ lineHeight = finalFontSize * lineHeightMultiplier;
3116
+ }
3117
+ const totalFinalHeight = finalLines.length * lineHeight;
3118
+ let y =
3119
+ finalLines.length === 1
3120
+ ? verticalPadding
3121
+ : (height - totalFinalHeight) / 2;
3122
+ const renderSegment = async (segment, x, y) => {
3123
+ ctx.fillStyle = isHighlighted(highlightWords, segment.content)
3124
+ ? "red"
3125
+ : "black";
3126
+ switch (segment.type) {
3127
+ case "bold":
3128
+ ctx.font = `bold ${finalFontSize}px Arial`;
3129
+ break;
3130
+ case "italic":
3131
+ ctx.font = `italic ${finalFontSize}px Arial`;
3132
+ break;
3133
+ case "bolditalic":
3134
+ ctx.font = `bold italic ${finalFontSize}px Arial`;
3135
+ break;
3136
+ case "monospace":
3137
+ ctx.font = `${finalFontSize}px 'Courier New', monospace`;
3138
+ break;
3139
+ case "strikethrough":
3140
+ case "text":
3141
+ default:
3142
+ ctx.font = `${finalFontSize}px Arial`;
3143
+ break;
3144
+ }
3145
+ if (segment.type === "emoji") {
3146
+ const emojiSize = finalFontSize * 1.2;
3147
+ const emojiY = y + (lineHeight - emojiSize) / 2;
3148
+ if (!emojiCache[segment.content])
3149
+ throw new Error(`Emoji ${segment.content} tidak ditemukan di cache`);
3150
+ const emojiImg = await loadImage(
3151
+ Buffer.from(emojiCache[segment.content], "base64")
3152
+ );
3153
+ ctx.drawImage(emojiImg, x, emojiY, emojiSize, emojiSize);
3154
+ } else {
3155
+ ctx.fillText(segment.content, x, y);
3156
+ if (segment.type === "strikethrough") {
3157
+ ctx.strokeStyle = ctx.fillStyle;
3158
+ ctx.lineWidth = Math.max(1, finalFontSize / 15);
3159
+ const lineY = y + lineHeight / 2.1;
3160
+ ctx.beginPath();
3161
+ ctx.moveTo(x, lineY);
3162
+ ctx.lineTo(x + segment.width, lineY);
3163
+ ctx.stroke();
3164
+ }
3165
+ }
3166
+ };
3167
+ for (const line of finalLines) {
3168
+ const contentSegments = line.filter((seg) => seg.type !== "whitespace");
3169
+ if (contentSegments.length <= 1) {
3170
+ let x = margin;
3171
+ for (const segment of line) {
3172
+ await renderSegment(segment, x, y);
3173
+ x += segment.width;
3174
+ }
3175
+ } else {
3176
+ const totalContentWidth = contentSegments.reduce(
3177
+ (sum, seg) => sum + seg.width,
3178
+ 0
3179
+ );
3180
+ const spacePerGap =
3181
+ (availableWidth - totalContentWidth) / (contentSegments.length - 1);
3182
+ let currentX = margin;
3183
+ for (let i = 0; i < contentSegments.length; i++) {
3184
+ const segment = contentSegments[i];
3185
+ await renderSegment(segment, currentX, y);
3186
+ currentX += segment.width;
3187
+ if (i < contentSegments.length - 1) {
3188
+ currentX += spacePerGap;
3189
+ }
3190
+ }
3191
+ }
3192
+ y += lineHeight;
3193
+ }
3194
+ const buffer = canvas.toBuffer("image/png");
3195
+ const blurredBuffer = await sharp(buffer).blur(3).toBuffer();
3196
+ return blurredBuffer;
3197
+ } catch (error) {
3198
+ console.error("Terjadi error di bratGenerator:", error);
3199
+ throw error;
3200
+ }
3201
+ }
3202
+
3203
+ module.exports = {
3204
+ generateQuote,
3205
+ generateMeme,
3206
+ generateFakeStory,
3207
+ generateFakeTweet,
3208
+ generateFakeCallAndroid,
3209
+ generateFakeCallIphone,
3210
+ generateFakeChatIphone,
3211
+ generateInstagramProfile,
3212
+ generateTiktokProfile,
3213
+ generateTwitterProfile,
3214
+ generateYtProfile,
3215
+ generateGoogleSearch,
3216
+ generateGoogleLyrics,
3217
+ bratGenerator,
3218
+ bratVidGenerator,
3219
+ generateAnimatedBratVid,
3220
+ };