---
name: caption
description: สร้าง word-by-word bilingual captions (ไทย + อังกฤษ) ซิงค์กับเสียงพูดในวิดีโอ แล้ว render ด้วย HyperFrames เป็น MP4 ทั้งแบบ 16:9 และ 9:16 Triggers on "สร้าง caption", "ใส่ subtitle", "ทำ caption", "word-by-word", "ซับไตเติ้ล", หรือเมื่อ user ส่ง path ของวิดีโอพร้อมขอ caption
---

# Caption Skill

สร้าง word-by-word bilingual captions ซิงค์กับเสียงแล้ว render ด้วย HyperFrames

## ภาพรวม

1. **Transcribe** — Whisper large-v3-turbo + word timestamps
2. **Translate** — Whisper translate task (medium + large แล้วเทียบ) → English segments
3. **Group** — แบ่ง words เป็น caption groups ที่มีความหมาย
4. **Compose** — สร้าง HyperFrames HTML
5. **Render** — ออกมาเป็น MP4

---

## Step 1: เตรียม Project

```bash
mkdir -p <project-dir>/capture/assets/fonts
cd <project-dir>
cp "<video-path>" video.mp4
ffmpeg -y -i video.mp4 -c:v libx264 -r 30 -g 30 -keyint_min 30 \
  -movflags +faststart -c:a copy video_fixed.mp4
```

ตรวจสอบ duration และขนาด:
```bash
ffprobe -v quiet -print_format json -show_format -show_streams video_fixed.mp4
```

### 💡 Reuse fonts จาก project เดิม

ถ้าเคยสร้าง caption project มาก่อน ไม่ต้อง download fonts ใหม่ — copy จาก project เดิมได้เลย:

```bash
# ถ้ามี project เก่าอยู่
cp <old-project>/capture/assets/fonts/* <project-dir>/capture/assets/fonts/
```

(Sarabun 400/700/800 Thai+Latin = 6 ไฟล์ woff2, รวม ~60KB)

---

## Step 2: Transcribe พร้อม Word Timestamps

ใช้ Python `openai-whisper` โดยตรง (ไม่ใช่ `hyperframes transcribe`) เพราะต้องการ word-level timestamps:

### 💡 Pipeline ที่เร็วที่สุด (แนะนำ)

**whisper.cpp + Metal GPU** สำหรับ Thai transcribe (3.8x เร็วกว่า openai-whisper) + **openai-whisper medium** สำหรับ English translate:

```
Thai transcribe:  whisper-cli (Metal GPU, ~2.6s for 14s clip)
English translate: openai-whisper medium (CPU, ~3.6s for 14s clip)
─────────────────────────────────────────────────────────────
Total: ~6.7s  vs  openai-whisper large-v3-turbo CPU: ~17.5s  → 2.6x faster
```

### Step 2a: Thai Transcribe — whisper.cpp (Metal GPU)

```bash
# แปลงเป็น WAV 16kHz (whisper.cpp ไม่อ่าน MP4 ได้โดยตรง)
ffmpeg -y -i video_fixed.mp4 -ar 16000 -ac 1 -c:a pcm_s16le audio.wav

# Thai transcribe + token timestamps (JSON)
whisper-cli \
  -m ~/.cache/whisper/ggml-large-v3-turbo.bin \
  -f audio.wav \
  -l th \
  --output-json-full \
  -of whisper_cpp_th \
  -t 8 \
  -np \
  -bo 5 -bs 5 \
  -sow
```

> ⚠️ ต้อง download GGML model ก่อนครั้งแรก:
> `curl -L "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin" -o ~/.cache/whisper/ggml-large-v3-turbo.bin`
> (~1.5GB, ครั้งเดียว cache ไว้ตลอด)

อ่าน JSON output:

```python
import json
with open('whisper_cpp_th.json') as f:
    data = json.load(f)
for seg in data['transcription']:
    start_ms = seg['offsets']['from']
    end_ms = seg['offsets']['to']
    text = seg['text'].strip()
    print(f'[{start_ms/1000:.2f}-{end_ms/1000:.2f}] {text}')
    # Token-level timestamps (Thai = character fragments เหมือน openai-whisper)
    for tok in seg.get('tokens', []):
        if tok['text'].startswith('['): continue  # skip special tokens
        t_from = tok['offsets']['from'] / 1000
        t_to = tok['offsets']['to'] / 1000
        print(f'  {t_from:.3f}-{t_to:.3f} | {tok["text"]}')
```

