UNPKG

@cognima/banners

Version:

Biblioteca avançada para geração de banners dinâmicos para diversas plataformas e aplicações

682 lines (584 loc) 23.6 kB
"use strict"; /** * Módulo de Thumbnail do YouTube * * Este módulo gera thumbnails no estilo do YouTube com título, avatar do canal, * duração do vídeo e outros elementos visuais. * * @author Cognima Team (melhorado) * @version 2.0.0 */ Object.defineProperty(exports, "__esModule", { value: true }); const pureimage = require("pureimage"); const path = require("path"); const { loadImageWithAxios, encodeToBuffer, roundRect, wrapText, registerFontIfNeeded, isValidHexColor, DEFAULT_FONT_FAMILY, applyTextShadow, clearShadow, createLinearGradient, hexToRgba, formatTime } = require("../utils"); /** * @class YouTubeThumbnail * @classdesc Gera uma thumbnail no estilo do YouTube. * @example const thumbnail = new YouTubeThumbnail() * .setTitle("Como criar thumbnails atraentes para o YouTube") * .setBackground("image", "background.jpg") * .setChannelAvatar("avatar.png") * .setChannelName("Canal Criativo") * .setDuration(1250) * .build(); */ module.exports = class YouTubeThumbnail { constructor(options) { // Dados Principais this.title = "Título do Vídeo"; this.background = null; this.channelName = null; this.channelAvatar = null; this.duration = 0; // em segundos this.views = null; this.publishedAt = null; // Elementos Visuais this.overlayText = null; this.overlayTextColor = "#FFFFFF"; this.overlayTextBackground = "#FF0000"; this.showPlayButton = true; // Personalização Visual this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path }; this.titleColor = "#FFFFFF"; this.titleBackground = "rgba(0, 0, 0, 0.6)"; this.overlayOpacity = 0.3; this.useTextShadow = true; this.style = "standard"; // standard, shorts, premium // Configurações de Layout this.cardWidth = 1280; this.cardHeight = 720; this.cornerRadius = 12; } // --- Setters para Dados Principais --- /** * Define o título do vídeo * @param {string} text - Título do vídeo * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setTitle(text) { if (!text || typeof text !== "string") throw new Error("O título do vídeo deve ser uma string não vazia."); this.title = text; return this; } /** * Define o plano de fundo da thumbnail * @param {string} type - Tipo de plano de fundo ('color' ou 'image') * @param {string} value - Valor do plano de fundo (cor hexadecimal ou URL/caminho da imagem) * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setBackground(type, value) { const types = ["color", "image"]; if (!type || !types.includes(type.toLowerCase())) throw new Error("O tipo de plano de fundo deve ser 'color' ou 'image'."); if (!value) throw new Error("O valor do plano de fundo não pode estar vazio."); if (type.toLowerCase() === "color" && !isValidHexColor(value)) throw new Error("Cor de plano de fundo inválida. Use o formato hexadecimal."); this.background = { type: type.toLowerCase(), value }; return this; } /** * Define o nome do canal * @param {string} name - Nome do canal * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setChannelName(name) { if (!name || typeof name !== "string") throw new Error("O nome do canal deve ser uma string não vazia."); this.channelName = name; return this; } /** * Define o avatar do canal * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setChannelAvatar(image) { if (!image) throw new Error("A fonte da imagem do avatar não pode estar vazia."); this.channelAvatar = image; return this; } /** * Define a duração do vídeo * @param {number} seconds - Duração em segundos * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setDuration(seconds) { if (typeof seconds !== "number" || seconds < 0) throw new Error("A duração do vídeo deve ser um número não negativo."); this.duration = seconds; return this; } /** * Define o número de visualizações * @param {number} count - Número de visualizações * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setViews(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de visualizações deve ser um número não negativo."); this.views = count; return this; } /** * Define a data de publicação * @param {string} date - Data de publicação (ex: "há 2 dias") * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setPublishedAt(date) { if (!date || typeof date !== "string") throw new Error("A data de publicação deve ser uma string não vazia."); this.publishedAt = date; return this; } // --- Setters para Elementos Visuais --- /** * Define o texto de sobreposição (ex: "NOVO", "AO VIVO", etc.) * @param {string} text - Texto de sobreposição * @param {string} textColor - Cor do texto (hexadecimal) * @param {string} backgroundColor - Cor de fundo (hexadecimal) * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setOverlayText(text, textColor = "#FFFFFF", backgroundColor = "#FF0000") { if (!text || typeof text !== "string") throw new Error("O texto de sobreposição deve ser uma string não vazia."); if (textColor && !isValidHexColor(textColor)) throw new Error("Cor de texto inválida. Use o formato hexadecimal."); if (backgroundColor && !isValidHexColor(backgroundColor)) throw new Error("Cor de fundo inválida. Use o formato hexadecimal."); this.overlayText = text; this.overlayTextColor = textColor; this.overlayTextBackground = backgroundColor; return this; } /** * Ativa ou desativa o botão de play * @param {boolean} show - Se o botão de play deve ser exibido * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ showPlayButton(show = true) { this.showPlayButton = show; return this; } // --- Setters para Personalização Visual --- /** * Define a cor do título * @param {string} color - Cor hexadecimal * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setTitleColor(color) { if (!color || !isValidHexColor(color)) throw new Error("Cor de título inválida. Use o formato hexadecimal."); this.titleColor = color; return this; } /** * Define o fundo do título * @param {string} color - Cor hexadecimal ou rgba * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setTitleBackground(color) { this.titleBackground = color; return this; } /** * Define a opacidade da sobreposição * @param {number} opacity - Valor de opacidade (0-1) * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setOverlayOpacity(opacity) { if (typeof opacity !== "number" || opacity < 0 || opacity > 1) throw new Error("A opacidade da sobreposição deve estar entre 0 e 1."); this.overlayOpacity = opacity; return this; } /** * Ativa ou desativa a sombra de texto * @param {boolean} enabled - Se a sombra de texto deve ser ativada * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ enableTextShadow(enabled = true) { this.useTextShadow = enabled; return this; } /** * Define o estilo da thumbnail * @param {string} style - Estilo da thumbnail ('standard', 'shorts', 'premium') * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setStyle(style) { const validStyles = ["standard", "shorts", "premium"]; if (!style || !validStyles.includes(style.toLowerCase())) { throw new Error(`Estilo de thumbnail inválido. Use um dos seguintes: ${validStyles.join(", ")}`); } this.style = style.toLowerCase(); // Ajusta as dimensões com base no estilo if (this.style === "shorts") { this.cardWidth = 1080; this.cardHeight = 1920; } else { this.cardWidth = 1280; this.cardHeight = 720; } return this; } /** * Define as dimensões do card * @param {number} width - Largura do card em pixels * @param {number} height - Altura do card em pixels * @returns {YouTubeThumbnail} - Instância atual para encadeamento */ setCardDimensions(width, height) { if (typeof width !== "number" || width < 640 || width > 1920) { throw new Error("A largura do card deve estar entre 640 e 1920 pixels."); } if (typeof height !== "number" || height < 360 || height > 1920) { throw new Error("A altura do card deve estar entre 360 e 1920 pixels."); } this.cardWidth = width; this.cardHeight = height; return this; } // --- Método de Construção --- /** * Constrói a thumbnail e retorna um buffer de imagem * @returns {Promise<Buffer>} - Buffer contendo a imagem da thumbnail */ async build() { if (!this.background) throw new Error("O plano de fundo da thumbnail deve ser definido usando setBackground()."); // --- Registro de Fonte --- const registeredFontName = await registerFontIfNeeded(this.font); // --- Configuração do Canvas --- const cardWidth = this.cardWidth; const cardHeight = this.cardHeight; const padding = Math.floor(cardWidth * 0.03); // Padding proporcional const cornerRadius = this.cornerRadius; const canvas = pureimage.make(cardWidth, cardHeight); const ctx = canvas.getContext("2d"); // --- Desenha Plano de Fundo --- if (this.background.type === "color") { // Plano de fundo de cor sólida ctx.fillStyle = this.background.value; ctx.fillRect(0, 0, cardWidth, cardHeight); } else { // Plano de fundo de imagem try { const img = await loadImageWithAxios(this.background.value); const aspect = img.width / img.height; let drawWidth = cardWidth; let drawHeight = cardWidth / aspect; // Ajusta as dimensões para cobrir todo o card if (drawHeight < cardHeight) { drawHeight = cardHeight; drawWidth = cardHeight * aspect; } const offsetX = (cardWidth - drawWidth) / 2; const offsetY = (cardHeight - drawHeight) / 2; ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); // Aplica sobreposição para melhorar legibilidade do texto ctx.globalAlpha = this.overlayOpacity; ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, cardWidth, cardHeight); ctx.globalAlpha = 1; } catch (e) { console.error("Falha ao desenhar imagem de plano de fundo:", e.message); ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, cardWidth, cardHeight); } } // --- Desenha Elementos com base no Estilo --- if (this.style === "shorts") { // Estilo Shorts (vertical) await this._drawShortsStyle(ctx, registeredFontName, cardWidth, cardHeight, padding); } else if (this.style === "premium") { // Estilo Premium await this._drawPremiumStyle(ctx, registeredFontName, cardWidth, cardHeight, padding); } else { // Estilo Padrão await this._drawStandardStyle(ctx, registeredFontName, cardWidth, cardHeight, padding); } // --- Codifica e Retorna Buffer --- try { return await encodeToBuffer(canvas); } catch (err) { console.error("Falha ao codificar a Thumbnail do YouTube:", err); throw new Error("Não foi possível gerar o buffer de imagem da Thumbnail do YouTube."); } } // --- Métodos Auxiliares Privados --- /** * Desenha elementos no estilo padrão do YouTube * @private */ async _drawStandardStyle(ctx, fontName, width, height, padding) { // --- Desenha Duração do Vídeo --- if (this.duration > 0) { const durationText = this._formatDuration(this.duration); const durationWidth = 70; const durationHeight = 25; const durationX = width - durationWidth - padding; const durationY = height - durationHeight - padding; // Fundo da duração ctx.fillStyle = "rgba(0, 0, 0, 0.8)"; roundRect(ctx, durationX, durationY, durationWidth, durationHeight, 4, true, false); // Texto da duração ctx.fillStyle = "#FFFFFF"; ctx.font = `bold 14px ${fontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(durationText, durationX + durationWidth / 2, durationY + durationHeight / 2); } // --- Desenha Botão de Play (se ativado) --- if (this.showPlayButton) { const playSize = Math.min(width, height) * 0.15; const playX = width / 2 - playSize / 2; const playY = height / 2 - playSize / 2; // Círculo de fundo ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.beginPath(); ctx.arc(playX + playSize / 2, playY + playSize / 2, playSize / 2, 0, Math.PI * 2); ctx.fill(); // Triângulo de play ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); ctx.moveTo(playX + playSize * 0.35, playY + playSize * 0.25); ctx.lineTo(playX + playSize * 0.35, playY + playSize * 0.75); ctx.lineTo(playX + playSize * 0.75, playY + playSize * 0.5); ctx.closePath(); ctx.fill(); } // --- Desenha Texto de Sobreposição (se fornecido) --- if (this.overlayText) { const overlayPadding = 8; const overlayHeight = 30; ctx.font = `bold 16px ${fontName}-Bold`; const overlayWidth = ctx.measureText(this.overlayText).width + overlayPadding * 2; const overlayX = padding; const overlayY = padding; // Fundo do texto de sobreposição ctx.fillStyle = this.overlayTextBackground; roundRect(ctx, overlayX, overlayY, overlayWidth, overlayHeight, 4, true, false); // Texto de sobreposição ctx.fillStyle = this.overlayTextColor; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(this.overlayText, overlayX + overlayWidth / 2, overlayY + overlayHeight / 2); } // --- Desenha Título --- const titleFontSize = Math.floor(width * 0.04); // Tamanho proporcional const titleMaxWidth = width - padding * 2; const titleY = height - padding - titleFontSize * 3; // Fundo do título (opcional) if (this.titleBackground) { ctx.fillStyle = this.titleBackground; roundRect(ctx, 0, titleY - padding, width, padding * 2 + titleFontSize * 3, 0, true, false); } // Texto do título ctx.fillStyle = this.titleColor; ctx.font = `bold ${titleFontSize}px ${fontName}-Bold`; ctx.textAlign = "left"; ctx.textBaseline = "top"; // Aplica sombra de texto se ativada if (this.useTextShadow) { applyTextShadow(ctx, "rgba(0, 0, 0, 0.7)", 3, 2, 2); } wrapText(ctx, this.title, padding, titleY, titleMaxWidth, titleFontSize * 1.2, fontName); // Remove sombra if (this.useTextShadow) { clearShadow(ctx); } } /** * Desenha elementos no estilo Shorts do YouTube * @private */ async _drawShortsStyle(ctx, fontName, width, height, padding) { // --- Desenha Ícone de Shorts --- const shortsIconSize = 40; const shortsIconX = width - shortsIconSize - padding; const shortsIconY = padding; // Fundo do ícone ctx.fillStyle = "#FF0000"; roundRect(ctx, shortsIconX, shortsIconY, shortsIconSize, shortsIconSize, 8, true, false); // Letra "S" estilizada ctx.fillStyle = "#FFFFFF"; ctx.font = `bold 28px ${fontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("S", shortsIconX + shortsIconSize / 2, shortsIconY + shortsIconSize / 2); // --- Desenha Título (na parte inferior) --- const titleFontSize = Math.floor(width * 0.05); // Tamanho proporcional const titleMaxWidth = width - padding * 2; const titleY = height - padding * 6 - titleFontSize * 3; // Fundo do título ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; roundRect(ctx, 0, titleY - padding, width, padding * 2 + titleFontSize * 3, 0, true, false); // Texto do título ctx.fillStyle = "#FFFFFF"; ctx.font = `bold ${titleFontSize}px ${fontName}-Bold`; ctx.textAlign = "left"; ctx.textBaseline = "top"; // Aplica sombra de texto se ativada if (this.useTextShadow) { applyTextShadow(ctx, "rgba(0, 0, 0, 0.7)", 3, 2, 2); } wrapText(ctx, this.title, padding, titleY, titleMaxWidth, titleFontSize * 1.2, fontName); // Remove sombra if (this.useTextShadow) { clearShadow(ctx); } // --- Desenha Informações do Canal --- if (this.channelName) { const channelY = height - padding * 3; const avatarSize = 40; // Avatar do canal (se fornecido) if (this.channelAvatar) { try { const avatarImg = await loadImageWithAxios(this.channelAvatar); // Desenha círculo de recorte ctx.save(); ctx.beginPath(); ctx.arc(padding + avatarSize / 2, channelY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); ctx.clip(); // Desenha avatar ctx.drawImage(avatarImg, padding, channelY, avatarSize, avatarSize); ctx.restore(); } catch (e) { console.error("Falha ao desenhar avatar do canal:", e.message); } } // Nome do canal ctx.fillStyle = "#FFFFFF"; ctx.font = `16px ${fontName}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(this.channelName, padding + avatarSize + 10, channelY + avatarSize / 2); } } /** * Desenha elementos no estilo Premium do YouTube * @private */ async _drawPremiumStyle(ctx, fontName, width, height, padding) { // --- Desenha Gradiente Premium --- const gradient = createLinearGradient(ctx, 0, 0, width, height, ["#FF0000", "#8E2DE2"]); ctx.fillStyle = gradient; ctx.globalAlpha = 0.2; ctx.fillRect(0, 0, width, height); ctx.globalAlpha = 1; // --- Desenha Ícone Premium --- const premiumIconSize = 40; const premiumIconX = width - premiumIconSize - padding; const premiumIconY = padding; // Fundo do ícone ctx.fillStyle = "#FF0000"; roundRect(ctx, premiumIconX, premiumIconY, premiumIconSize, premiumIconSize, 20, true, false); // Símbolo Premium (triângulo) ctx.fillStyle = "#FFFFFF"; ctx.beginPath(); ctx.moveTo(premiumIconX + premiumIconSize / 2, premiumIconY + premiumIconSize * 0.25); ctx.lineTo(premiumIconX + premiumIconSize * 0.25, premiumIconY + premiumIconSize * 0.75); ctx.lineTo(premiumIconX + premiumIconSize * 0.75, premiumIconY + premiumIconSize * 0.75); ctx.closePath(); ctx.fill(); // --- Desenha Duração do Vídeo --- if (this.duration > 0) { const durationText = this._formatDuration(this.duration); const durationWidth = 70; const durationHeight = 25; const durationX = width - durationWidth - padding; const durationY = height - durationHeight - padding; // Fundo da duração ctx.fillStyle = "rgba(0, 0, 0, 0.8)"; roundRect(ctx, durationX, durationY, durationWidth, durationHeight, 12, true, false); // Texto da duração ctx.fillStyle = "#FFFFFF"; ctx.font = `bold 14px ${fontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(durationText, durationX + durationWidth / 2, durationY + durationHeight / 2); } // --- Desenha Título com Estilo Premium --- const titleFontSize = Math.floor(width * 0.05); // Tamanho proporcional const titleMaxWidth = width - padding * 2; const titleY = height - padding * 8; // Fundo do título com gradiente const titleGradient = createLinearGradient(ctx, 0, titleY - padding, width, titleY + titleFontSize * 3 + padding, ["rgba(0,0,0,0.9)", "rgba(0,0,0,0.7)"]); ctx.fillStyle = titleGradient; roundRect(ctx, 0, titleY - padding, width, padding * 2 + titleFontSize * 3, 0, true, false); // Texto do título ctx.fillStyle = "#FFFFFF"; ctx.font = `bold ${titleFontSize}px ${fontName}-Bold`; ctx.textAlign = "left"; ctx.textBaseline = "top"; // Aplica sombra de texto applyTextShadow(ctx, "rgba(0, 0, 0, 0.7)", 3, 2, 2); wrapText(ctx, this.title, padding, titleY, titleMaxWidth, titleFontSize * 1.2, fontName); // Remove sombra clearShadow(ctx); // --- Desenha Informações do Canal --- if (this.channelName) { const channelY = height - padding * 3; const avatarSize = 40; // Avatar do canal (se fornecido) if (this.channelAvatar) { try { const avatarImg = await loadImageWithAxios(this.channelAvatar); // Desenha círculo de recorte ctx.save(); ctx.beginPath(); ctx.arc(padding + avatarSize / 2, channelY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); ctx.clip(); // Desenha avatar ctx.drawImage(avatarImg, padding, channelY, avatarSize, avatarSize); ctx.restore(); // Borda premium ctx.strokeStyle = "#FF0000"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(padding + avatarSize / 2, channelY + avatarSize / 2, avatarSize / 2 + 2, 0, Math.PI * 2); ctx.stroke(); } catch (e) { console.error("Falha ao desenhar avatar do canal:", e.message); } } // Nome do canal ctx.fillStyle = "#FFFFFF"; ctx.font = `16px ${fontName}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(this.channelName, padding + avatarSize + 10, channelY + avatarSize / 2); // Ícone de verificado const verifiedSize = 16; const verifiedX = padding + avatarSize + 10 + ctx.measureText(this.channelName).width + 5; const verifiedY = channelY + avatarSize / 2 - verifiedSize / 2; ctx.fillStyle = "#3EA6FF"; ctx.beginPath(); ctx.arc(verifiedX + verifiedSize / 2, verifiedY + verifiedSize / 2, verifiedSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold 10px ${fontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("✓", verifiedX + verifiedSize / 2, verifiedY + verifiedSize / 2); } } /** * Formata a duração em segundos para o formato MM:SS ou HH:MM:SS * @param {number} seconds - Duração em segundos * @returns {string} - Duração formatada * @private */ _formatDuration(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } else { return `${minutes}:${secs.toString().padStart(2, '0')}`; } } };