# Build Guide — Product Placement Videos (Typ B)

> Automatisierter täglicher Workflow für **Multi-Scene-Product-Videos** (1080×1920, ~16–20 s, mit Voiceover + Music): 3 geschnittene Pexels-Szenen + finaler **Phone-Reveal** mit App-Screenshot, ElevenLabs-TTS-Voice + Music-Bett, Captions als Overlay.

Diese Doku ist so geschrieben, dass du sie samt deiner eigenen Produkt-Daten an **Claude Code** oder ein vergleichbares Agent-Tool geben kannst, und der Workflow wird mit deinem Produkt reproduziert.

---

## 1. Was du am Ende hast

Jeden Tag um 07:00 Uhr (oder Cron deiner Wahl):

1. **Theme-Picker** zieht ein Thema aus deiner Topic-Liste
2. **Claude Sonnet 4.5** schreibt ein 3-Szenen-Skript (Hook → Problem → Solution) + Phone-Reveal-Sub-CTA + Voiceover-Text
3. **Telegram-Bot** Approval-Loop, bis zu 4 Iterationen
4. **Pexels** sucht **pro Szene** ein passendes Background-Video
5. **ElevenLabs** generiert Voiceover (2/3 male, 1/3 female Rotation mit Topic-Bias)
6. **Random Music-Bett** aus deinem Drive
7. **FFmpeg-Renderer** komponiert: 3 Szenen + Phone-Reveal mit App-Screenshot, Voice, Music ducked auf 0.12
8. **Google Drive Upload** mit MP4 + Caption + Script.json

Result: 1 Produkt-Reel pro Tag mit Voiceover, fertig zum Posten — mit klarem App-Store-Download-CTA am Ende.

---

## 2. Was du vorab brauchst

| Item | Wo holen | Kostenlos? |
|---|---|---|
| n8n-Instanz (self-hosted) | `docker compose` auf VPS | ja (außer VPS) |
| Linux-VPS mit FFmpeg + Python + librsvg2 | Hetzner Cloud / DigitalOcean | ~5 €/mo |
| Google Drive (OAuth + 1 Folder) | https://console.cloud.google.com | ja |
| Anthropic API Key | https://console.anthropic.com | nutzungsabhängig |
| Pexels API Key | https://www.pexels.com/api/ | ja |
| **ElevenLabs API Key + Voice-IDs** | https://elevenlabs.io | $5/mo Starter |
| Telegram-Bot (BotFather) | t.me/BotFather | ja |
| Apple App Store Listing | App Store Connect | — |
| App-Screenshots (1284×2778) | aus deinem App-Store-Listing | — |
| 3 Music-MP3s (royalty-free) | pixabay.com/music | ja |

**Produkt-Assets-Checkliste:**
- 10-30 App-Screenshots (1284×2778, pure Screen-Content, **kein** Phone-Bezel) — am besten EN-Versionen
- `favicon.png` — Brand-Mark (1024×1024)
- Brand-Hex-Codes: Primary, Background-Dark, Background-Purple-Deep
- App-Store-URL: `https://apps.apple.com/app/<your-app>/id<id>`
- Sub-CTA-Pool (kurze imperative Phrasen, max 5 Wörter)

---

## 3. Server-Setup (FFmpeg-Renderer)

### 3.1 Verzeichnis-Layout

```
/opt/<your-renderer>/
├── render_server.py            # HTTP-Endpoint
├── render_product.py           # Multi-Scene + Phone-Reveal Renderer
├── render_reel.py              # (optional, falls auch One-Pager-Variante)
├── favicon.png                 # Brand-Mark
├── phone_frame.svg + .png      # Phone-Bezel (transparent außen + Display)
├── screen_mask.svg + .png      # Rounded-Corner-Mask für Screenshots
├── app_store_badge.png         # Apple "Download on the App Store" (3240px source)
├── fonts/                      # Manrope ExtraBold + Regular
└── (kein lokales music/ nötig — Music kommt aus Drive via n8n)
```

### 3.2 systemd-Service

```ini
# /etc/systemd/system/<your-renderer>.service
[Unit]
Description=Product Render Service
After=network.target

[Service]
Type=simple
WorkingDirectory=/opt/<your-renderer>
ExecStart=/usr/bin/python3 /opt/<your-renderer>/render_server.py
Restart=on-failure
Environment=RENDER_TOKEN=<RANDOM_64_HEX_STRING>

[Install]
WantedBy=multi-user.target
```

### 3.3 Renderer-HTTP-Endpoint

**POST /render-product** — empfängt JSON mit **per-Szene** `voice_b64` + optional `music_b64` + scenes, gibt `video/mp4` zurück.

**Wichtiger Designgrundsatz (v3, 1:1):** Es gibt KEIN separates `voiceover`-Feld und KEIN globales `voiceover_b64`. Jede Szene hat ihr eigenes `voice_b64` mit der TTS-Aufnahme ihres Caption-Texts. Der Renderer leitet die Szenen-Dauer **aus der gemessenen Voice-Audiolänge** (`ffprobe`) ab — der Caller spezifiziert KEINE `duration`-Felder.