### Step 2b: English Translate — openai-whisper medium

```python
import whisper, json

# ⚠️ whisper.cpp translate ไม่ทำงานกับภาษาไทย → ต้องใช้ openai-whisper medium
model_m = whisper.load_model('medium')
result_en = model_m.transcribe('video_fixed.mp4', language='th', task='translate')
for seg in result_en['segments']:
    print(f'[{seg["start"]:.2f}-{seg["end"]:.2f}] {seg["text"].strip()}')
```

### Fallback: ถ้าไม่มี whisper.cpp

ใช้ `openai-whisper large-v3-turbo` บน CPU ได้ (ช้ากว่าแต่ไม่ต้องติดตั้งเพิ่ม):

```python
import whisper, json

model = whisper.load_model('large-v3-turbo')

# Thai word-level timestamps
result_th = model.transcribe(
    'video_fixed.mp4',
    language='th',
    word_timestamps=True,
    beam_size=5,
    best_of=5
)
for seg in result_th['segments']:
    print(f'[{seg["start"]:.2f}-{seg["end"]:.2f}] {seg["text"].strip()}')
    for w in seg.get('words', []):
        print(f'  {w["start"]:.3f}-{w["end"]:.3f} | {w["word"]}')

# English translation (medium แม่นกว่า large-v3-turbo)
model_m = whisper.load_model('medium')
result_en = model_m.transcribe('video_fixed.mp4', language='th', task='translate')
for seg in result_en['segments']:
    print(f'[{seg["start"]:.2f}-{seg["end"]:.2f}] {seg["text"].strip()}')
```

### ⚠️ กฎภาษา

- ภาษาไทย → `language='th'` ห้ามใช้ `.en` suffix เด็ดขาด (`.en` จะแปล ไม่ใช่ถอดคำ)
- ภาษาอื่น → เปลี่ยน `language` ตามรหัส ISO 639-1

### ⚠️ อย่าเชื่อชื่อไฟล์ — ตรวจสอบภาษาเสียงจริง

ชื่อไฟล์/คลิปอาจหลอก เช่นชื่อไทยแต่เสียงอังกฤษ หรือชื่อคนหนึ่งแต่พูดถึงอีกคน
**สัญญาณว่า `language` ผิด:**

| อาการ | สาเหตุ | วิธีแก้ |
|-------|--------|---------|
| whisper.cpp ออกคำซ้ำๆ ไร้ความหมาย (เช่น "สิลิ่ง" ซ้ำ 20 หน) | เสียง EN แต่สั่ง `-l th` | เปลี่ยนเป็น `-l en` หรือ `auto` |
| openai-whisper ถอดเป็นอังกฤษแม้ `language='th'` | เสียงจริงเป็น EN | ยอมรับผล → เปลี่ยน pipeline เป็น EN mode |
| Word fragments เป็นพยางค์อังกฤษ (เช่น "Par-ano-id") | บังคับภาษาผิด → แตกเป็น sub-word | ใช้ `language='en'` จะได้คำเต็ม |

### 💡 English Audio Pipeline — ถ้าเสียงเป็นอังกฤษ

ถ้าพบว่าเสียงจริงเป็น EN (จากข้อสังเกตด้านบน) ให้:

1. **ข้าม whisper.cpp + translate** — ไม่ต้องใช้
2. **ใช้ openai-whisper large-v3-turbo กับ `language='en'`** เพื่อถอด word timestamps โดยตรง
3. **แปลไทยเอง** — แปลแต่ละ group เป็นคำไทย (ไม่ต้องใช้ Whisper translate)
4. **สลับโครงสร้าง caption** — EN word-by-word (highlight) บน + คำแปลไทยล่าง

```python
# EN audio → EN word timestamps
model = whisper.load_model('large-v3-turbo')
result = model.transcribe('video_fixed.mp4', language='en', word_timestamps=True, beam_size=5, best_of=5)
for seg in result['segments']:
    for w in seg['words']:
        print(f'{w["start"]:.3f}-{w["end"]:.3f} | {w["word"]}')
```

