# Build Guide — Product Placement Videos (Type B)

> Automated daily workflow for **multi-scene product videos** (1080×1920, ~16–20 s, with voiceover + music): 3 cut Pexels scenes + a final **phone reveal** with an app screenshot, ElevenLabs TTS voice + music bed, captions as overlay.

This doc is written so you can hand it — together with your own product data — to **Claude Code** or a comparable agent tool, and it will reproduce the workflow with your product.

---

## 1. What you'll have at the end

Every day at 07:00 (or the cron of your choice):

1. **Theme picker** pulls a topic from your topic list
2. **Claude Sonnet 4.5** writes a 3-scene script (hook → problem → solution) + a phone-reveal sub-CTA + voiceover text
3. **Telegram bot** approval loop, up to 4 iterations
4. **Pexels** searches for a matching background video **per scene**
5. **ElevenLabs** generates the voiceover (2/3 male, 1/3 female rotation with topic bias)
6. **Random music bed** out of your Drive
7. **FFmpeg renderer** composes: 3 scenes + phone reveal with the app screenshot, voice, music ducked to 0.12
8. **Google Drive upload** with the MP4 + caption + script.json

Result: 1 product reel per day with a voiceover, ready to post — with a clear App Store download CTA at the end.

> Note on Telegram approvals: this guide uses `ok` / `no` as the reply tokens. The original workflow runs on German replies (`ok` / `nein`) — when you build the EN version, make sure the n8n Switch node listens for the same strings you actually type. Keep doc and Switch aligned.

---

## 2. What you need up front

| Item | Where to get it | Free? |
|---|---|---|
| n8n instance (self-hosted) | `docker compose` on a VPS | yes (except the VPS) |
| Linux VPS with FFmpeg + Python + librsvg2 | Hetzner Cloud / DigitalOcean | ~€5/mo |
| Google Drive (OAuth + 1 folder) | https://console.cloud.google.com | yes |
| Anthropic API key | https://console.anthropic.com | usage-based |
| Pexels API key | https://www.pexels.com/api/ | yes |
| **ElevenLabs API key + voice IDs** | https://elevenlabs.io | $5/mo Starter |
| Telegram bot (BotFather) | t.me/BotFather | yes |
| Apple App Store listing | App Store Connect | — |
| App screenshots (1284×2778) | from your App Store listing | — |
| 3 music MP3s (royalty-free) | pixabay.com/music | yes |