Body-Schema:
```jsonc
{
  "scenes": [
    {
      "type": "scene",
      "role": "hook",
      "text": "Your hook in 2nd person, 8-15 words (also spoken word-for-word)",
      "highlight": "highlight phrase",
      "background_url": "https://...pexels.mp4",
      "voice_b64": "<base64-encoded MP3 of scenes[].text spoken>"
    },
    {
      "type": "scene",
      "role": "problem",
      "text": "Mechanism / why-this-happens explanation",
      "highlight": "the mechanism",
      "background_url": "https://...pexels.mp4",
      "voice_b64": "<base64-encoded MP3>"
    },
    {
      "type": "scene",
      "role": "solution",
      "text": "Bridge to your product without selling",
      "highlight": "your product hint",
      "background_url": "https://...pexels.mp4",
      "voice_b64": "<base64-encoded MP3>"
    },
    {
      "type": "phone_reveal",
      "phone_png_url": "https://drive.google.com/uc?id=<ID>&export=download",
      "tagline": "",                              // optional brand-statement
      "sub_cta": "Imperative call (max 5 words)",
      "app_store_url": "https://apps.apple.com/app/yourapp/id123",
      "voice_b64": "<base64-encoded MP3 of phone_reveal.sub_cta spoken>"
    }
  ],
  "music_b64": "<base64-encoded MP3>",           // optional, Music-Bett über die ganze Video-Länge
  "accent": "#YOUR_PRIMARY"
}
```

Renderer leitet pro Szene ab:
- `scene.duration = max(MIN, voice_dur + 0.3s)`
- `MIN_PEXELS_DURATION = 2.5s` (Pexels-Szenen)
- `MIN_PHONE_REVEAL_DURATION = 4.0s` (Phone-Reveal)

Wenn Voice kürzer als MIN: Szene wird auf MIN gehalten, Voice mit Stille gepaddet.

Auth-Header: `X-Token: <RENDER_TOKEN>`. Timeout: 300s (typisch dauert ein Render 30-90s).

### 3.4 Visual-Layout

**Mittlere Szenen (hook / problem / solution):**
```
┌──────────────────────────────────┐
│                       [favicon]  │  ← klein rechts oben, weiß
│                                  │
│  Pexels-Portrait-Video           │
│  mit Vignette + Dark-Layer       │
│                                  │
│                                  │
│  YOUR HOOK TEXT WITH             │  ← Manrope ExtraBold, weiß
│  HIGHLIGHTED PHRASE IN ACCENT    │  ← Highlight in #PRIMARY
│                                  │
│  (untere Hälfte / Drittel)       │
└──────────────────────────────────┘
```

**Phone-Reveal (letzte Szene):**
```
┌──────────────────────────────────┐
│  Dark-Blue → Purple Gradient BG  │
│                                  │
│       ┌──────────┐               │
│       │  Phone   │               │  ← floating sin-Y, Phone-Frame
│       │  Frame   │               │     SVG + Display-Mask + Screenshot
│       │ Screen   │               │
│       │   shot   │               │
│       └──────────┘               │
│                                  │
│       [F] Brand                  │  ← Favicon + Wordmark
│                                  │
│       Sub-CTA in white           │  ← max 5 Wörter, imperativ
│                                  │
│   [Download on the App Store]    │  ← Apple-Badge, 360px wide
│         dopafy.app               │  ← optional Domain-Text
└──────────────────────────────────┘
```

### 3.5 FFmpeg-Pipeline-Highlights

- **Phone-Frame**: SVG mit `fill-rule="evenodd"` für transparenten Display + schwarzen Bezel ohne Sub-Pixel-Spitzen an Outer-Corners
- **Screenshot-Rounded-Corners**: `alphamerge` mit separater Mask-PNG (Display-Inner-Radius)
- **Apple-Badge**: Separater FFmpeg-Overlay-Pass mit `scale=360:-1:flags=lanczos` (librsvg sampled mit nearest-neighbor → embedded base64-image im SVG wäre unscharf)
- **Per-Szene-Voice-Padding**: Jede Szene wird zu einem AAC-Segment der Länge `scene.duration` zusammengesetzt aus `voice_b64` + Silence-Padding via `apad=whole_dur=<duration>`. Forced-Stereo via `aformat=channel_layouts=stereo,apad=...` damit Concat-Demuxer alle Segmente sauber zusammenführt.
- **Audio-Concat + Music-Mix**: Per-Szene-Voice-Segmente werden mit FFmpeg-Concat-Demuxer in einen kontinuierlichen Voice-Track gemerged, dann mit Music (volume=0.12) gemixt.
- **Video-Concat**: FFmpeg-Demuxer für Hard-Cuts zwischen Szenen (Crossfade `xfade` ist Future-Improvement)

---

## 4. Google Drive Setup

Lege folgende Folder-Struktur an:

```
/<Your Product> Reels/
  /Style/                  ← Brand-Style-Guide
  /Topic_Briefings/        ← Markdown pro Topic
  /Screenshots_Appstore/   ← App-Screenshots (1284×2778 PNGs)
  /Music/                  ← 3-5 royalty-free MP3s (~30-60s, looped vom Renderer)
  /Output/                 ← Workflow legt Tages-Subfolder an
```

**Wichtig:** Alle Folder, die der Renderer per URL pullt (`Screenshots_Appstore`, `Music`), müssen **"Anyone with the link → Viewer"** geshared sein. Sonst liefert Drive eine HTML-Login-Page statt der Datei (→ FFmpeg-Crash "Invalid PNG signature").

Style- und Topic-Briefings-Folder bleiben private — n8n liest die via authenticated OAuth.

### 4.1 Style-Guide-Markdown (`Style/style_guide.md`)