ดูรายละเอียดโครงสร้าง caption สำหรับ EN audio ที่ **Step 5: English Audio Template**

### ⚠️ Thai Word Fragments — ต้องรวมมือ

Whisper แยกภาษาไทยเป็นตัวอักษร/สระ ไม่ใช่คำ
**ห้ามใช้ threshold merging** (เช่น gap < 0.05s) เพราะสระ/วรรณยุกต์ซ้อนทับเวลา (gap = 0.000s) → รวมทุกอย่างเป็นก้อนเดียว

วิธีที่ถูก: **อ่าน segment text เต็ม → แบ่งเป็นคำ → แมป fragments เข้าคำแต่ละคำตามลำดับ + timestamps**

💡 **คำอังกฤษ/ต่างประเทศในประโยคไทย** (เช่น Bitcoin, Pizza, Laszlo) Whisper ถอดเป็นคำเดียวสมบูรณ์ ไม่แตกเป็นตัวอักษร — ใช้ timestamps ได้ทันทีไม่ต้องรวม fragment

```
# Segment text: "ที่เรียกตัวเองมันเป็น Australia ในโลกจริงๆ"
# Fragments (ตามลำดับ):
#   ท(0.18) ี่(0.34) เร(0.38) ี(0.48) ย(0.52) ก(0.52)
#   ต(0.54) ั(0.60) ว(0.60)
#   เ(0.62) อง(0.70) ...
#
# รวมเป็นคำ:
#   ที่      = ท+ี่           → 0.18–0.38
#   เรียก    = เร+ี+ย+ก       → 0.38–0.54
#   ตัว      = ต+ั+ว           → 0.54–0.62
#   เอง      = เ+อง           → 0.62–0.80
#   ...
```

จดบันทึกแต่ละคำพร้อม start/end time สำหรับใช้ใน GROUPS ด้านล่าง

### ⚠️ Thai Word Timestamps หายหลัง ~60% ของ audio

Whisper มักแมป fragment เดียวครอบช่วงที่เหลือทั้งหมด เช่น:

```
# Audio 18.7s → หลัง 11s timestamps กลายเป็น:
#   อ(11.02) า(11.08) ห(11.14) าร(11.14–18.66)  ← ผิดเห็นๆ
```

**วิธีแก้:**
1. ใช้ segment boundaries จาก English translation (medium model แบ่ง segment ดีกว่า)
2. แบ่งช่วงที่ไม่มี word timestamps เป็น groups กว้างขึ้น (3-5 วินาที)
3. กะ timing เองโดยแจกจ่ายคำเท่าๆ กันในแต่ละ segment

### ⚠️ คำผิดที่ Whisper ได้ยินผิดบ่อย (ภาษาไทย)

| Whisper ได้ยิน | คำจริง | สาเหตุ |
|---|---|---|
| โกรนมิด | โลก | พยัญชนะคล้าย |
| ทั้งสือ | หนังสือ | หนังสือ ออกเสียงเร็ว → ทั้งสือ |
| เหตุ | เห็น | ต/ต+็ น สับสน |
| อดิต | อดีต | สระ ิ/ี สับสน |
| อาหาร | อ่าน | คำที่ตามมาถูกกลืน |
| เคียนเสียน | เขียนเรื่อง | คำซ้อนกัน |
| เหตุการ | เหตุการณ์ | หาย ณ์ (การสะกษา) |
| ละลึก | รำลึก | รำ → ละ เสียงคล้าย |
| ควรสำคัญ | ความสำคัญ | ความ → ควร สระสับสน |
| (คำซ้ำไร้ความหมาย) | — | **ภาษาผิด** — เสียง EN แต่สั่ง `-l th` → ถอดเป็นเสียงไร้ความหมาย แก้โดยเปลี่ยนภาษา |

ตรวจทานทุก segment เทียบกับ English translation แล้วแก้คำที่ผิดเหล่านี้

---

## Step 5b: English Audio Template

ถ้าเสียงเป็นอังกฤษ → สลับโครงสร้าง: **EN word-by-word (highlight) บน + คำแปลไทยล่าง**

เปลี่ยนจาก template หลัก:
- `.cg-thai` → เปลี่ยนชื่อ class เป็น `.cg-en-words` (แต่ยังเป็นแถวที่มี `<span>` ต่อคำ)
- `.cg-en` → เปลี่ยนชื่อ class เป็น `.cg-th` (แถบคำแปลไทย)
- HTML structure:

```html
<div id="cg-0" class="cg">
  <div class="cg-inner">
    <div class="cg-en-words">
      <span id="cw-0-0" class="cw">Breaking</span>
      <span id="cw-0-1" class="cw">news</span>
    </div>
    <div class="cg-th">ข่าวด่วน</div>
  </div>
</div>
```

- CSS เหมือนเดิม เพียงเปลี่ยนชื่อ class:
  - `.cg-en-words` = `.cg-thai` (row ที่มี `<span>` ต่อคำ + word-by-word highlight)
  - `.cg-th` = `.cg-en` (static text row, ขนาดเล็กกว่า)
- GSAP animation เดียวกันทุกประการ — ไม่ต้องแก้
- GROUPS data: `en` field เก็บคำแปลไทย, `words` เก็บ EN words + timestamps

### 💡 Thai translation guidelines สำหรับ EN audio

- แปลตามใจความ ไม่ต้องตรงคำต่อคำ — สั้นกระชับพอดีบรรทัด
- ชื่อคน/สถานที่ → ทับศัพท์ (Peter Thiel → ปีเตอร์ ทีล, Buenos Aires → บัวโนสไอเรส)
- ตัวเลข/จำนวนเงิน → เก็บเป็นตัวเลข ($12 million → 12 ล้านดอลลาร์)
- ถ้าประโยคตัดกลางคัน (เช่น "...most") → ใส่ "..." ต่อท้ายภาษาไทย

---

## Step 3: Download Thai Font

Hyperframes ไม่มี Thai font ใน auto-resolved list ต้อง download ไว้ local:

```bash
# Sarabun Thai subsets
curl -sL "https://fonts.gstatic.com/s/sarabun/v17/DtVjJx26TKEr37c9aAFJn2QN.woff2" \
  -o capture/assets/fonts/Sarabun-400-thai.woff2
curl -sL "https://fonts.gstatic.com/s/sarabun/v17/DtVmJx26TKEr37c9YK5sik8s6zDX.woff2" \
  -o capture/assets/fonts/Sarabun-700-thai.woff2
curl -sL "https://fonts.gstatic.com/s/sarabun/v17/DtVmJx26TKEr37c9YLJvik8s6zDX.woff2" \
  -o capture/assets/fonts/Sarabun-800-thai.woff2

# Latin subsets (สำหรับ English line)
curl -sL "https://fonts.gstatic.com/s/sarabun/v17/DtVjJx26TKEr37c9aBVJnw.woff2" \
  -o capture/assets/fonts/Sarabun-400-latin.woff2
curl -sL "https://fonts.gstatic.com/s/sarabun/v17/DtVmJx26TKEr37c9YK5silss6w.woff2" \
  -o capture/assets/fonts/Sarabun-700-latin.woff2
curl -sL "https://fonts.gstatic.com/s/sarabun/v17/DtVmJx26TKEr37c9YLJvilss6w.woff2" \
  -o capture/assets/fonts/Sarabun-800-latin.woff2
```

---

## Step 4: วางแผน Caption Groups

แบ่งคำเป็น groups ตามหลัก:
- **2–4 คำต่อ group** สำหรับ high-energy / conversational (ไม่เกิน 5)
- แบ่งที่ sentence boundary หรือ pause > 150ms
- แต่ละ group มี English translation ที่ match ความหมาย
- **gap ระหว่าง groups** (เช่น silence) → ปล่อย caption ว่าง
- **ช่วงที่ไม่มี word timestamps** → ใช้ groups กว้างขึ้น (3-5 วินาที) และกะ timing เอง

### ⚠️ English segment ยาวครอบหลาย Thai group → ห้ามแปะทั้งประโยคต่อ group

openai-whisper translate ออกมาเป็น **ประโยคยาวประโยคเดียว** ที่ครอบหลาย Thai group
(เช่น 1 ประโยค EN = 3-4 Thai groups) ถ้าเอา**ทั้งประโยค**ไปใส่ทุก group ที่ทับช่วงเวลานั้น
→ EN ซ้ำเต็มประโยคทุกบรรทัด + **ล้นออกนอกจอ**ตอน render

