diff --git a/README.md b/README.md index ae24129..c07d189 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,12 @@ Viewing: the live portal crops the band; Jellyfin shows it as a thin strip at th Cross-check when it matters: everyone is in the same voice chat, so audio-waveform sync is sample-accurate. Why continuous and not a start burst: a PC clock drifts vs the server (10 ppm ≈ 4 frames over 2 h). +Dropped frames / gaps: OBS keeps stamping frames from its clock, so after a drop the file simply has a hole and +every frame that exists is still at the right time — the timecode stays correct. The sweep **never re-encodes a +stream** to fill holes (hours of CPU); the sidecar lists the gaps (`gaps.count`, `total_missing_ms`, first 50 +positions) so editors know where a clip may need a nudge. Premiere preserves timing on such files by default; +in Resolve check "Timecode" interpretation on gappy clips. Clips (short) are still converted to constant frame rate. + ## Layout on the server /srv/media/streams///.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty) diff --git a/STATUS.md b/STATUS.md index 635bb6f..1b7b86a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -83,6 +83,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea - Caddy runs in a container: anything it serves from disk must be a mount (`/srv/live` was missing → 404). - Go Live quality depends on the browser's encoder: Windows Chrome uses the GPU (fine); Linux Chromium on NVIDIA is software H.264 Baseline (soft at any bitrate, CPU-limited at 1440p60) and cannot be fixed by flags — Linux streamers use OBS. The stats strip shows encoder + profile. - Several videos inside one non-season folder = ONE Jellyfin episode with "versions". Streams are therefore published flat as `<Title>__<ts>.mp4` in the streamer's folder (plugin 0.1.3 parses the title from the name). Four or five helmet cams a night each get their own episode. +- Streams are never re-encoded for VFR/gaps (dropped frames are legitimate; a 3 h re-encode is hours of CPU). The timecode sidecar lists gaps instead. Clips still get CFR. - The sweep's VFR check used a gawk-only function; Debian's mawk made it pass everything silently. Portable now. - A deploy block that is only half-run leaves old binaries in `/usr/local/bin`; `grep -c` the installed script before blaming logic. - MediaMTX hot-reloads `paths`/recording settings but NOT `authMethod`/auth settings: those need a container restart (between streams). diff --git a/bin/normalize-sweep.sh b/bin/normalize-sweep.sh index 9a9fdf9..3f662d1 100755 --- a/bin/normalize-sweep.sh +++ b/bin/normalize-sweep.sh @@ -61,7 +61,9 @@ probe() { med=d[int(n/2)]; if (med<=0) { print "cfr"; exit } bad=0; for(i=0;i<n;i++){ x=d[i]-med; if(x<0)x=-x; if (x > med*0.25) bad++ } print (bad > n*0.02) ? "vfr" : "cfr" }') - [[ $vfr == vfr ]] && { echo "encode vfr"; return; } + # streams: gaps from dropped frames are legitimate and re-encoding a 3 h file is hours of CPU — never VFR-encode + # them (the timecode sidecar lists the gaps instead). Clips (short, often genuinely VFR) still get CFR. + if [[ $vfr == vfr && $f != "$ROOT/streams/"* ]]; then echo "encode vfr"; return; fi local moov mdat; moov=$(grep -o '[0-9]*:moov' <<<"$head" | cut -d: -f1 | head -1); mdat=$(grep -o '[0-9]*:mdat' <<<"$head" | cut -d: -f1 | head -1) if [[ -z $moov || ( -n $mdat && $mdat -lt $moov ) ]]; then echo "remux index-last"; return; fi echo "ok" diff --git a/bin/vgstrip.py b/bin/vgstrip.py index 2bb4287..edea160 100755 --- a/bin/vgstrip.py +++ b/bin/vgstrip.py @@ -68,10 +68,15 @@ def decode_band_row(row: bytes, w: int): def probe(path): out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", - "stream=width,height,avg_frame_rate:format=duration", "-of", "json", path], + "stream=width,height,avg_frame_rate,r_frame_rate:format=duration", "-of", "json", path], capture_output=True, text=True, check=True).stdout j = json.loads(out); st = j["streams"][0] - num, den = st["avg_frame_rate"].split("/"); fps = float(num) / float(den or 1) + # nominal rate (r_frame_rate) — avg_frame_rate is skewed by dropped frames / gaps + def rate(x): + try: num, den = x.split("/"); return float(num) / float(den or 1) + except Exception: return 0.0 + fps = rate(st.get("r_frame_rate", "")) or rate(st.get("avg_frame_rate", "")) or 60.0 + if fps > 240: fps = rate(st.get("avg_frame_rate", "")) or 60.0 # bogus r_frame_rate on some files return int(st["width"]), int(st["height"]), fps, float(j["format"]["duration"]) def sample_window(path, w, h, fps, start_s, dur_s): @@ -120,15 +125,30 @@ def analyse(path, win_s=4.0): return {"source": "strip", "t0_ms": t0, "drift_ms": drift, "fps": fps, "width": w, "height": h, "duration_s": dur, "samples": samples} +def find_gaps(path, fps, max_list=50): + """Holes in the video timeline (dropped frames): deltas > 1.5 frame periods. Demux only, fast.""" + out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time", + "-of", "csv=p=0", path], capture_output=True, text=True).stdout + pts = sorted(float(x.strip(",").split(",")[0]) for x in out.split() if x.strip(",") not in ("", "N/A")) + period = 1.0 / (fps or 60.0) + gaps, total = [], 0.0 + for a, b in zip(pts, pts[1:]): + d = b - a + if d > period * 1.5: + total += d - period + if len(gaps) < max_list: gaps.append({"at_s": round(a, 3), "missing_ms": int(round((d - period) * 1000))}) + return {"count": sum(1 for a, b in zip(pts, pts[1:]) if b - a > period * 1.5), "total_missing_ms": int(round(total * 1000)), "first": gaps} + def timecode_string(t0_ms, fps, tz="America/New_York"): """Time-of-day timecode for the tmcd track: HH:MM:SS:FF (drop-frame ';' for 29.97/59.94).""" from datetime import datetime, timezone from zoneinfo import ZoneInfo local = datetime.fromtimestamp(t0_ms / 1000.0, timezone.utc).astimezone(ZoneInfo(tz)) frac = (t0_ms % 1000) / 1000.0 - nominal = round(fps) + nominal = max(1, round(fps)) ff = min(nominal - 1, int(frac * fps)) - sep = ";" if abs(fps - nominal) > 0.01 else ":" + drop = any(abs(fps - r) < 0.05 for r in (29.97, 59.94, 23.976)) # true NTSC rates only + sep = ";" if drop else ":" return f"{local:%H:%M:%S}{sep}{ff:02d}", local def plan(path, arrival_ms=None, tz="America/New_York"): @@ -142,14 +162,18 @@ def plan(path, arrival_ms=None, tz="America/New_York"): else: t0 = None; src = "none" out = {"source": src, "fps": r.get("fps"), "drift_ms": r.get("drift_ms", 0), "samples": r.get("samples", [])} + try: + out["gaps"] = find_gaps(path, r.get("fps") or 60.0) + except Exception: + pass if t0 is not None: tc, local = timecode_string(t0, r.get("fps") or 60.0, tz) utc = datetime.fromtimestamp(t0 / 1000.0, timezone.utc) out.update({"t0_ms": t0, "timestamp": utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t0 % 1000:03d}Z", "timecode": tc, "timecode_tz": tz, "local_start": local.isoformat()}) - print(f"TC_TIMECODE={tc}") - print(f"TC_CREATION={utc.strftime('%Y-%m-%dT%H:%M:%S')}.{t0 % 1000:03d}Z") - print(f"TC_SOURCE={src}") + print(f"TC_TIMECODE='{tc}'") # quoted: drop-frame uses ';' which the shell would split on + print(f"TC_CREATION='{utc.strftime('%Y-%m-%dT%H:%M:%S')}.{t0 % 1000:03d}Z'") + print(f"TC_SOURCE='{src}'") print("TC_JSON=" + json.dumps(json.dumps(out))) # double-encoded: shell-safe single token return out