```markdown
# <Your Product> — Brand Voice & Style

## Tone
- Wissenschaftlich aber zugänglich
- Empowernd, nicht moralisierend
- 2. Person Singular (you)
- Keine Hype-Sprache, keine Detox-Kult-Tone

## Scene-Aufbau (immer 3 Szenen)
1. `hook`: Konkreter Pain-Point in 8-12 Wörtern (kein "Did you know")
2. `problem`: Mechanismus / Wissenschaft dahinter (eine Analogie genügt)
3. `solution`: Bridge zum Produkt ohne Hard-Sell ("There's a fix")

## Sub-CTA-Pool (max 5 Wörter, imperativ, action-driven)
- "Break the loop. Today."
- "Reclaim your mornings."
- "Stop the scroll spiral."
- "End your <pain>."
- "Take back your <thing>."

## Phone-PNG-Mapping (Naming Convention für Screenshots_Appstore)
- Filenames enden auf `_eng.png` für englische Versionen
- Slug-style: `dashboard_eng.png`, `goal_detail_eng.png`, `streak_eng.png`
- Claude wählt 1 Screenshot pro Video thematisch passend

## Hook-Constraints
- KEIN "Imagine if..." / "Picture this..."
- Frage ODER provokante Beobachtung ODER überraschende Statistik
```

### 4.2 Topic-Briefings (`Topic_Briefings/01_<slug>.md`)

```markdown
# Topic: <Topic Name>

## Pain-Point (Hook-Material)
Was fühlt deine Zielperson, wenn sie diesen Hook liest? Konkret, 1-2 Sätze.

## Mechanismus (Problem-Material)
Wissenschaftliche Erklärung / Mechanismus, in 1-2 plain-language-Sätzen.

## Solution-Hinweis (Solution-Material ohne Hard-Sell)
Wie kommt der Bridge zum Produkt? Nicht "Buy now" — sondern "Here's a fix".

## Empfohlene Phone-Screenshots
- `dashboard_eng.png` (wenn Topic über tracking)
- `goal_detail_eng.png` (wenn Topic über goals)

## Pexels-Suchbegriffe pro Szene
- Hook: "person tired scrolling phone night"
- Problem: "brain concept abstract"
- Solution: "morning sunrise calm focus"
```

Mindestens 5 Topic-Briefings, idealerweise 10-15.

---

## 5. ElevenLabs Setup

### 5.1 API-Key + Voices

1. Account auf https://elevenlabs.io ($5/mo Starter reicht für ~3-5 Videos/Tag)
2. Im Dashboard 2-3 Voices wählen (mix male/female), per "Add to my voices" zum Account hinzufügen
3. Voice-IDs kopieren (Format: 20-Char-String wie `i4CzbCVWoqvD0P1QJCUL`)
4. API-Key holen: Settings → API Keys → "Create new key" → Scopes minimal:
   - **Text to Speech: Access** (zwingend)
   - **Models: Access** (optional)
   - **Voices: Read** (optional)
   - alle anderen: **No Access**

### 5.2 Voice-Rotation-Strategie

Drei Voices in Rotation: 2× male, 1× female. Plus optional Topic-Bias-Heuristik:
- Sleep/exhaustion/emotional topics → female bias
- Focus/discipline/performance topics → male bias

Implementation siehe Pick-Voice-Code-Node in §6.6.

---

## 6. n8n-Workflow

### 6.1 Credentials (in n8n vorab anlegen)

| Credential | Type | Wofür |
|---|---|---|
| Anthropic API | `anthropicApi` | Claude Sonnet 4.5 |
| Google Drive OAuth2 | `googleDriveOAuth2Api` | Drive-Operations |
| Telegram | `telegramApi` | Approval-Chat |

ElevenLabs-Key wird **hardcoded im Code-Node `TTS Per Scene`** (`const xi_key = 'sk_...'`). Alternative: per `$env.ELEVENLABS_API_KEY` — dann muss aber n8n `N8N_BLOCK_ENV_ACCESS_IN_NODE=false` gesetzt sein.
Pexels-Key inline im HTTP-Request-Node-Header.

### 6.2 Node-Pipeline (~52 Nodes, v3)

```
[Schedule 07:00]
       ↓
[Pick Theme]
       ↓
[Style: List → Download → Aggregate]        ← Drive-Chain 1
       ↓
[Briefings: List → Download → Aggregate]    ← Drive-Chain 2
       ↓
[Phone-PNGs: List → Aggregate Names]        ← Drive-Chain 3 (PNG-Filenames für Claude)
       ↓
[Merge Context]                              ← Sequenziell, NICHT parallel!
       ↓
[Script v1]                                  ← Claude generiert 3 Szenen + Phone-Reveal-Sub-CTA
       ↓
[Telegram v1 → Switch v1]
       ↓ approve   ↓ feedback
[Set Approved v1]  [Set state v2] → [Content v2] → [Telegram v2] → ...
       ↓                                     (analog v3, v4)
       ↓ ←←←← all approved paths converge ←←←←
[Prepare Pexels Queries]      ← Code-Node: 1 item pro scene
       ↓
[Pexels Search] (HTTP-Request)             ← Loop über items
       ↓
[Pick Best Clip + Reassemble]             ← Code-Node: 1080-Portrait wählen, merge mit approved
       ↓
[Resolve Phone-PNG ID]                     ← Match phone_png_name → Drive-ID
       ↓
[Pick Voice]                                ← 2/3 male, 1/3 female + topic-bias
       ↓
[TTS Per Scene] (Code)                      ← Sequential await: ruft ElevenLabs pro Szene + phone_reveal,
                                              setzt scenes[i].voice_b64 und phone_reveal.voice_b64.
                                              Brand-Pronunciation-Fix (Dopafy → Do-pa-fei) hier drin.
       ↓
[Music: List MP3s] (Drive-Search)
       ↓
[Music: Pick Random] (Code)
       ↓
[Music: Download] (Drive)
       ↓
[Music: To Base64] (Code)
       ↓
[Assemble Render Body]                      ← Code: scenes mit per-Szene voice_b64 + music_b64 → render_body
       ↓
[Render Hetzner] (HTTP-Request, returns binary MP4)
       ↓
[Drive: Sub-Folder anlegen]
       ↓
[Binary durchreichen]                       ← `$('Render Hetzner').first().binary`
       ↓
       ├→ [Drive: MP4 hochladen]
       ├→ [Caption als Datei] → [Drive: caption.md hochladen]
       └→ [Script.json als Datei] → [Drive: script.json hochladen]
```