**Product asset checklist:**
- 10-30 app screenshots (1284×2778, pure screen content, **no** phone bezel) — preferably EN versions
- `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 (short imperative phrases, max 5 words)

---

## 3. Server setup (FFmpeg renderer)

### 3.1 Directory layout

```
/opt/<your-renderer>/
├── render_server.py            # HTTP endpoint
├── render_product.py           # multi-scene + phone-reveal renderer
├── render_reel.py              # (optional, if you also run the one-pager variant)
├── favicon.png                 # brand mark
├── phone_frame.svg + .png      # phone bezel (transparent outside + display)
├── screen_mask.svg + .png      # rounded-corner mask for screenshots
├── app_store_badge.png         # Apple "Download on the App Store" (3240px source)
├── fonts/                      # Manrope ExtraBold + Regular
└── (no local music/ needed — music comes from 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** — receives JSON with **per-scene** `voice_b64` + optional `music_b64` + scenes, returns `video/mp4`.

**Important design principle (v3, 1:1):** there is NO separate `voiceover` field and NO global `voiceover_b64`. Every scene has its own `voice_b64` containing the TTS recording of its caption text. The renderer derives the scene duration **from the measured voice audio length** (`ffprobe`) — the caller does NOT specify any `duration` field.

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 bed across the whole video
  "accent": "#YOUR_PRIMARY"
}
```

Per-scene, the renderer derives:
- `scene.duration = max(MIN, voice_dur + 0.3s)`
- `MIN_PEXELS_DURATION = 2.5s` (Pexels scenes)
- `MIN_PHONE_REVEAL_DURATION = 4.0s` (phone reveal)

If voice is shorter than MIN: the scene is held at MIN, voice is padded with silence.

Auth header: `X-Token: <RENDER_TOKEN>`. Timeout: 300s (a render typically takes 30-90s).

### 3.4 Visual layout

**Middle scenes (hook / problem / solution):**
```
┌──────────────────────────────────┐
│                       [favicon]  │  ← small, top right, white
│                                  │
│  Pexels portrait video           │
│  with vignette + dark layer      │
│                                  │
│                                  │
│  YOUR HOOK TEXT WITH             │  ← Manrope ExtraBold, white
│  HIGHLIGHTED PHRASE IN ACCENT    │  ← highlight in #PRIMARY
│                                  │
│  (lower half / third)            │
└──────────────────────────────────┘
```

**Phone reveal (final scene):**
```
┌──────────────────────────────────┐
│  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 words, imperative
│                                  │
│   [Download on the App Store]    │  ← Apple badge, 360px wide
│         yourbrand.app            │  ← optional domain text
└──────────────────────────────────┘
```

### 3.5 FFmpeg pipeline highlights

- **Phone frame:** SVG with `fill-rule="evenodd"` for a transparent display + black bezel without sub-pixel spikes at the outer corners
- **Screenshot rounded corners:** `alphamerge` with a separate mask PNG (display inner radius)
- **Apple badge:** separate FFmpeg overlay pass with `scale=360:-1:flags=lanczos` (librsvg samples with nearest-neighbor → an embedded base64 image inside the SVG would look fuzzy)
- **Per-scene voice padding:** every scene becomes an AAC segment of length `scene.duration` assembled from `voice_b64` + silence padding via `apad=whole_dur=<duration>`. Forced stereo via `aformat=channel_layouts=stereo,apad=...` so the concat demuxer merges all segments cleanly.
- **Audio concat + music mix:** per-scene voice segments are merged with the FFmpeg concat demuxer into a continuous voice track, then mixed with music (volume=0.12).
- **Video concat:** FFmpeg demuxer for hard cuts between scenes (crossfade `xfade` is a future improvement)

---

## 4. Google Drive setup

Create this folder structure:

```
/<Your Product> Reels/
  /Style/                  ← brand style guide
  /Topic_Briefings/        ← markdown per topic
  /Screenshots_Appstore/   ← app screenshots (1284×2778 PNGs)
  /Music/                  ← 3-5 royalty-free MP3s (~30-60s, looped by the renderer)
  /Output/                 ← workflow creates a daily subfolder
```

**Important:** every folder the renderer pulls by URL (`Screenshots_Appstore`, `Music`) must be shared **"Anyone with the link → Viewer"**. Otherwise Drive returns an HTML login page instead of the file (→ FFmpeg crashes with "Invalid PNG signature").

The Style and Topic_Briefings folders stay private — n8n reads them via authenticated OAuth.

### 4.1 Style guide markdown (`Style/style_guide.md`)

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

## Tone
- Scientific but accessible
- Empowering, not moralizing
- 2nd person singular (you)
- No hype language, no detox-cult tone

## Scene structure (always 3 scenes)
1. `hook`: concrete pain point in 8-12 words (no "Did you know")
2. `problem`: mechanism / science behind it (one analogy is enough)
3. `solution`: bridge to the product without a hard sell ("There's a fix")

## Sub-CTA pool (max 5 words, imperative, 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 for Screenshots_Appstore)
- Filenames end with `_eng.png` for the English versions
- Slug style: `dashboard_eng.png`, `goal_detail_eng.png`, `streak_eng.png`
- Claude picks 1 screenshot per video, thematically fitting

## Hook constraints
- NO "Imagine if..." / "Picture this..."
- Question OR provocative observation OR surprising statistic
```

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

```markdown
# Topic: <Topic Name>

## Pain point (hook material)
What does your target person feel when they read this hook? Concrete, 1-2 sentences.

## Mechanism (problem material)
Scientific explanation / mechanism, in 1-2 plain-language sentences.

## Solution hint (solution material without a hard sell)
How do we bridge to the product? Not "Buy now" — instead "Here's a fix".

## Recommended phone screenshots
- `dashboard_eng.png` (if the topic is about tracking)
- `goal_detail_eng.png` (if the topic is about goals)

## Pexels search terms per scene
- Hook: "person tired scrolling phone night"
- Problem: "brain concept abstract"
- Solution: "morning sunrise calm focus"
```

At least 5 topic briefings, ideally 10-15.

---

## 5. ElevenLabs setup

### 5.1 API key + voices

