diff --git a/apps/client-js/docs/experiment-quick-reference.md b/apps/client-js/docs/experiment-quick-reference.md new file mode 100644 index 00000000..e5585a16 --- /dev/null +++ b/apps/client-js/docs/experiment-quick-reference.md @@ -0,0 +1,108 @@ +# Experiment Quick Reference + +Copy-paste commands and checklist for running filtered playback baseline experiments. + +## Quick Start (Terminal A) + +```bash +cd ~/Documents/Baylor/Spring\ 26/CSI_5v92_AF/BaylorMultimediaLab/moqtail +mkdir -p logs analysis + +cd apps/client-js +npm run dev +``` + +Wait for output: + +``` + VITE v7.3.1 ready in 500 ms + ➜ Local: http://localhost:5173/ +``` + +## Browser: Connect & Configure + +1. Go to `http://localhost:5173` +2. Relay URL: `https://localhost:4433` +3. Namespace: `moqtail` +4. Click **Connect**, wait for tracks to load +5. Toggle **Filtered Playback** ON +6. Metadata Delay Mode: **Fixed delay** +7. Fixed Metadata Delay: **1200 ms** + +## Browser: Capture (5 min) + +1. Click first video track to start +2. Wait 30 s for stabilization +3. Trigger 3–5 track switches (click different tracks in sidebar every 10 s) +4. Note file timestamp printed in console: `client-metrics_YYYY-MM-DD_HH-MM-SS.csv` + +## Terminal B (or new tab): Analyze + +```bash +# Go to client-js root +cd apps/client-js + +# List captured CSV +ls -lh ../logs/ + +# Short report (replace YYYY-MM-DD_HH-MM-SS with your timestamp) +npm run analyze:switch-baseline -- --csv ../logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv + +# Full JSON report +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv \ + --output-json ./analysis/run1_fixed-1200ms.json +``` + +## Variants (repeat capture + analysis for each) + +### 600 ms Fixed + +```bash +# Browser: Set Fixed Metadata Delay to 600 ms +# Capture 5 min, note timestamp +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_.csv \ + --output-json ./analysis/run2_fixed-600ms.json +``` + +### 2000 ms Fixed + +```bash +# Browser: Set Fixed Metadata Delay to 2000 ms +# Capture 5 min, note timestamp +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_.csv \ + --output-json ./analysis/run3_fixed-2000ms.json +``` + +### Variable (800–2000 ms) + +```bash +# Browser: +# - Metadata Delay Mode: Variable delay +# - Min: 800 ms, Max: 2000 ms +# Capture 5 min, note timestamp +npm run analyze:switch-baseline -- \ + --csv ../logs/client-metrics_.csv \ + --output-json ./analysis/run4_variable-800-2000ms.json +``` + +## Summary Table (after all runs) + +| Delay Config | Total Events | Success | Rejected | Error | Jump-FWD | Jump-BCK | Misalign | Discontin | +| ------------ | ------------ | ------- | -------- | ----- | -------- | -------- | -------- | --------- | +| 600 ms | ? | ? | ? | ? | ? | ? | ? | ? | +| 1200 ms | ? | ? | ? | ? | ? | ? | ? | ? | +| 2000 ms | ? | ? | ? | ? | ? | ? | ? | ? | +| Variable | ? | ? | ? | ? | ? | ? | ? | ? | + +## Notes + +- **Jump-forward**: playback delta > +0.35 s +- **Jump-backward**: playback delta < -0.35 s +- **Misalignment**: alignment error > ±0.4 s +- **Discontinuity**: error outcome OR group regression OR live-offset delta > ±1.25 s +- **Behind-live threshold**: 1.5 s (only events when live_offset_s ≥ 1.5 are counted) + +All CSV files auto-saved to `logs/`; all JSON reports in `analysis/`. diff --git a/apps/client-js/docs/filtered-playback-experiments.md b/apps/client-js/docs/filtered-playback-experiments.md new file mode 100644 index 00000000..01bf7de4 --- /dev/null +++ b/apps/client-js/docs/filtered-playback-experiments.md @@ -0,0 +1,341 @@ +# Filtered Playback Baseline Experiments + +This guide reproduces switch behavior while playback is behind live and captures evidence for: + +- jump-forward (positive playback-time discontinuity) +- jump-backward (negative playback-time discontinuity) +- misalignment (large alignment error between switches) +- discontinuity (error outcomes, group regressions, or large live-offset deltas) + +## Prerequisites + +1. **Running relay and publisher:** Ensure you have a moqtail relay running and a publisher streaming to it (e.g., on `https://localhost:4433` in the namespace `moqtail`). +2. **Working directory:** You must be in the moqtail project root. + +## 0. Create Analysis Directory + +```bash +mkdir -p logs analysis +``` + +## 1. Start the Player (Terminal A) + +From project root, navigate to client and start dev: + +```bash +cd apps/client-js +npm run dev +``` + +**Wait for output:** + +``` + VITE v7.3.1 ready in 500 ms + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose +``` + +The Vite middleware now logs all metrics to `logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv` automatically. + +## 2. Open Player UI (Terminal B, or Browser) + +Open **`http://localhost:5173`** in your browser. + +You should see the MOQtail player sidebar with: + +- Connection fields (Relay URL, Namespace) +- Connect button +- Filtered Playback control section (currently OFF) + +## 3. Configure Connection + +In the browser sidebar: + +1. **Relay URL:** `https://localhost:4433` (or your relay endpoint) +2. **Namespace:** `moqtail` (or your namespace) +3. Click **Connect** + +Wait ~2–5 seconds. You should see: + +- Status dot change to **green** (Catalog loaded) +- Video and Audio track lists populate in the sidebar + +## 4. Enable Filtered Playback + +Once tracks are loaded: + +1. Toggle **Filtered Playback** checkbox to **ON** +2. Select **Metadata Delay Mode:** `Fixed delay` (for determinism) +3. Set **Fixed Metadata Delay:** `1200` ms (recommended starting point) +4. Keep ABR **auto-switch enabled** (do not disable for baseline) + +**Now ready to capture Run 1.** + +## 5. Run Capture — Run 1: 1200 ms Fixed Delay (5 minutes) + +**Goal:** Auto-switch baseline + forced manual switches while behind live. + +### Steps: + +1. **Start playback:** Click the first video track in the sidebar to start playback. + - Observe the video player begin (black background until buffered). + - Wait **30 seconds** for metrics to stabilize. Watch **Playout State** and **Switch Baseline** panels. + +2. **Observe behind-live:** After stabilization, you should see: + - `Live Offset`: ~1–3 seconds (you are behind live edge) + - `Metadata Delay`: increases to ~1200 ms when a group is detected + - `Metadata Ready`: toggles between Yes/No as delay counts down + - Video may pause periodically (gating). + +3. **Trigger switches (Minute 1–4):** Force 3–5 track changes while behind live: + - Disable auto-switch (in Settings panel if you have access, or rely on natural ABR event). + - Click a different **Video** track in the sidebar (e.g., 720p → 1080p → 720p). + - Wait 10 s between each switch. + - **Observe Switch Baseline panel:** You should see fields populate with switch_outcome, playback_delta, alignment_error, etc. + +4. **Note anomalies:** Watch for: + - Video **stalls** (video paused while metadata gate is active) + - **Jumps** in the progress bar (jump-forward or jump-backward) + - **Repeated metadata delays** (indicates regrouping) + +5. **End capture (Minute 5):** Click a track again to stop playback or just let it run. + - **Do not close the browser or stop the dev server yet.** + +### Metrics are now in: `logs/client-metrics_YYYY-MM-DD_HH-MM-SS.csv` + +Note the **timestamp** of the file (you'll need it for analysis). + +## 6. Analyze Run 1 (Terminal B, while dev server is running or after) + +### Step 6a: List captured CSVs + +```bash +ls -lh ../../logs/ +``` + +You should see something like: + +``` +-rw-r--r-- client-metrics_2026-04-09_14-30-45.csv (50–100 KB) +``` + +### Step 6b: Run short report (console output) + +Replace `` with your actual file timestamp: + +```bash +npm run analyze:switch-baseline -- --csv ../../logs/client-metrics_2026-04-09_14-30-45.csv +``` + +You will see a summary like: + +``` +=== Filtered Playback Switch Baseline Report === +CSV: ../../logs/client-metrics_2026-04-09_14-30-45.csv +Events (behind-live only): 8 + +Outcome counts: + success: 5 + rejected: 2 + error: 1 + +Finding counts: + jump-forward: 3 + jump-backward: 2 + misalignment: 4 + discontinuity: 1 + +Sample events: + [1] 2026-04-09T14:30:50.000Z outcome=success issues=jump-forward|misalignment ... + ... +``` + +### Step 6c: Generate full JSON report + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_2026-04-09_14-30-45.csv \ + --output-json ./analysis/run1_fixed-1200ms.json +``` + +This creates `analysis/run1_fixed-1200ms.json` with all events and metadata. + +## 7. (Optional) Run Additional Variants + +Repeat steps 3–6 with different delay configurations to build a comparison matrix. + +### Variant A: 600 ms Delay (Low) + +In browser sidebar: + +- Fixed Metadata Delay: `600` ms +- Repeat capture (~5 min) + +**Analysis:** + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --output-json ./analysis/run2_fixed-600ms.json +``` + +### Variant B: 2000 ms Delay (High) + +In browser sidebar: + +- Fixed Metadata Delay: `2000` ms +- Repeat capture (~5 min) + +**Analysis:** + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --output-json ./analysis/run3_fixed-2000ms.json +``` + +### Variant C: Variable Delay (800–2000 ms) + +In browser sidebar: + +- Metadata Delay Mode: `Variable delay` +- Min Delay: `800` ms +- Max Delay: `2000` ms +- Repeat capture (~5 min) + +**Analysis:** + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --output-json ./analysis/run4_variable-800-2000ms.json +``` + +## 8. Interpretation Rules + +The analyzer **deduplicates** terminal switch events by signature (outcome + track pairs + timestamps). It reports one final event per unique switch **that occurred behind live** (live_offset_s ≥ behind-live-threshold, default 1.5 s). + +**Classification logic:** + +| Issue | Condition | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `jump-forward` | `switch_playback_delta_s > jump-threshold` (default 0.35 s) | +| `jump-backward` | `switch_playback_delta_s < -jump-threshold` (default -0.35 s) | +| `misalignment` | `\|switch_alignment_error_s\| > misalignment-threshold` (default 0.4 s) | +| `discontinuity` | Any of: outcome is `rejected` \| `error`, OR group regression (`switch_group_delta < 0`), OR large live-offset shift (`\|switch_live_offset_delta_s\| > threshold`, default 1.25 s) | + +**Thresholds are tunable** if you want to adjust sensitivity: + +```bash +npm run analyze:switch-baseline -- \ + --csv ../../logs/client-metrics_.csv \ + --jump-threshold 0.50 \ + --misalignment-threshold 0.50 \ + --output-json ./analysis/run1_custom-thresholds.json +``` + +## 9. Summary and Next Steps + +After running one or more variants: + +### Step 9a: Review JSON Reports + +Each JSON report (`analysis/run*.json`) contains: + +```json +{ + "inputCsv": "...", + "thresholds": { ... }, + "summary": { + "totalEvents": 8, + "outcomes": { "success": 5, "rejected": 2, "error": 1, "other": 0 }, + "findings": { "jumpForward": 3, "jumpBackward": 2, "misalignment": 4, "discontinuity": 1 } + }, + "events": [ { "outcome": "success", "issues": ["jump-forward", "misalignment"], ... }, ... ] +} +``` + +### Step 9b: Fill in Findings Summary + +Use this template for your investigation notes: + +```text +EXPERIMENT RESULTS — Filtered Playback Behind-Live Baseline + +Trial 1: 1200 ms Fixed Delay + CSV: logs/client-metrics_2026-04-09_14-30-45.csv + Total Behind-Live Switch Events: 8 + Outcomes: success=5, rejected=2, error=1 + + Findings: + jump-forward: 3 events + jump-backward: 2 events + misalignment: 4 events + discontinuity: 1 event + + Example Events: + - (success) 720→1080 @ playback 18.0s, delta=+0.9s, alignErr=0.9s → JUMP-FORWARD + MISALIGNMENT + - (error) 1080→720 @ playback 17.2s, delta=-0.8s, groupDelta=-3 → JUMP-BACKWARD + DISCONTINUITY + +Trial 2: 600 ms Fixed Delay + CSV: logs/client-metrics_.csv + Total Behind-Live Switch Events: 4 + Outcomes: success=3, rejected=1 + + [Similar format...] + +Trial 3: 2000 ms Fixed Delay + [...] + +COMPARATIVE ANALYSIS: +- Delay = 600 ms: 4 events, [breakdown] +- Delay = 1200 ms: 8 events, [breakdown] +- Delay = 2000 ms: [pending] +- Delay = Variable: [pending] + +HYPOTHESIS: +- Longer delays correlate with more switch events (higher behind-live duration). +- Jump-forward occurs [X] conditions; jump-backward [Y]. +- Primary failure mode: [describe pattern]. + +NEXT MITIGATION: +- Proposal A: [describe Milestone 4 time-alignment strategy] +- Proposal B: [alternative] +``` + +### Step 9c: Inspect Raw CSV (Optional) + +View the full CSV if you need to inspect specific rows: + +```bash +head -20 ../../logs/client-metrics_2026-04-09_14-30-45.csv # First 20 rows (header + samples) +tail -20 ../../logs/client-metrics_2026-04-09_14-30-45.csv # Last 20 rows +wc -l ../../logs/client-metrics_2026-04-09_14-30-45.csv # Total row count +``` + +### Step 9d: Aggregate Multiple Runs (Bash) + +Combine all JSON reports into a single matrix: + +```bash +cd analysis +echo "Run summaries:" > combined-findings.txt +for f in *.json; do + echo "" >> combined-findings.txt + echo "=== $f ===" >> combined-findings.txt + cat "$f" | grep -A 10 '"summary"' >> combined-findings.txt +done +cat combined-findings.txt +``` + +## 10. Summary + +You now have a complete reproducible baseline. Key deliverables: + +1. **CSV logs** in `logs/` with all switch telemetry (raw data). +2. **JSON reports** in `analysis/` with classification and statistics. +3. **Findings** written up per template (for investigation notes). +4. **Thresholds** tunable if classification is too loose or tight. + +**Next phase (Milestone 4):** Use these findings to design and validate time-aligned switching logic that avoids jump-forward/jump-backward during behind-live filtering. diff --git a/apps/client-js/package.json b/apps/client-js/package.json index 3c0e2dac..bea61839 100644 --- a/apps/client-js/package.json +++ b/apps/client-js/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "format": "prettier --check .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "analyze:switch-baseline": "node scripts/analyze-switch-baseline.mjs" }, "dependencies": { "@tailwindcss/vite": "^4.2.1", diff --git a/apps/client-js/scripts/analyze-switch-baseline.mjs b/apps/client-js/scripts/analyze-switch-baseline.mjs new file mode 100644 index 00000000..2e1eb7ad --- /dev/null +++ b/apps/client-js/scripts/analyze-switch-baseline.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +function parseArgs(argv) { + const args = { + csv: null, + help: false, + behindLiveThreshold: 1.5, + jumpThreshold: 0.35, + misalignmentThreshold: 0.4, + discontinuityLiveOffsetThreshold: 1.25, + outputJson: null, + }; + + for (let i = 2; i < argv.length; i++) { + const current = argv[i]; + const next = argv[i + 1]; + + if (current === '--csv' && next) { + args.csv = next; + i++; + continue; + } + if (current === '--help' || current === '-h') { + args.help = true; + continue; + } + if (current === '--behind-live-threshold' && next) { + args.behindLiveThreshold = Number(next); + i++; + continue; + } + if (current === '--jump-threshold' && next) { + args.jumpThreshold = Number(next); + i++; + continue; + } + if (current === '--misalignment-threshold' && next) { + args.misalignmentThreshold = Number(next); + i++; + continue; + } + if (current === '--discontinuity-live-offset-threshold' && next) { + args.discontinuityLiveOffsetThreshold = Number(next); + i++; + continue; + } + if (current === '--output-json' && next) { + args.outputJson = next; + i++; + continue; + } + } + + return args; +} + +function parseCsv(csvText) { + const lines = csvText + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + + if (lines.length < 2) { + return []; + } + + const headers = lines[0].split(','); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(','); + const row = {}; + for (let j = 0; j < headers.length; j++) { + row[headers[j]] = values[j] ?? ''; + } + rows.push(row); + } + + return rows; +} + +function num(row, key) { + const raw = row[key]; + if (raw === undefined || raw === null || raw === '') return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +function text(row, key) { + const value = row[key]; + return value === undefined || value === null || value === '' ? null : value; +} + +function makeSignature(row) { + const fields = [ + row.switch_outcome ?? '', + row.switch_from_track ?? '', + row.switch_to_track ?? '', + row.switch_requested_at_ms ?? '', + row.switch_settled_at_ms ?? '', + row.switch_duration_ms ?? '', + row.switch_from_group ?? '', + row.switch_to_group ?? '', + row.switch_group_delta ?? '', + row.switch_playback_delta_s ?? '', + row.switch_alignment_error_s ?? '', + ]; + return fields.join('|'); +} + +function classifyEvent(row, thresholds) { + const issues = []; + const playbackDelta = num(row, 'switch_playback_delta_s'); + const alignmentError = num(row, 'switch_alignment_error_s'); + const liveOffsetDelta = num(row, 'switch_live_offset_delta_s'); + const groupDelta = num(row, 'switch_group_delta'); + const outcome = text(row, 'switch_outcome'); + + if (playbackDelta !== null && playbackDelta > thresholds.jumpThreshold) { + issues.push('jump-forward'); + } + if (playbackDelta !== null && playbackDelta < -thresholds.jumpThreshold) { + issues.push('jump-backward'); + } + if (alignmentError !== null && Math.abs(alignmentError) > thresholds.misalignmentThreshold) { + issues.push('misalignment'); + } + + const discontinuity = + outcome === 'error' || + outcome === 'rejected' || + (groupDelta !== null && groupDelta < 0) || + (liveOffsetDelta !== null && + Math.abs(liveOffsetDelta) > thresholds.discontinuityLiveOffsetThreshold); + + if (discontinuity) { + issues.push('discontinuity'); + } + + return { + outcome, + issues, + playbackDelta, + alignmentError, + liveOffsetDelta, + groupDelta, + fromTrack: text(row, 'switch_from_track'), + toTrack: text(row, 'switch_to_track'), + fromGroup: text(row, 'switch_from_group'), + toGroup: text(row, 'switch_to_group'), + switchDurationMs: num(row, 'switch_duration_ms'), + switchRequestedAtMs: num(row, 'switch_requested_at_ms'), + switchSettledAtMs: num(row, 'switch_settled_at_ms'), + liveOffsetAtSwitch: num(row, 'switch_from_live_offset_s'), + timestamp: text(row, 'timestamp'), + }; +} + +function extractTerminalSwitchEvents(rows, behindLiveThreshold) { + const events = []; + let lastSignature = null; + + for (const row of rows) { + const outcome = text(row, 'switch_outcome'); + if (outcome !== 'success' && outcome !== 'rejected' && outcome !== 'error') { + continue; + } + + const liveOffsetAtSwitch = num(row, 'switch_from_live_offset_s'); + if (liveOffsetAtSwitch === null || liveOffsetAtSwitch < behindLiveThreshold) { + continue; + } + + const signature = makeSignature(row); + if (signature === lastSignature) { + continue; + } + + events.push(row); + lastSignature = signature; + } + + return events; +} + +function summarize(events) { + const summary = { + totalEvents: events.length, + outcomes: { + success: 0, + rejected: 0, + error: 0, + other: 0, + }, + findings: { + jumpForward: 0, + jumpBackward: 0, + misalignment: 0, + discontinuity: 0, + }, + }; + + for (const event of events) { + const outcome = event.outcome ?? 'other'; + if (outcome === 'success' || outcome === 'rejected' || outcome === 'error') { + summary.outcomes[outcome]++; + } else { + summary.outcomes.other++; + } + + if (event.issues.includes('jump-forward')) summary.findings.jumpForward++; + if (event.issues.includes('jump-backward')) summary.findings.jumpBackward++; + if (event.issues.includes('misalignment')) summary.findings.misalignment++; + if (event.issues.includes('discontinuity')) summary.findings.discontinuity++; + } + + return summary; +} + +function printReport(inputCsvPath, thresholds, summary, events) { + console.log('=== Filtered Playback Switch Baseline Report ==='); + console.log(`CSV: ${inputCsvPath}`); + console.log(`Events (behind-live only): ${summary.totalEvents}`); + console.log(''); + console.log('Thresholds:'); + console.log(` behindLiveThreshold: ${thresholds.behindLiveThreshold.toFixed(2)} s`); + console.log(` jumpThreshold: ${thresholds.jumpThreshold.toFixed(2)} s`); + console.log(` misalignmentThreshold: ${thresholds.misalignmentThreshold.toFixed(2)} s`); + console.log( + ` discontinuityLiveOffsetThreshold: ${thresholds.discontinuityLiveOffsetThreshold.toFixed(2)} s`, + ); + console.log(''); + + console.log('Outcome counts:'); + console.log(` success: ${summary.outcomes.success}`); + console.log(` rejected: ${summary.outcomes.rejected}`); + console.log(` error: ${summary.outcomes.error}`); + if (summary.outcomes.other > 0) { + console.log(` other: ${summary.outcomes.other}`); + } + console.log(''); + + console.log('Finding counts:'); + console.log(` jump-forward: ${summary.findings.jumpForward}`); + console.log(` jump-backward: ${summary.findings.jumpBackward}`); + console.log(` misalignment: ${summary.findings.misalignment}`); + console.log(` discontinuity: ${summary.findings.discontinuity}`); + console.log(''); + + if (events.length > 0) { + console.log('Sample events:'); + const sampleSize = Math.min(10, events.length); + for (let i = 0; i < sampleSize; i++) { + const event = events[i]; + console.log( + [ + ` [${i + 1}]`, + event.timestamp ?? '-', + `outcome=${event.outcome ?? '-'}`, + `issues=${event.issues.join('|') || 'none'}`, + `from=${event.fromTrack ?? '-'}(${event.fromGroup ?? '-'})`, + `to=${event.toTrack ?? '-'}(${event.toGroup ?? '-'})`, + `durationMs=${event.switchDurationMs ?? '-'}`, + `playbackDelta=${event.playbackDelta ?? '-'}s`, + `alignErr=${event.alignmentError ?? '-'}s`, + `liveOffsetDelta=${event.liveOffsetDelta ?? '-'}s`, + ].join(' '), + ); + } + } +} + +function usage() { + console.log('Usage: node scripts/analyze-switch-baseline.mjs --csv [options]'); + console.log(''); + console.log('Options:'); + console.log(' --behind-live-threshold default: 1.5'); + console.log(' --jump-threshold default: 0.35'); + console.log(' --misalignment-threshold default: 0.4'); + console.log(' --discontinuity-live-offset-threshold default: 1.25'); + console.log(' --output-json write full report JSON'); +} + +function main() { + const args = parseArgs(process.argv); + if (args.help) { + usage(); + process.exit(0); + } + + if (!args.csv) { + usage(); + process.exit(0); + } + + const csvPath = path.resolve(process.cwd(), args.csv); + if (!fs.existsSync(csvPath)) { + console.error(`CSV file not found: ${csvPath}`); + process.exit(1); + } + + const csv = fs.readFileSync(csvPath, 'utf8'); + const rows = parseCsv(csv); + const terminalRows = extractTerminalSwitchEvents(rows, args.behindLiveThreshold); + const events = terminalRows.map(row => + classifyEvent(row, { + jumpThreshold: args.jumpThreshold, + misalignmentThreshold: args.misalignmentThreshold, + discontinuityLiveOffsetThreshold: args.discontinuityLiveOffsetThreshold, + }), + ); + const summary = summarize(events); + + printReport(csvPath, args, summary, events); + + if (args.outputJson) { + const outputPath = path.resolve(process.cwd(), args.outputJson); + const payload = { + inputCsv: csvPath, + thresholds: { + behindLiveThreshold: args.behindLiveThreshold, + jumpThreshold: args.jumpThreshold, + misalignmentThreshold: args.misalignmentThreshold, + discontinuityLiveOffsetThreshold: args.discontinuityLiveOffsetThreshold, + }, + summary, + events, + }; + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + console.log(''); + console.log(`Wrote JSON report: ${outputPath}`); + } +} + +main(); diff --git a/apps/client-js/src/app.tsx b/apps/client-js/src/app.tsx index 259f467b..85f04c55 100644 --- a/apps/client-js/src/app.tsx +++ b/apps/client-js/src/app.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useState, useRef, useCallback } from 'preact/hooks'; +import { useState, useRef, useCallback, useEffect } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Player } from '@/lib/player'; import { cn } from '@/lib/utils'; @@ -29,6 +29,12 @@ import { MetricsPanel } from '@/components/MetricsPanel'; type Track = CMSF['tracks'][number]; type Status = 'idle' | 'connecting' | 'ready' | 'restarting' | 'playing' | 'error'; +type ModerationDelayMode = 'fixed' | 'variable'; + +interface GroupModerationDecision { + delayMs: number; + readyAt: number; +} const GITHUB_REPO = 'moqtail/moqtail'; @@ -83,36 +89,30 @@ function Checkbox({ onChange: (checked: boolean) => void; }) { return ( - - {/* Real input — kept 1 px so it remains in the accessibility tree without position:absolute */} - onChange((e.target as HTMLInputElement).checked)} - /> - {/* Visual indicator */} - - + ); } @@ -227,11 +227,40 @@ export function App() { const [abrSettings, setAbrSettings] = useState(DEFAULT_ABR_SETTINGS); const [abrMetrics, setAbrMetrics] = useState(null); const [metricsSnapshot, setMetricsSnapshot] = useState(null); + const [filteredPlaybackEnabled, setFilteredPlaybackEnabled] = useState(false); + const [moderationDelayMode, setModerationDelayMode] = useState('fixed'); + const [fixedModerationDelayMs, setFixedModerationDelayMs] = useState(1200); + const [variableDelayMinMs, setVariableDelayMinMs] = useState(800); + const [variableDelayMaxMs, setVariableDelayMaxMs] = useState(2000); const abrRef = useRef(null); const rulesRef = useRef(null); const metricsRef = useRef(null); + const moderationDecisionsRef = useRef>(new Map()); + const moderationTimersRef = useRef>(new Map()); + const gatePausedPlaybackRef = useRef(false); + + const clearModerationSimulation = useCallback(() => { + for (const timeoutId of moderationTimersRef.current.values()) { + window.clearTimeout(timeoutId); + } + moderationTimersRef.current.clear(); + moderationDecisionsRef.current.clear(); + gatePausedPlaybackRef.current = false; + playerRef.current?.setMetadataState(true, 0); + }, []); + + const getSyntheticModerationDelayMs = useCallback(() => { + if (moderationDelayMode === 'fixed') { + return Math.max(0, fixedModerationDelayMs); + } + + const minMs = Math.max(0, Math.min(variableDelayMinMs, variableDelayMaxMs)); + const maxMs = Math.max(minMs, Math.max(variableDelayMinMs, variableDelayMaxMs)); + return minMs + Math.round(Math.random() * (maxMs - minMs)); + }, [fixedModerationDelayMs, moderationDelayMode, variableDelayMaxMs, variableDelayMinMs]); const disposePlayer = useCallback(async () => { + clearModerationSimulation(); if (abrRef.current) { abrRef.current.stop(); abrRef.current = null; @@ -255,7 +284,79 @@ export function App() { } catch {} bufferRef.current = null; } - }, []); + }, [clearModerationSimulation]); + + useEffect(() => { + return () => { + clearModerationSimulation(); + }; + }, [clearModerationSimulation]); + + useEffect(() => { + clearModerationSimulation(); + }, [ + clearModerationSimulation, + filteredPlaybackEnabled, + moderationDelayMode, + fixedModerationDelayMs, + variableDelayMinMs, + variableDelayMaxMs, + ]); + + useEffect(() => { + const player = playerRef.current; + const video = videoRef.current; + if (!player) return; + + if (!filteredPlaybackEnabled || status !== 'playing') { + player.setMetadataState(true, 0); + if (gatePausedPlaybackRef.current && video) { + gatePausedPlaybackRef.current = false; + void video.play().catch(() => {}); + } + return; + } + + const currentGroup = abrMetrics?.currentVideoGroup; + if (!currentGroup) { + player.setMetadataState(true, 0); + return; + } + + let decision = moderationDecisionsRef.current.get(currentGroup); + if (!decision) { + const delayMs = getSyntheticModerationDelayMs(); + decision = { + delayMs, + readyAt: Date.now() + delayMs, + }; + moderationDecisionsRef.current.set(currentGroup, decision); + + const timeoutId = window.setTimeout(() => { + moderationTimersRef.current.delete(currentGroup); + }, delayMs + 100); + moderationTimersRef.current.set(currentGroup, timeoutId); + } + + const remainingDelayMs = Math.max(0, decision.readyAt - Date.now()); + const metadataReady = remainingDelayMs === 0; + player.setMetadataState(metadataReady, remainingDelayMs); + + if (!video) return; + + if (!metadataReady) { + if (!video.paused) { + video.pause(); + gatePausedPlaybackRef.current = true; + } + return; + } + + if (gatePausedPlaybackRef.current) { + gatePausedPlaybackRef.current = false; + void video.play().catch(() => {}); + } + }, [abrMetrics, filteredPlaybackEnabled, getSyntheticModerationDelayMs, status]); const handleConnect = useCallback(async () => { if (!videoRef.current) return; @@ -543,6 +644,86 @@ export function App() { )} +
+
+
+