### 6.3 Claude-Output-Schema (JSON Structured Output, v3 — 1:1)

**Wichtig:** Es gibt **kein** separates `voiceover`-Feld. `scenes[].text` ist gleichzeitig der Bildschirm-Caption UND das, was der Speaker sagt (1:1). Genauso ist `phone_reveal.sub_cta` gleichzeitig on-screen UND gesprochen. **Kein** `duration`-Feld — Renderer leitet aus TTS-Audio ab.

```jsonc
{
  "kicker": "DOPAMINE",                          // optional, top-label kann auch leer sein
  "hook_pain_point": "...",                      // 1-Satz-Pain-Point (Claude-internal, nicht im Video)
  "scenes": [
    {"role": "hook",     "text": "[8-15 words, displayed AND spoken word-for-word]", "highlight": "...", "pexels_query": "..."},
    {"role": "problem",  "text": "...", "highlight": "...", "pexels_query": "..."},
    {"role": "solution", "text": "...", "highlight": "...", "pexels_query": "..."}
  ],
  "phone_reveal": {
    "phone_png_name": "<one filename from the pool>",
    "tagline": "",
    "sub_cta": "[max 5 words imperative, also spoken word-for-word, MUST include brand name]",
    "app_store_url": "https://apps.apple.com/app/yourapp/id123"
  },
  "caption": "Long-form social caption with App-Store-CTA + hashtags",
  "slug": "kebab-case-slug"
}
```

**Hard rules** (in System-Message des Claude-Nodes):
> English, 2nd person, short sentences, concrete pain-point hook, max 5 word imperative sub-CTA including brand name, empty tagline, phone_png_name from pool, brand voice scientific+realistic+empowering, each scenes[].text 8-15 words (the text is displayed AND read aloud word-for-word — scene length = time to speak this text), caption ends with app store CTA + hashtags.

### 6.4 Set Approved v1/v2/v3/v4 (KRITISCH)

Nach `Telegram sendAndWait` ist `$json` die Telegram-Reply, NICHT der Claude-Output. Mappings müssen explizit den Claude-Node referenzieren — **nur 4 Felder durchreichen, kein voiceover_text mehr**:

```javascript
"scenes":       "={{ $('Script v1').item.json.output.scenes }}"
"phone_reveal": "={{ $('Script v1').item.json.output.phone_reveal }}"
"caption":      "={{ $('Script v1').item.json.output.caption }}"
"slug":         "={{ $('Script v1').item.json.output.slug }}"
// für v2/v3/v4 entsprechend $('Content v2'), $('Content v3'), $('Content v4 (final)')
```

### 6.5 Pexels-Pattern (Split → HTTP → Aggregate)

n8n's Code-Node hat **kein `fetch`** im task-runner-Sandbox. Pexels-Loop deshalb als 3 Nodes:

**1. `Prepare Pexels Queries`** (Code) — kein `duration`-Feld mehr im Output:
```javascript
const approved = $input.first().json;
return (approved.scenes || []).map((scene, idx) => ({
  json: {
    _scene_index: idx,
    role: scene.role,
    text: scene.text,
    highlight: scene.highlight,
    pexels_query: scene.pexels_query || 'minimal calm'
  }
}));
```

**2. `Pexels Search`** (HTTP-Request, läuft 1× pro Item automatisch):
```
GET https://api.pexels.com/videos/search
Query: query={{ $json.pexels_query }}, orientation=portrait, size=medium, per_page=10
Header: Authorization: <PEXELS_KEY>
```

**3. `Pick Best Clip + Reassemble`** (Code) — enriched-Scenes ohne `duration`:
```javascript
const responses = $input.all();
const queryItems = $('Prepare Pexels Queries').all();
const enriched = [];
for (let i = 0; i < responses.length; i++) {
  const data = responses[i].json;
  const scene = queryItems[i].json;
  let best = null, bestScore = -1;
  for (const v of (data.videos || [])) {
    for (const f of (v.video_files || [])) {
      if (f.width && f.height && f.height > f.width) {
        const score = 1 - Math.abs((f.width || 0) - 1080) / 1080;
        if (score > bestScore) { bestScore = score; best = f.link; }
      }
    }
  }
  if (!best && data.videos?.[0]?.video_files?.[0]) best = data.videos[0].video_files[0].link;
  if (!best) throw new Error(`No Pexels clip for: ${scene.pexels_query}`);
  enriched.push({ type: 'scene', role: scene.role, text: scene.text, highlight: scene.highlight, background_url: best });
}
// approved state finden (kommt von einem der 4 Set Approved nodes)
let approved = null;
for (const name of ['Set Approved v4', 'Set Approved v3', 'Set Approved v2', 'Set Approved v1']) {
  try { const item = $(name).first(); if (item) { approved = item.json; break; } } catch (e) {}
}
return [{ json: { ...approved, scenes: enriched } }];
```