1. Account at https://elevenlabs.io ($5/mo Starter is enough for ~3-5 videos/day)
2. Pick 2-3 voices in the dashboard (mix of male/female), add them to your account via "Add to my voices"
3. Copy the voice IDs (format: 20-char string like `i4CzbCVWoqvD0P1QJCUL`)
4. Get the API key: Settings → API Keys → "Create new key" → minimal scopes:
   - **Text to Speech: Access** (required)
   - **Models: Access** (optional)
   - **Voices: Read** (optional)
   - all others: **No Access**

### 5.2 Voice rotation strategy

Three voices in rotation: 2× male, 1× female. Plus an optional topic-bias heuristic:
- Sleep/exhaustion/emotional topics → female bias
- Focus/discipline/performance topics → male bias

For the implementation see the Pick-Voice Code node in §6.6.

---

## 6. n8n workflow

### 6.1 Credentials (set up in n8n in advance)

| Credential | Type | What for |
|---|---|---|
| Anthropic API | `anthropicApi` | Claude Sonnet 4.5 |
| Google Drive OAuth2 | `googleDriveOAuth2Api` | Drive operations |
| Telegram | `telegramApi` | Approval chat |

The ElevenLabs key is **hardcoded in the `TTS Per Scene` Code node** (`const xi_key = 'sk_...'`). Alternative: via `$env.ELEVENLABS_API_KEY` — but then n8n needs `N8N_BLOCK_ENV_ACCESS_IN_NODE=false`.
The Pexels key is inline in the 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 for Claude)
       ↓
[Merge Context]                              ← sequential, NOT parallel!
       ↓
[Script v1]                                  ← Claude generates 3 scenes + phone-reveal sub-CTA
       ↓
[Telegram v1 → Switch v1]
       ↓ approve   ↓ feedback
[Set Approved v1]  [Set state v2] → [Content v2] → [Telegram v2] → ...
       ↓                                     (same for v3, v4)
       ↓ ←←←← all approved paths converge ←←←←
[Prepare Pexels Queries]      ← Code node: 1 item per scene
       ↓
[Pexels Search] (HTTP-Request)             ← loops over items
       ↓
[Pick Best Clip + Reassemble]             ← Code node: pick 1080-portrait, merge with 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: calls ElevenLabs per scene + phone_reveal,
                                              sets scenes[i].voice_b64 and phone_reveal.voice_b64.
                                              Brand pronunciation fix (Dopafy → Do-pa-fei) lives here.
       ↓
[Music: List MP3s] (Drive-Search)
       ↓
[Music: Pick Random] (Code)
       ↓
[Music: Download] (Drive)
       ↓
[Music: To Base64] (Code)
       ↓
[Assemble Render Body]                      ← Code: scenes with per-scene voice_b64 + music_b64 → render_body
       ↓
[Render Hetzner] (HTTP-Request, returns binary MP4)
       ↓
[Drive: create subfolder]
       ↓
[Pass binary through]                       ← `$('Render Hetzner').first().binary`
       ↓
       ├→ [Drive: upload MP4]
       ├→ [Caption as file] → [Drive: upload caption.md]
       └→ [Script.json as file] → [Drive: upload script.json]
```

### 6.3 Claude output schema (JSON structured output, v3 — 1:1)

**Important:** there is **no** separate `voiceover` field. `scenes[].text` is both the on-screen caption AND what the speaker says (1:1). Likewise, `phone_reveal.sub_cta` is on-screen AND spoken. **No** `duration` field — the renderer derives it from the TTS audio.

```jsonc
{
  "kicker": "DOPAMINE",                          // optional, the top label can also be empty
  "hook_pain_point": "...",                      // 1-sentence pain point (Claude-internal, not in the 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 the system message of the Claude node):
> 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 (CRITICAL)

After `Telegram sendAndWait`, `$json` is the Telegram reply, NOT the Claude output. Mappings have to reference the Claude node explicitly — **only forward 4 fields, no more voiceover_text**:

```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 }}"
// for v2/v3/v4 use $('Content v2'), $('Content v3'), $('Content v4 (final)') accordingly
```

### 6.5 Pexels pattern (Split → HTTP → Aggregate)

n8n's Code node has **no `fetch`** in the task-runner sandbox. So run the Pexels loop as 3 nodes:

**1. `Prepare Pexels Queries`** (Code) — no more `duration` field in the 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, runs automatically 1× per item):
```
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 without `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 });
}
// find the approved state (comes from one of the 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)

