package main
import (
"context"
"encoding/json"
"fmt"
"image"
"log"
"os"
"path/filepath"
"strings"
"github.com/disintegration/imaging"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/gemini"
"ldpost/shared/groq"
"ldpost/shared/state"
"ldpost/shared/telegram"
"ldpost/shared/workspace"
)
// ─── Types ────────────────────────────────────────────────────────────────────
type slidePrompts struct {
DescricaoOriginal string `json:"descricao_original"`
PromptGemini string `json:"prompt_gemini"`
}
type artPromptsFile struct {
Slide1 slidePrompts `json:"slide1"`
Slide2 slidePrompts `json:"slide2"`
}
type artChoices struct {
Slide1 string // "bottom" or "right"
Slide2 string
}
// ─── Text parsing ─────────────────────────────────────────────────────────────
// parseImageSection extracts content under "## Imagem N" heading.
func parseImageSection(text, heading string) string {
lines := strings.Split(text, "\n")
inSection := false
var sb strings.Builder
for _, line := range lines {
if strings.HasPrefix(line, "## ") {
title := strings.TrimSpace(strings.TrimPrefix(line, "## "))
if strings.EqualFold(title, heading) {
inSection = true
continue
} else if inSection {
break
}
}
if inSection {
sb.WriteString(line + "\n")
}
}
return strings.TrimSpace(sb.String())
}
// firstNWords returns first n words from a string.
func firstNWords(s string, n int) string {
words := strings.Fields(s)
if len(words) <= n {
return s
}
return strings.Join(words[:n], " ")
}
// ─── Groq prompt builder ──────────────────────────────────────────────────────
const systemPromptImgTranslate = `Você converte descrições de imagens em prompts otimizados para geração com Gemini Image Generation.
Regras:
- Prompt em inglês
- Estilo: clean, professional, tech-focused, suitable for LinkedIn carousel
- Incluir: composição, estilo visual, paleta de cores sugerida (azul/branco/cinza para tech)
- Evitar: rostos humanos, texto em destaque (o Gemini erra texto), logos de marcas reais
- Máximo 100 palavras
- Retornar APENAS o prompt, sem explicação`
func buildGeminiPrompt(gc *groq.GroqClient, postContext, desc string) (string, error) {
system := systemPromptImgTranslate + "\n\nContexto do post: " + firstNWords(postContext, 50)
user := "Descrição original: " + desc
prompt, err := gc.Chat(groq.TextModel, groq.TextMessages(system, user), 0.4, 150)
if err != nil {
return "", fmt.Errorf("traduzir prompt: %w", err)
}
return strings.TrimSpace(prompt), nil
}
// ─── Image helpers ────────────────────────────────────────────────────────────
// findInputImage looks for input/imagemN in common formats.
func findInputImage(inputPath, base string) string {
for _, ext := range []string{".png", ".jpg", ".jpeg", ".webp"} {
p := filepath.Join(inputPath, base+ext)
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func cropBottomVariant(img image.Image, px int) image.Image {
b := img.Bounds()
newH := b.Max.Y - b.Min.Y - px
if newH <= 0 {
return img
}
return imaging.Crop(img, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+newH))
}
func cropRightVariant(img image.Image, px int) image.Image {
b := img.Bounds()
newW := b.Max.X - b.Min.X - px
if newW <= 0 {
return img
}
return imaging.Crop(img, image.Rect(b.Min.X, b.Min.Y, b.Min.X+newW, b.Max.Y))
}
// saveVariants crops bottom and right variants, returns (bottomPath, rightPath).
func saveVariants(rawPath, artRawDir, name string, cropBottom, cropRight int) (string, string, error) {
img, err := imaging.Open(rawPath)
if err != nil {
return "", "", fmt.Errorf("abrir %s: %w", rawPath, err)
}
bottomPath := filepath.Join(artRawDir, name+"-crop-bottom.png")
rightPath := filepath.Join(artRawDir, name+"-crop-right.png")
if err := imaging.Save(cropBottomVariant(img, cropBottom), bottomPath); err != nil {
return "", "", fmt.Errorf("salvar crop-bottom: %w", err)
}
if err := imaging.Save(cropRightVariant(img, cropRight), rightPath); err != nil {
return "", "", fmt.Errorf("salvar crop-right: %w", err)
}
return bottomPath, rightPath, nil
}
// ─── Telegram flow ────────────────────────────────────────────────────────────
func sendArtPreview(bot *telegram.Bot, slug string,
s1bottom, s1right, s2bottom, s2right string) error {
if _, err := bot.SendMessage(fmt.Sprintf(
"🎨 ldpost-art | %s\n\nGerei 2 imagens para o carrossel.\nEscolha a variante de cada uma:",
slug)); err != nil {
return fmt.Errorf("msg intro: %w", err)
}
if err := bot.SendMediaGroup([]telegram.MediaFile{
{Path: s1bottom, Caption: "Slide 1 — A) Corte de baixo"},
{Path: s1right, Caption: "Slide 1 — B) Corte da direita"},
}); err != nil {
log.Printf("[WARN] media group slide1: %v", err)
}
if err := bot.SendMediaGroup([]telegram.MediaFile{
{Path: s2bottom, Caption: "Slide 2 — A) Corte de baixo"},
{Path: s2right, Caption: "Slide 2 — B) Corte da direita"},
}); err != nil {
log.Printf("[WARN] media group slide2: %v", err)
}
buttons := [][]telegram.InlineButton{
{
{Text: "Slide 1: Corte de baixo", CallbackData: "s1_bottom"},
{Text: "Slide 1: Corte da direita", CallbackData: "s1_right"},
},
{
{Text: "Slide 2: Corte de baixo", CallbackData: "s2_bottom"},
{Text: "Slide 2: Corte da direita", CallbackData: "s2_right"},
},
{
{Text: "🔄 Regenerar tudo", CallbackData: "regenerar"},
{Text: "✅ Confirmar escolhas", CallbackData: "confirmar"},
},
}
if _, err := bot.SendMessageWithKeyboard("Selecione as variantes preferidas:", buttons); err != nil {
return fmt.Errorf("botões: %w", err)
}
return nil
}
// waitChoices polls Telegram until both slides are chosen and confirmed.
// Returns ("regenerar", {}) if user requests full regeneration.
func waitChoices(bot *telegram.Bot) (action string, choices artChoices) {
all := []string{"s1_bottom", "s1_right", "s2_bottom", "s2_right", "regenerar", "confirmar"}
for {
cb, err := bot.WaitForCallback(all, 0)
if err != nil {
log.Printf("[WARN] WaitForCallback: %v", err)
continue
}
switch cb {
case "s1_bottom":
choices.Slide1 = "bottom"
bot.SendMessage("✓ Slide 1: corte de baixo selecionado")
case "s1_right":
choices.Slide1 = "right"
bot.SendMessage("✓ Slide 1: corte da direita selecionado")
case "s2_bottom":
choices.Slide2 = "bottom"
bot.SendMessage("✓ Slide 2: corte de baixo selecionado")
case "s2_right":
choices.Slide2 = "right"
bot.SendMessage("✓ Slide 2: corte da direita selecionado")
case "regenerar":
return "regenerar", artChoices{}
case "confirmar":
if choices.Slide1 == "" || choices.Slide2 == "" {
bot.SendMessage("⚠️ Selecione variante para ambos os slides antes de confirmar.")
continue
}
return "confirmar", choices
}
}
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagPost string
flagWorkspace string
flagDryRun bool
flagSkipGeneration bool
flagCropBottom int
flagCropRight int
)
root := &cobra.Command{
Use: "ldpost-art --post ",
Short: "Gera imagens do carrossel via Gemini e envia para aprovação no Telegram",
RunE: func(cmd *cobra.Command, args []string) error {
if flagPost == "" {
return fmt.Errorf("--post é obrigatório")
}
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// CLI flags override .env
if !cmd.Flags().Changed("crop-bottom") {
flagCropBottom = cfg.CropBottomPx
}
if !cmd.Flags().Changed("crop-right") {
flagCropRight = cfg.CropRightPx
}
if !flagDryRun && !flagSkipGeneration {
if err := cfg.Validate("groq", "gemini"); err != nil {
return err
}
} else if !flagDryRun {
if err := cfg.Validate("groq"); err != nil {
return err
}
}
// ── 1. Encontrar post e validar state ─────────────────────────
postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost)
if err != nil {
return fmt.Errorf("post %q: %w", flagPost, err)
}
s, err := state.LoadState(postPath)
if err != nil {
return fmt.Errorf("ler state: %w", err)
}
if !s.IsStatus(state.StatusWaitingArt) {
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingArt)
return fmt.Errorf("estado incorreto — rode o agente correto para o status atual")
}
inputPath := workspace.InputPath(postPath)
artRawPath := workspace.ArtRawPath(postPath)
outputPath := workspace.OutputPath(postPath)
// ── 2. Ler input/texto.md ─────────────────────────────────────
textoRaw, err := os.ReadFile(workspace.InputTextoPath(postPath))
if err != nil {
return fmt.Errorf("input/texto.md: %w", err)
}
textoStr := string(textoRaw)
desc1 := parseImageSection(textoStr, "Imagem 1")
desc2 := parseImageSection(textoStr, "Imagem 2")
if desc1 == "" {
desc1 = "Abstract tech illustration for LinkedIn post slide 1"
log.Printf("[WARN] seção 'Imagem 1' não encontrada em texto.md — usando fallback")
}
if desc2 == "" {
desc2 = "Abstract tech illustration for LinkedIn post slide 2"
log.Printf("[WARN] seção 'Imagem 2' não encontrada em texto.md — usando fallback")
}
// ── 3. Ler post final para contexto ──────────────────────────
postContext := ""
if data, err := os.ReadFile(workspace.OutputPostPath(postPath)); err == nil {
postContext = strings.TrimSpace(string(data))
} else if finalPath := workspace.WorkPath(postPath) + "/editor-final.md"; func() bool {
_, e := os.Stat(finalPath)
return e == nil
}() {
if data, err := os.ReadFile(workspace.WorkPath(postPath) + "/editor-final.md"); err == nil {
postContext = strings.TrimSpace(string(data))
}
}
gc := groq.NewGroqClient(cfg.GroqAPIKey)
// ── 4. Loop principal (suporta regeneração) ───────────────────
for {
// ── 4a. Checar imagens de input direto ────────────────────
var raw1, raw2 string
if flagSkipGeneration {
raw1 = findInputImage(inputPath, "imagem1")
raw2 = findInputImage(inputPath, "imagem2")
if raw1 == "" || raw2 == "" {
return fmt.Errorf("--skip-generation requer input/imagem1.* e input/imagem2.*")
}
log.Printf("[INFO] usando imagens de input: %s, %s", filepath.Base(raw1), filepath.Base(raw2))
} else {
// ── 4b. Construir prompts via Groq ────────────────────
log.Printf("[INFO] gerando prompts Gemini via Groq...")
prompt1, err := buildGeminiPrompt(gc, postContext, desc1)
if err != nil {
log.Printf("[WARN] prompt slide1: %v — usando descrição original", err)
prompt1 = desc1
}
prompt2, err := buildGeminiPrompt(gc, postContext, desc2)
if err != nil {
log.Printf("[WARN] prompt slide2: %v — usando descrição original", err)
prompt2 = desc2
}
apf := artPromptsFile{
Slide1: slidePrompts{DescricaoOriginal: desc1, PromptGemini: prompt1},
Slide2: slidePrompts{DescricaoOriginal: desc2, PromptGemini: prompt2},
}
apfData, _ := json.MarshalIndent(apf, "", " ")
if err := os.WriteFile(workspace.ArtPromptsPath(postPath), apfData, 0644); err != nil {
log.Printf("[WARN] salvar art-prompts.json: %v", err)
}
log.Printf("[INFO] art-prompts.json salvo")
if flagDryRun {
fmt.Printf("=== DRY-RUN — ldpost-art ===\n")
fmt.Printf("post: %s\n", flagPost)
fmt.Printf("modelo: %s\n", gemini.ImageModelV2)
fmt.Printf("crop: bottom=%dpx right=%dpx\n", flagCropBottom, flagCropRight)
fmt.Printf("\nSlide 1 — descrição: %s\n", desc1)
fmt.Printf("Slide 1 — prompt: %s\n\n", prompt1)
fmt.Printf("Slide 2 — descrição: %s\n", desc2)
fmt.Printf("Slide 2 — prompt: %s\n", prompt2)
return nil
}
// ── 4c. Gerar imagens via Gemini ──────────────────────
gm := gemini.New(cfg.GeminiAPIKey)
imageModel := gemini.ImageModelV2
log.Printf("[INFO] gerando slide1 via Gemini...")
img1Bytes, err := gm.GenerateImageSquare(context.Background(), imageModel, prompt1)
if err != nil {
log.Printf("[WARN] gemini slide1: %v", err)
// fallback para input/imagem1.*
raw1 = findInputImage(inputPath, "imagem1")
if raw1 == "" {
return fmt.Errorf("falha ao gerar slide1 e sem fallback em input/imagem1.*: %w", err)
}
log.Printf("[INFO] usando fallback: %s", filepath.Base(raw1))
} else {
raw1 = filepath.Join(artRawPath, "slide1-raw.png")
if err := os.WriteFile(raw1, img1Bytes, 0644); err != nil {
return fmt.Errorf("salvar slide1-raw.png: %w", err)
}
}
log.Printf("[INFO] gerando slide2 via Gemini...")
img2Bytes, err := gm.GenerateImageSquare(context.Background(), imageModel, prompt2)
if err != nil {
log.Printf("[WARN] gemini slide2: %v", err)
raw2 = findInputImage(inputPath, "imagem2")
if raw2 == "" {
return fmt.Errorf("falha ao gerar slide2 e sem fallback em input/imagem2.*: %w", err)
}
log.Printf("[INFO] usando fallback: %s", filepath.Base(raw2))
} else {
raw2 = filepath.Join(artRawPath, "slide2-raw.png")
if err := os.WriteFile(raw2, img2Bytes, 0644); err != nil {
return fmt.Errorf("salvar slide2-raw.png: %w", err)
}
}
}
// ── 4d. Crop 2 variantes por imagem ──────────────────────
log.Printf("[INFO] gerando variantes de crop (bottom=%dpx, right=%dpx)...", flagCropBottom, flagCropRight)
s1bottom, s1right, err := saveVariants(raw1, artRawPath, "slide1", flagCropBottom, flagCropRight)
if err != nil {
return fmt.Errorf("crop slide1: %w", err)
}
s2bottom, s2right, err := saveVariants(raw2, artRawPath, "slide2", flagCropBottom, flagCropRight)
if err != nil {
return fmt.Errorf("crop slide2: %w", err)
}
log.Printf("[INFO] variantes salvas em %s", artRawPath)
// ── 4e. Telegram: enviar preview e aguardar escolha ───────
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
// Auto-approve first variant
log.Printf("[INFO] Telegram não configurado — aprovando variante 'bottom' automaticamente")
if err := copyFile(s1bottom, filepath.Join(outputPath, "slide1.png")); err != nil {
return err
}
if err := copyFile(s2bottom, filepath.Join(outputPath, "slide2.png")); err != nil {
return err
}
break
}
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
if err := sendArtPreview(bot, flagPost, s1bottom, s1right, s2bottom, s2right); err != nil {
log.Printf("[WARN] enviar preview: %v", err)
}
action, choices := waitChoices(bot)
if action == "regenerar" {
if flagSkipGeneration {
return fmt.Errorf("--skip-generation ativo: não é possível regenerar")
}
log.Printf("[INFO] regenerando imagens...")
bot.SendMessage("🔄 Regenerando imagens...")
continue // restart loop
}
// Copiar variantes escolhidas para output/
slide1src := s1bottom
if choices.Slide1 == "right" {
slide1src = s1right
}
slide2src := s2bottom
if choices.Slide2 == "right" {
slide2src = s2right
}
if err := copyFile(slide1src, filepath.Join(outputPath, "slide1.png")); err != nil {
return err
}
if err := copyFile(slide2src, filepath.Join(outputPath, "slide2.png")); err != nil {
return err
}
bot.SendMessage(fmt.Sprintf(
"✅ Imagens salvas em output/\n\nPróximo: ldpost-director --post %s",
flagPost))
break
}
// ── 5. Atualizar state ────────────────────────────────────────
s.Status = state.StatusWaitingDirector
s.SetEtapa("art", state.EtapaDone)
s.SetEtapa("director", state.EtapaWaiting)
if err := state.SaveState(postPath, s); err != nil {
return fmt.Errorf("state: %w", err)
}
fmt.Printf("✅ Arte concluída.\n")
fmt.Printf(" output/slide1.png e output/slide2.png\n")
fmt.Printf(" Próximo: ldpost-director --post %s\n", flagPost)
return nil
},
}
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório)")
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Mostra prompts sem chamar Gemini")
root.Flags().BoolVar(&flagSkipGeneration, "skip-generation", false, "Usa input/imagem1.* diretamente")
root.Flags().IntVar(&flagCropBottom, "crop-bottom", 48, "Pixels a cortar de baixo (override LDPOST_CROP_BOTTOM)")
root.Flags().IntVar(&flagCropRight, "crop-right", 48, "Pixels a cortar da direita (override LDPOST_CROP_RIGHT)")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("ler %s: %w", src, err)
}
if err := os.WriteFile(dst, data, 0644); err != nil {
return fmt.Errorf("escrever %s: %w", dst, err)
}
return nil
}