@cognima/banners 0.0.1-beta

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 (48) hide show
  1. package/assets/fonts/Manrope/Manrope-Bold.ttf +0 -0
  2. package/assets/fonts/Manrope/Manrope-Regular.ttf +0 -0
  3. package/assets/fonts/Others/AbyssinicaSIL-Regular.ttf +0 -0
  4. package/assets/fonts/Others/ChirpRegular.ttf +0 -0
  5. package/assets/fonts/Poppins/Poppins-Bold.ttf +0 -0
  6. package/assets/fonts/Poppins/Poppins-Medium.ttf +0 -0
  7. package/assets/fonts/Poppins/Poppins-Regular.ttf +0 -0
  8. package/assets/placeholders/album_art.png +0 -0
  9. package/assets/placeholders/avatar.png +0 -0
  10. package/assets/placeholders/badge.jpg +0 -0
  11. package/assets/placeholders/badge.png +0 -0
  12. package/assets/placeholders/badge_2.jpg +0 -0
  13. package/assets/placeholders/badge_3.jpg +0 -0
  14. package/assets/placeholders/badge_4.jpg +0 -0
  15. package/assets/placeholders/badge_5.jpg +0 -0
  16. package/assets/placeholders/banner.jpeg +0 -0
  17. package/assets/placeholders/images.jpeg +0 -0
  18. package/index.js +153 -0
  19. package/package.json +34 -0
  20. package/src/animation-effects.js +631 -0
  21. package/src/cache-manager.js +258 -0
  22. package/src/community-banner.js +1536 -0
  23. package/src/constants.js +208 -0
  24. package/src/discord-profile.js +584 -0
  25. package/src/e-commerce-banner.js +1214 -0
  26. package/src/effects.js +355 -0
  27. package/src/error-handler.js +305 -0
  28. package/src/event-banner.js +1319 -0
  29. package/src/facebook-post.js +679 -0
  30. package/src/gradient-welcome.js +430 -0
  31. package/src/image-filters.js +1034 -0
  32. package/src/image-processor.js +1014 -0
  33. package/src/instagram-post.js +504 -0
  34. package/src/interactive-elements.js +1208 -0
  35. package/src/linkedin-post.js +658 -0
  36. package/src/marketing-banner.js +1089 -0
  37. package/src/minimalist-banner.js +892 -0
  38. package/src/modern-profile.js +755 -0
  39. package/src/performance-optimizer.js +216 -0
  40. package/src/telegram-header.js +544 -0
  41. package/src/test-runner.js +645 -0
  42. package/src/tiktok-post.js +713 -0
  43. package/src/twitter-header.js +604 -0
  44. package/src/validator.js +442 -0
  45. package/src/welcome-leave.js +445 -0
  46. package/src/whatsapp-status.js +386 -0
  47. package/src/youtube-thumbnail.js +681 -0
  48. package/utils.js +710 -0