**Important (v3):** instead of a separate `ElevenLabs TTS` HTTP-Request node + SplitInBatches loop, we use **one single Code node** that runs through all scenes + the phone reveal sequentially using `this.helpers.httpRequest()` with `await`. Advantages:
- Real sequential execution (no ElevenLabs concurrency-limit problems)
- No SplitInBatches cross-branch issues
- Brand pronunciation fix (`Dopafy → Do-pa-fei`) and key handling in one place

```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; or $env.ELEVENLABS_API_KEY
                                         // (then set N8N_BLOCK_ENV_ACCESS_IN_NODE=false)

const fix = s => (s || '')
  .replace(/\bYOURBRAND\b/g, 'phonetic-pronunciation-spelling')  // adapt to your brand
  .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 time:** ~25-40s (4 sequential 5-10s calls). The ElevenLabs Starter concurrency limit (3 parallel) is guaranteed to not be exceeded.

**Brand pronunciation fix:** if your brand name comes out weird (e.g. "Spotify" gets read as "Spo-tee-fee"), drop a phonetic replacement into the `fix` function. Only the TTS input gets replaced — the on-screen text keeps the normal spelling.

### 6.8 Claude-model node

Schema quirk: `model` must be given as an RL object, NOT as a 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 on the music folder ID, ext=.mp3
[Music: Pick Random]  ← Code: Math.random() pick from items
[Music: Download]     ← Drive download by ID
[Music: To Base64]    ← Code: binary.data.data → music_b64 (mind the binary mode!)
```

### 6.10 Assemble Render Body

Now reads from **`TTS Per Scene`** (instead of the old ElevenLabs node). Per-scene `voice_b64` already sits in `scenes[].voice_b64` and `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' }
} }];
```

No more `voiceover_text` and no more `duration` fields — the renderer derives the duration from every `voice_b64`.

### 6.11 Pass binary through (post-render)

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

---

## 7. n8n binary storage mode (CRITICAL)

On self-hosted n8n, `binary.data.data` returns a file reference string (`"filesystem-v2"`) instead of real base64 when the binary mode is `filesystem`. Voiceover + music would then both be passed to the renderer as "filesystem-v2" → FFmpeg crashes.

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

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

Then `docker compose up -d n8n` and verify with `docker exec n8n env | grep BINARY`.

**On n8n Cloud:** the env is not changeable. Workaround: pull via Drive URL instead of inline base64 (upload audio to Drive → send URLs to the renderer).

---

## 8. Smoke test (iteration checklist)

1. **Check Drive permissions:** `curl -sL "https://drive.google.com/uc?id=<PNG_ID>&export=download" -o /tmp/test.bin && xxd /tmp/test.bin | head -1`
   - Expect: `8950 4e47 ...` (PNG magic)
   - If `3c21 646f ...` (HTML): the folder is not shared "Anyone with link"
2. **Curl-test the ElevenLabs key directly:** `curl -H "xi-api-key: $KEY" https://api.elevenlabs.io/v1/user`
3. **Renderer standalone:** curl with test.json against `/render-product`, `scp` the MP4 back, take a look
4. **Create the n8n workflow as `active: false`**
5. **Drive list nodes UI setup:** flip 4× googleDrive nodes to "Search File/Folder" (MCP bug)
6. **"Execute Workflow"** in the n8n editor (NOT "Execute Step"! that uses cached binary)
7. **Telegram iteration:** 1× `ok` for end-to-end
8. **Check the Drive output:** MP4 + caption.md + script.json in the daily subfolder
9. **Watch the MP4:** voice in sync? Music quiet in the background? Phone screenshot sharp? Apple badge crisp?
10. **When green:** flip to `active: true` for the daily run

---

## 9. Known gotchas (from real experience)

