nextav/docs/backlog/LIVE_TRANSCODE_REINTRODUCTI...

580 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Live Transcoding Reintroduction — Comprehensive Implementation Proposal
> Status: **Backlog / Design Proposal**
> Author: Engineering
> Target Branch: `feature/live-transcode-v2`
> Related Docs:
> - `docs/active/media-streaming/TRANSCODING_REMOVAL_DESIGN.md`
> - `docs/active/media-streaming/TRANSCODING_REMOVAL_SUMMARY.md`
> - `docs/archive/transcoding-legacy/JELLYFIN_TRANSCODING_ARCHITECTURE.md`
> - `docs/archive/transcoding-legacy/STASH-ANALYSIS-AND-SOLUTION-PLAN.md`
> - `docs/archive/transcoding-legacy/03-process-management-transcoding.md`
> - `docs/archive/transcoding-legacy/ANTI-JITTER-IMPLEMENTATION.md`
> - `docs/archive/transcoding-legacy/VIDEO-PLAYER-REPLACEMENT-PLAN.md`
---
## 1. Why Bring Live Transcoding Back?
NextAV currently routes any nonnativelysupported format (MKV/AVI/WMV/FLV/MOV/TS/etc.) to a **Local Player Launcher** (VLC/IINA/PotPlayer). This eliminated server CPU usage but came at three real costs:
| Loss | User Impact |
|------|-------------|
| Crossdevice playback (phone, tablet, TV browser) | "I can't watch my MKVs on iPad" |
| Singleclick inbrowser playback for all formats | Friction: install app, allow protocol, retry |
| Adaptive bitrate over slow networks | Large files unplayable over LAN/WAN |
The goal of v2 is to bring back **YouTube/Jellyfinclass live transcoding** that does **not** repeat the historical pain points (process leaks, jitter, broken seek, duration corruption, runaway CPU).
---
## 2. Honest Postmortem of v1 (Why It Was Removed)
From the legacy docs and `archive/transcoding-legacy/*`, the v1 transcoder failed at six things at once:
### 2.1 Process Lifecycle
- FFmpeg child processes orphaned on browser close / route change.
- No central registry → could not enumerate or kill cleanly.
- No client heartbeat → processes ran forever when client died silently.
- `kill('SIGKILL')` raced with the HTTP stream close, leading to half-shut pipes and `EPIPE`/`ECONNRESET`.
### 2.2 Seeking
- Old design transcoded **from t=0** on every seek. A seek to 1h00m wasted 1h of CPU before producing one byte.
- The Stash analysis (`STASH-ANALYSIS-AND-SOLUTION-PLAN.md`) correctly identified that the fix is `-ss <seek> -i <file>` (input seek) + restart, but it was never properly wired into the player.
### 2.3 Duration & Progress Jitter
- The browser's `HTMLMediaElement.duration` reflects **buffered** duration when the upstream is a piped MP4 with `frag_keyframe+empty_moov`. The progress bar therefore reported absurd durations (e.g. "6s" for a 9min video) and jumped backwards as new fragments arrived.
- `ANTI-JITTER-IMPLEMENTATION.md` patched the symptom in the UI, not the cause (lack of a known duration / HLS playlist with `#EXT-X-ENDLIST`).
### 2.4 Format Detection
- Decision was made per request based on a JSON blob in `media.codec_info`. That blob was sometimes missing, sometimes wrong (the scanner only ran ffprobe once and never reprobed), so the same file would sometimes stream direct, sometimes transcode.
### 2.5 Resource Contention
- No concurrency cap. Three simultaneous 1080p `libx264 -preset veryfast` jobs would saturate any consumer CPU and starve thumbnail generation and the Next.js event loop.
- No throttling: FFmpeg encoded as fast as possible and ran 60+ seconds ahead of the player, wasting CPU if the user closed the tab.
### 2.6 HLS Half-Implementation
- `src/app/api/stream/hls/...` exists today and generates a **fake playlist** that points at byterange slices of the source file (`ts-segmentation-service.ts`). For real `.ts` Transport Streams this can work, but for arbitrary inputs (MKV/AVI) the resulting "segments" are not valid TS and players fail with `bufferAppendError`.
- The `hls-session-manager.ts` heartbeat exists but nothing in the player calls it.
---
## 3. Design Principles for v2
1. **HLS-first, MP4-fragment second.** All live transcoding produces an HLS playlist + MPEG-TS (or fMP4) segments on disk. The browser sees a finite playlist with a real `#EXT-X-PLAYLIST-TYPE:VOD` and `#EXT-X-ENDLIST`, so `duration` is correct from frame 1 and seeking works without restarts inside the encoded window.
2. **Stash-style seek = restart.** Seeks outside the already-encoded window kill the FFmpeg job and restart with `-ss <t>` at the new position. The playlist is regenerated with a virtual timeline that maps to real timestamps via `#EXT-X-DISCONTINUITY`.
3. **One job per (videoId, profile).** A Job is keyed by `videoId + quality + audioTrack + subTrack`. Re-requests of the same key attach to the existing job; different keys evict the older one for that videoId after a grace period.
4. **Heartbeats are mandatory.** The client pings every 20s. Jobs without a ping for 60s (HLS) / 10s (progressive) are killed. This is Jellyfin's model and the only one proven to survive real users.
5. **Server-Sent Events for control.** A single SSE channel per session reports `ready`, `segment-available`, `transcode-error`, `transcode-progress`, replacing polling.
6. **Hard concurrency cap.** Default `MAX_CONCURRENT_TRANSCODES=2` per node. Additional requests get HTTP 503 with `Retry-After`, plus a clear UI message.
7. **Direct streaming is always tried first.** Live transcoding is a fallback, not the default. H.264/AAC MP4 over range requests stays the happy path.
8. **Disk is the cache.** Every produced segment is written to `/tmp/nextav-hls/<videoId>/<profile>/seg_%05d.ts`. After the job is killed, segments live another 5 minutes so the user can rewind without re-encoding.
9. **Observability is shipped, not patched on.** Every job emits structured logs + a `/api/transcode/status` snapshot suitable for a tiny admin page.
10. **Feature-flagged rollout.** Live transcoding is behind `ENABLE_LIVE_TRANSCODE=false` until validated on a Linux box with both software and hardware encoders.
---
## 4. End-to-End Architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ Browser │
│ ArtPlayer + hls.js ──► /api/transcode/start (POST, returns job) │
│ │ │
│ │ loads m3u8 │
│ ▼ │
│ /api/transcode/[jobId]/master.m3u8 ──► VOD playlist (finite) │
│ /api/transcode/[jobId]/seg/000.ts ──► segment file (disk) │
│ │ │
│ │ every 20s │
│ /api/transcode/[jobId]/ping (POST) │
│ │ │
│ /api/transcode/[jobId] (DELETE) on unmount / close │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Next.js Server │
│ │
│ TranscodeOrchestrator (singleton, src/lib/transcode/orchestrator) │
│ ├─ JobRegistry (Map<jobId, TranscodeJob>) │
│ ├─ HeartbeatManager (kill-timers per job) │
│ ├─ ConcurrencyGate (semaphore, MAX_CONCURRENT_TRANSCODES) │
│ ├─ SegmentJanitor (LRU eviction, disk-budget aware) │
│ └─ HwCapabilityProbe (one-time on boot, picks h264_videotoolbox│
│ / h264_nvenc / h264_qsv / libx264) │
│ │
│ TranscodeJob │
│ ├─ ffmpeg ChildProcess (spawned, attached to AbortController) │
│ ├─ outDir (/tmp/nextav-hls/<videoId>/<profile>) │
│ ├─ playlistPath, segmentCount, lastSegmentMtime │
│ ├─ lastPing, isPaused (throttler input) │
│ └─ status: pending | encoding | ready | paused | failed | killed │
│ │
│ FormatDecisionEngine (src/lib/transcode/decision.ts) │
│ direct | hls-remux (no re-encode) | hls-transcode │
└──────────────────────────────────────────────────────────────────────┘
```
---
## 5. Data & API Surface
### 5.1 New / Modified Tables
No schema changes to user data, but a small operational table to survive process restarts:
```sql
CREATE TABLE IF NOT EXISTS transcode_jobs (
job_id TEXT PRIMARY KEY,
media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE,
profile TEXT NOT NULL, -- e.g. '720p-h264-aac'
out_dir TEXT NOT NULL,
start_ts REAL NOT NULL DEFAULT 0, -- the -ss value
status TEXT NOT NULL, -- pending|encoding|ready|paused|failed|killed
pid INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_ping_at DATETIME,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_transcode_jobs_media ON transcode_jobs(media_id);
CREATE INDEX IF NOT EXISTS idx_transcode_jobs_status ON transcode_jobs(status);
```
On boot, `TranscodeOrchestrator.recover()` reads `status IN ('encoding','paused')`, attempts `process.kill(pid, 0)` to see if the process is alive; if not, mark `killed`, schedule disk cleanup.
### 5.2 HTTP API
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/transcode/start` | Body: `{ mediaId, profile?, startTime? }`. Returns `{ jobId, playlistUrl, etaSeconds, profile }`. Idempotent on `(mediaId, profile)` within 5s. |
| `GET` | `/api/transcode/:jobId/master.m3u8` | The VOD playlist. Regenerated on every request from disk state — always reflects what segments actually exist. Includes `#EXT-X-PLAYLIST-TYPE:VOD` and `#EXT-X-ENDLIST` once encoding finishes. |
| `GET` | `/api/transcode/:jobId/seg/:n.ts` | Streams a segment file. Sets `Cache-Control: private, max-age=3600` and strong ETag. |
| `POST` | `/api/transcode/:jobId/ping` | Body: `{ position, isPaused }`. Resets kill-timer, feeds throttler. |
| `POST` | `/api/transcode/:jobId/seek` | Body: `{ position }`. If the target is **inside** the encoded window → 204 (client just seeks). If **outside** → orchestrator kills and restarts with `-ss position`, responds with a **new** `jobId` and the client reloads. |
| `DELETE` | `/api/transcode/:jobId` | Kills the job and schedules cleanup. |
| `GET` | `/api/transcode/:jobId/events` | SSE: `ready`, `segment`, `progress`, `error`, `killed`. |
| `GET` | `/api/transcode/status` | Admin/debug snapshot of `JobRegistry`. |
### 5.3 Profiles
```ts
// src/lib/transcode/profiles.ts
export const PROFILES = {
'1080p-h264-aac': { vCodec:'h264', vBitrate:'5000k', maxHeight:1080, aCodec:'aac', aBitrate:'192k' },
'720p-h264-aac': { vCodec:'h264', vBitrate:'2800k', maxHeight: 720, aCodec:'aac', aBitrate:'160k' },
'480p-h264-aac': { vCodec:'h264', vBitrate:'1200k', maxHeight: 480, aCodec:'aac', aBitrate:'128k' },
'remux-copy': { vCodec:'copy', aCodec:'copy' }, // for AVC+AAC in MKV — wrap into HLS without re-encoding
} as const;
```
The client picks an initial profile from the saved user preference; ABR is **not** in v2 scope (singlevariant playlist only). Quality switching = new job.
---
## 6. Format Decision Engine
```ts
// src/lib/transcode/decision.ts
type Decision =
| { kind: 'direct' } // browser can play the file as-is
| { kind: 'hls-remux'; profile: 'remux-copy' } // codecs OK, container not
| { kind: 'hls-transcode'; profile: '720p-h264-aac' | ... };
```
Rules, evaluated in order:
1. If `extension ∈ {mp4,m4v,webm,ogg,ogv}` **and** `videoCodec ∈ {h264,vp8,vp9,av1}` **and** `audioCodec ∈ {aac,opus,vorbis}``direct`.
2. If `videoCodec ∈ {h264}` **and** `audioCodec ∈ {aac}` but container is anything else (mkv, ts, mov) → `hls-remux` (`-c copy`, basically free CPU).
3. If `videoCodec ∈ {hevc,vp9,av1}` and the requesting User-Agent indicates support (Safari/Chrome with hardware decode) → `direct` with a probe header (HTTP 200 + `X-Probe: 1`); on player error within 5s, client posts `/api/transcode/start` to fall back.
4. Otherwise → `hls-transcode` with the user's preferred profile (default 720p).
The decision is computed **fresh** on every `/api/transcode/start` using a server-side ffprobe of the file (cached in `media.codec_info` with a TTL of 7 days and a `codec_info_version` integer so we can invalidate the whole cache atomically).
---
## 7. FFmpeg Command Templates
### 7.1 Transcode (the hot path)
```bash
ffmpeg \
-hide_banner -loglevel error -nostats \
-ss <START_TS> \ # input seek for fast start
-copyts \ # keep original timestamps for accurate playlist
-i "<INPUT_PATH>" \
-map 0:v:0 -map 0:a:0? \
-c:v <VCODEC> \ # h264_videotoolbox|h264_nvenc|h264_qsv|libx264
-preset <PRESET> \ # libx264: veryfast | hw: p4/quality
-profile:v main -level 4.1 \
-pix_fmt yuv420p \
-vf "scale=-2:'min(<MAX_H>,ih)':flags=lanczos" \
-b:v <VBITRATE> -maxrate <VBITRATE> -bufsize <2*VBITRATE> \
-force_key_frames "expr:gte(t,n_forced*4)" \
-c:a aac -b:a <ABITRATE> -ac 2 \
-f hls \
-hls_time 4 \ # 4s segments — fast startup, good seek granularity
-hls_list_size 0 \ # keep all segments in the playlist
-hls_segment_type mpegts \
-hls_flags independent_segments+temp_file \
-hls_segment_filename "<OUT_DIR>/seg_%05d.ts" \
-start_number 0 \
"<OUT_DIR>/index.m3u8"
```
Key choices and **why**:
- `-ss` **before** `-i` (input seek): O(1) on indexed containers; correctly drops everything before the seek point.
- `-copyts` + `-start_at_zero` are NOT used together — we keep original timestamps so when we discontinuity-merge after a seek-restart the player handles it via `#EXT-X-DISCONTINUITY`.
- `-force_key_frames expr:gte(t,n_forced*4)` makes every segment independently decodable, which lets `-hls_flags independent_segments` work and gives clean seeks.
- `-hls_flags temp_file` writes `seg_00042.ts.tmp` and renames atomically, so the playlist regenerator never sees a half-written segment.
- We do **not** use `-re` — we want to encode as fast as the CPU allows, then **throttle** via stdin `p`/`u` once we are >60s ahead of playback (Jellyfin pattern).
### 7.2 Remux (free path for h264+aac in mkv/mov)
Same skeleton but `-c:v copy -c:a copy -bsf:v h264_mp4toannexb` (the bitstream filter is required to convert AVC's length-prefixed NAL units into AnnexB for MPEGTS).
### 7.3 Hardware Acceleration
Probed once at boot by running `ffmpeg -hide_banner -encoders | grep h264_`. Selection priority:
1. **macOS dev**: `h264_videotoolbox` (zero CPU, ships with macOS)
2. **Linux + NVIDIA**: `h264_nvenc`
3. **Linux + Intel iGPU**: `h264_qsv` (requires `libmfx`)
4. **Fallback everywhere**: `libx264 -preset veryfast`
The choice is recorded in `/api/transcode/status` and surfaced in Settings → "Transcoding Backend: h264_videotoolbox (auto)".
---
## 8. Process Lifecycle (the part v1 got wrong)
```
client click play
POST /api/transcode/start
│ ┌─────────────────────────────────────────────────────────┐
│ │ ConcurrencyGate.acquire() ── may 503 with Retry-After │
│ └─────────────────────────────────────────────────────────┘
orchestrator.startJob(mediaId, profile, startTs)
├─ pick existing job? (same key, alive, ping <30s) → return it
├─ ffprobe file (cached) → decide remux vs transcode
├─ mkdir <out_dir>
├─ spawn ffmpeg, attach { stdout: ignore, stderr: pipe-to-ring-buffer, stdio:[pipe,ignore,pipe] }
├─ insert into DB (status=encoding, pid)
├─ HeartbeatManager.armKillTimer(jobId, 60s)
├─ wait for first segment to appear (fs.watch on out_dir)
│ ── on timeout 15s → kill + fail with TRANSCODE_STARTUP_TIMEOUT
└─ return { jobId, playlistUrl: /api/transcode/<jobId>/master.m3u8 }
client loads master.m3u8 every ~segment
client posts /ping every 20s ── resets kill-timer
client posts /seek when user drags ── may trigger restart
client posts DELETE on close ── immediate kill
orchestrator monitors:
- ffmpeg exit code → status=ready (0) | failed (non-zero, capture stderr ring)
- stderr ring → parse "frame=… time=00:01:23.45" for progress events
- throttler tick (1Hz):
if (encoder_position - last_ping_position) > 60s and !isPaused
ffmpeg.stdin.write('p\n') // pause
else if was paused and gap < 30s
ffmpeg.stdin.write('u\n') // resume
```
### 8.1 Kill Is a State Machine, Not a Signal
```ts
async function killJob(jobId: string, reason: string) {
const job = registry.get(jobId);
if (!job || job.status === 'killed') return;
job.status = 'killed';
// 1. Try graceful: write 'q' to stdin (ffmpeg writes trailers, exits 0)
try { job.proc.stdin.write('q\n'); } catch {}
// 2. Wait up to 3s
const exited = await waitForExit(job.proc, 3_000);
// 3. SIGTERM, then 2s grace
if (!exited) { job.proc.kill('SIGTERM'); await waitForExit(job.proc, 2_000); }
// 4. SIGKILL
if (!job.proc.killed) job.proc.kill('SIGKILL');
// 5. DB + concurrency release + janitor schedule
db.prepare('UPDATE transcode_jobs SET status=? WHERE job_id=?').run('killed', jobId);
gate.release();
janitor.scheduleCleanup(job.outDir, /* delayMs */ 5 * 60_000);
}
```
This is the three-layer pattern from `archive/transcoding-legacy/03-process-management-transcoding.md` (HTTP context → registry → forced kill), ported faithfully.
### 8.2 Seek Handling
```ts
// /api/transcode/[jobId]/seek
if (target >= job.encodedFromTs && target <= job.lastSegmentTs - 8) {
// Inside the encoded window — the client already has segments. No action.
return new Response(null, { status: 204 });
}
// Outside → restart. Old job dies; new job starts at target.
const newJob = await orchestrator.restart(job, /* startTs */ target);
return Response.json({ jobId: newJob.id, playlistUrl: newJob.playlistUrl });
```
---
## 9. Playlist Regeneration (eliminates duration jitter)
The legacy implementation streamed the playlist FFmpeg wrote directly. That playlist grew over time, which is why the browser reported a growing duration and the bar jumped.
v2 regenerates the playlist on **every GET** from the on-disk segment files plus the known total duration:
```ts
function buildPlaylist(job: TranscodeJob): string {
const segs = fs.readdirSync(job.outDir)
.filter(f => /^seg_\d{5}\.ts$/.test(f))
.sort();
const totalDuration = job.knownDuration; // from ffprobe, REAL duration
const lines = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:5',
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD',
];
// Emit all real segments
for (const seg of segs) lines.push(`#EXTINF:4.000,`, `seg/${seg}`);
// If FFmpeg has not yet produced the tail, append a "gap" sentinel so the
// browser knows total duration. We DO NOT emit #EXT-X-ENDLIST until the
// encoder exits cleanly — that flips the playlist into final VOD mode.
if (job.status === 'ready') lines.push('#EXT-X-ENDLIST');
return lines.join('\n');
}
```
The trick: even before `#EXT-X-ENDLIST` is emitted, the player has a `#EXT-X-PLAYLIST-TYPE:VOD` hint and the real ffprobe duration is stored in the segment count expectation. We also send `Content-Duration` header for ArtPlayer to consume.
---
## 10. UI / Player Integration
### 10.1 Component Flow
`unified-video-player.tsx`:
```
detectVideoFormat(video)
├─ direct → ArtPlayer with src=/api/stream/direct/<id>
├─ hls-* → POST /api/transcode/start
│ ArtPlayer + hls.js with src=<playlistUrl>
│ on artplayer "seek" event → POST /seek (may swap source)
│ every 20s timer → POST /ping
│ on unmount / onClose → DELETE
└─ local-player → existing LocalPlayerLauncher (kept as final fallback
for formats we explicitly refuse, e.g. raw VOB / RealMedia)
```
### 10.2 UI States
| State | UI |
|-------|-----|
| `requesting` | Spinner + "Preparing video…" + ETA from `etaSeconds` |
| `encoding`, no segments yet | Same spinner; auto-timeout 15s → error UI |
| `encoding`, playing | Hidden, but a tiny "Transcoding" badge in the player chrome (toggle in settings) |
| `503 Retry-After` | Toast: "Server busy transcoding 2 other videos. Try again in 30s." |
| `failed` | Error card with stderr snippet + button "Open in local player" (degrades to existing LocalPlayerLauncher) |
### 10.3 Settings
New Settings → Playback section:
- **Transcoding**: Off / Auto / Always
- **Preferred quality**: 480p / 720p / 1080p / Source
- **Hardware acceleration**: read-only label of detected backend
- **Show transcoding badge in player**: toggle
Stored in `localStorage` and mirrored to `user_preferences` (table TBD or reuse existing settings store).
---
## 11. Concurrency, Throttling, Disk
### 11.1 Concurrency Gate
```ts
class Semaphore {
constructor(public max: number) {}
// acquire returns immediately or rejects with { code: 'BUSY', retryAfterSec }
}
```
`MAX_CONCURRENT_TRANSCODES` env var, default 2 for dev, configurable in Settings → Admin.
### 11.2 Throttling (the Jellyfin pattern)
A 1Hz ticker per active job:
```
encoderPosition = parse from stderr 'time=00:hh:mm:ss.ms'
playheadPosition = job.lastPingPosition
gap = encoderPosition - playheadPosition
if (gap > 60 && !job.paused) send 'p\n'; job.paused = true
if (gap < 30 && job.paused) send 'u\n'; job.paused = false
```
This caps wasted work at ~60s of encode beyond playback.
### 11.3 Disk Budget (Segment Janitor)
- Per-job soft cap: `5 minutes * profile_bitrate` ≈ 100 MB for 720p.
- Global soft cap: `TRANSCODE_DISK_BUDGET_MB`, default 5000 MB.
- LRU eviction of **whole jobs** (never partial — a half-deleted job breaks the playlist) when global cap is exceeded.
- After job kill: keep `outDir` alive 5 minutes so a quick reopen reuses the cache; then `rm -rf`.
---
## 12. Failure Modes & Recovery
| Failure | Detection | Response |
|---------|-----------|----------|
| FFmpeg won't start (binary missing) | spawn `ENOENT` | Hard 500 + log; surface in Settings "FFmpeg not found at <path>" |
| First segment never appears | 15s fs.watch timeout | Kill job, return `TRANSCODE_STARTUP_TIMEOUT`, client falls back to local-player UI |
| FFmpeg crashes mid-stream | `exit` event with code ≠ 0 | Capture last 50 stderr lines, mark `failed`, push SSE error, client offers retry or local-player |
| Disk full | `ENOSPC` in stderr | Janitor force-cleanup, then either restart job or fail with explicit message |
| Client tab closed silently | No ping for 60s | Auto-kill via HeartbeatManager |
| Next.js process restart | DB has `status=encoding` rows with dead PIDs | `recover()` on boot marks them killed, cleans disks |
| Two clients open same video | Same `(mediaId, profile)` key | Both attach to one job; ping is per-session, kill only when **all** sessions are gone (refcount on job) |
| H.264 in MKV thought to be HEVC | Bad codec_info cache | `decision.ts` re-probes if codec_info is older than 7 days or absent |
---
## 13. Cleanup of v1 Debris (must happen before v2)
The following files are dead code from v1 and must be deleted (not patched), so v2 starts from a clean slate:
- `src/lib/ffmpeg/process-registry.ts` (will be replaced by `src/lib/transcode/orchestrator.ts`)
- `src/lib/hls-session-manager.ts`
- `src/lib/ts-segmentation-service.ts`
- `src/lib/hls-error-handler.ts`
- `src/lib/ts-converter.ts`
- `src/app/api/stream/hls/**`
- `src/app/api/stream/[id]/transcode/**` (will be replaced by `/api/transcode/**`)
- `src/app/api/videos/[id]/convert-ts/**`
- `src/app/api/ffmpeg/status/**` (replaced by `/api/transcode/status`)
- `src/lib/hooks/use-protected-duration.ts` (no longer needed once the playlist reports correct duration)
The `local-player-launcher` UI and `/api/external-stream/[id]` stay — they remain the user-visible fallback when transcoding is disabled, fails, or is rate-limited.
---
## 14. New Files (proposed layout)
```
src/lib/transcode/
orchestrator.ts # JobRegistry, start/kill/recover, singleton
job.ts # TranscodeJob class
heartbeat.ts # HeartbeatManager
concurrency.ts # Semaphore
janitor.ts # disk LRU cleanup
throttler.ts # 1Hz stdin p/u
hw-probe.ts # one-time encoder probe, cached
profiles.ts # PROFILES constant
decision.ts # FormatDecisionEngine
ffmpeg-args.ts # buildTranscodeArgs / buildRemuxArgs
playlist.ts # buildPlaylist (regenerates m3u8 from disk)
ffprobe.ts # cached probe, writes media.codec_info
src/app/api/transcode/
start/route.ts # POST
[jobId]/master.m3u8/route.ts # GET
[jobId]/seg/[name]/route.ts # GET
[jobId]/ping/route.ts # POST
[jobId]/seek/route.ts # POST
[jobId]/route.ts # DELETE
[jobId]/events/route.ts # GET (SSE)
status/route.ts # GET admin
src/components/
unified-video-player.tsx # MODIFIED: branches on decision
transcode-status-badge.tsx # NEW: tiny chrome badge
```
---
## 15. Phased Roadmap
| Phase | Scope | Exit Criteria | Risk |
|-------|-------|--------------|------|
| **0. Cleanup** | Delete dead v1 code (§13), drop `fluent-ffmpeg` dep, keep only the `ffmpeg` binary for thumbnails. | `pnpm build` green; existing direct-stream + local-player flow untouched. | Low |
| **1. Spike: orchestrator + remux only** | `decision.ts` for `hls-remux` only (h264+aac in mkv/mov). Full orchestrator, kill state machine, heartbeat, single-segment playlist regen. No transcoding yet — copy only. | Play a 2 GB MKV (h264+aac) in Chrome/Safari/Firefox. Seek, pause, close. `lsof` shows no leaked FDs after 5 minutes. | **High** (the lifecycle code is the hard part) |
| **2. Software transcoding** | Add `libx264 -preset veryfast` path. 720p only. Throttler + concurrency gate. | Play an AVI (mpeg4+mp3) end-to-end. Two concurrent jobs run, third gets 503. Kill -9 the server: on restart, dead-job rows are cleaned, no orphaned ffmpeg. | High |
| **3. Hardware encoder + profiles** | `hw-probe.ts`, 480p/720p/1080p, settings UI. | macOS dev box uses `h264_videotoolbox`, CPU < 30% on 1080p. Linux box auto-picks `h264_nvenc` if available. | Medium |
| **4. Seek-restart + discontinuity** | `/seek` endpoint, restart-on-large-seek, `#EXT-X-DISCONTINUITY` in regenerated playlist. | Seek to t=1h in a 2h MKV completes within 3s on hw encoder; progress bar shows correct global timeline. | Medium |
| **5. SSE + observability** | `/events` SSE channel, `/status` admin page, structured stderr capture. | A dev panel at `/admin/transcode` shows live jobs, CPU%, ETA. | Low |
| **6. Polish + flags** | Feature flag default-on per env; user-facing error UI; docs. | E2E run-book passes (see §17). Move this doc to `docs/active/media-streaming/`. | Low |
Estimated effort: **6 calendar weeks** at one engineer, dominated by Phases 1 and 2.
---
## 16. Configuration (env)
```env
ENABLE_LIVE_TRANSCODE=false # master switch
FFMPEG_PATH=/usr/bin/ffmpeg
FFPROBE_PATH=/usr/bin/ffprobe
TRANSCODE_TMP_DIR=/tmp/nextav-hls
MAX_CONCURRENT_TRANSCODES=2
TRANSCODE_DISK_BUDGET_MB=5000
TRANSCODE_HEARTBEAT_TIMEOUT_MS=60000
TRANSCODE_STARTUP_TIMEOUT_MS=15000
TRANSCODE_HWACCEL=auto # auto|videotoolbox|nvenc|qsv|none
TRANSCODE_DEFAULT_PROFILE=720p-h264-aac
```
---
## 17. Acceptance / Run-Book
Manual E2E (must all pass on macOS + Linux):
1. **Direct path untouched**: play an MP4(H.264+AAC), confirm `/api/stream/direct/<id>` is used and no transcode rows appear.
2. **Remux happy path**: play an MKV (H.264+AAC). Confirm `hls-remux`, CPU < 5%.
3. **Transcode happy path**: play an AVI (MPEG-4 ASP). Confirm software encoder, CPU usage matches `nproc/2` budget, video plays smoothly.
4. **HW encoder**: on a Mac, confirm `h264_videotoolbox` is chosen, CPU < 30% on 1080p.
5. **Seek inside window**: seek +30s, no new job spawned.
6. **Seek outside window**: seek +30 minutes, old job dies within 3s, new job spawns, video resumes within 5s.
7. **Concurrency**: open three transcoding videos simultaneously; third receives 503 + UI toast.
8. **Heartbeat death**: close tab during transcoding, wait 90s, confirm process is killed (`ps aux | grep ffmpeg` empty) and segments are cleaned within 5 min.
9. **Crash recovery**: `kill -9` the Next.js server during transcoding, restart, confirm DB rows cleaned, disk cleaned, no orphan ffmpeg.
10. **Disk pressure**: fill disk to 95%, request a transcode fails with explicit `ENOSPC` message, not a hang.
11. **Player error fallback**: corrupt a segment on disk while playing SSE error event UI offers "Open in local player" without page reload.
12. **Duration correctness**: For a 9:00 video, the progress bar reports 9:00 from the first frame and never moves backward.
---
## 18. Risks & Open Questions
- **Q1**: ABR is out of scope for v2. Should we plan a v3 with multi-variant playlists, or is single-variant "good enough" for a personal media library? *Recommendation: defer; single-variant with manual quality switch covers 95% of the use case.*
- **Q2**: Subtitles. MKVs frequently carry SRT/SSA tracks. Initial proposal: extract on demand to WebVTT via `ffmpeg -map 0:s:0 -c:s webvtt`, expose as `/api/transcode/<jobId>/sub/<idx>.vtt`. Not in Phase 14; track as a follow-up.
- **Q3**: Multi-audio tracks. Same story as subtitles out of scope for v2, but the profile key already supports `+audioTrack` for future use.
- **R1**: Process management is **the** historical failure point. Phase 1 must be ruthlessly tested with `lsof`, `ps`, and crash injection before any encoding work begins. If Phase 1 cannot demonstrate zero leaks under chaos testing, **abort the whole effort** and keep the local-player approach.
- **R2**: Browser HLS behavior varies. Safari handles HLS natively; Chrome/Firefox need hls.js. The chosen `MEDIA-SEQUENCE` / `DISCONTINUITY` strategy must be validated on all three. Reserve 1 week of Phase 4 just for cross-browser bug triage.
---
## 19. Decision Required Before Coding
1. Approve the **HLS-on-disk** approach (vs. the v1 piped-MP4 approach). *Strongly recommended; everything else in this doc depends on it.*
2. Approve **single-variant playlists** for v2 (no ABR).
3. Confirm the **delete-first** stance on legacy code 13). If we keep v1 stubs around, the new code paths will be ambiguous and the old bugs will resurface.
4. Confirm `MAX_CONCURRENT_TRANSCODES=2` as the default for a personal/home server. Adjust upward only when the target deployment is a multi-user server.
Once these four are signed off, work can start with Phase 0 cleanup.