@@ -0,0 +1,681 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Módulo de Thumbnail do YouTube
5
+ *
6
+ * Este módulo gera thumbnails no estilo do YouTube com título, avatar do canal,
7
+ * duração do vídeo e outros elementos visuais.
8
+ *
9
+ * @author Cognima Team (melhorado)
10
+ * @version 2.0.0
11
+ */
12
+
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+
15
+ const pureimage = require("pureimage");
16
+ const path = require("path");
17
+ const {
18
+ loadImageWithAxios,
19
+ encodeToBuffer,
20
+ roundRect,
21
+ wrapText,
22
+ registerFontIfNeeded,
23
+ isValidHexColor,
24
+ DEFAULT_FONT_FAMILY,
25
+ applyTextShadow,
26
+ clearShadow,
27
+ createLinearGradient,
28
+ hexToRgba,
29
+ formatTime
30
+ } = require("../utils");
31
+
32
+ /**
33
+ * @class YouTubeThumbnail
34
+ * @classdesc Gera uma thumbnail no estilo do YouTube.
35
+ * @example const thumbnail = new YouTubeThumbnail()
36
+ * .setTitle("Como criar thumbnails atraentes para o YouTube")
37
+ * .setBackground("image", "background.jpg")
38
+ * .setChannelAvatar("avatar.png")
39
+ * .setChannelName("Canal Criativo")
40
+ * .setDuration(1250)
41
+ * .build();
42
+ */
43
+ module.exports = class YouTubeThumbnail {
44
+ constructor(options) {
45
+ // Dados Principais
46
+ this.title = "Título do Vídeo";
47
+ this.background = null;
48
+ this.channelName = null;
49
+ this.channelAvatar = null;
50
+ this.duration = 0; // em segundos
51
+ this.views = null;
52
+ this.publishedAt = null;
53
+
54
+ // Elementos Visuais
55
+ this.overlayText = null;
56
+ this.overlayTextColor = "#FFFFFF";
57
+ this.overlayTextBackground = "#FF0000";
58
+ this.showPlayButton = true;
59
+
60
+ // Personalização Visual
61
+ this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path };
62
+ this.titleColor = "#FFFFFF";
63
+ this.titleBackground = "rgba(0, 0, 0, 0.6)";
64
+ this.overlayOpacity = 0.3;
65
+ this.useTextShadow = true;
66
+ this.style = "standard"; // standard, shorts, premium
67
+
68
+ // Configurações de Layout
69
+ this.cardWidth = 1280;
70
+ this.cardHeight = 720;
71
+ this.cornerRadius = 12;
72
+ }
73
+
74
+ // --- Setters para Dados Principais ---
75
+ /**
76
+ * Define o título do vídeo
77
+ * @param {string} text - Título do vídeo
78
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
79
+ */
80
+ setTitle(text) {
81
+ if (!text || typeof text !== "string") throw new Error("O título do vídeo deve ser uma string não vazia.");
82
+ this.title = text;
83
+ return this;
84
+ }
85
+
86
+ /**
87
+ * Define o plano de fundo da thumbnail
88
+ * @param {string} type - Tipo de plano de fundo ('color' ou 'image')
89
+ * @param {string} value - Valor do plano de fundo (cor hexadecimal ou URL/caminho da imagem)
90
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
91
+ */
92
+ setBackground(type, value) {
93
+ const types = ["color", "image"];
94
+ if (!type || !types.includes(type.toLowerCase())) throw new Error("O tipo de plano de fundo deve ser 'color' ou 'image'.");
95
+ if (!value) throw new Error("O valor do plano de fundo não pode estar vazio.");
96
+ if (type.toLowerCase() === "color" && !isValidHexColor(value)) throw new Error("Cor de plano de fundo inválida. Use o formato hexadecimal.");
97
+ this.background = { type: type.toLowerCase(), value };
98
+ return this;
99
+ }
100
+
101
+ /**
102
+ * Define o nome do canal
103
+ * @param {string} name - Nome do canal
104
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
105
+ */
106
+ setChannelName(name) {
107
+ if (!name || typeof name !== "string") throw new Error("O nome do canal deve ser uma string não vazia.");
108
+ this.channelName = name;
109
+ return this;
110
+ }
111
+
112
+ /**
113
+ * Define o avatar do canal
114
+ * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar
115
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
116
+ */
117
+ setChannelAvatar(image) {
118
+ if (!image) throw new Error("A fonte da imagem do avatar não pode estar vazia.");
119
+ this.channelAvatar = image;
120
+ return this;
121
+ }
122
+
123
+ /**
124
+ * Define a duração do vídeo
125
+ * @param {number} seconds - Duração em segundos
126
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
127
+ */
128
+ setDuration(seconds) {
129
+ if (typeof seconds !== "number" || seconds < 0) throw new Error("A duração do vídeo deve ser um número não negativo.");
130
+ this.duration = seconds;
131
+ return this;
132
+ }
133
+
134
+ /**
135
+ * Define o número de visualizações
136
+ * @param {number} count - Número de visualizações
137
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
138
+ */
139
+ setViews(count) {
140
+ if (typeof count !== "number" || count < 0) throw new Error("O número de visualizações deve ser um número não negativo.");
141
+ this.views = count;
142
+ return this;
143
+ }
144
+
145
+ /**
146
+ * Define a data de publicação
147
+ * @param {string} date - Data de publicação (ex: "há 2 dias")
148
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
149
+ */
150
+ setPublishedAt(date) {
151
+ if (!date || typeof date !== "string") throw new Error("A data de publicação deve ser uma string não vazia.");
152
+ this.publishedAt = date;
153
+ return this;
154
+ }
155
+
156
+ // --- Setters para Elementos Visuais ---
157
+ /**
158
+ * Define o texto de sobreposição (ex: "NOVO", "AO VIVO", etc.)
159
+ * @param {string} text - Texto de sobreposição
160
+ * @param {string} textColor - Cor do texto (hexadecimal)
161
+ * @param {string} backgroundColor - Cor de fundo (hexadecimal)
162
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
163
+ */
164
+ setOverlayText(text, textColor = "#FFFFFF", backgroundColor = "#FF0000") {
165
+ if (!text || typeof text !== "string") throw new Error("O texto de sobreposição deve ser uma string não vazia.");
166
+ if (textColor && !isValidHexColor(textColor)) throw new Error("Cor de texto inválida. Use o formato hexadecimal.");
167
+ if (backgroundColor && !isValidHexColor(backgroundColor)) throw new Error("Cor de fundo inválida. Use o formato hexadecimal.");
168
+
169
+ this.overlayText = text;
170
+ this.overlayTextColor = textColor;
171
+ this.overlayTextBackground = backgroundColor;
172
+
173
+ return this;
174
+ }
175
+
176
+ /**
177
+ * Ativa ou desativa o botão de play
178
+ * @param {boolean} show - Se o botão de play deve ser exibido
179
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
180
+ */
181
+ setShowPlayButton(show = true) {
182
+ this.showPlayButton = show;
183
+ return this;
184
+ }
185
+
186
+ // --- Setters para Personalização Visual ---
187
+ /**
188
+ * Define a cor do título
189
+ * @param {string} color - Cor hexadecimal
190
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
191
+ */
192
+ setTitleColor(color) {
193
+ if (!color || !isValidHexColor(color)) throw new Error("Cor de título inválida. Use o formato hexadecimal.");
194
+ this.titleColor = color;
195
+ return this;
196
+ }
197
+
198
+ /**
199
+ * Define o fundo do título
200
+ * @param {string} color - Cor hexadecimal ou rgba
201
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
202
+ */
203
+ setTitleBackground(color) {
204
+ this.titleBackground = color;
205
+ return this;
206
+ }
207
+
208
+ /**
209
+ * Define a opacidade da sobreposição
210
+ * @param {number} opacity - Valor de opacidade (0-1)
211
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
212
+ */
213
+ setOverlayOpacity(opacity) {
214
+ if (typeof opacity !== "number" || opacity < 0 || opacity > 1) throw new Error("A opacidade da sobreposição deve estar entre 0 e 1.");
215
+ this.overlayOpacity = opacity;
216
+ return this;
217
+ }
218
+
219
+ /**
220
+ * Ativa ou desativa a sombra de texto
221
+ * @param {boolean} enabled - Se a sombra de texto deve ser ativada
222
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
223
+ */
224
+ enableTextShadow(enabled = true) {
225
+ this.useTextShadow = enabled;
226
+ return this;
227
+ }
228
+
229
+ /**
230
+ * Define o estilo da thumbnail
231
+ * @param {string} style - Estilo da thumbnail ('standard', 'shorts', 'premium')
232
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
233
+ */
234
+ setStyle(style) {
235
+ const validStyles = ["standard", "shorts", "premium"];
236
+ if (!style || !validStyles.includes(style.toLowerCase())) {
237
+ throw new Error(`Estilo de thumbnail inválido. Use um dos seguintes: ${validStyles.join(", ")}`);
238
+ }
239
+
240
+ this.style = style.toLowerCase();
241
+
242
+ // Ajusta as dimensões com base no estilo
243
+ if (this.style === "shorts") {
244
+ this.cardWidth = 1080;
245
+ this.cardHeight = 1920;
246
+ } else {
247
+ this.cardWidth = 1280;
248
+ this.cardHeight = 720;
249
+ }
250
+
251
+ return this;
252
+ }
253
+
254
+ /**
255
+ * Define as dimensões do card
256
+ * @param {number} width - Largura do card em pixels
257
+ * @param {number} height - Altura do card em pixels
258
+ * @returns {YouTubeThumbnail} - Instância atual para encadeamento
259
+ */
260
+ setCardDimensions(width, height) {
261
+ if (typeof width !== "number" || width < 640 || width > 1920) {
262
+ throw new Error("A largura do card deve estar entre 640 e 1920 pixels.");
263
+ }
264
+
265
+ if (typeof height !== "number" || height < 360 || height > 1920) {
266
+ throw new Error("A altura do card deve estar entre 360 e 1920 pixels.");
267
+ }
268
+
269
+ this.cardWidth = width;
270
+ this.cardHeight = height;
271
+
272
+ return this;
273
+ }
274
+
275
+ // --- Método de Construção ---
276
+ /**
277
+ * Constrói a thumbnail e retorna um buffer de imagem
278
+ * @returns {Promise<Buffer>} - Buffer contendo a imagem da thumbnail
279
+ */
280
+ async build() {
281
+ if (!this.background) throw new Error("O plano de fundo da thumbnail deve ser definido usando setBackground().");
282
+
283
+ // --- Registro de Fonte ---
284
+ const registeredFontName = await registerFontIfNeeded(this.font);
285
+
286
+ // --- Configuração do Canvas ---
287
+ const cardWidth = this.cardWidth;
288
+ const cardHeight = this.cardHeight;
289
+ const padding = Math.floor(cardWidth * 0.03); // Padding proporcional
290
+ const cornerRadius = this.cornerRadius;
291
+
292
+ const canvas = pureimage.make(cardWidth, cardHeight);
293
+ const ctx = canvas.getContext("2d");
294
+
295
+ // --- Desenha Plano de Fundo ---
296
+ if (this.background.type === "color") {
297
+ // Plano de fundo de cor sólida
298
+ ctx.fillStyle = this.background.value;
299
+ ctx.fillRect(0, 0, cardWidth, cardHeight);
300
+ } else {
301
+ // Plano de fundo de imagem
302
+ try {
303
+ const img = await loadImageWithAxios(this.background.value);
304
+ const aspect = img.width / img.height;
305
+ let drawWidth = cardWidth;
306
+ let drawHeight = cardWidth / aspect;
307
+
308
+ // Ajusta as dimensões para cobrir todo o card
309
+ if (drawHeight < cardHeight) {
310
+ drawHeight = cardHeight;
311
+ drawWidth = cardHeight * aspect;
312
+ }
313
+
314
+ const offsetX = (cardWidth - drawWidth) / 2;
315
+ const offsetY = (cardHeight - drawHeight) / 2;
316
+
317
+ ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
318
+
319
+ // Aplica sobreposição para melhorar legibilidade do texto
320
+ ctx.globalAlpha = this.overlayOpacity;
321
+ ctx.fillStyle = "#000000";
322
+ ctx.fillRect(0, 0, cardWidth, cardHeight);
323
+ ctx.globalAlpha = 1;
324
+ } catch (e) {
325
+ console.error("Falha ao desenhar imagem de plano de fundo:", e.message);
326
+ ctx.fillStyle = "#000000";
327
+ ctx.fillRect(0, 0, cardWidth, cardHeight);
328
+ }
329
+ }
330
+
331
+ // --- Desenha Elementos com base no Estilo ---
332
+ if (this.style === "shorts") {
333
+ // Estilo Shorts (vertical)
334
+ await this._drawShortsStyle(ctx, registeredFontName, cardWidth, cardHeight, padding);
335
+ } else if (this.style === "premium") {
336
+ // Estilo Premium
337
+ await this._drawPremiumStyle(ctx, registeredFontName, cardWidth, cardHeight, padding);
338
+ } else {
339
+ // Estilo Padrão
340
+ await this._drawStandardStyle(ctx, registeredFontName, cardWidth, cardHeight, padding);
341
+ }
342
+
343
+ // --- Codifica e Retorna Buffer ---
344
+ try {
345
+ return await encodeToBuffer(canvas);
346
+ } catch (err) {
347
+ console.error("Falha ao codificar a Thumbnail do YouTube:", err);
348
+ throw new Error("Não foi possível gerar o buffer de imagem da Thumbnail do YouTube.");
349
+ }
350
+ }
351
+
352
+ // --- Métodos Auxiliares Privados ---
353
+ /**
354
+ * Desenha elementos no estilo padrão do YouTube
355
+ * @private
356
+ */
357
+ async _drawStandardStyle(ctx, fontName, width, height, padding) {
358
+ // --- Desenha Duração do Vídeo ---
359
+ if (this.duration > 0) {
360
+ const durationText = this._formatDuration(this.duration);
361
+ const durationWidth = 70;
362
+ const durationHeight = 25;
363
+ const durationX = width - durationWidth - padding;
364
+ const durationY = height - durationHeight - padding;
365
+
366
+ // Fundo da duração
367
+ ctx.fillStyle = "rgba(0, 0, 0, 0.8)";
368
+ roundRect(ctx, durationX, durationY, durationWidth, durationHeight, 4, true, false);
369
+
370
+ // Texto da duração
371
+ ctx.fillStyle = "#FFFFFF";
372
+ ctx.font = `bold 14px ${fontName}-Bold`;
373
+ ctx.textAlign = "center";
374
+ ctx.textBaseline = "middle";
375
+ ctx.fillText(durationText, durationX + durationWidth / 2, durationY + durationHeight / 2);
376
+ }
377
+
378
+ // --- Desenha Botão de Play (se ativado) ---
379
+ if (this.showPlayButton) {
380
+ const playSize = Math.min(width, height) * 0.15;
381
+ const playX = width / 2 - playSize / 2;
382
+ const playY = height / 2 - playSize / 2;
383
+
384
+ // Círculo de fundo
385
+ ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
386
+ ctx.beginPath();
387
+ ctx.arc(playX + playSize / 2, playY + playSize / 2, playSize / 2, 0, Math.PI * 2);
388
+ ctx.fill();
389
+
390
+ // Triângulo de play
391
+ ctx.fillStyle = "#FFFFFF";
392
+ ctx.beginPath();
393
+ ctx.moveTo(playX + playSize * 0.35, playY + playSize * 0.25);
394
+ ctx.lineTo(playX + playSize * 0.35, playY + playSize * 0.75);
395
+ ctx.lineTo(playX + playSize * 0.75, playY + playSize * 0.5);
396
+ ctx.closePath();
397
+ ctx.fill();
398
+ }
399
+
400
+ // --- Desenha Texto de Sobreposição (se fornecido) ---
401
+ if (this.overlayText) {
402
+ const overlayPadding = 8;
403
+ const overlayHeight = 30;
404
+
405
+ ctx.font = `bold 16px ${fontName}-Bold`;
406
+ const overlayWidth = ctx.measureText(this.overlayText).width + overlayPadding * 2;
407
+
408
+ const overlayX = padding;
409
+ const overlayY = padding;
410
+
411
+ // Fundo do texto de sobreposição
412
+ ctx.fillStyle = this.overlayTextBackground;
413
+ roundRect(ctx, overlayX, overlayY, overlayWidth, overlayHeight, 4, true, false);
414
+
415
+ // Texto de sobreposição
416
+ ctx.fillStyle = this.overlayTextColor;
417
+ ctx.textAlign = "center";
418
+ ctx.textBaseline = "middle";
419
+ ctx.fillText(this.overlayText, overlayX + overlayWidth / 2, overlayY + overlayHeight / 2);
420
+ }
421
+
422
+ // --- Desenha Título ---
423
+ const titleFontSize = Math.floor(width * 0.04); // Tamanho proporcional
424
+ const titleMaxWidth = width - padding * 2;
425
+ const titleY = height - padding - titleFontSize * 3;
426
+
427
+ // Fundo do título (opcional)
428
+ if (this.titleBackground) {
429
+ ctx.fillStyle = this.titleBackground;
430
+ roundRect(ctx, 0, titleY - padding, width, padding * 2 + titleFontSize * 3, 0, true, false);
431
+ }
432
+
433
+ // Texto do título
434
+ ctx.fillStyle = this.titleColor;
435
+ ctx.font = `bold ${titleFontSize}px ${fontName}-Bold`;
436
+ ctx.textAlign = "left";
437
+ ctx.textBaseline = "top";
438
+
439
+ // Aplica sombra de texto se ativada
440
+ if (this.useTextShadow) {
441
+ applyTextShadow(ctx, "rgba(0, 0, 0, 0.7)", 3, 2, 2);
442
+ }
443
+
444
+ wrapText(ctx, this.title, padding, titleY, titleMaxWidth, titleFontSize * 1.2, fontName);
445
+
446
+ // Remove sombra
447
+ if (this.useTextShadow) {
448
+ clearShadow(ctx);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Desenha elementos no estilo Shorts do YouTube
454
+ * @private
455
+ */
456
+ async _drawShortsStyle(ctx, fontName, width, height, padding) {
457
+ // --- Desenha Ícone de Shorts ---
458
+ const shortsIconSize = 40;
459
+ const shortsIconX = width - shortsIconSize - padding;
460
+ const shortsIconY = padding;
461
+
462
+ // Fundo do ícone
463
+ ctx.fillStyle = "#FF0000";
464
+ roundRect(ctx, shortsIconX, shortsIconY, shortsIconSize, shortsIconSize, 8, true, false);
465
+
466
+ // Letra "S" estilizada
467
+ ctx.fillStyle = "#FFFFFF";
468
+ ctx.font = `bold 28px ${fontName}-Bold`;
469
+ ctx.textAlign = "center";
470
+ ctx.textBaseline = "middle";
471
+ ctx.fillText("S", shortsIconX + shortsIconSize / 2, shortsIconY + shortsIconSize / 2);
472
+
473
+ // --- Desenha Título (na parte inferior) ---
474
+ const titleFontSize = Math.floor(width * 0.05); // Tamanho proporcional
475
+ const titleMaxWidth = width - padding * 2;
476
+ const titleY = height - padding * 6 - titleFontSize * 3;
477
+
478
+ // Fundo do título
479
+ ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
480
+ roundRect(ctx, 0, titleY - padding, width, padding * 2 + titleFontSize * 3, 0, true, false);
481
+
482
+ // Texto do título
483
+ ctx.fillStyle = "#FFFFFF";
484
+ ctx.font = `bold ${titleFontSize}px ${fontName}-Bold`;
485
+ ctx.textAlign = "left";
486
+ ctx.textBaseline = "top";
487
+
488
+ // Aplica sombra de texto se ativada
489
+ if (this.useTextShadow) {
490
+ applyTextShadow(ctx, "rgba(0, 0, 0, 0.7)", 3, 2, 2);
491
+ }
492
+
493
+ wrapText(ctx, this.title, padding, titleY, titleMaxWidth, titleFontSize * 1.2, fontName);
494
+
495
+ // Remove sombra
496
+ if (this.useTextShadow) {
497
+ clearShadow(ctx);
498
+ }
499
+
500
+ // --- Desenha Informações do Canal ---
501
+ if (this.channelName) {
502
+ const channelY = height - padding * 3;
503
+ const avatarSize = 40;
504
+
505
+ // Avatar do canal (se fornecido)
506
+ if (this.channelAvatar) {
507
+ try {
508
+ const avatarImg = await loadImageWithAxios(this.channelAvatar);
509
+
510
+ // Desenha círculo de recorte
511
+ ctx.save();
512
+ ctx.beginPath();
513
+ ctx.arc(padding + avatarSize / 2, channelY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
514
+ ctx.clip();
515
+
516
+ // Desenha avatar
517
+ ctx.drawImage(avatarImg, padding, channelY, avatarSize, avatarSize);
518
+ ctx.restore();
519
+ } catch (e) {
520
+ console.error("Falha ao desenhar avatar do canal:", e.message);
521
+ }
522
+ }
523
+
524
+ // Nome do canal
525
+ ctx.fillStyle = "#FFFFFF";
526
+ ctx.font = `16px ${fontName}`;
527
+ ctx.textAlign = "left";
528
+ ctx.textBaseline = "middle";
529
+ ctx.fillText(this.channelName, padding + avatarSize + 10, channelY + avatarSize / 2);
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Desenha elementos no estilo Premium do YouTube
535
+ * @private
536
+ */
537
+ async _drawPremiumStyle(ctx, fontName, width, height, padding) {
538
+ // --- Desenha Gradiente Premium ---
539
+ const gradient = createLinearGradient(ctx, 0, 0, width, height, ["#FF0000", "#8E2DE2"]);
540
+ ctx.fillStyle = gradient;
541
+ ctx.globalAlpha = 0.2;
542
+ ctx.fillRect(0, 0, width, height);
543
+ ctx.globalAlpha = 1;
544
+
545
+ // --- Desenha Ícone Premium ---
546
+ const premiumIconSize = 40;
547
+ const premiumIconX = width - premiumIconSize - padding;
548
+ const premiumIconY = padding;
549
+
550
+ // Fundo do ícone
551
+ ctx.fillStyle = "#FF0000";
552
+ roundRect(ctx, premiumIconX, premiumIconY, premiumIconSize, premiumIconSize, 20, true, false);
553
+
554
+ // Símbolo Premium (triângulo)
555
+ ctx.fillStyle = "#FFFFFF";
556
+ ctx.beginPath();
557
+ ctx.moveTo(premiumIconX + premiumIconSize / 2, premiumIconY + premiumIconSize * 0.25);
558
+ ctx.lineTo(premiumIconX + premiumIconSize * 0.25, premiumIconY + premiumIconSize * 0.75);
559
+ ctx.lineTo(premiumIconX + premiumIconSize * 0.75, premiumIconY + premiumIconSize * 0.75);
560
+ ctx.closePath();
561
+ ctx.fill();
562
+
563
+ // --- Desenha Duração do Vídeo ---
564
+ if (this.duration > 0) {
565
+ const durationText = this._formatDuration(this.duration);
566
+ const durationWidth = 70;
567
+ const durationHeight = 25;
568
+ const durationX = width - durationWidth - padding;
569
+ const durationY = height - durationHeight - padding;
570
+
571
+ // Fundo da duração
572
+ ctx.fillStyle = "rgba(0, 0, 0, 0.8)";
573
+ roundRect(ctx, durationX, durationY, durationWidth, durationHeight, 12, true, false);
574
+
575
+ // Texto da duração
576
+ ctx.fillStyle = "#FFFFFF";
577
+ ctx.font = `bold 14px ${fontName}-Bold`;
578
+ ctx.textAlign = "center";
579
+ ctx.textBaseline = "middle";
580
+ ctx.fillText(durationText, durationX + durationWidth / 2, durationY + durationHeight / 2);
581
+ }
582
+
583
+ // --- Desenha Título com Estilo Premium ---
584
+ const titleFontSize = Math.floor(width * 0.05); // Tamanho proporcional
585
+ const titleMaxWidth = width - padding * 2;
586
+ const titleY = height - padding * 8;
587
+
588
+ // Fundo do título com gradiente
589
+ const titleGradient = createLinearGradient(ctx, 0, titleY - padding, width, titleY + titleFontSize * 3 + padding, ["rgba(0,0,0,0.9)", "rgba(0,0,0,0.7)"]);
590
+ ctx.fillStyle = titleGradient;
591
+ roundRect(ctx, 0, titleY - padding, width, padding * 2 + titleFontSize * 3, 0, true, false);
592
+
593
+ // Texto do título
594
+ ctx.fillStyle = "#FFFFFF";
595
+ ctx.font = `bold ${titleFontSize}px ${fontName}-Bold`;
596
+ ctx.textAlign = "left";
597
+ ctx.textBaseline = "top";
598
+
599
+ // Aplica sombra de texto
600
+ applyTextShadow(ctx, "rgba(0, 0, 0, 0.7)", 3, 2, 2);
601
+
602
+ wrapText(ctx, this.title, padding, titleY, titleMaxWidth, titleFontSize * 1.2, fontName);
603
+
604
+ // Remove sombra
605
+ clearShadow(ctx);
606
+
607
+ // --- Desenha Informações do Canal ---
608
+ if (this.channelName) {
609
+ const channelY = height - padding * 3;
610
+ const avatarSize = 40;
611
+
612
+ // Avatar do canal (se fornecido)
613
+ if (this.channelAvatar) {
614
+ try {
615
+ const avatarImg = await loadImageWithAxios(this.channelAvatar);
616
+
617
+ // Desenha círculo de recorte
618
+ ctx.save();
619
+ ctx.beginPath();
620
+ ctx.arc(padding + avatarSize / 2, channelY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
621
+ ctx.clip();
622
+
623
+ // Desenha avatar
624
+ ctx.drawImage(avatarImg, padding, channelY, avatarSize, avatarSize);
625
+ ctx.restore();
626
+
627
+ // Borda premium
628
+ ctx.strokeStyle = "#FF0000";
629
+ ctx.lineWidth = 2;
630
+ ctx.beginPath();
631
+ ctx.arc(padding + avatarSize / 2, channelY + avatarSize / 2, avatarSize / 2 + 2, 0, Math.PI * 2);
632
+ ctx.stroke();
633
+ } catch (e) {
634
+ console.error("Falha ao desenhar avatar do canal:", e.message);
635
+ }
636
+ }
637
+
638
+ // Nome do canal
639
+ ctx.fillStyle = "#FFFFFF";
640
+ ctx.font = `16px ${fontName}`;
641
+ ctx.textAlign = "left";
642
+ ctx.textBaseline = "middle";
643
+ ctx.fillText(this.channelName, padding + avatarSize + 10, channelY + avatarSize / 2);
644
+
645
+ // Ícone de verificado
646
+ const verifiedSize = 16;
647
+ const verifiedX = padding + avatarSize + 10 + ctx.measureText(this.channelName).width + 5;
648
+ const verifiedY = channelY + avatarSize / 2 - verifiedSize / 2;
649
+
650
+ ctx.fillStyle = "#3EA6FF";
651
+ ctx.beginPath();
652
+ ctx.arc(verifiedX + verifiedSize / 2, verifiedY + verifiedSize / 2, verifiedSize / 2, 0, Math.PI * 2);
653
+ ctx.fill();
654
+
655
+ ctx.fillStyle = "#FFFFFF";
656
+ ctx.font = `bold 10px ${fontName}-Bold`;
657
+ ctx.textAlign = "center";
658
+ ctx.textBaseline = "middle";
659
+ ctx.fillText("✓", verifiedX + verifiedSize / 2, verifiedY + verifiedSize / 2);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Formata a duração em segundos para o formato MM:SS ou HH:MM:SS
665
+ * @param {number} seconds - Duração em segundos
666
+ * @returns {string} - Duração formatada
667
+ * @private
668
+ */
669
+ _formatDuration(seconds) {
670
+ const hours = Math.floor(seconds / 3600);
671
+ const minutes = Math.floor((seconds % 3600) / 60);
672
+ const secs = Math.floor(seconds % 60);
673
+
674
+ if (hours > 0) {
675
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
676
+ } else {
677
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
678
+ }
679
+ }
680
+ };
681
+