]> git.laktatnebel.de Git - websitegenerator.git/commitdiff
Social-Asset-Generator (Phase 2) implementiert
authorClaude Code AI <claude@poseidon.laktatnebel.de>
Thu, 25 Jun 2026 10:01:25 +0000 (12:01 +0200)
committerClaude Code AI <claude@poseidon.laktatnebel.de>
Thu, 25 Jun 2026 10:01:25 +0000 (12:01 +0200)
Neue Klassen in package .social:
- SocialSlide (Record): Typen HERO, SECTION, CTA + Heading/Subtext/Hero
- SocialGenerator: Orchestrierung pro Artikel
  - h2-Überschriften aus Markdown-Body extrahieren
  - {social}-Marker: nur markierte Überschriften nutzen (max. 6)
  - Ohne Marker: erste 6 Überschriften
  - Hero-Crops erzeugen (4:5, 9:16, 1:1)
  - Slides planen: Slide 1 = Hero+Titel, Folge-Slides = Abschnitte, letzter = CTA
  - caption.txt: Hook-Platzhalter + Excerpt + Hashtags + Link
- SlideRenderer: ImageMagick-Pipeline
  - Hero auf Zielformat croppen (fill + center)
  - SVG-Template proportional auf 1080px Breite skalieren
  - SVG als Overlay am unteren Rand kompositionieren (Deko-Balken sichtbar)
  - Text per -annotate mit Zeilenumbruch-Hilfsmethode

SiteConfig: neue Felder socialOutputPath, socialTemplate,
  socialHashtags, socialFont (aus paths/social_assets in config.yaml)

Main: --emit-social Flag verdrahtet

SPEC.md: Phase 2 + Roadmap ergänzt (Social-Asset-Generator, TOC-Bug, Kandidaten)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SPEC.md
src/main/java/de/laktatnebel/product/websitegenerator/Main.java
src/main/java/de/laktatnebel/product/websitegenerator/config/SiteConfig.java
src/main/java/de/laktatnebel/product/websitegenerator/social/SlideRenderer.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/social/SocialGenerator.java [new file with mode: 0644]
src/main/java/de/laktatnebel/product/websitegenerator/social/SocialSlide.java [new file with mode: 0644]

diff --git a/SPEC.md b/SPEC.md
index b7ee564d1a91e4023c037fc8201f9e53fda4ae55..28e196b98d701a2e224e1b8a75332ddbb3bc8958 100644 (file)
--- a/SPEC.md
+++ b/SPEC.md
@@ -245,3 +245,165 @@ meta_*, canonical_url, strikte Bild-Konventionen + Validierung.
 4. Test gegen echtes Schema (db/schema.sql) mit lokaler PostgreSQL:
    generiertes SQL muss fehlerfrei durchlaufen und korrekt verknüpfen.
 5. Lint-Ausgabe vervollständigen.