### 6.6 Pick-Voice-Code-Node

```javascript
const VOICES = {
  female: { id: '<YOUR_FEMALE_VOICE_ID>', name: 'Voice Name' },
  male1:  { id: '<YOUR_MALE_1_VOICE_ID>', name: 'Voice Name' },
  male2:  { id: '<YOUR_MALE_2_VOICE_ID>', name: 'Voice Name' },
};
const approved = $input.first().json;
const theme = ($('Pick Theme').item.json.theme || '').toLowerCase();
const femaleTopics = ['sleep', 'exhausted', 'emotional', 'overwhelmed', 'calm', 'gentle'];
const maleTopics   = ['focus', 'discipline', 'performance', 'cold', 'productivity', 'challenge'];
const biasFemale = femaleTopics.some(t => theme.includes(t));
const biasMale   = maleTopics.some(t => theme.includes(t));
let voice;
if (biasFemale && !biasMale) voice = VOICES.female;
else if (biasMale && !biasFemale) voice = Math.random() < 0.5 ? VOICES.male1 : VOICES.male2;
else {
  const r = Math.random();
  if (r < 1/3) voice = VOICES.female;
  else if (r < 2/3) voice = VOICES.male1;
  else voice = VOICES.male2;
}
return [{ json: { ...approved, voice_id: voice.id, voice_name: voice.name } }];
```

### 6.7 TTS Per Scene (Single Code-Node)

**Wichtig (v3):** Statt einem separaten `ElevenLabs TTS`-HTTP-Request-Node + SplitInBatches-Loop wird **ein einziger Code-Node** verwendet, der via `this.helpers.httpRequest()` mit `await` sequentiell durch alle Szenen + Phone-Reveal läuft. Vorteile:
- Echtes sequentielles Execution (kein ElevenLabs-Concurrency-Limit-Problem)
- Keine SplitInBatches-Cross-Branch-Probleme
- Brand-Pronunciation-Fix (`Dopafy → Do-pa-fei`) und Key-Handling an einer Stelle

```javascript
// TTS Per Scene: sequential ElevenLabs calls for all scenes + phone_reveal.
// scenes[].text is 1:1 the voiceover. Brand pronunciation fix applied.
const approved = $input.first().json;
const voice_id = approved.voice_id;
const xi_key = '<YOUR_ELEVENLABS_KEY>';  // hardcoded; alternativ $env.ELEVENLABS_API_KEY
                                         // (dann N8N_BLOCK_ENV_ACCESS_IN_NODE=false setzen)

const fix = s => (s || '')
  .replace(/\bYOURBRAND\b/g, 'phonetic-pronunciation-spelling')  // an Brand anpassen
  .replace(/\bYourbrand\b/g, 'phonetic-pronunciation-spelling');

const callTTS = async (text) => {
  const buf = await this.helpers.httpRequest({
    method: 'POST',
    url: `https://api.elevenlabs.io/v1/text-to-speech/${voice_id}`,
    headers: {
      'xi-api-key': xi_key,
      'Content-Type': 'application/json',
      'Accept': 'audio/mpeg',
    },
    body: {
      text,
      model_id: 'eleven_multilingual_v2',
      voice_settings: { stability: 0.5, similarity_boost: 0.75 },
    },
    json: true,
    encoding: 'arraybuffer',
    returnFullResponse: false,
  });
  return Buffer.from(buf).toString('base64');
};

const scenes = [];
for (const sc of (approved.scenes || [])) {
  const voice_b64 = await callTTS(fix(sc.text || ''));
  scenes.push({ ...sc, voice_b64 });
}
const pr = approved.phone_reveal || {};
const phone_b64 = await callTTS(fix(pr.sub_cta || ''));
const phone_reveal = { ...pr, voice_b64: phone_b64 };

return [{ json: { ...approved, scenes, phone_reveal } }];
```

**Total-Zeit:** ~25-40s (4 sequentielle 5-10s-Calls). ElevenLabs-Starter-Concurrency-Limit (3 parallel) ist garantiert nicht überschritten.

**Brand-Pronunciation-Fix:** Wenn dein Brand-Name komisch ausgesprochen wird (z.B. „Spotify" wird zu „Spo-tee-fee" gelesen), Phonetic-Replacement in der `fix`-Funktion einbauen. Nur das TTS-Input wird ersetzt — Bildschirm-Text bleibt mit normaler Schreibweise.

### 6.8 Claude-Modell-Node

Schema-Quirk: `model` muss als RL-Object angegeben werden, NICHT als plain string:

```jsonc
{
  "model": {
    "__rl": true,
    "value": "claude-sonnet-4-5-20250929",
    "mode": "list",
    "cachedResultName": "Claude Sonnet 4.5"
  },
  "options": { "temperature": 0.8, "maxTokens": 4000 }
}
```

### 6.9 Music-Chain (4 Nodes)

```
[Music: List MP3s]  ← Drive-Search auf Music-Folder-ID, ext=.mp3
[Music: Pick Random]  ← Code: Math.random() pick aus items
[Music: Download]     ← Drive-Download per ID
[Music: To Base64]    ← Code: binary.data.data → music_b64 (siehe Binary-Mode!)
```

### 6.10 Assemble Render Body

Liest jetzt von **`TTS Per Scene`** (statt vom alten ElevenLabs-Node). Per-Szene-`voice_b64` ist bereits in `scenes[].voice_b64` und `phone_reveal.voice_b64`.

```javascript
const approved = $('TTS Per Scene').first().json;
const music_b64 = $('Music: To Base64').first().json.music_b64 || null;

