# Video Tutorial DevGuide

**Alias:** `video devGuide`  
**Scope:** Any PWA in the 360LM platform  
**Standard:** H.264 · AI voiceover (edge-tts) · Playwright recording · ffmpeg pipeline

---

## 1. When to Build a Tutorial

Build a video tutorial when a PWA has:
- Multi-step flows that aren't self-evident from the UI
- Role-based access (different views per user type)
- An existing in-app guided tour (? button) — the video is the longer companion

Trigger a **rebuild** only on major feature additions that change the visible flow. Minor UI tweaks don't need a rebuild.

---

## 2. Directory Structure

```
[pwa]/tutorial/
├── tutorial.html              ← web page (auth-protected, served to end users)
├── [pwa]-tutorial-hi-h264.mp4 ← auto-deployed by build script
├── [pwa]-tutorial-en-h264.mp4 ← auto-deployed by build script
├── videos/                    ← raw Playwright recordings (.webm)
│   └── [clip-id].webm
└── production/                ← NOT served to web users
    ├── scripts.json           ← all narrations (hi + en)
    ├── generate_tts.py        ← produces audio/ and srt/
    ├── build_tutorial.py      ← assembles final MP4
    ├── audio/
    │   ├── hi/[segment-id].mp3
    │   └── en/[segment-id].mp3
    ├── srt/
    │   ├── hi/[segment-id].srt
    │   └── en/[segment-id].srt
    ├── processed/             ← intermediate per-segment MP4s (temp)
    │   ├── hi/
    │   └── en/
    └── output/                ← build output (source of truth for deploy)
        ├── [pwa]-tutorial-hi-h264.mp4
        └── [pwa]-tutorial-en-h264.mp4
```

Copy `production/` from the custodian tutorial and adapt. Copy `tutorial.html` as the page template.

---

## 3. Segment Types

Every tutorial is a sequence of three kinds of segments:

| Type | Content | Script key |
|---|---|---|
| `intro` | App name card (lavfi color source, no WebM) | `intro` |
| `section` | Section number + title card (lavfi, no WebM) | `s1`, `s2`, … |
| `clip` | Slowed Playwright recording + narration + captions | `01`, `02`, … |
| `outro` | End card (lavfi, no WebM) | `outro` |

---

## 4. scripts.json Format

```json
{
  "sections": [
    { "id": "s1", "number": "1", "title": "Getting Started",
      "narration": { "hi": "Section 1...", "en": "Section 1..." } }
  ],
  "clips": [
    { "id": "01", "section": "s1", "file": "01-login.webm",
      "narration": { "hi": "Hinglish narration...", "en": "English narration..." } }
  ],
  "intro":  { "hi": "...", "en": "..." },
  "outro":  { "hi": "...", "en": "..." }
}
```

Narration style: conversational, second-person ("aap dekh sakte hain…" / "you can see…"). Keep each clip narration under 20 seconds — the video is slowed 2.5× so narration drives duration.

---

## 5. TTS Generation (`generate_tts.py`)

### edge-tts v7 API — critical details

```python
import edge_tts, asyncio

VOICES = { "hi": "hi-IN-MadhurNeural", "en": "en-IN-PrabhatNeural" }

async def speak(text, voice, mp3_out, srt_out):
    communicate = edge_tts.Communicate(text, voice)
    audio_chunks = []
    word_events = []

    async for chunk in communicate.stream():
        if chunk["type"] == "audio":
            audio_chunks.append(chunk["data"])
        elif chunk["type"] == "WordBoundary":
            word_events.append(chunk)

    with open(mp3_out, "wb") as f:
        f.write(b"".join(audio_chunks))

    # Build SRT — 6 words per cue
    # Word boundary offsets are in 100-nanosecond units → divide by 10,000,000
    write_srt(word_events, srt_out)
```

**SRT timing:** `offset ÷ 10_000_000` → seconds. `duration ÷ 10_000_000` → cue duration. Group 6 words per cue.

**Do NOT use** `asyncio.run()` or `async for ... in Communicate()` (v7 dropped the async-iterator form). Use `.stream()` with an explicit `async for`.

---

## 6. Video Recording with Playwright

### Required test setup

```javascript
// [pwa].spec.js  — inside describe() block
test.use({
  launchOptions: { slowMo: 800 },   // MUST be inside launchOptions
  video: 'on',                       // MUST be top-level, not inside describe()
  viewport: { width: 800, height: 450 },
});
```

`slowMo` at the top level is silently ignored by Playwright — it must be inside `launchOptions`.

### After recording

Playwright saves `[test-name].webm` in the test-results folder. Copy each clip's WebM to `[pwa]/tutorial/videos/[clip-id].webm`. Rename to match the `file` field in `scripts.json`.

One clip = one user flow (login, one action, confirm result). Keep clips under ~30 seconds of real time (75 seconds slowed). Do not combine unrelated actions into a single clip.

---

## 7. Build Pipeline (`build_tutorial.py`)

### Key constants

```python
CRF     = 18       # H.264 quality (near-lossless; increase to 22 to reduce file size)
SLOWDOWN = 2.5     # all clips slowed to 0.4× (narration-driven duration)
W, H    = 800, 450 # output resolution — must match Playwright viewport
```

### Per-clip ffmpeg pipeline