วิธีที่ถูก: **แบ่งคำของประโยค EN กระจายตามจำนวน Thai groups ในช่วงนั้น** (front-loaded)
```
# EN seg [0.0-3.6]: "The event of Bitcoin Pizza Day is a day that a man named Laszlo..."
# ครอบ 3 Thai groups → แบ่งคำ:
#   group0 → "The event of Bitcoin Pizza Day is"
#   group1 → "a day that a man named Laszlo"
#   group2 → "Hanjek bought pizza with Bitcoin."
```
ไม่ align เป๊ะคำต่อคำ (2 ภาษาเรียงต่างกัน) แต่ EN ไหลตามจังหวะ Thai + แต่ละบรรทัดสั้น พอดีจอ

### ⚠️ Silence gaps ระหว่าง segments

เมื่อมีช่วงเงียบระหว่าง speech segments (เช่น segment 1 จบที่ 7.34s, segment 2 เริ่มที่ 8.52s) ต้อง:
1. **หยุด caption ตอน segment จบ** — groupEnd = segment end
2. **ไม่มี caption ระหว่างช่วงเงียบ** — ไม่ต้องสร้าง group ครอบคลุม
3. **เริ่ม group ใหม่ตอน speech กลับมา** — groupStart = segment start ถัดไป

```
# ตัวอย่าง timing:
#   Segment 1: 0.00–7.34  → Groups 0–5
#   [เงียบ 7.34–8.52]     → ไม่มี caption ✅
#   Segment 2: 8.52–9.42  → Group 6
#   [เงียบ 9.42–10.78]    → ไม่มี caption ✅
#   Segment 3: 10.78–13.60 → Groups 7–9
```

```javascript
var GROUPS = [
  {
    id: 0, groupStart: 0.00, groupEnd: 1.78,
    en: "English phrase here",
    words: [
      { wi: 0, wordStart: 0.00 },  // คำแรก
      { wi: 1, wordStart: 1.08 }   // คำสอง
    ]
  },
  // ...
];
```

---

## Step 5: สร้าง index.html

### Template พื้นฐาน (16:9, 1920×1080)

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    @font-face {
      font-family: 'Sarabun'; font-style: normal; font-weight: 400;
      src: url('capture/assets/fonts/Sarabun-400-thai.woff2') format('woff2');
      unicode-range: U+02D7, U+0303, U+0331, U+0E01-0E5B, U+200C-200D, U+25CC;
    }
    @font-face {
      font-family: 'Sarabun'; font-style: normal; font-weight: 400;
      src: url('capture/assets/fonts/Sarabun-400-latin.woff2') format('woff2');
      unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
        U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
    }
    @font-face {
      font-family: 'Sarabun'; font-style: normal; font-weight: 800;
      src: url('capture/assets/fonts/Sarabun-800-thai.woff2') format('woff2');
      unicode-range: U+02D7, U+0303, U+0331, U+0E01-0E5B, U+200C-200D, U+25CC;
    }
    @font-face {
      font-family: 'Sarabun'; font-style: normal; font-weight: 800;
      src: url('capture/assets/fonts/Sarabun-800-latin.woff2') format('woff2');
      unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
        U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
    }
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { background: #000; }
    #root {
      position: relative; width: 1920px; height: 1080px;
      overflow: hidden; background: #000;
    }
    video.bg-track {
      position: absolute; inset: 0; width: 100%; height: 100%;
      object-fit: cover; z-index: 1;
    }
    .cg {
      position: absolute; bottom: 64px; left: 0; width: 100%;
      display: flex; justify-content: center; align-items: center;
      opacity: 0; visibility: hidden; z-index: 10;
    }
    .cg-inner {
      display: flex; flex-direction: column; align-items: center; gap: 8px;
      background: rgba(0,0,0,0.68); border-radius: 24px; padding: 20px 56px 18px;
    }
    .cg-thai {
      display: flex; flex-wrap: nowrap; align-items: center; gap: 20px;
    }
    .cw {
      font-family: 'Sarabun', sans-serif; font-size: 72px; font-weight: 800;
      color: rgba(255,255,255,0.42); display: inline-block;
      white-space: nowrap; line-height: 1.2;
    }
    .cg-en {
      font-family: 'Sarabun', sans-serif; font-size: 36px; font-weight: 400;
      color: rgba(255,255,255,0.65); white-space: normal;  /* ห้าม nowrap → EN ยาวจะล้นจอ */
      max-width: 1180px; text-wrap: balance; text-align: center;
      line-height: 1.25; letter-spacing: 0.01em;
    }
  </style>