+
+---
+
+# SPEC (Phase 2): Social-Asset-Generator (Instagram via Buffer)
+
+Status: Design abgeschlossen, Implementierung offen.
+Reihenfolge: NACH dem Markdown-Loader. Baut auf der content/blog/<slug>/-
+Struktur auf – sobald Artikel mit Hero + Überschriften vorliegen, ist
+dies nur ein weiterer Output-Schritt über denselben Daten.
+
+## Zweck
+
+Das manuelle Zusammenschieben von Instagram-Posts am Handy entfällt.
+Der Build-Lauf erzeugt fertige Bild-Assets + Caption-Text pro Artikel.
+Der Redakteur lädt diese am Rechner in Buffer.com hoch – KEINE direkte
+Instagram-Integration, keine API, kein OAuth. Buffer übernimmt Timing
+und Posting. Lokal-first: Dateien raus, Mensch behält Kontrolle.
+
+WICHTIG: Speichern ≠ Posten (anders als bei der Website). Das Posten
+bleibt bewusst manuell, weil Social Timing und Kontext braucht.
+
+## Scope Phase 2
+
+Nur KARUSSELL-Posts. Das ist der Sweet Spot:
+- Artikelstruktur (Hero + ## Überschriften) = Karussellstruktur, fast
+  vollständig automatisierbar.
+- Story: später, teilautomatisierbar, dünner Mehrwert.
+- Reel: NICHT automatisieren. Statisches Hero + Text als Pseudo-Video
+  wird als Bot-Content erkannt und vom Algorithmus bestraft. Reels
+  brauchen echtes Bewegtbild.
+
+## Output-Layout
+
+```
+social/<slug>/
+├── slide_1.png        Hero + Titel-Overlay
+├── slide_2.png        erste ## Überschrift
+├── slide_3.png        zweite ## Überschrift
+├── ...
+├── slide_N.png        CTA "Ganzen Artikel lesen → Link in Bio"
+├── hero_1x1.png       Hero zugeschnitten 1080×1080
+├── hero_4x5.png       Hero zugeschnitten 1080×1350
+├── hero_9x16.png      Hero zugeschnitten 1080×1920 (Story-Format)
+└── caption.txt        Hook-Vorschläge + excerpt + Hashtags
+```
+
+## Slide-Erzeugung
+
+- Slides sind HTML/SVG mit den bestehenden CSS-Design-Tokens
+  (--deep, --teal, --yellow, --mint, --color-primary, --color-accent),
+  zu PNG gerastert. Gleiches Muster wie die Website-Templates, nur
+  anderes Zielformat → Markenbild bleibt automatisch konsistent.
+- Format: 1080×1350 (4:5) für Karussell – beste IG-Sichtbarkeit.
+- Slide 1: Hero-Bild als Hintergrund, Titel als Overlay (Barlow Condensed,
+  wie page-hero-title).
+- Slide 2..N-1: je eine ## Überschrift, ggf. mit erstem Satz darunter.
+- Slide N: CTA mit Logo + "Link in Bio".
+
+## Slide-Anzahl begrenzen (DESIGN-ENTSCHEIDUNG, offen)
+
+IG deckelt bei 10 Slides, Aufmerksamkeit bei deutlich weniger.
+Richtwert: Hero-Slide + max. 5–6 Überschriften-Slides + CTA.
+
+Bei Artikeln mit mehr ## Überschriften – zwei Optionen (zu entscheiden):
+- a) Generator nimmt die ersten N Überschriften.
+- b) Im Markdown markieren welche Überschriften "social-relevant" sind
+     (z.B. ## Titel {social} oder eine Frontmatter-Liste).
+
+Analog zur TOC-Hierarchie-Frage: nicht sofort lösen, aber festlegen
+bevor gebaut wird.
+
+## Caption (halbautomatisch)
+
+caption.txt enthält:
+- 3 vom LLM vorgeschlagene HOOKS (erste Caption-Zeile) – der Mensch wählt.
+  Der Hook ist der eine Teil den kein Template aus dem Artikel ziehen kann:
+  "Warum einen Coach?" ist ein guter Artikeltitel, aber ein schlechter
+  IG-Hook. Einsatz für Claude-in-Claude-API: 3 Varianten generieren.
+- excerpt als Caption-Körper.
+- Hashtags aus den tags (+ feste Marken-Hashtags aus config).
+
+Hook NICHT automatisch final setzen – das ist der eine Schritt zu viel,
+der aus "spart Zeit" ein "klingt nach Bot" macht.
+
+## Architektur-Einordnung
+
+- Neuer Output-Schritt im Generator, KEIN neuer Stack.
+- Gleiche content/blog/<slug>/-Eingabe, Output in social/<slug>/.
+- Hero-Crops nutzen den bestehenden ImageProcessor (ImageMagick).
+- Slide-Rendering: HTML→PNG (z.B. headless Chromium oder bestehende
+  SVG-Pipeline). Render-Technik teilweise schon vorhanden.
+
+## Voraussetzung
+
+Setzt den Markdown-Loader (Phase 1) voraus: sauberes Hero
+(hero_<slug>_1) und ## Überschriften müssen strukturiert vorliegen.
+Erst Loader, dann Social-Bäcker.
+
+---
+
+# Roadmap-Einordnung
+
+Diese SPEC beschreibt zwei aufeinander aufbauende Phasen. Hier ihr Platz
+im Gesamtbild des Projekts. Reihenfolge ist bindend: Phase 2 setzt Phase 1
+voraus.
+
+## Bereits erledigt (v0.1.0)
+
+- Vollständige Seitenstruktur: Start, Coaching, Blog, Camps, Tools, Kontakt
+- Kontaktformular + Event-Anmeldung über Postfix (DKIM/SPF/DMARC, -f Envelope)
+- Event-System mit Galerien (masonry/carousel/slideshow) + Lightbox
+- SEO: JSON-LD Schemas, Meta-Descriptions, Breadcrumbs
+- Responsive Bildverarbeitung (WebP, 3 Größen), EXIF-auto-orient
+- CSS-Refactoring, event.ftlh modularisiert (Freemarker-Macros)
+- Markdown-Loader v1 (Hero einzeln + Tags) – Grundgerüst steht
+
+## Phase 1 – Markdown-Blog-Loader (NÄCHSTER SCHRITT)
+
+Siehe oberen Teil dieser SPEC. Erweitert den v1-Loader auf:
+mehrere Heroes (Carousel), Card, Gallery, hero_style, unpublish,
+meta_*, canonical_url, strikte Bild-Konventionen + Validierung.
+→ Blog-Artikel als content/blog/<slug>/artikel.md statt handgeschriebenem SQL.
+
+## Phase 2 – Social-Asset-Generator
+
+Siehe oberen Teil dieser SPEC. Karussell-PNGs + caption.txt nach
+social/<slug>/ für Upload in Buffer. Setzt Phase 1 voraus.
+
+## Phase 3+ – Weitere Kandidaten (Priorität offen, durch OWNER zu setzen)
+
+Aus den bisherigen Sessions als Kandidaten notiert – NICHT priorisiert,
+Owner entscheidet:
+
+- TOC-Hierarchie-Bug: Inhaltsverzeichnis in Blog-Artikeln nummeriert
+  flach durch, ignoriert h2/h3-Ebenen. Kleiner main.js-Fix + CSS.
+  (Guter erster Task zum Ausprobieren von Claude Code.)
+- Eingebettete Bilder im Markdown: AttributeProvider im MarkdownRenderer,
+  der site.contextPath vor src setzt.
+- Lightbox-Verbesserungen (Pfeil-Navigation in der Galerie).
+- RSS-Feed für Blog.
+- Such-Funktion (clientseitig, z.B. Lunr.js).
+- Server-Härtung: fail2ban, unattended-upgrades, Apache-Hardening.
+- Story-Format für Social (Phase 2 erweitern).
+- Loader für weitere Inhaltstypen (Events, Pages) – aktuell bewusst
+  SQL-only; nur falls Pflegeaufwand es rechtfertigt.
+
+## Langfristig / Kommerzialisierung
+
+- Mehrsprachigkeit (EN) – im Datenmodell aktuell nicht vorgesehen.
+- Web-UI als optionaler Aufsatz über dem Markdown-Format (Decap-CMS-artig).
+  Markdown bleibt kanonisch, UI ist nur Editor. Datenmodell muss dafür
+  NICHT angefasst werden.
+- Zielgruppe: technisch interessierte Nicht-Developer (Webagenturen,
+  digitale Nomaden). Differenzierung gegenüber WordPress/Jekyll:
+  lokal-first, geräte-souverän, "Speichern = veröffentlichen", kein
+  Plugin-Höllenloch.
+
+## Owner-Hoheit
+
+Offene Punkte, bekannte Fehler und die endgültige Roadmap-Priorisierung
+pflegt der Owner. Obige Phase-3-Liste ist Vorschlag/Gedächtnisstütze
+aus den Entwicklungs-Sessions, keine festgelegte Reihenfolge.
index 63a7729fa240b8cae9563bf0d530f427e4766521..9c97582e176314fe6c8a456d5fa382752bd60ad0 100644 (file)
@@ -3,6 +3,7 @@ package de.laktatnebel.product.websitegenerator;
 import de.laktatnebel.product.websitegenerator.config.SiteConfig;
 import de.laktatnebel.product.websitegenerator.generator.SiteGenerator;
 import de.laktatnebel.product.websitegenerator.loader.ContentBuild;
+import de.laktatnebel.product.websitegenerator.social.SocialGenerator;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -17,6 +18,7 @@ import java.nio.file.Path;
  *   java -jar ssg.jar --dry-run              → ohne Dateien zu schreiben
  *   java -jar ssg.jar --emit-content         → Markdown-Artikel zu SQL generieren (db/articles/_generated/)
  *   java -jar ssg.jar --check-content        → Markdown-Artikel prüfen (Lint, schreibt nichts)
+ *   java -jar ssg.jar --emit-social          → Social-Assets erzeugen (social/&lt;slug&gt;/)
  *
  * Die Content-Modi (--emit-content / --check-content) laufen eigenständig
  * und starten NICHT den Generator – sie sind als separater Schritt im
@@ -34,6 +36,7 @@ public class Main {
         boolean dryRun       = false;
         boolean emitContent  = false;
         boolean checkContent = false;
+        boolean emitSocial   = false;
 
         for (String arg : args) {
             switch (arg) {
@@ -49,6 +52,10 @@ public class Main {
                     checkContent = true;
                     log.info("Modus: check-content (Lint, schreibt nichts)");
                 }
+                case "--emit-social" -> {
+                    emitSocial = true;
+                    log.info("Modus: emit-social (Social-Assets erzeugen)");
+                }
                 default -> configPath = Path.of(arg);
             }
         }
@@ -64,6 +71,29 @@ public class Main {
             return;
         }
 
+        // ── Social-Modus: Social-Assets erzeugen ─────────────────────
+        if (emitSocial) {
+            if (config.socialTemplate == null) {
+                log.error("paths.social_template fehlt in config.yaml – abgebrochen.");
+                System.exit(1);
+            }
+            try {
+                int count = new SocialGenerator(
+                        config.contentPath,
+                        config.socialOutputPath,
+                        config.socialTemplate,
+                        config.baseUrl,
+                        config.socialHashtags,
+                        config.socialFont
+                ).run();
+                log.info("Social-Lauf abgeschlossen: {} Artikel verarbeitet", count);
+            } catch (Exception e) {
+                log.error("Social-Lauf fehlgeschlagen: {}", e.getMessage(), e);
+                System.exit(1);
+            }
+            return;
+        }
+
         // ── Content-Modus: Markdown → SQL (ohne Generator) ────────────
         if (emitContent || checkContent) {
             try {
index 237df049d60f5dbf863ae9d1cd0dbc56882096e2..62ff3105edcc6b88d2b83dcde7c537e45af5d3db 100644 (file)
@@ -6,6 +6,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -37,8 +38,12 @@ public class SiteConfig {
     public final String templatesPath;
     public final Path   staticPath;
     public final String dbSchema;
-    public final Path   contentPath;   // Markdown-Quelle (content/)
-    public final Path   articlesPath;  // Ziel für generierte SQLs (db/articles/)
+    public final Path   contentPath;       // Markdown-Quelle (content/)
+    public final Path   articlesPath;     // Ziel für generierte SQLs (db/articles/)
+    public final Path   socialOutputPath; // Ziel für Social-Assets (social/)
+    public final Path   socialTemplate;   // SVG-Template für Slides
+    public final List<String> socialHashtags; // Feste Marken-Hashtags
+    public final String socialFont;          // ImageMagick-Fontname für Slide-Text
 
     // Blog
     public final double navTagCoverage;
@@ -98,6 +103,17 @@ public class SiteConfig {
         dbSchema          = (String) db.getOrDefault("schema", "public");
         contentPath       = Path.of((String) paths.getOrDefault("content",  "content"));
         articlesPath      = Path.of((String) paths.getOrDefault("articles", "db/articles"));
+        socialOutputPath  = Path.of((String) paths.getOrDefault("social",   "social"));
+        String st = (String) paths.getOrDefault("social_template", null);
+        socialTemplate    = st != null ? Path.of(st) : null;
+
+        Map<String, Object> socialCfg = raw.containsKey("social_assets")
+                ? (Map<String, Object>) raw.get("social_assets") : Map.of();
+        Object hashtagsRaw = socialCfg.get("hashtags");
+        socialHashtags = hashtagsRaw instanceof List<?> hl
+                ? hl.stream().map(Object::toString).toList()
+                : List.of();
+        socialFont = (String) socialCfg.getOrDefault("font", "DejaVu-Sans-Bold");
 
         // Blog
         Map<String, Object> blog = raw.containsKey("blog") ? (Map<String, Object>) raw.get("blog") : Map.of();
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/social/SlideRenderer.java b/src/main/java/de/laktatnebel/product/websitegenerator/social/SlideRenderer.java
new file mode 100644 (file)
index 0000000..4266b25
--- /dev/null
@@ -0,0 +1,225 @@
+package de.laktatnebel.product.websitegenerator.social;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Rendert einzelne Slides und Hero-Crops via ImageMagick.
+ *
+ * Pipeline pro Slide (1080×1350, 4:5):
+ *   1. Hero-Bild auf 1080×1350 croppen (fill + center)
+ *   2. SVG-Template proportional auf 1080px Breite skalieren (→ 1080×1920)
+ *   3. SVG-PNG am unteren Rand über Foto kompositionieren (Deko-Balken sichtbar)
+ *   4. Dunkel-Gradient für Lesbarkeit + Text-Annotation
+ */
+public final class SlideRenderer {
+
+    private static final Logger log = LoggerFactory.getLogger(SlideRenderer.class);
+
+    // Karussell: 4:5
+    public static final int CAROUSEL_W = 1080;
+    public static final int CAROUSEL_H = 1350;
+
+    // Story: 9:16
+    public static final int STORY_W = 1080;
+    public static final int STORY_H = 1920;
+
+    // Square: 1:1
+    public static final int SQUARE_W = 1080;
+    public static final int SQUARE_H = 1080;
+
+    // SVG-Template-Originalhöhe (1080×1920)
+    private static final int SVG_NATIVE_H = 1920;
+
+    private final Path   svgTemplate;
+    private final String font;
+    private       Path   cachedSvgPng;
+
+    public SlideRenderer(Path svgTemplate, String font) {
+        this.svgTemplate = svgTemplate;
+        this.font        = font != null ? font : "DejaVu-Sans-Bold";
+    }
+
+    // ── Hero-Crops ───────────────────────────────────────────────
+
+    public void cropHero(Path heroImage, Path outDir) throws IOException, InterruptedException {
+        Files.createDirectories(outDir);
+        crop(heroImage, outDir.resolve("hero_4x5.png"),  CAROUSEL_W, CAROUSEL_H);
+        crop(heroImage, outDir.resolve("hero_9x16.png"), STORY_W,    STORY_H);
+        crop(heroImage, outDir.resolve("hero_1x1.png"),  SQUARE_W,   SQUARE_H);
+    }
+
+    private void crop(Path src, Path dst, int w, int h) throws IOException, InterruptedException {
+        run(cmd("magick",
+                src.toString(),
+                "-auto-orient",
+                "-resize", w + "x" + h + "^",
+                "-gravity", "Center",
+                "-extent", w + "x" + h,
+                dst.toString()));
+    }
+
+    // ── Slide rendern ────────────────────────────────────────────
+
+    public void renderSlide(SocialSlide slide, int index, Path outDir)
+            throws IOException, InterruptedException {
+        Files.createDirectories(outDir);
+        Path out = outDir.resolve("slide_" + index + ".png");
+
+        Path bg  = buildBackground(slide.heroImage(), outDir);
+        Path base = composite(bg, outDir, CAROUSEL_W, CAROUSEL_H);
+        annotateText(base, slide, out);
+
+        // Temporäre Zwischendateien aufräumen
+        Files.deleteIfExists(bg);
+        if (!base.equals(out)) Files.deleteIfExists(base);
+        log.debug("→ {}", out.getFileName());
+    }
+
+    /** Foto auf 1080×1350 croppen → temporäre Datei */
+    private Path buildBackground(Path heroImage, Path tmpDir)
+            throws IOException, InterruptedException {
+        Path bg = tmpDir.resolve("_bg_tmp.png");
+        crop(heroImage, bg, CAROUSEL_W, CAROUSEL_H);
+        return bg;
+    }
+
+    /**
+     * SVG-Overlay über Foto kompositionieren.
+     * SVG wird auf 1080px Breite skaliert (→ 1920px hoch),
+     * dann am unteren Rand des 1350px-Canvas ausgerichtet.
+     */
+    private Path composite(Path bg, Path tmpDir, int w, int h)
+            throws IOException, InterruptedException {
+        Path svgPng = getSvgPng(tmpDir);
+        Path out    = tmpDir.resolve("_comp_tmp.png");
+
+        // Offset: SVG-Boden = Canvas-Boden → svgTop = h - SVG_NATIVE_H
+        int yOffset = h - SVG_NATIVE_H; // negativ bei 4:5: 1350-1920 = -570
+
+        run(cmd("magick", bg.toString(),
+                svgPng.toString(),
+                "-geometry", "+0" + yOffset,
+                "-composite",
+                out.toString()));
+        return out;
+    }
+
+    /** SVG einmalig in PNG umwandeln und cachen (auf 1080px Breite skaliert). */
+    private Path getSvgPng(Path tmpDir) throws IOException, InterruptedException {
+        if (cachedSvgPng != null && Files.exists(cachedSvgPng)) return cachedSvgPng;
+        Path out = tmpDir.resolve("_svg_overlay.png");
+        run(cmd("magick", svgTemplate.toString(),
+                "-resize", CAROUSEL_W + "x",   // Breite fixieren, Höhe proportional
+                out.toString()));
+        cachedSvgPng = out;
+        return out;
+    }
+
+    /** Text auf das kompositionierte Bild schreiben. */
+    private void annotateText(Path base, SocialSlide slide, Path out)
+            throws IOException, InterruptedException {
+
+        List<String> args = new ArrayList<>();
+        args.add("magick");
+        args.add(base.toString());
+
+        switch (slide.type()) {
+            case HERO -> {
+                // Großer Titel, mittig-unten
+                addTextArgs(args, slide.heading(), 80, "-gravity", "South", "+0+250");
+                if (slide.subtext() != null) {
+                    addTextArgs(args, slide.subtext(), 42, "-gravity", "South", "+0+170");
+                }
+            }
+            case SECTION -> {
+                // Überschrift + optionaler erster Satz
+                addTextArgs(args, slide.heading(), 72, "-gravity", "South", "+0+250");
+                if (slide.subtext() != null) {
+                    addTextArgs(args, slide.subtext(), 38, "-gravity", "South", "+0+170");
+                }
+            }
+            case CTA -> {
+                addTextArgs(args, slide.heading(), 60, "-gravity", "Center", "+0+0");
+            }
+        }
+
+        args.add(out.toString());
+        run(args.toArray(new String[0]));
+    }
+
+    private void addTextArgs(List<String> args, String text,
+                             int size, String... gravity) {
+        String wrapped = wrapText(text, 28);
+        for (String a : gravity) args.add(a);
+        args.addAll(List.of(
+                "-font",    font,
+                "-pointsize", String.valueOf(size),
+                "-fill",    "white",
+                "-stroke",  "#00000088",
+                "-strokewidth", "2",
+                "-annotate", gravity[gravity.length - 1],  // letzte gravity-Option = offset
+                wrapped
+        ));
+    }
+
+    // ── ImageMagick-Aufruf ───────────────────────────────────────
+
+    private void run(String... cmd) throws IOException, InterruptedException {
+        // Versuche erst "magick" (ImageMagick 7), dann "convert" (6)
+        String[] actualCmd = cmd;
+        if ("magick".equals(cmd[0])) {
+            try {
+                Process check = new ProcessBuilder("magick", "-version")
+                        .redirectErrorStream(true).start();
+                if (check.waitFor() != 0) {
+                    actualCmd    = cmd.clone();
+                    actualCmd[0] = "convert";
+                }
+            } catch (IOException ignored) {
+                actualCmd    = cmd.clone();
+                actualCmd[0] = "convert";
+            }
+        }
+
+        ProcessBuilder pb = new ProcessBuilder(actualCmd);
+        pb.redirectErrorStream(true);
+        Process proc = pb.start();
+        String  out  = new String(proc.getInputStream().readAllBytes());
+        int     exit = proc.waitFor();
+        if (exit != 0) {
+            throw new IOException("ImageMagick Fehler (exit " + exit + "): " + out
+                    + "\nKommando: " + String.join(" ", actualCmd));
+        }
+    }
+
+    private String[] cmd(String... parts) { return parts; }
+
+    // ── Hilfsmethoden ────────────────────────────────────────────
+
+    /** Einfacher Zeilenumbruch bei maxCharsPerLine Zeichen (an Wortgrenzen). */
+    static String wrapText(String text, int maxCharsPerLine) {
+        if (text == null) return "";
+        if (text.length() <= maxCharsPerLine) return text;
+        StringBuilder sb = new StringBuilder();
+        String[] words = text.split("\\s+");
+        int lineLen = 0;
+        for (String word : words) {
+            if (lineLen > 0 && lineLen + 1 + word.length() > maxCharsPerLine) {
+                sb.append('\n');
+                lineLen = 0;
+            } else if (lineLen > 0) {
+                sb.append(' ');
+                lineLen++;
+            }
+            sb.append(word);
+            lineLen += word.length();
+        }
+        return sb.toString();
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialGenerator.java b/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialGenerator.java
new file mode 100644 (file)
index 0000000..7b63c7c
--- /dev/null
@@ -0,0 +1,242 @@
+package de.laktatnebel.product.websitegenerator.social;
+
+import de.laktatnebel.product.websitegenerator.loader.ContentLoader;
+import de.laktatnebel.product.websitegenerator.loader.LoadedArticle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.util.*;
+import java.util.regex.*;
+import java.util.stream.Collectors;
+
+/**
+ * Erzeugt Social-Media-Assets für alle Blog-Artikel:
+ *
+ *   social/&lt;slug&gt;/
+ *     slide_1.png   – Hero + Titel
+ *     slide_2.png   – erste h2-Überschrift
+ *     ...
+ *     slide_N.png   – CTA
+ *     hero_4x5.png  – Hero zugeschnitten 1080×1350
+ *     hero_9x16.png – Hero zugeschnitten 1080×1920
+ *     hero_1x1.png  – Hero zugeschnitten 1080×1080
+ *     caption.txt   – Hook-Vorschläge + Excerpt + Hashtags
+ *
+ * Aufgerufen mit --emit-social aus Main.
+ */
+public final class SocialGenerator {
+
+    private static final Logger log = LoggerFactory.getLogger(SocialGenerator.class);
+
+    private static final int     MAX_SECTION_SLIDES = 6;
+    private static final String  SOCIAL_MARKER      = "{social}";
+    private static final Pattern H2_PATTERN         =
+            Pattern.compile("^##\\s+(.+)$", Pattern.MULTILINE);
+
+    private final Path         contentRoot;
+    private final Path         socialRoot;
+    private final Path         svgTemplate;
+    private final String       baseUrl;
+    private final List<String> fixedHashtags;
+    private final String       font;
+
+    public SocialGenerator(Path contentRoot, Path socialRoot, Path svgTemplate,
+                           String baseUrl, List<String> fixedHashtags, String font) {
+        this.contentRoot   = contentRoot;
+        this.socialRoot    = socialRoot;
+        this.svgTemplate   = svgTemplate;
+        this.baseUrl       = baseUrl;
+        this.fixedHashtags = fixedHashtags != null ? fixedHashtags : List.of();
+        this.font          = font;
+    }
+
+    public int run() throws IOException {
+        if (!Files.exists(svgTemplate)) {
+            log.error("SVG-Template nicht gefunden: {}", svgTemplate);
+            return 0;
+        }
+
+        ContentLoader loader = new ContentLoader(true);
+        List<LoadedArticle> articles = loader.loadBlogArticles(contentRoot);
+        if (articles.isEmpty()) {
+            log.info("Keine Artikel gefunden – Social-Generator übersprungen.");
+            return 0;
+        }
+
+        SlideRenderer renderer = new SlideRenderer(svgTemplate, font);
+        int generated = 0;
+
+        for (LoadedArticle article : articles) {
+            try {
+                generate(article, renderer);
+                generated++;
+            } catch (Exception e) {
+                log.error("✗ {}: {}", article.slug(), e.getMessage(), e);
+            }
+        }
+
+        log.info("Social-Assets erzeugt: {} Artikel", generated);
+        return generated;
+    }
+
+    // ── Pro Artikel ──────────────────────────────────────────────
+
+    private void generate(LoadedArticle article, SlideRenderer renderer)
+            throws IOException, InterruptedException {
+
+        String folderSlug = article.slug().replace("blog/", "");
+        Path   outDir     = socialRoot.resolve(folderSlug);
+        Files.createDirectories(outDir);
+
+        Path heroImage = resolveHeroImage(article);
+        if (heroImage == null) {
+            log.warn("⚠ {}: kein Hero-Bild → Social-Assets übersprungen", article.slug());
+            return;
+        }
+
+        renderer.cropHero(heroImage, outDir);
+
+        List<SocialSlide> slides = planSlides(article, heroImage);
+        for (int i = 0; i < slides.size(); i++) {
+            renderer.renderSlide(slides.get(i), i + 1, outDir);
+        }
+
+        writeCaption(article, outDir);
+        log.info("✓ {} → {} Slides + hero-crops + caption.txt", article.slug(), slides.size());
+    }
+
+    // ── Slides planen ────────────────────────────────────────────
+
+    private List<SocialSlide> planSlides(LoadedArticle article, Path heroImage) {
+        List<SocialSlide> slides = new ArrayList<>();
+
+        slides.add(new SocialSlide(
+                SocialSlide.Type.HERO,
+                article.title(),
+                article.subtitle(),
+                heroImage
+        ));
+
+        for (HeadingEntry h : selectHeadings(extractHeadings(article.body()))) {
+            slides.add(new SocialSlide(
+                    SocialSlide.Type.SECTION,
+                    h.text(),
+                    h.firstSentence(),
+                    heroImage
+            ));
+        }
+
+        slides.add(new SocialSlide(
+                SocialSlide.Type.CTA,
+                "Ganzen Artikel lesen\n→ Link in Bio",
+                null,
+                heroImage
+        ));
+
+        return slides;
+    }
+
+    private record HeadingEntry(String text, String firstSentence, boolean social) {}
+
+    private List<HeadingEntry> extractHeadings(String body) {
+        List<HeadingEntry> result = new ArrayList<>();
+        String[] lines = body.split("\n", -1);
+
+        for (int i = 0; i < lines.length; i++) {
+            String line = lines[i].trim();
+            Matcher m = H2_PATTERN.matcher(line);
+            if (!m.matches()) continue;
+
+            String  raw    = m.group(1).trim();
+            boolean social = raw.contains(SOCIAL_MARKER);
+            String  text   = raw.replace(SOCIAL_MARKER, "").trim();
+
+            String firstSentence = null;
+            for (int j = i + 1; j < lines.length; j++) {
+                String l = lines[j].trim();
+                if (l.isEmpty() || l.startsWith("#") || l.startsWith("!") || l.startsWith("-"))
+                    continue;
+                String clean = l.replaceAll("[*_`]", "")
+                                .replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1")
+                                .trim();
+                if (!clean.isEmpty()) {
+                    int dot = firstSentenceEnd(clean);
+                    firstSentence = dot >= 0 ? clean.substring(0, dot + 1) : clean;
+                    if (firstSentence.length() > 120)
+                        firstSentence = firstSentence.substring(0, 117) + "…";
+                }
+                break;
+            }
+
+            result.add(new HeadingEntry(text, firstSentence, social));
+        }
+        return result;
+    }
+
+    private List<HeadingEntry> selectHeadings(List<HeadingEntry> all) {
+        boolean anyMarked = all.stream().anyMatch(HeadingEntry::social);
+        return all.stream()
+                .filter(h -> !anyMarked || h.social())
+                .limit(MAX_SECTION_SLIDES)
+                .collect(Collectors.toList());
+    }
+
+    private int firstSentenceEnd(String s) {
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (c == '.' || c == '!' || c == '?') return i;
+        }
+        return -1;
+    }
+
+    // ── Caption ──────────────────────────────────────────────────
+
+    private void writeCaption(LoadedArticle article, Path outDir) throws IOException {
+        StringBuilder sb = new StringBuilder();
+
+        sb.append("=== HOOK-VORSCHLÄGE (einen wählen und anpassen) ===\n");
+        sb.append("(Placeholder – manuell ersetzen oder LLM-Integration aktivieren)\n");
+        sb.append("[Hook 1: Frage stellen, z.B. \"Warum ...?\"]\n");
+        sb.append("---\n");
+        sb.append("[Hook 2: Überraschung/Kontrast, z.B. \"Alle denken X, aber ...\"]\n");
+        sb.append("---\n");
+        sb.append("[Hook 3: Nutzen, z.B. \"So sparst du ... mit ...\"]\n");
+        sb.append("\n");
+
+        sb.append("=== CAPTION ===\n");
+        if (article.excerpt() != null && !article.excerpt().isBlank()) {
+            sb.append(article.excerpt()).append("\n");
+        } else {
+            sb.append("[Excerpt fehlt – in artikel.md ergänzen]\n");
+        }
+        sb.append("\n");
+
+        sb.append("=== HASHTAGS ===\n");
+        String tags = article.tags().stream()
+                .map(t -> "#" + t.replace("-", "").replace(" ", ""))
+                .collect(Collectors.joining(" "));
+        String fixed = fixedHashtags.stream()
+                .map(t -> t.startsWith("#") ? t : "#" + t)
+                .collect(Collectors.joining(" "));
+        sb.append(tags);
+        if (!tags.isEmpty() && !fixed.isEmpty()) sb.append(" ");
+        sb.append(fixed).append("\n");
+        sb.append("\n");
+
+        sb.append("=== LINK IN BIO ===\n");
+        sb.append(baseUrl).append("/").append(article.slug()).append("\n");
+
+        Files.writeString(outDir.resolve("caption.txt"), sb.toString());
+    }
+
+    // ── Helpers ──────────────────────────────────────────────────
+
+    private Path resolveHeroImage(LoadedArticle article) {
+        if (article.heroImageFiles().isEmpty()) return null;
+        String folderSlug = article.slug().replace("blog/", "");
+        return contentRoot.resolve("blog").resolve(folderSlug)
+                          .resolve(article.heroImageFiles().get(0));
+    }
+}
diff --git a/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialSlide.java b/src/main/java/de/laktatnebel/product/websitegenerator/social/SocialSlide.java
new file mode 100644 (file)
index 0000000..a058657
--- /dev/null
@@ -0,0 +1,15 @@
+package de.laktatnebel.product.websitegenerator.social;
+
+import java.nio.file.Path;
+
+/**
+ * Eine geplante Instagram-Karussell-Folie.
+ */
+public record SocialSlide(
+        Type   type,
+        String heading,     // Überschrift (bereinigt, ohne {social}-Marker)
+        String subtext,     // erster Satz nach der Überschrift (optional, null)
+        Path   heroImage    // immer das Hero-Bild als Hintergrund
+) {
+    public enum Type { HERO, SECTION, CTA }
+}