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