</head>
<body>
  <div id="root" data-composition-id="root" data-start="0" data-width="1920" data-height="1080">
    <video id="bg-vid" class="bg-track clip"
      data-start="0" data-duration="__DURATION__" data-track-index="0"
      src="video_fixed.mp4" muted playsinline></video>
    <audio id="bg-aud" class="clip"
      data-start="0" data-duration="__DURATION__" data-track-index="2"
      src="video_fixed.mp4" data-volume="1"></audio>

    <!-- Caption groups — สร้างตาม GROUPS -->
    <!-- g0 ตัวอย่าง: -->
    <div id="cg-0" class="cg">
      <div class="cg-inner">
        <div class="cg-thai">
          <span id="cw-0-0" class="cw">คำแรก</span>
          <span id="cw-0-1" class="cw">คำสอง</span>
        </div>
        <div class="cg-en">English phrase</div>
      </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <script>
      window.__timelines = window.__timelines || {};
      var tl = gsap.timeline({ paused: true });
      var ORANGE = '#F7931A';
      var DIM    = 'rgba(255,255,255,0.42)';

      var GROUPS = [ /* ... ใส่ data จาก Step 4 */ ];

      GROUPS.forEach(function (grp) {
        var gEl = document.getElementById('cg-' + grp.id);
        var t0 = grp.groupStart, t1 = grp.groupEnd;

        tl.set(gEl, { visibility: 'visible' }, t0);
        tl.fromTo(gEl,
          { opacity: 0, scale: 0.93, y: 10 },
          { opacity: 1, scale: 1, y: 0, duration: 0.20, ease: 'power3.out', overwrite: 'auto' },
          t0);

        var firstEl = document.getElementById('cw-' + grp.id + '-0');
        tl.set(firstEl, { color: ORANGE }, t0);
        tl.fromTo(firstEl, { scale: 0.88 },
          { scale: 1.05, duration: 0.15, ease: 'back.out(1.6)', overwrite: 'auto' }, t0 + 0.05);
        tl.to(firstEl, { scale: 1, duration: 0.12, ease: 'power2.out', overwrite: 'auto' }, t0 + 0.20);

        for (var idx = 1; idx < grp.words.length; idx++) {
          var w    = grp.words[idx];
          var wt   = w.wordStart;
          var prev = document.getElementById('cw-' + grp.id + '-' + grp.words[idx-1].wi);
          var curr = document.getElementById('cw-' + grp.id + '-' + w.wi);
          tl.to(prev, { color: DIM, scale: 1, duration: 0.12, ease: 'power1.in', overwrite: 'auto' }, wt);
          tl.set(curr, { color: ORANGE }, wt);
          tl.fromTo(curr, { scale: 0.88 },
            { scale: 1.05, duration: 0.15, ease: 'back.out(1.6)', overwrite: 'auto' }, wt + 0.02);
          tl.to(curr, { scale: 1, duration: 0.12, ease: 'power2.out', overwrite: 'auto' }, wt + 0.17);
        }

        tl.set('[id^="cw-' + grp.id + '-"]', { color: DIM, scale: 1 }, t1 - 0.13);
        tl.to(gEl, { opacity: 0, scale: 0.95, duration: 0.12, ease: 'power2.in', overwrite: 'auto' }, t1 - 0.12);
        tl.set(gEl, { opacity: 0, visibility: 'hidden' }, t1);
      });

      /* caption self-lint */
      GROUPS.forEach(function (grp) {
        var gEl = document.getElementById('cg-' + grp.id);
        if (!gEl) return;
        tl.seek(grp.groupEnd + 0.01);
        var s = window.getComputedStyle(gEl);
        if (s.opacity !== '0' && s.visibility !== 'hidden')
          console.warn('[caption-lint] group ' + grp.id + ' still visible at t=' + (grp.groupEnd + 0.01).toFixed(2) + 's');
      });
      tl.seek(0);
      window.__timelines['root'] = tl;
    </script>
  </div>
