@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
JavaScript
"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"
};
}
}
};