const scenes = (approved.scenes || []).map(sc => ({
  type: 'scene',
  role: sc.role,
  text: sc.text,
  highlight: sc.highlight,
  background_url: sc.background_url,
  voice_b64: sc.voice_b64,
}));

scenes.push({
  type: 'phone_reveal',
  phone_png_url: approved.phone_png_drive_url,
  tagline: approved.phone_reveal?.tagline || '',
  sub_cta: approved.phone_reveal?.sub_cta || '',
  app_store_url: approved.phone_reveal?.app_store_url || 'https://apps.apple.com/app/...',
  voice_b64: approved.phone_reveal?.voice_b64 || '',
});

return [{ json: {
  slug: approved.slug,
  caption: approved.caption,
  render_body: { scenes, music_b64, accent: '#YOUR_PRIMARY' }
} }];
```

Keine `voiceover_text` und keine `duration`-Felder mehr — Renderer leitet die Dauer aus jedem `voice_b64` ab.

### 6.11 Binary durchreichen (Post-Render)

```javascript
const binary = $('Render Hetzner').first().binary;   // NICHT $input.first().binary!
const folder = $('Drive: Sub-Folder anlegen').item.json;
const meta = $('Assemble Render Body').item.json;
return [{ json: { ...meta, folder_id: folder.id }, binary }];
```

---

## 7. n8n Binary-Storage-Mode (KRITISCH)

Bei self-hosted n8n liefert `binary.data.data` einen File-Reference-String (`"filesystem-v2"`) statt echtem base64, wenn der Binary-Mode auf `filesystem` steht. Voiceover + Music würden dann beide als "filesystem-v2" zum Renderer durchgereicht → FFmpeg crashed.

**Fix**: In `docker-compose.yml`:

```yaml
services:
  n8n:
    environment:
      - N8N_DEFAULT_BINARY_DATA_MODE=default
      # ... andere envs
```

Dann `docker compose up -d n8n` und `docker exec n8n env | grep BINARY` verifiziert.

**Bei n8n-Cloud**: env nicht änderbar. Workaround: Drive-URL-Pull statt Inline-base64 (Audio nach Drive hochladen → URLs an Renderer schicken).

---

## 8. Smoke-Test (Iterations-Checkliste)

1. **Drive-Permissions checken**: `curl -sL "https://drive.google.com/uc?id=<PNG_ID>&export=download" -o /tmp/test.bin && xxd /tmp/test.bin | head -1`
   - Erwarte: `8950 4e47 ...` (PNG-Magic)
   - Wenn `3c21 646f ...` (HTML): Folder nicht "Anyone with link" geshared
2. **ElevenLabs-Key direkt curl-testen**: `curl -H "xi-api-key: $KEY" https://api.elevenlabs.io/v1/user`
3. **Renderer standalone**: curl mit test.json gegen `/render-product`, MP4 zurück-`scp`en, ansehen
4. **n8n Workflow als `active: false`** anlegen
5. **Drive-List-Nodes UI-Setup**: 4× googleDrive-Nodes auf "Search File/Folder" (MCP-Bug)
6. **"Execute Workflow"** im n8n-Editor (nicht "Execute Step"! das nutzt cached binary)
7. **Telegram-Iteration**: 1× `ok` für End-to-End
8. **Drive-Output checken**: MP4 + caption.md + script.json im Tages-Subfolder
9. **MP4 ansehen**: Voice synchron? Music leise im Hintergrund? Phone-Screenshot scharf? Apple-Badge crisp?
10. **Wenn grün**: `active: true` für täglichen Run

---

## 9. Bekannte Stolperfallen (aus echter Erfahrung)