+ Filtered Playback +

+

+ Simulate moderation latency by pausing playout until metadata is ready for the + current video group. +

+
+ +
+ + + + + + {moderationDelayMode === 'fixed' ? ( + + + setFixedModerationDelayMs(Number((e.target as HTMLInputElement).value) || 0) + } + disabled={!filteredPlaybackEnabled} + class={inputCls} + /> + + ) : ( +
+ + + setVariableDelayMinMs(Number((e.target as HTMLInputElement).value) || 0) + } + disabled={!filteredPlaybackEnabled} + class={inputCls} + /> + + + + setVariableDelayMaxMs(Number((e.target as HTMLInputElement).value) || 0) + } + disabled={!filteredPlaybackEnabled} + class={inputCls} + /> + +
+ )} +
+ {/* Tracks */} {hasTracks && (
diff --git a/apps/client-js/src/components/MetricsPanel.tsx b/apps/client-js/src/components/MetricsPanel.tsx index 6680c34c..afca57d7 100644 --- a/apps/client-js/src/components/MetricsPanel.tsx +++ b/apps/client-js/src/components/MetricsPanel.tsx @@ -83,6 +83,38 @@ const METRIC_DEFS: MetricDef[] = [ color: '#14b8a6', extract: s => s.deliveryTimeMs, }, + { + key: 'liveOffset', + label: 'Live Offset', + unit: 's', + unitLabel: 'Seconds', + color: '#f97316', + extract: s => s.liveOffsetSeconds ?? 0, + }, + { + key: 'metadataDelay', + label: 'Metadata Delay', + unit: 'ms', + unitLabel: 'ms', + color: '#8b5cf6', + extract: s => s.metadataDelayMs, + }, + { + key: 'switchDuration', + label: 'Switch Duration', + unit: 'ms', + unitLabel: 'ms', + color: '#f43f5e', + extract: s => s.switchDurationMs ?? 0, + }, + { + key: 'switchAlignmentError', + label: 'Switch Alignment Error', + unit: 's', + unitLabel: 'Seconds', + color: '#10b981', + extract: s => s.switchAlignmentErrorSeconds ?? 0, + }, ]; const METRIC_MAP = new Map(METRIC_DEFS.map(d => [d.key, d])); @@ -309,6 +341,93 @@ export function MetricsPanel({ metrics, snapshot, tracks }: MetricsPanelProps) { activeMetrics={activeMetrics} onToggle={toggle} /> + + + +
+ + + {/* ── Playout State ── */} +
+

+ Playout State +

+
+ + + + + +
+
+ + {/* ── Switch Baseline ── */} +
+

+ Switch Baseline +

+
+ + + + + + + + + +
diff --git a/apps/client-js/src/lib/abr/AbrController.ts b/apps/client-js/src/lib/abr/AbrController.ts index ec925444..53cfa81c 100644 --- a/apps/client-js/src/lib/abr/AbrController.ts +++ b/apps/client-js/src/lib/abr/AbrController.ts @@ -20,6 +20,29 @@ export interface AbrMetrics { playbackRate: number; deliveryTimeMs: number; lastObjectBytes: number; + liveEdgeTime: number | null; + playbackTime: number | null; + liveOffsetSeconds: number | null; + currentVideoGroup: string | null; + pendingSwitchTrack: string | null; + metadataReady: boolean; + metadataDelayMs: number; + switchOutcome: 'idle' | 'pending' | 'success' | 'rejected' | 'error'; + switchFromTrack: string | null; + switchToTrack: string | null; + switchRequestedAtMs: number | null; + switchSettledAtMs: number | null; + switchDurationMs: number | null; + switchFromPlaybackTime: number | null; + switchToPlaybackTime: number | null; + switchPlaybackDeltaSeconds: number | null; + switchFromLiveOffsetSeconds: number | null; + switchToLiveOffsetSeconds: number | null; + switchLiveOffsetDeltaSeconds: number | null; + switchFromGroup: string | null; + switchToGroup: string | null; + switchGroupDelta: number | null; + switchAlignmentErrorSeconds: number | null; switchHistory: SwitchEvent[]; mode: 'auto' | 'manual'; switching: boolean; @@ -105,6 +128,29 @@ export class AbrController { playbackRate, deliveryTimeMs, lastObjectBytes, + liveEdgeTime, + playbackTime, + liveOffsetSeconds, + currentVideoGroup, + pendingSwitchTrack, + metadataReady, + metadataDelayMs, + switchOutcome, + switchFromTrack, + switchToTrack, + switchRequestedAtMs, + switchSettledAtMs, + switchDurationMs, + switchFromPlaybackTime, + switchToPlaybackTime, + switchPlaybackDeltaSeconds, + switchFromLiveOffsetSeconds, + switchToLiveOffsetSeconds, + switchLiveOffsetDeltaSeconds, + switchFromGroup, + switchToGroup, + switchGroupDelta, + switchAlignmentErrorSeconds, } = raw; // Find the active track index in the sorted tracks array @@ -124,6 +170,29 @@ export class AbrController { playbackRate, deliveryTimeMs, lastObjectBytes, + liveEdgeTime, + playbackTime, + liveOffsetSeconds, + currentVideoGroup, + pendingSwitchTrack, + metadataReady, + metadataDelayMs, + switchOutcome, + switchFromTrack, + switchToTrack, + switchRequestedAtMs, + switchSettledAtMs, + switchDurationMs, + switchFromPlaybackTime, + switchToPlaybackTime, + switchPlaybackDeltaSeconds, + switchFromLiveOffsetSeconds, + switchToLiveOffsetSeconds, + switchLiveOffsetDeltaSeconds, + switchFromGroup, + switchToGroup, + switchGroupDelta, + switchAlignmentErrorSeconds, switchHistory: [...this.#switchHistory], mode, switching: this.#switching, diff --git a/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts b/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts index a5421576..b2258392 100644 --- a/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts +++ b/apps/client-js/src/lib/abr/__tests__/AbrController.test.ts @@ -43,6 +43,29 @@ function makePlayerMetrics(overrides: Partial { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); it('starts empty', () => { const player = makeMockPlayer(); diff --git a/apps/client-js/src/lib/metrics/types.ts b/apps/client-js/src/lib/metrics/types.ts index 85cdadfb..fb196d06 100644 --- a/apps/client-js/src/lib/metrics/types.ts +++ b/apps/client-js/src/lib/metrics/types.ts @@ -9,6 +9,29 @@ export interface MetricsSample { totalFrames: number; playbackRate: number; deliveryTimeMs: number; + liveEdgeTime: number | null; + playbackTime: number | null; + liveOffsetSeconds: number | null; + currentVideoGroup: string | null; + pendingSwitchTrack: string | null; + metadataReady: boolean; + metadataDelayMs: number; + switchOutcome: 'idle' | 'pending' | 'success' | 'rejected' | 'error'; + switchFromTrack: string | null; + switchToTrack: string | null; + switchRequestedAtMs: number | null; + switchSettledAtMs: number | null; + switchDurationMs: number | null; + switchFromPlaybackTime: number | null; + switchToPlaybackTime: number | null; + switchPlaybackDeltaSeconds: number | null; + switchFromLiveOffsetSeconds: number | null; + switchToLiveOffsetSeconds: number | null; + switchLiveOffsetDeltaSeconds: number | null; + switchFromGroup: string | null; + switchToGroup: string | null; + switchGroupDelta: number | null; + switchAlignmentErrorSeconds: number | null; } export interface MetricsSnapshot { diff --git a/apps/client-js/src/lib/player.ts b/apps/client-js/src/lib/player.ts index 791a2775..898c2b12 100644 --- a/apps/client-js/src/lib/player.ts +++ b/apps/client-js/src/lib/player.ts @@ -37,6 +37,27 @@ interface PendingSwitch { mimeType: string; } +type SwitchOutcome = 'idle' | 'pending' | 'success' | 'rejected' | 'error'; + +interface SwitchTelemetry { + outcome: SwitchOutcome; + fromTrack: string | null; + toTrack: string | null; + requestedAtMs: number | null; + settledAtMs: number | null; + durationMs: number | null; + fromPlaybackTime: number | null; + toPlaybackTime: number | null; + playbackDeltaSeconds: number | null; + fromLiveOffsetSeconds: number | null; + toLiveOffsetSeconds: number | null; + liveOffsetDeltaSeconds: number | null; + fromGroup: string | null; + toGroup: string | null; + groupDelta: number | null; + alignmentErrorSeconds: number | null; +} + interface MOQStreamStruct { trackName: string; source: ReadableStream; @@ -77,6 +98,42 @@ const DefaultOptions = { } satisfies Required> & Pick; +export interface PlayerMetrics { + bandwidthBps: number; + fastEmaBps: number; + slowEmaBps: number; + bufferSeconds: number; + activeTrack: string | null; + droppedFrames: number; + totalFrames: number; + playbackRate: number; + deliveryTimeMs: number; + lastObjectBytes: number; + liveEdgeTime: number | null; + playbackTime: number | null; + liveOffsetSeconds: number | null; + currentVideoGroup: string | null; + pendingSwitchTrack: string | null; + metadataReady: boolean; + metadataDelayMs: number; + switchOutcome: SwitchOutcome; + switchFromTrack: string | null; + switchToTrack: string | null; + switchRequestedAtMs: number | null; + switchSettledAtMs: number | null; + switchDurationMs: number | null; + switchFromPlaybackTime: number | null; + switchToPlaybackTime: number | null; + switchPlaybackDeltaSeconds: number | null; + switchFromLiveOffsetSeconds: number | null; + switchToLiveOffsetSeconds: number | null; + switchLiveOffsetDeltaSeconds: number | null; + switchFromGroup: string | null; + switchToGroup: string | null; + switchGroupDelta: number | null; + switchAlignmentErrorSeconds: number | null; +} + export class Player { catalog: CMSFCatalog | null = null; client: MOQtailClient | null = null; @@ -84,6 +141,26 @@ export class Player { #element: HTMLVideoElement | null = null; #mse?: MediaSource; #streams: MOQStreamStruct[] = []; + #metadataReady = true; + #metadataDelayMs = 0; + #switchTelemetry: SwitchTelemetry = { + outcome: 'idle', + fromTrack: null, + toTrack: null, + requestedAtMs: null, + settledAtMs: null, + durationMs: null, + fromPlaybackTime: null, + toPlaybackTime: null, + playbackDeltaSeconds: null, + fromLiveOffsetSeconds: null, + toLiveOffsetSeconds: null, + liveOffsetDeltaSeconds: null, + fromGroup: null, + toGroup: null, + groupDelta: null, + alignmentErrorSeconds: null, + }; #options: Required> & Pick; @@ -161,6 +238,24 @@ export class Player { this.#element = null; this.#mse = undefined; this.#streams = []; + this.#switchTelemetry = { + outcome: 'idle', + fromTrack: null, + toTrack: null, + requestedAtMs: null, + settledAtMs: null, + durationMs: null, + fromPlaybackTime: null, + toPlaybackTime: null, + playbackDeltaSeconds: null, + fromLiveOffsetSeconds: null, + toLiveOffsetSeconds: null, + liveOffsetDeltaSeconds: null, + fromGroup: null, + toGroup: null, + groupDelta: null, + alignmentErrorSeconds: null, + }; } async attachMedia(element: HTMLVideoElement) { @@ -320,6 +415,50 @@ export class Player { struct.trackName = newTrackName; struct.pendingSwitch = null; + const settledAtMs = Date.now(); + const toPlaybackTime = this.#element ? this.#element.currentTime : null; + const toLiveEdgeTime = + this.#element?.buffered && this.#element.buffered.length > 0 + ? this.#element.buffered.end(this.#element.buffered.length - 1) + : null; + const toLiveOffsetSeconds = + toLiveEdgeTime !== null && toPlaybackTime !== null + ? Math.max(0, toLiveEdgeTime - toPlaybackTime) + : null; + const fromGroupBigInt = + this.#switchTelemetry.fromGroup !== null + ? BigInt(this.#switchTelemetry.fromGroup) + : null; + const groupDelta = + fromGroupBigInt !== null ? Number(object.location.group - fromGroupBigInt) : null; + const playbackDeltaSeconds = + this.#switchTelemetry.fromPlaybackTime !== null && toPlaybackTime !== null + ? toPlaybackTime - this.#switchTelemetry.fromPlaybackTime + : null; + const liveOffsetDeltaSeconds = + this.#switchTelemetry.fromLiveOffsetSeconds !== null && toLiveOffsetSeconds !== null + ? toLiveOffsetSeconds - this.#switchTelemetry.fromLiveOffsetSeconds + : null; + const durationMs = + this.#switchTelemetry.requestedAtMs !== null + ? settledAtMs - this.#switchTelemetry.requestedAtMs + : null; + + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'success', + settledAtMs, + durationMs, + toPlaybackTime, + playbackDeltaSeconds, + toLiveOffsetSeconds, + liveOffsetDeltaSeconds, + toGroup: object.location.group.toString(), + groupDelta, + alignmentErrorSeconds: + playbackDeltaSeconds !== null ? Math.abs(playbackDeltaSeconds) : null, + }; + // changeType() must not be called while the SourceBuffer is updating if (sourceBuffer.updating) await waitForBufferUpdate(sourceBuffer); try { @@ -332,6 +471,11 @@ export class Player { `switchTrack: failed to apply init segment for ${newTrackName}:`, switchError, ); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'error', + settledAtMs: Date.now(), + }; // Release the guard and abort the write stream — the source buffer // may be in an inconsistent state after a partial changeType/append. this.#options.onTrackSwitched?.(newTrackName); @@ -402,24 +546,15 @@ export class Player { } } - getMetrics(): { - bandwidthBps: number; - fastEmaBps: number; - slowEmaBps: number; - bufferSeconds: number; - activeTrack: string | null; - droppedFrames: number; - totalFrames: number; - playbackRate: number; - deliveryTimeMs: number; - lastObjectBytes: number; - } { + getMetrics(): PlayerMetrics { const videoStruct = this.#streams.find(s => this.catalog?.getRole(s.trackName) === 'video'); const buffered = this.#element?.buffered; + const liveEdgeTime = buffered && buffered.length > 0 ? buffered.end(buffered.length - 1) : null; + const playbackTime = this.#element ? this.#element.currentTime : null; const bufferSeconds = - buffered && buffered.length > 0 && this.#element - ? Math.max(0, buffered.end(buffered.length - 1) - this.#element.currentTime) - : 0; + liveEdgeTime !== null && playbackTime !== null ? Math.max(0, liveEdgeTime - playbackTime) : 0; + const currentVideoGroup = + videoStruct && videoStruct.lastGroupId >= 0n ? videoStruct.lastGroupId.toString() : null; const quality = this.#element?.getVideoPlaybackQuality?.(); return { bandwidthBps: videoStruct?.tracker.getBandwidthBps() ?? 0, @@ -432,6 +567,32 @@ export class Player { playbackRate: this.#element?.playbackRate ?? 1, deliveryTimeMs: videoStruct?.tracker.getLastDeliveryTimeMs() ?? 0, lastObjectBytes: videoStruct?.tracker.getLastObjectBytes() ?? 0, + liveEdgeTime, + playbackTime, + liveOffsetSeconds: + liveEdgeTime !== null && playbackTime !== null + ? Math.max(0, liveEdgeTime - playbackTime) + : null, + currentVideoGroup, + pendingSwitchTrack: videoStruct?.pendingSwitch?.trackName ?? null, + metadataReady: this.#metadataReady, + metadataDelayMs: this.#metadataDelayMs, + switchOutcome: this.#switchTelemetry.outcome, + switchFromTrack: this.#switchTelemetry.fromTrack, + switchToTrack: this.#switchTelemetry.toTrack, + switchRequestedAtMs: this.#switchTelemetry.requestedAtMs, + switchSettledAtMs: this.#switchTelemetry.settledAtMs, + switchDurationMs: this.#switchTelemetry.durationMs, + switchFromPlaybackTime: this.#switchTelemetry.fromPlaybackTime, + switchToPlaybackTime: this.#switchTelemetry.toPlaybackTime, + switchPlaybackDeltaSeconds: this.#switchTelemetry.playbackDeltaSeconds, + switchFromLiveOffsetSeconds: this.#switchTelemetry.fromLiveOffsetSeconds, + switchToLiveOffsetSeconds: this.#switchTelemetry.toLiveOffsetSeconds, + switchLiveOffsetDeltaSeconds: this.#switchTelemetry.liveOffsetDeltaSeconds, + switchFromGroup: this.#switchTelemetry.fromGroup, + switchToGroup: this.#switchTelemetry.toGroup, + switchGroupDelta: this.#switchTelemetry.groupDelta, + switchAlignmentErrorSeconds: this.#switchTelemetry.alignmentErrorSeconds, }; } @@ -440,6 +601,11 @@ export class Player { videoStruct?.tracker.setAlphas(alphaFast, alphaSlow); } + setMetadataState(ready: boolean, delayMs: number): void { + this.#metadataReady = ready; + this.#metadataDelayMs = Math.max(0, delayMs); + } + /** Poll WebTransport stats for the active video track's goodput tracker. */ async pollGoodput(): Promise { const videoStruct = this.#streams.find(s => this.catalog?.getRole(s.trackName) === 'video'); @@ -478,6 +644,11 @@ export class Player { if (!initData || !role || !codec) { logger.error('media', `switchTrack: missing catalog data for track ${trackName}`); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'error', + settledAtMs: Date.now(), + }; this.#options.onTrackSwitched?.(videoStruct.trackName); return; } @@ -496,6 +667,11 @@ export class Player { `switchTrack: SWITCH rejected for ${trackName}:`, result.errorReason.phrase, ); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'rejected', + settledAtMs: Date.now(), + }; this.#options.onTrackSwitched?.(videoStruct.trackName); return; } @@ -512,9 +688,42 @@ export class Player { // guard) is NOT called here — it fires in the write handler AFTER the relay // has actually delivered data on the new track. This prevents rapid // consecutive SWITCH messages that corrupt the relay's switch context. + const requestedAtMs = Date.now(); + const fromPlaybackTime = this.#element ? this.#element.currentTime : null; + const fromLiveEdgeTime = + this.#element?.buffered && this.#element.buffered.length > 0 + ? this.#element.buffered.end(this.#element.buffered.length - 1) + : null; + const fromLiveOffsetSeconds = + fromLiveEdgeTime !== null && fromPlaybackTime !== null + ? Math.max(0, fromLiveEdgeTime - fromPlaybackTime) + : null; videoStruct.pendingSwitch = { trackName, initData: initData.buffer as ArrayBuffer, mimeType }; + this.#switchTelemetry = { + outcome: 'pending', + fromTrack: videoStruct.trackName, + toTrack: trackName, + requestedAtMs, + settledAtMs: null, + durationMs: null, + fromPlaybackTime, + toPlaybackTime: null, + playbackDeltaSeconds: null, + fromLiveOffsetSeconds, + toLiveOffsetSeconds: null, + liveOffsetDeltaSeconds: null, + fromGroup: videoStruct.lastGroupId >= 0n ? videoStruct.lastGroupId.toString() : null, + toGroup: null, + groupDelta: null, + alignmentErrorSeconds: null, + }; } catch (error) { logger.error('media', 'switchTrack: unexpected error', error); + this.#switchTelemetry = { + ...this.#switchTelemetry, + outcome: 'error', + settledAtMs: Date.now(), + }; this.#options.onTrackSwitched?.(videoStruct.trackName); } } diff --git a/apps/client-js/tsconfig.app.json b/apps/client-js/tsconfig.app.json index c8d5ca6f..aa0170fd 100644 --- a/apps/client-js/tsconfig.app.json +++ b/apps/client-js/tsconfig.app.json @@ -7,7 +7,6 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], "skipLibCheck": true, - "baseUrl": ".", "paths": { "react": ["./node_modules/preact/compat/"], "react-dom": ["./node_modules/preact/compat/"], diff --git a/apps/client-js/vite.config.ts b/apps/client-js/vite.config.ts index 9cbc4138..213dba45 100644 --- a/apps/client-js/vite.config.ts +++ b/apps/client-js/vite.config.ts @@ -25,25 +25,36 @@ function metricsLogPlugin(): Plugin { } let body = ''; - req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); req.on('end', () => { if (!logPath) { fs.mkdirSync(logDir, { recursive: true }); - const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); + const ts = new Date() + .toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .slice(0, 19); logPath = path.join(logDir, `client-metrics_${ts}.csv`); } - if (!headerWritten) { - const header = body.split('\n')[0]; - if (header) { - fs.appendFileSync(logPath, header + '\n'); - headerWritten = true; - } + const lines = body.split('\n'); + const firstLine = lines[0]?.trim() ?? ''; + + // First line is treated as header only when it looks like a CSV header row. + const firstLineIsHeader = firstLine.startsWith('timestamp,'); + if (firstLineIsHeader && !headerWritten && firstLine) { + fs.appendFileSync(logPath, firstLine + '\n'); + headerWritten = true; } - // Append data lines (skip header if present) - const lines = body.split('\n'); - const dataLines = lines.slice(headerWritten ? 1 : 0).filter(l => l.length > 0); + // Append only data lines. If this payload starts with a header, skip exactly that line. + const dataLines = lines + .slice(firstLineIsHeader ? 1 : 0) + .map(line => line.trim()) + .filter(line => line.length > 0); + if (dataLines.length > 0) { fs.appendFileSync(logPath, dataLines.join('\n') + '\n'); } diff --git a/libs/moqtail-ts/tsconfig.json b/libs/moqtail-ts/tsconfig.json index 1b4cecf9..b67c0c4f 100644 --- a/libs/moqtail-ts/tsconfig.json +++ b/libs/moqtail-ts/tsconfig.json @@ -4,7 +4,7 @@ "rootDir": ".", "declaration": false, "noUnusedLocals": false, - "moduleResolution": "node", + "moduleResolution": "bundler", "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "importHelpers": true, @@ -15,7 +15,6 @@ "skipDefaultLibCheck": true, "checkJs": false, "types": ["vitest/importMeta"], - "baseUrl": ".", "paths": { "@": ["./src"], "@/*": ["./src/*"]