</body>
</html>
```

### Vertical 9:16 (1080×1920)

เปลี่ยนค่าต่อไปนี้:

| ค่า | 16:9 | 9:16 |
|-----|------|------|
| `data-width/height` | `1920` / `1080` | `1080` / `1920` |
| `#root` width/height | `1920px` / `1080px` | `1080px` / `1920px` |
| `.cg` bottom | `64px` | `280px` |
| `.cw` font-size | `72px` | `62px` |
| `.cg-en` font-size | `36px` | `32px` |
| `.cg-thai` flex-wrap | `nowrap` | `wrap` + `justify-content: center` |
| `.cg-inner` max-width | — | `960px` |

---

## Step 6: Lint & Render

```bash
# ตรวจสอบก่อน render เสมอ
npx hyperframes lint

# Render 16:9
npx hyperframes render --output output_16x9.mp4

# snapshot ตรวจสอบ caption จริง
npx hyperframes snapshot --at 1,3,5,7,10,13
```

---

## Step 7: ตัดเสียงเงียบ (optional)

Caption ถูก bake เป็น pixel ไปแล้วตั้งแต่ขั้นตอน render — ตัด MP4 ได้เลยโดยไม่ต้อง render ใหม่

### ⚠️ ห้ามใช้ `-c copy` + `-ss/-to`

`-c copy` ไม่ frame-accurate — ffmpeg snap ไปที่ keyframe ทำให้ segment ซ้อนกัน → **เสียงซ้ำ + ภาพกระตุก**

ต้องใช้ `filter_complex trim` เท่านั้น:

```python
import subprocess, re, os, json

INPUT     = "output.mp4"        # ไฟล์จาก Step 6
OUTPUT    = "output_cut.mp4"
THRESHOLD = 0.30                # ตัด silence ≥ 0.3s (ปรับได้)
NOISE     = "-35dB"             # sensitivity (ลดลงถ้าตัดมากเกิน)

# ── 1. duration ────────────────────────────────────────────────────────────
r = subprocess.run(
    ["ffprobe","-v","quiet","-print_format","json","-show_format", INPUT],
    capture_output=True, text=True)
total = float(json.loads(r.stdout)["format"]["duration"])

# ── 2. detect silences ────────────────────────────────────────────────────
r = subprocess.run(
    ["ffmpeg","-i", INPUT,
     "-af", f"silencedetect=noise={NOISE}:d=0.25", "-f","null","-"],
    capture_output=True, text=True)
starts = [float(x) for x in re.findall(r"silence_start:\s*([\d.]+)", r.stderr)]
ends   = [float(x) for x in re.findall(r"silence_end:\s*([\d.]+)",   r.stderr)]
durs   = [float(x) for x in re.findall(r"silence_duration:\s*([\d.]+)", r.stderr)]

# ── 3. build keep segments ────────────────────────────────────────────────
keeps = []
cursor = 0.0
for s, e, d in zip(starts, ends, durs):
    if d >= THRESHOLD:
        if cursor < s:
            keeps.append((cursor, s))
        cursor = e
if cursor < total:
    keeps.append((cursor, total))

n = len(keeps)
print(f"Keep {n} segments, total {sum(b-a for a,b in keeps):.2f}s "
      f"(removed {total - sum(b-a for a,b in keeps):.2f}s)")

# ── 4. filter_complex: trim + setpts + concat ─────────────────────────────
filter_parts, concat_inputs = [], []
for i, (a, b) in enumerate(keeps):
    filter_parts.append(f"[0:v]trim=start={a:.6f}:end={b:.6f},setpts=PTS-STARTPTS[v{i}]")
    filter_parts.append(f"[0:a]atrim=start={a:.6f}:end={b:.6f},asetpts=PTS-STARTPTS[a{i}]")
    concat_inputs.append(f"[v{i}][a{i}]")   # interleaved per segment — สำคัญมาก

filter_parts.append(f"{''.join(concat_inputs)}concat=n={n}:v=1:a=1[vout][aout]")

subprocess.run([
    "ffmpeg", "-y", "-i", INPUT,
    "-filter_complex", ";".join(filter_parts),
    "-map", "[vout]", "-map", "[aout]",
    "-c:v", "libx264", "-preset", "fast", "-crf", "18",
    "-c:a", "aac", "-b:a", "192k",
    "-movflags", "+faststart",
    OUTPUT
], check=True)
print(f"✓ Done → {OUTPUT}")
```