| Bug | Wie zu erkennen | Fix |
|---|---|---|
| **Race-Condition** parallele Drive-Branches | "Cannot assign to read only property 'name'" im Merge-Node | Drive-Chains sequenziell verkabeln |
| **Claude-Modell-Schema** | "could not get a parameter" Sub-Node | `model` als RL-Object, nicht string |
| **Tournament-Parser jsonBody** | "invalid syntax" im ElevenLabs-Node | JSON-Template, kein JS-Object-Literal |
| **ElevenLabs "Field required"** | 422 mit `"text": null` im body | Set-Approved nutzt `$json.output.X` statt `$('Script v1').item.json.output.X` |
| **fetch is not defined** | "ReferenceError" im Code-Node | Pexels in separate HTTP-Nodes (Split→HTTP→Aggregate) |
| **Binary "filesystem-v2"** | Renderer-Timeout, body zeigt `voiceover_b64: "filesystem-v2"` | `N8N_DEFAULT_BINARY_DATA_MODE=default` env-Var setzen |
| **Drive-PNG liefert HTML** | Renderer-Log: "Invalid PNG signature 0x3C21646F63747970" | Drive-Folder als "Anyone with link" sharen |
| **Binary verloren nach Drive-Subfolder** | "binary file 'data' not found" beim Upload | `$('Render Hetzner').first().binary` |
| **"Execute Step" nutzt cache** | Body zeigt alte Werte trotz env-Fix | "Execute Workflow" für vollen Run, nicht "Execute Step" |
| **Phone-Frame outer-corner spikes** | Schwarzer Bezel hat kleine Spitzen | Single-Path SVG mit `fill-rule="evenodd"` |
| **Screenshot ragt über Display** | Ecken-Kante am Display-Übergang | `alphamerge` mit screen_mask.png |
| **Apple-Badge unscharf** | Pixelig trotz großer Source | Separater FFmpeg-Lanczos-Overlay, nicht SVG-embedded |
| **n8n MCP `update_workflow` defekt** | Änderungen wirken nicht | DELETE + CREATE statt UPDATE |
| **Drive-List per MCP create** | Phantom-Folder | Nach create in UI auf "Search" stellen |
| **Pexels-CDN 403** | Renderer "HTTP 403" | Browser-User-Agent in `urllib`-Request |
| **Voice ≠ Bildschirm-Text** (v1) | Sprecher liest anderen Text als angezeigt | Per-Szene-TTS-Architektur — kein separates `voiceover_text`-Feld mehr, scenes[].text ist 1:1 das gesprochene |
| **Bildwechsel mitten im Satz** (v1) | Szenen-Cut while Voice noch redet | Renderer leitet `scene.duration` aus `_probe_duration(voice_path) + buffer` ab — nicht hartkodiert |
| **ElevenLabs "Too many concurrent requests"** | HTTP 429, max 3 parallel auf Starter | Sequential `await this.helpers.httpRequest()` in single Code-Node statt parallel-multi-item HTTP |
| **HTTP-Node `batching` löst NICHT Concurrency** | Trotz `batchSize=1, batchInterval=200ms` immer noch "too many" | `batchInterval` ist Pause zwischen Starts, blockiert NICHT auf Completion — daher SplitInBatches mit Back-Connect ODER (besser) single Code-Node |
| **SplitInBatches v3 Output-Mapping unklar** | Loop läuft sofort in Done-Branch | n8n v3: `main[0]`=Loop, `main[1]`=Done (in v1 umgekehrt). Im UI visuell prüfen |
| **SplitInBatches Done-Branch sieht Loop-Body-Nodes nicht** | "Node 'X' hasn't been executed" trotz erfolgreicher Iterations | Cross-Branch-Execution-Isolation. Workaround: Done-Branch nicht nutzen, single Code-Node-Approach wählen |
| **n8n Code-Node `$env` blockiert** | "access to env vars denied" trotz gesetztem env | Default: `N8N_BLOCK_ENV_ACCESS_IN_NODE=true`. Entweder `=false` setzen ODER Key inline im jsCode hardcoden |
| **Auto-classifier blockt Secret in MCP create_workflow** | jsCode mit Key wird abgelehnt | Workflow ohne Key per MCP erstellen, dann Key manuell im n8n-UI in Code-Node einfügen |
| **`fetch` undefined in Code-Node** | ReferenceError | `this.helpers.httpRequest({...encoding: 'arraybuffer', returnFullResponse: false})` für Binary-Responses verwenden |

---

## 10. Wie du das mit Claude Code reproduzierst

Schicke Claude Code diesen Prompt:

```
Ich will den Workflow aus BUILD_GUIDE_PRODUCT_VIDEO.md mit folgenden Daten nachbauen:

PRODUKT
- App-Name: <DEIN PRODUKT>
- App-Store-URL: https://apps.apple.com/app/<...>/id<...>
- 1-Satz-Pitch: <z.B. "Hilft dir, deine Dopamin-Balance zu verbessern">
- Zielgruppe: <z.B. "Menschen mit Konzentrationsproblemen, 25-45">

BRAND
- Logo/Wordmark: /pfad/wordmark.png
- Favicon: /pfad/favicon.png
- Primary Color: #XXXXXX
- Background Dark: #XXXXXX
- Background Purple/Accent: #XXXXXX

ASSETS
- Screenshots-Folder lokal: /pfad/screenshots/ (1284×2778 PNGs, EN-Versionen)
- Music-MP3s lokal: /pfad/music/ (3-5 Tracks, ambient/cinematic)

INFRASTRUKTUR
- Server: <IP>, root-SSH-Zugriff
- n8n läuft via: <Docker-Compose unter /opt/n8n/>
- Renderer-Port: 8088

API-KEYS
- Anthropic: sk-ant-...
- Pexels: ...
- ElevenLabs: sk_... (mit 3 Voice-IDs: 2 male + 1 female)
- Telegram-Bot-Token + Chat-ID

TOPICS (mindestens 5)
1. <Topic 1 + 1-Satz-Pain-Point>
2. <Topic 2 + 1-Satz-Pain-Point>
3. <Topic 3 + 1-Satz-Pain-Point>
...

STYLE-TONE
- <z.B. "wissenschaftlich, kein Hype, du-Form">
- <z.B. "Englisch für International, oder Deutsch für DACH">

Bitte:
1. Lege Drive-Folder-Struktur an (5 Folder, gibt mir die IDs)
2. Stelle Drive-Folder Screenshots_Appstore + Music auf "Anyone with link → Viewer"
3. Generiere Style-Guide-MD + 5 Topic-Briefing-MDs
4. Lade Screenshots + Music in Drive hoch
5. Deploye den Renderer (render_product.py + render_server.py + Assets) auf meinen Server
6. Setze in docker-compose:
   - `N8N_DEFAULT_BINARY_DATA_MODE=default` (Binary-Mode-Fix)
   - Optional `N8N_BLOCK_ENV_ACCESS_IN_NODE=false` (wenn Key per env statt hardcoded)
   - Optional `ELEVENLABS_API_KEY=sk_...` (wenn obiges gesetzt)
7. Baue den n8n-Workflow (Schedule + 4-Iter-Approval + Pexels-Split-HTTP-Aggregate + `TTS Per Scene` single Code-Node + Music + Render + Drive)
8. Wichtig: Brand-Pronunciation-Fix im TTS-Code anpassen (z.B. `Spotify → Spo-ti-fy`)
9. Wichtig: 1:1-Schema — scenes[].text wird vorgelesen, kein separates voiceover-Feld
10. Smoke-Test durchführen und MP4-Drive-Link liefern

Wichtig: beachte alle Stolperfallen aus Abschnitt 9 der BUILD_GUIDE.
```

