UNPKG

@cognima/banners

Version:

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

714 lines (607 loc) 21.8 kB
"use strict"; /** * Módulo de Banner de Post do TikTok * * Este módulo gera banners no estilo de posts do TikTok com * elementos visuais característicos da plataforma. * * @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, formatNumber } = require("../utils"); const { DEFAULT_COLORS, LAYOUT, DEFAULT_DIMENSIONS } = require("./constants"); const { applyGlassmorphism, applyMultiColorGradient, applyGlow } = require("./effects"); /** * @class TikTokPost * @classdesc Gera um banner no estilo de post do TikTok. * @example const post = new TikTokPost() * .setUsername("usuario_tiktok") * .setCaption("Texto da legenda com #hashtags") * .setImage("imagem.jpg") * .setLikes(15000) * .setComments(500) * .setShares(200) * .setMusicInfo("Nome da música - Artista") * .build(); */ module.exports = class TikTokPost { constructor(options) { // Dados Principais this.username = "usuario_tiktok"; this.userAvatar = null; this.verified = false; this.caption = "Texto da legenda com #hashtags"; this.image = null; this.likes = 0; this.comments = 0; this.shares = 0; this.bookmarks = 0; this.musicInfo = null; this.duration = "00:30"; this.hashtags = []; this.effect = null; // Personalização Visual this.font = { name: options?.font?.name ?? DEFAULT_FONT_FAMILY, path: options?.font?.path }; this.theme = "dark"; // dark, light this.style = "standard"; // standard, duet, stitch this.useGlassmorphism = false; this.useTextShadow = true; this.useGradientOverlay = true; // Configurações de Layout this.cardWidth = DEFAULT_DIMENSIONS.story.width; this.cardHeight = DEFAULT_DIMENSIONS.story.height; this.cornerRadius = 0; } // --- Setters para Dados Principais --- /** * Define o nome de usuário * @param {string} text - Nome de usuário (sem @) * @returns {TikTokPost} - Instância atual para encadeamento */ setUsername(text) { if (!text || typeof text !== "string") throw new Error("O nome de usuário deve ser uma string não vazia."); this.username = text.replace(/^@/, ''); // Remove @ se presente return this; } /** * Define o avatar do usuário * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem do avatar * @returns {TikTokPost} - Instância atual para encadeamento */ setUserAvatar(image) { if (!image) throw new Error("A fonte da imagem do avatar não pode estar vazia."); this.userAvatar = image; return this; } /** * Define se o usuário é verificado * @param {boolean} isVerified - Se o usuário é verificado * @returns {TikTokPost} - Instância atual para encadeamento */ setVerified(isVerified = true) { this.verified = !!isVerified; return this; } /** * Define a legenda do post * @param {string} text - Texto da legenda * @returns {TikTokPost} - Instância atual para encadeamento */ setCaption(text) { if (!text || typeof text !== "string") throw new Error("A legenda deve ser uma string não vazia."); this.caption = text; // Extrai hashtags automaticamente const hashtagRegex = /#(\w+)/g; const matches = text.match(hashtagRegex); if (matches) { this.hashtags = matches.map(tag => tag.substring(1)); // Remove o # do início } return this; } /** * Define a imagem principal do post * @param {string|Buffer|Object} image - URL, Buffer ou caminho da imagem * @returns {TikTokPost} - Instância atual para encadeamento */ setImage(image) { if (!image) throw new Error("A fonte da imagem não pode estar vazia."); this.image = image; return this; } /** * Define o número de curtidas * @param {number} count - Número de curtidas * @returns {TikTokPost} - Instância atual para encadeamento */ setLikes(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de curtidas deve ser um número não negativo."); this.likes = count; return this; } /** * Define o número de comentários * @param {number} count - Número de comentários * @returns {TikTokPost} - Instância atual para encadeamento */ setComments(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de comentários deve ser um número não negativo."); this.comments = count; return this; } /** * Define o número de compartilhamentos * @param {number} count - Número de compartilhamentos * @returns {TikTokPost} - Instância atual para encadeamento */ setShares(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de compartilhamentos deve ser um número não negativo."); this.shares = count; return this; } /** * Define o número de salvamentos * @param {number} count - Número de salvamentos * @returns {TikTokPost} - Instância atual para encadeamento */ setBookmarks(count) { if (typeof count !== "number" || count < 0) throw new Error("O número de salvamentos deve ser um número não negativo."); this.bookmarks = count; return this; } /** * Define as informações da música * @param {string} text - Informações da música (ex: "Nome da música - Artista") * @returns {TikTokPost} - Instância atual para encadeamento */ setMusicInfo(text) { if (!text || typeof text !== "string") throw new Error("As informações da música devem ser uma string não vazia."); this.musicInfo = text; return this; } /** * Define a duração do vídeo * @param {string} text - Duração do vídeo (ex: "00:30") * @returns {TikTokPost} - Instância atual para encadeamento */ setDuration(text) { if (!text || typeof text !== "string") throw new Error("A duração deve ser uma string não vazia."); this.duration = text; return this; } /** * Define as hashtags do post * @param {Array<string>} tags - Array de hashtags (sem o #) * @returns {TikTokPost} - Instância atual para encadeamento */ setHashtags(tags) { if (!Array.isArray(tags)) throw new Error("As hashtags devem ser um array de strings."); this.hashtags = tags; return this; } /** * Define o efeito usado no post * @param {string} text - Nome do efeito * @returns {TikTokPost} - Instância atual para encadeamento */ setEffect(text) { if (!text || typeof text !== "string") throw new Error("O efeito deve ser uma string não vazia."); this.effect = text; return this; } // --- Setters para Personalização Visual --- /** * Define o tema * @param {string} theme - Tema ('dark', 'light') * @returns {TikTokPost} - Instância atual para encadeamento */ setTheme(theme) { const validThemes = ["dark", "light"]; if (!theme || !validThemes.includes(theme.toLowerCase())) { throw new Error(`Tema inválido. Use um dos seguintes: ${validThemes.join(", ")}`); } this.theme = theme.toLowerCase(); return this; } /** * Define o estilo * @param {string} style - Estilo ('standard', 'duet', 'stitch') * @returns {TikTokPost} - Instância atual para encadeamento */ setStyle(style) { const validStyles = ["standard", "duet", "stitch"]; if (!style || !validStyles.includes(style.toLowerCase())) { throw new Error(`Estilo inválido. Use um dos seguintes: ${validStyles.join(", ")}`); } this.style = style.toLowerCase(); return this; } /** * Ativa ou desativa o efeito de glassmorphism * @param {boolean} enabled - Se o efeito deve ser ativado * @returns {TikTokPost} - Instância atual para encadeamento */ enableGlassmorphism(enabled = true) { this.useGlassmorphism = enabled; return this; } /** * Ativa ou desativa a sombra de texto * @param {boolean} enabled - Se a sombra de texto deve ser ativada * @returns {TikTokPost} - Instância atual para encadeamento */ enableTextShadow(enabled = true) { this.useTextShadow = enabled; return this; } /** * Ativa ou desativa o overlay de gradiente * @param {boolean} enabled - Se o overlay de gradiente deve ser ativado * @returns {TikTokPost} - Instância atual para encadeamento */ enableGradientOverlay(enabled = true) { this.useGradientOverlay = enabled; 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 {TikTokPost} - Instância atual para encadeamento */ setCardDimensions(width, height) { if (typeof width !== "number" || width < 400 || width > 1080) { throw new Error("A largura do card deve estar entre 400 e 1080 pixels."); } if (typeof height !== "number" || height < 600 || height > 1920) { throw new Error("A altura do card deve estar entre 600 e 1920 pixels."); } this.cardWidth = width; this.cardHeight = height; return this; } /** * Define o raio dos cantos arredondados * @param {number} radius - Raio dos cantos em pixels * @returns {TikTokPost} - Instância atual para encadeamento */ setCornerRadius(radius) { if (typeof radius !== "number" || radius < 0) throw new Error("O raio dos cantos deve ser um número não negativo."); this.cornerRadius = radius; return this; } // --- Método de Construção --- /** * Constrói o banner e retorna um buffer de imagem * @returns {Promise<Buffer>} - Buffer contendo a imagem do banner */ async build() { // --- Registro de Fonte --- const registeredFontName = await registerFontIfNeeded(this.font); // --- Configuração do Canvas --- const cardWidth = this.cardWidth; const cardHeight = this.cardHeight; const cornerRadius = this.cornerRadius; const padding = 20; const canvas = pureimage.make(cardWidth, cardHeight); const ctx = canvas.getContext("2d"); // --- Configuração de Cores com base no Tema --- const colors = this._getThemeColors(); // --- Desenha Plano de Fundo --- ctx.fillStyle = colors.background; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); // --- Desenha Imagem Principal --- try { ctx.save(); if (cornerRadius > 0) { roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, false, false); ctx.clip(); } const img = await loadImageWithAxios(this.image || path.join(__dirname, "../assets/placeholders/banner.png")); const aspect = img.width / img.height; let drawWidth, drawHeight; // Ajusta as dimensões com base no estilo if (this.style === "duet") { // Modo dueto (imagem ocupa metade da largura) drawWidth = cardWidth / 2; drawHeight = cardHeight; if (drawWidth / drawHeight > aspect) { drawWidth = drawHeight * aspect; } else { drawHeight = drawWidth / aspect; } const offsetX = 0; const offsetY = (cardHeight - drawHeight) / 2; ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); // Desenha área para o segundo vídeo ctx.fillStyle = colors.secondaryBackground; ctx.fillRect(cardWidth / 2, 0, cardWidth / 2, cardHeight); } else if (this.style === "stitch") { // Modo stitch (imagem ocupa parte superior) drawWidth = cardWidth; drawHeight = cardHeight * 0.4; if (drawWidth / drawHeight > aspect) { drawWidth = drawHeight * aspect; } else { drawHeight = drawWidth / aspect; } const offsetX = (cardWidth - drawWidth) / 2; const offsetY = 0; ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); // Desenha área para o segundo vídeo ctx.fillStyle = colors.secondaryBackground; ctx.fillRect(0, cardHeight * 0.4, cardWidth, cardHeight * 0.6); } else { // Modo padrão (imagem ocupa tela inteira) drawWidth = cardWidth; drawHeight = cardHeight; if (drawWidth / drawHeight > aspect) { drawWidth = drawHeight * aspect; } else { drawHeight = drawWidth / aspect; } const offsetX = (cardWidth - drawWidth) / 2; const offsetY = (cardHeight - drawHeight) / 2; ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight); } ctx.restore(); } catch (e) { console.error("Falha ao desenhar imagem principal:", e.message); // Plano de fundo de fallback ctx.fillStyle = colors.background; roundRect(ctx, 0, 0, cardWidth, cardHeight, cornerRadius, true, false); } // --- Aplica Overlay de Gradiente --- if (this.useGradientOverlay) { const gradient = createLinearGradient( ctx, 0, cardHeight * 0.7, 0, cardHeight, "rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0.7)", "vertical" ); ctx.fillStyle = gradient; ctx.fillRect(0, cardHeight * 0.7, cardWidth, cardHeight * 0.3); } // --- Desenha Interface do TikTok --- // --- Barra Superior --- const topBarHeight = 50; // Aplica efeito de glassmorphism se ativado if (this.useGlassmorphism) { applyGlassmorphism( ctx, 0, 0, cardWidth, topBarHeight, 0, 0.3, "#000000" ); } // Ícones da barra superior ctx.fillStyle = colors.text; ctx.font = `bold 16px ${registeredFontName}-Bold`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; // Ícone de transmissão ao vivo (se aplicável) if (this.style === "standard") { ctx.fillText("LIVE", padding, topBarHeight / 2); } // Duração do vídeo ctx.textAlign = "right"; ctx.fillText(this.duration, cardWidth - padding, topBarHeight / 2); // --- Barra Lateral (Ações) --- const sideBarWidth = 80; const sideBarX = cardWidth - sideBarWidth; const sideBarY = cardHeight * 0.3; // Avatar do usuário const avatarSize = 50; const avatarX = sideBarX + (sideBarWidth - avatarSize) / 2; const avatarY = sideBarY; ctx.save(); ctx.beginPath(); ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); try { const avatarImg = await loadImageWithAxios(this.userAvatar || path.join(__dirname, "../assets/placeholders/avatar.png")); ctx.drawImage(avatarImg, avatarX, avatarY, avatarSize, avatarSize); } catch (e) { console.error("Falha ao desenhar avatar:", e.message); // Avatar de fallback ctx.fillStyle = "#FF0050"; ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold ${avatarSize / 3}px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(this.username.charAt(0).toUpperCase(), avatarX + avatarSize / 2, avatarY + avatarSize / 2); } ctx.restore(); // Botão de seguir const followButtonSize = 20; const followButtonX = avatarX + (avatarSize - followButtonSize) / 2; const followButtonY = avatarY + avatarSize + 5; ctx.fillStyle = "#FF0050"; ctx.beginPath(); ctx.arc(followButtonX + followButtonSize / 2, followButtonY + followButtonSize / 2, followButtonSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold ${followButtonSize * 0.7}px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("+", followButtonX + followButtonSize / 2, followButtonY + followButtonSize / 2); // Ícones de interação const iconSpacing = 70; let currentIconY = followButtonY + followButtonSize + 30; // Ícone de curtida ctx.fillStyle = colors.text; ctx.font = `bold 14px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.fillText("♥", avatarX + avatarSize / 2, currentIconY); ctx.fillText(formatNumber(this.likes), avatarX + avatarSize / 2, currentIconY + 25); currentIconY += iconSpacing; // Ícone de comentário ctx.fillText("💬", avatarX + avatarSize / 2, currentIconY); ctx.fillText(formatNumber(this.comments), avatarX + avatarSize / 2, currentIconY + 25); currentIconY += iconSpacing; // Ícone de compartilhamento ctx.fillText("↗", avatarX + avatarSize / 2, currentIconY); ctx.fillText(formatNumber(this.shares), avatarX + avatarSize / 2, currentIconY + 25); currentIconY += iconSpacing; // Ícone de salvamento ctx.fillText("🔖", avatarX + avatarSize / 2, currentIconY); ctx.fillText(formatNumber(this.bookmarks), avatarX + avatarSize / 2, currentIconY + 25); // --- Barra Inferior (Informações) --- const bottomBarHeight = 150; const bottomBarY = cardHeight - bottomBarHeight; // Aplica efeito de glassmorphism se ativado if (this.useGlassmorphism) { applyGlassmorphism( ctx, 0, bottomBarY, cardWidth - sideBarWidth, bottomBarHeight, 0, 0.3, "#000000" ); } // Nome de usuário ctx.fillStyle = colors.text; ctx.font = `bold 18px ${registeredFontName}-Bold`; ctx.textAlign = "left"; ctx.textBaseline = "top"; // Aplica sombra de texto se ativada if (this.useTextShadow) { applyTextShadow(ctx, "rgba(0, 0, 0, 0.5)", 2, 1, 1); } const usernameText = `@${this.username}`; const usernameWidth = ctx.measureText(usernameText).width; ctx.fillText(usernameText, padding, bottomBarY + padding); // Desenha ícone de verificado (se aplicável) if (this.verified) { const verifiedSize = 16; const verifiedX = padding + usernameWidth + 5; ctx.fillStyle = "#20D5EC"; ctx.beginPath(); ctx.arc(verifiedX + verifiedSize / 2, bottomBarY + padding + 9, verifiedSize / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#FFFFFF"; ctx.font = `bold 12px ${registeredFontName}-Bold`; ctx.textAlign = "center"; ctx.fillText("✓", verifiedX + verifiedSize / 2, bottomBarY + padding + 9); } // Remove sombra para o próximo texto if (this.useTextShadow) { clearShadow(ctx); } // Legenda ctx.fillStyle = colors.text; ctx.font = `regular 16px ${registeredFontName}-Regular`; ctx.textAlign = "left"; // Aplica sombra de texto se ativada if (this.useTextShadow) { applyTextShadow(ctx, "rgba(0, 0, 0, 0.5)", 2, 1, 1); } wrapText(ctx, this.caption, padding, bottomBarY + padding + 30, cardWidth - sideBarWidth - padding * 2, 20, registeredFontName); // Remove sombra para o próximo texto if (this.useTextShadow) { clearShadow(ctx); } // Informações da música if (this.musicInfo) { ctx.fillStyle = colors.text; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; // Aplica sombra de texto se ativada if (this.useTextShadow) { applyTextShadow(ctx, "rgba(0, 0, 0, 0.5)", 2, 1, 1); } ctx.fillText(`🎵 ${this.musicInfo}`, padding, cardHeight - padding); // Remove sombra para o próximo texto if (this.useTextShadow) { clearShadow(ctx); } } // Efeito (se fornecido) if (this.effect) { ctx.fillStyle = colors.text; ctx.font = `regular 14px ${registeredFontName}-Regular`; ctx.textAlign = "right"; ctx.textBaseline = "bottom"; // Aplica sombra de texto se ativada if (this.useTextShadow) { applyTextShadow(ctx, "rgba(0, 0, 0, 0.5)", 2, 1, 1); } ctx.fillText(`✨ ${this.effect}`, cardWidth - sideBarWidth - padding, cardHeight - padding); // Remove sombra para o próximo texto if (this.useTextShadow) { clearShadow(ctx); } } // --- Codifica e Retorna Buffer --- try { return await encodeToBuffer(canvas); } catch (err) { console.error("Falha ao codificar o Post do TikTok:", err); throw new Error("Não foi possível gerar o buffer de imagem do Post do TikTok."); } } // --- Métodos Auxiliares Privados --- /** * Obtém as cores com base no tema selecionado * @private */ _getThemeColors() { switch (this.theme) { case "light": return { background: "#FFFFFF", secondaryBackground: "#F8F8F8", text: "#000000", textSecondary: "#888888", accent: "#FF0050" }; case "dark": default: return { background: "#000000", secondaryBackground: "#121212", text: "#FFFFFF", textSecondary: "#AAAAAA", accent: "#FF0050" }; } } };