### ปรับ parameters

| Parameter | ค่าตั้งต้น | เปลี่ยนเมื่อ |
|---|---|---|
| `NOISE` | `-35dB` | ตัดน้อยเกิน → ลดเป็น `-30dB`; ตัดมากเกิน → เพิ่มเป็น `-40dB` |
| `THRESHOLD` | `0.3s` | ต้องการตัดสั้นลง → `0.2s`; อยากเก็บ pause → `0.5s` |
| `d=0.25` ใน silencedetect | `0.25s` | minimum duration ที่ถือว่าเป็น silence |

---

## Design Decisions

| ตัวเลือก | ค่าที่ใช้ | เหตุผล |
|---|---|---|
| Active word color | `#F7931A` | สีส้ม Bitcoin อย่างเป็นทางการ — เปลี่ยนตาม topic |
| Inactive word opacity | `42%` | ต่ำพอให้เห็นชัดว่า active คือคำไหน |
| English opacity | `65%` | รองจาก Thai แต่ยังอ่านได้ |
| Pill background | `rgba(0,0,0,0.68)` | contrast บน background สว่างหรือมืด |
| Font | Sarabun 800 | อ่านง่าย bold พอสำหรับวิดีโอ |
| Animation | scale-pop `back.out(1.6)` | bouncy เหมาะ social content |

สำหรับ topic อื่น เปลี่ยน active color:
- 💜 Crypto/Finance อื่นๆ → `#7B61FF`
- 🔴 Breaking news → `#EF4444`
- 💚 Health/Nature → `#22C55E`
- 🔵 Tech/Corporate → `#3B82F6`

---

## Checklist ก่อน Deliver

- [ ] `npx hyperframes lint` → 0 errors, 0 warnings
- [ ] **GROUPS words count = HTML `<span>` count** ทุก group — ไม่ตรง = GSAP `target null` warnings
- [ ] `npx hyperframes snapshot` → caption แสดงทุก frame ที่มีเสียง
- [ ] ไม่มี caption ค้างหลัง group.end (self-lint ใน script)
- [ ] Audio stream อยู่ใน output (`ffprobe` เห็น audio track)
- [ ] ถ้า render vertical → ตรวจ frame ที่มีคำยาวว่า wrap ถูกต้อง
- [ ] ถ้าตัดเสียงเงียบ → ใช้ `filter_complex trim` เท่านั้น ห้าม `-c copy`

---

## 🔄 Continuous Learning — หลังส่งงานทุกครั้ง

หลัง render + deliver เสร็จทุกคลิป ให้ทบทวนและอัพเดท skill นี้:

### บันทึกข้อมูลใหม่ลงในส่วนที่เกี่ยวข้อง

| สิ่งที่ค้นพบ | อัพเดทที่ไหน |
|---|---|
| คำผิด Whisper ใหม่ | เพิ่มในตาราง "คำผิดที่ Whisper ได้ยินผิดบ่อย" |
| ปัญหาใหม่กับ model/threshold | เพิ่มใน "⚠️ Thai Word Fragments" หรือสร้าง section ใหม่ |
| เทคนิคใหม่ที่ประหยัดเวลา | เพิ่มเป็น "💡 tip" ใน step ที่เกี่ยวข้อง |
| Bug pattern ใหม่ (เช่น GSAP warnings) | เพิ่มใน Checklist |
| Design tweak ที่ดีขึ้น | อัพเดท Design Decisions |

### คำถามที่ต้องถามตัวเองหลังทุกคลิป

```
1. มีคำไหน Whisper ถอดผิดที่ยังไม่อยู่ในตารางไหม?
2. มีปัญหาใหม่ที่ไม่เคยเจอมาก่อนไหม?
3. มีอะไรที่ทำซ้ำๆ แล้วเสียเวลา → สามารถ automate/shortcut ได้ไหม?
4. parameter ไหนปรับแล้วดีขึ้น (beam_size, threshold, font-size, animation)?
5. มีสิ่งที่ user ต้องแก้ไขหลังส่งงานไหม?
```

ถ้าคำตอบของข้อไหนเป็น "มี" → อัพเดท skill ทันทีก่อนปิด session