Claude wird Drive-Setup, Briefings, Renderer-Deploy, n8n-MCP-Calls und SSH-Konfiguration in sinnvoller Reihenfolge abarbeiten.

---

## 11. Renderer-Code-Reference

Der Python-Renderer (~430 Zeilen) hat diese Hauptkomponenten:

```python
# Brand-Konstanten
PRIMARY        = "#YOUR_PRIMARY"
BG_DARK        = "#YOUR_BG_DARK"
BG_PURPLE      = "#YOUR_BG_PURPLE"
BADGE_W        = 360
APP_STORE_URL_DEFAULT = "https://apps.apple.com/app/<...>/id<...>"

# v3: Per-Szene-Voice-Konstanten
MIN_PEXELS_DURATION = 2.5         # Floor für Pexels-Szenen (kurze Hooks)
MIN_PHONE_REVEAL_DURATION = 4.0   # Floor für Phone-Reveal (Animation-Hold-Time)
VOICE_TAIL_BUFFER = 0.3           # Stille am Ende jeder Szene

def _probe_duration(path) -> float:
    """ffprobe Audio/Video-Dauer in Sekunden"""

def _resolve_scene_voice_and_duration(scene, work_dir):
    """Decode scene.voice_b64 → MP3-Datei, probe Dauer, setze scene.duration.
    Stores resolved voice path in scene['_voice_path'] for audio assembly.
       scene.duration = max(MIN_*, voice_dur + VOICE_TAIL_BUFFER)"""

def _build_per_scene_audio(scenes, music_b64, video_duration, work_dir, out_audio):
    """Per-Szene-Audio-Assembly (v3 Replacement für altes _build_audio):
    1. Pro Szene: AAC-Segment der Länge scene.duration:
       - voice present: MP3 + apad=whole_dur=<duration> (Stille-Padding)
       - keine voice: pure Stille via anullsrc
       - Forced stereo via aformat=channel_layouts=stereo
    2. Concat aller Segmente in einen voice-Track via concat-demuxer
    3. Mix voice + music (volume=0.12) → AAC out_audio"""

def _scene_overlay_svg(text, highlight, accent) -> bytes:
    """SVG mit Caption-Text, Highlight-Word in Accent-Color, Manrope ExtraBold"""

def _phone_reveal_svg(tagline, sub_cta, accent) -> bytes:
    """SVG mit Favicon + Wordmark + (optional) Tagline + Sub-CTA
       Apple-Badge ist NICHT im SVG (separater FFmpeg-Overlay-Pass)"""

def _render_pexels_scene(scene, output_path):
    """1 Pexels-clip + caption-overlay → scene_NN.mp4 mit scene.duration"""

def _render_phone_reveal(scene, output_path):
    """5-Input-Composition: bg-gradient + app-screenshot + phone-frame-svg + text-svg + apple-badge
       Dauer = scene.duration (von _resolve_scene_voice_and_duration gesetzt)"""

def render(payload, out_path):
    """v3 Flow:
    1. Pro Szene: _resolve_scene_voice_and_duration() (decode voice_b64, set duration)
    2. Pro Szene: _render_pexels_scene oder _render_phone_reveal (mit jetzt-bekannter duration)
    3. Concat alle Scene-MP4s zu silent_video via concat-demuxer
    4. _build_per_scene_audio() → AAC mit per-Szene-padded-voice + Music
    5. Mux silent_video + audio_track → out_path (libx264, AAC, +faststart)
    """
```

### Bibliotheken
```
python3, ffmpeg (mit libfdk_aac oder aac), librsvg2-bin (für SVG-to-PNG)
```

### Server
```python
# render_server.py — minimal http.server
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.headers.get('X-Token') != RENDER_TOKEN: self.send_error(401); return
        if self.path in ('/render-product', '/render-dopafy'):
            body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
            out = tempfile.mktemp(suffix='.mp4')
            render(body, out)
            with open(out, 'rb') as f: mp4 = f.read()
            self.send_response(200); self.send_header('Content-Type', 'video/mp4')
            self.end_headers(); self.wfile.write(mp4)
```

---

## 12. Output

Pro Tag im Drive:
- `Output/20260301_topic-slug/<brand>_20260301_0702_topic-slug.mp4` (1080×1920, ~16-20 s, 3-6 MB)
- `Output/20260301_topic-slug/caption.md` (Social-Caption inkl. App-Store-CTA + Hashtags)
- `Output/20260301_topic-slug/script.json` (Full Claude-Output für Debug/Re-Run)

Post-Production:
- MP4 manuell auf TikTok/IG Reels/YouTube Shorts hochladen
- Caption.md-Text direkt copy-paste in den Post
- Oder: weitere n8n-Stage anhängen (Buffer/Postiz/etc.) für Auto-Publishing

---

**Geschätzte Total-Setup-Zeit mit Claude Code: 90-150 Minuten** (Drive-Setup, Briefings, Renderer-Deploy, n8n-Build, env-Switch, Smoke-Test). Erstes erfolgreiches Render typischerweise nach 3-5 Iteration-Cycles.