```
WebM → setpts=2.5*PTS → tpad (hold last frame) → subtitles burn-in → H.264 tmp
tmp + MP3 → mux → segment.mp4
```

**tpad**: `tpad=stop_mode=clone:stop_duration=N` — holds the last frame when audio is longer than the slowed video. Required when narration outlasts the action.

**Subtitle filter**: commas inside `force_style=` MUST be escaped as `\,` in ffmpeg's simple filtergraph. Example:
```python
r"force_style='FontName=DejaVu Sans\,FontSize=15\,PrimaryColour=&H00FFFFFF\,...'"
```

**lavfi color source** for title cards: input is `-f lavfi -i "color=c=0x0f172a:size=800x450:rate=25:duration=N"`. The `-vf` must NOT repeat `color=` — use only `drawtext` filters on the source.

### Concat step

```
[segment1.mp4, segment2.mp4, …] → concat demuxer → H.264 master → auto-deployed to tutorial/
```

`-movflags +faststart` is mandatory on the final output — it moves the moov atom to the front so browsers can start streaming without downloading the whole file.

**Do NOT use H.265 for this pipeline.** H.265 only saves space when encoding from raw/lossless source. Transcoding from H.264 CRF 18 → H.265 produces generation loss and larger files. H.265 is noted as a future option for direct-from-raw builds only.

### Running the build

```bash
cd [pwa]/tutorial/production

# Generate TTS (only needed when narrations change)
python3 generate_tts.py

# Build full tutorial (both languages)
python3 build_tutorial.py both

# Build one language only
python3 build_tutorial.py hi
python3 build_tutorial.py en
```

The script auto-deploys finished MP4s to `[pwa]/tutorial/` on success. No manual copy step.

---

## 8. Tutorial HTML Page

### File: `[pwa]/tutorial/tutorial.html`

Use `custodian/tutorial/tutorial.html` as the template. Adapt:
- `<title>` and `<h1>` — PWA name
- `VIDEO_SOURCES` object — `src`, `dur`, `desc` for each language
- Chapter index rows — one `S1`…`SN` block per section, with `chapter-title` and `chapter-sub`
- Tips text at the bottom

### Authentication

The tutorial page must redirect unauthenticated users to the hub login:

```javascript
// Add at top of <script> in tutorial.html
(async () => {
  const res = await fetch('/db/rpc/get_session', {
    headers: { 'Content-Type': 'application/json',
                'Accept-Profile': 'hub' }
  });
  if (!res.ok || !(await res.json())?.user_id) {
    const next = encodeURIComponent(location.pathname);
    location.href = `/hub/?next=${next}`;
  }
})();
```

This matches the hub `?next=` redirect pattern ([[feedback_hub_next_redirect]]).

### Language switching (preserves playback position)

```javascript
function switchLang(lang) {
  if (lang === currentLang) return;
  currentLang = lang;
  const savedTime  = video.currentTime;
  const wasPlaying = !video.paused;
  src.src = VIDEO_SOURCES[lang].src;
  video.load();
  if (savedTime > 0) {
    video.addEventListener('loadedmetadata', function restore() {
      video.currentTime = Math.min(savedTime, video.duration);
      if (wasPlaying) video.play();
    }, { once: true });
  }
}
```

---

## 9. PWA Integration — Tutorial Button

Every PWA that has a tutorial video must expose a button in its main UI that opens the tutorial page. Standard placement: next to the existing `?` help button in the app header or toolbar.

```html
<!-- In the PWA main HTML, near the ? help button -->
<button class="icon-btn" onclick="openTutorial()" title="Video Tutorial">
  ▶
</button>
```

```javascript
function openTutorial() {
  window.open('/[pwa]/tutorial/tutorial.html', '_blank');
}
```

If the PWA has no `?` help button, add both together in the same session.

---

## 10. Naming Conventions

| Item | Pattern | Example |
|---|---|---|
| Tutorial directory | `[pwa]/tutorial/` | `finance/custodian/tutorial/` |
| Final MP4 (hi) | `[pwa]-tutorial-hi-h264.mp4` | `custodian-tutorial-hi-h264.mp4` |
| Final MP4 (en) | `[pwa]-tutorial-en-h264.mp4` | `custodian-tutorial-en-h264.mp4` |
| Clip WebM | `[clip-id]-[short-desc].webm` | `01-login.webm` |
| Segment ID | 2-digit number, `s1`–`sN`, `intro`, `outro` | `01`, `01b`, `s1`, `intro` |

---

## 11. Codec Decision

**H.264 CRF 18, always.** Reasons:
- Universal browser support (Chrome, Firefox, Safari, Android, iOS)
- CRF 18 is near-lossless quality; the files are small because Playwright recordings are low-motion screencasts
- H.265 only helps from raw/lossless source. From H.264 CRF 18, you get generation loss + no size reduction

To reduce file size if needed: raise CRF to 22 (still excellent for screencast material). Do not transcode to H.265.

---

## 12. Reference — Custodian Tutorial

The first tutorial built with this pipeline:

- PWA: Fund Custodian (`finance/custodian/`)
- Languages: Hinglish (hi) + English (en)
- Sections: 8 · Clips: 19 · Intro/outro: 2
- Output: ~7 min (hi), ~6.5 min (en) · ~8 MB each
- Build script: `finance/custodian/tutorial/production/build_tutorial.py`
- Committed: Phase 4.10 (v6)