| Bug | How to spot it | Fix |
|---|---|---|
| **Race condition** on parallel Drive branches | "Cannot assign to read only property 'name'" in the merge node | Wire the Drive chains sequentially |
| **Claude-model schema** | "could not get a parameter" sub-node | `model` as RL object, not a string |
| **Tournament-parser jsonBody** | "invalid syntax" in the ElevenLabs node | JSON template, not a JS object literal |
| **ElevenLabs "Field required"** | 422 with `"text": null` in the body | Set-Approved uses `$json.output.X` instead of `$('Script v1').item.json.output.X` |
| **fetch is not defined** | "ReferenceError" in the Code node | Move Pexels into separate HTTP nodes (Split → HTTP → Aggregate) |
| **Binary "filesystem-v2"** | Renderer timeout, body shows `voiceover_b64: "filesystem-v2"` | Set the `N8N_DEFAULT_BINARY_DATA_MODE=default` env var |
| **Drive PNG returns HTML** | Renderer log: "Invalid PNG signature 0x3C21646F63747970" | Share the Drive folder as "Anyone with link" |
| **Binary lost after Drive subfolder** | "binary file 'data' not found" on upload | `$('Render Hetzner').first().binary` |
| **"Execute Step" uses cache** | Body shows stale values despite the env fix | "Execute Workflow" for a full run, not "Execute Step" |
| **Phone-frame outer-corner spikes** | The black bezel has tiny spikes | Single-path SVG with `fill-rule="evenodd"` |
| **Screenshot bleeds past the display** | Corner edge at the display transition | `alphamerge` with screen_mask.png |
| **Apple badge looks fuzzy** | Pixelated despite the large source | Separate FFmpeg lanczos overlay, not SVG-embedded |
| **n8n MCP `update_workflow` broken** | Changes don't take effect | DELETE + CREATE instead of UPDATE |
| **Drive list via MCP create** | Phantom folder | After create, switch to "Search" in the UI |
| **Pexels CDN 403** | Renderer "HTTP 403" | Browser User-Agent in the `urllib` request |
| **Voice ≠ on-screen text** (v1) | The speaker reads different text than what's shown | Per-scene TTS architecture — no more separate `voiceover_text` field, scenes[].text is 1:1 what's spoken |
| **Image cut mid-sentence** (v1) | Scene cuts while voice is still talking | Renderer derives `scene.duration` from `_probe_duration(voice_path) + buffer` — not hardcoded |
| **ElevenLabs "Too many concurrent requests"** | HTTP 429, max 3 parallel on Starter | Sequential `await this.helpers.httpRequest()` in a single Code node instead of parallel multi-item HTTP |
| **HTTP-node `batching` does NOT fix concurrency** | Despite `batchSize=1, batchInterval=200ms`, still "too many" | `batchInterval` is the pause between starts, it does NOT block on completion — therefore SplitInBatches with back-connect OR (better) a single Code node |
| **SplitInBatches v3 output mapping unclear** | The loop falls into the Done branch immediately | n8n v3: `main[0]`=Loop, `main[1]`=Done (in v1 the other way around). Check visually in the UI |
| **SplitInBatches Done branch can't see loop-body nodes** | "Node 'X' hasn't been executed" despite successful iterations | Cross-branch execution isolation. Workaround: don't use the Done branch, pick the single-Code-node approach |
| **n8n Code node `$env` blocked** | "access to env vars denied" despite the env being set | Default: `N8N_BLOCK_ENV_ACCESS_IN_NODE=true`. Either set it to `=false` OR hardcode the key inline in jsCode |
| **Auto-classifier blocks the secret in MCP create_workflow** | jsCode with the key gets rejected | Create the workflow without the key via MCP, then paste the key manually into the Code node in the n8n UI |
| **`fetch` undefined in Code node** | ReferenceError | Use `this.helpers.httpRequest({...encoding: 'arraybuffer', returnFullResponse: false})` for binary responses |

---

## 10. How to reproduce this with Claude Code

Send Claude Code this prompt:

```
I want to rebuild the workflow from BUILD_GUIDE_PRODUCT_VIDEO.md with the following data:

PRODUCT
- App name: <YOUR PRODUCT>
- App Store URL: https://apps.apple.com/app/<...>/id<...>
- 1-sentence pitch: <e.g. "Helps you improve your dopamine balance">
- Target group: <e.g. "People with focus problems, 25-45">

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

ASSETS
- Screenshots folder local: /path/screenshots/ (1284×2778 PNGs, EN versions)
- Music MP3s local: /path/music/ (3-5 tracks, ambient/cinematic)

INFRASTRUCTURE
- Server: <IP>, root SSH access
- n8n runs via: <Docker Compose under /opt/n8n/>
- Renderer port: 8088

API KEYS
- Anthropic: sk-ant-...
- Pexels: ...
- ElevenLabs: sk_... (with 3 voice IDs: 2 male + 1 female)
- Telegram bot token + chat ID

TOPICS (at least 5)
1. <Topic 1 + 1-sentence pain point>
2. <Topic 2 + 1-sentence pain point>
3. <Topic 3 + 1-sentence pain point>
...

STYLE TONE
- <e.g. "scientific, no hype, 2nd person">
- <e.g. "English for international, or German for DACH">

Please:
1. Create the Drive folder structure (5 folders, give me the IDs)
2. Share the Drive folders Screenshots_Appstore + Music as "Anyone with link → Viewer"
3. Generate the style guide MD + 5 topic briefing MDs
4. Upload screenshots + music into Drive
5. Deploy the renderer (render_product.py + render_server.py + assets) to my server
6. In docker-compose set:
   - `N8N_DEFAULT_BINARY_DATA_MODE=default` (binary-mode fix)
   - Optional `N8N_BLOCK_ENV_ACCESS_IN_NODE=false` (if the key goes via env instead of hardcoded)
   - Optional `ELEVENLABS_API_KEY=sk_...` (if the one above is set)
7. Build the n8n workflow (Schedule + 4-iter approval + Pexels-Split-HTTP-Aggregate + `TTS Per Scene` single Code node + Music + Render + Drive)
8. Important: adapt the brand pronunciation fix in the TTS code (e.g. `Spotify → Spo-ti-fy`)
9. Important: 1:1 schema — scenes[].text is read aloud, no separate voiceover field
10. Run a smoke test and deliver the MP4 Drive link

Important: respect all gotchas from section 9 of the BUILD_GUIDE.
```

Claude will run through Drive setup, briefings, renderer deploy, n8n MCP calls and SSH config in a sensible order.

---

## 11. Renderer code reference

The Python renderer (~430 lines) has these main components:

```python
# Brand constants
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-scene-voice constants
MIN_PEXELS_DURATION = 2.5         # floor for Pexels scenes (short hooks)
MIN_PHONE_REVEAL_DURATION = 4.0   # floor for the phone reveal (animation hold time)
VOICE_TAIL_BUFFER = 0.3           # silence at the end of each scene

def _probe_duration(path) -> float:
    """ffprobe audio/video duration in seconds"""

def _resolve_scene_voice_and_duration(scene, work_dir):
    """Decode scene.voice_b64 → MP3 file, probe duration, set 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-scene audio assembly (v3 replacement for the old _build_audio):
    1. Per scene: AAC segment of length scene.duration:
       - voice present: MP3 + apad=whole_dur=<duration> (silence padding)
       - no voice: pure silence via anullsrc
       - forced stereo via aformat=channel_layouts=stereo
    2. Concat all segments into a voice track via the concat demuxer
    3. Mix voice + music (volume=0.12) → AAC out_audio"""

def _scene_overlay_svg(text, highlight, accent) -> bytes:
    """SVG with caption text, highlight word in accent color, Manrope ExtraBold"""

def _phone_reveal_svg(tagline, sub_cta, accent) -> bytes:
    """SVG with favicon + wordmark + (optional) tagline + sub-CTA.
       The Apple badge is NOT in the SVG (separate FFmpeg overlay pass)"""

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

def _render_phone_reveal(scene, output_path):
    """5-input composition: bg-gradient + app-screenshot + phone-frame-svg + text-svg + apple-badge.
       Duration = scene.duration (set by _resolve_scene_voice_and_duration)"""

def render(payload, out_path):
    """v3 flow:
    1. Per scene: _resolve_scene_voice_and_duration() (decode voice_b64, set duration)
    2. Per scene: _render_pexels_scene or _render_phone_reveal (with the now-known duration)
    3. Concat all scene MP4s into silent_video via the concat demuxer
    4. _build_per_scene_audio() → AAC with per-scene-padded voice + music
    5. Mux silent_video + audio_track → out_path (libx264, AAC, +faststart)
    """
```

### Libraries
```
python3, ffmpeg (with libfdk_aac or aac), librsvg2-bin (for 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

Every day in 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 including App Store CTA + hashtags)
- `Output/20260301_topic-slug/script.json` (full Claude output for debug/re-run)

Post-production:
- Upload the MP4 manually to TikTok/IG Reels/YouTube Shorts
- Copy-paste the caption.md text directly into the post
- Or: wire on another n8n stage (Buffer/Postiz/etc.) for auto publishing

---

**Estimated total setup time with Claude Code: 90–150 minutes** (Drive setup, briefings, renderer deploy, n8n build, env switch, smoke test). The first successful render typically lands after 3-5 iteration cycles.
