Timecode: nominal fps + proper drop-frame detection, shell-safe plan output; streams never VFR-re-encoded; gap list in the sidecar; docs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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).
|
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
|
## Layout on the server
|
||||||
|
|
||||||
/srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty)
|
/srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty)
|
||||||
|
|||||||
@@ -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).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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).
|
- MediaMTX hot-reloads `paths`/recording settings but NOT `authMethod`/auth settings: those need a container restart (between streams).
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ probe() {
|
|||||||
med=d[int(n/2)]; if (med<=0) { print "cfr"; exit }
|
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++ }
|
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" }')
|
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)
|
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
|
if [[ -z $moov || ( -n $mdat && $mdat -lt $moov ) ]]; then echo "remux index-last"; return; fi
|
||||||
echo "ok"
|
echo "ok"
|
||||||
|
|||||||
@@ -68,10 +68,15 @@ def decode_band_row(row: bytes, w: int):
|
|||||||
|
|
||||||
def probe(path):
|
def probe(path):
|
||||||
out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
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
|
capture_output=True, text=True, check=True).stdout
|
||||||
j = json.loads(out); st = j["streams"][0]
|
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"])
|
return int(st["width"]), int(st["height"]), fps, float(j["format"]["duration"])
|
||||||
|
|
||||||
def sample_window(path, w, h, fps, start_s, dur_s):
|
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,
|
return {"source": "strip", "t0_ms": t0, "drift_ms": drift, "fps": fps, "width": w, "height": h,
|
||||||
"duration_s": dur, "samples": samples}
|
"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"):
|
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)."""
|
"""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 datetime import datetime, timezone
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
local = datetime.fromtimestamp(t0_ms / 1000.0, timezone.utc).astimezone(ZoneInfo(tz))
|
local = datetime.fromtimestamp(t0_ms / 1000.0, timezone.utc).astimezone(ZoneInfo(tz))
|
||||||
frac = (t0_ms % 1000) / 1000.0
|
frac = (t0_ms % 1000) / 1000.0
|
||||||
nominal = round(fps)
|
nominal = max(1, round(fps))
|
||||||
ff = min(nominal - 1, int(frac * 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
|
return f"{local:%H:%M:%S}{sep}{ff:02d}", local
|
||||||
|
|
||||||
def plan(path, arrival_ms=None, tz="America/New_York"):
|
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:
|
else:
|
||||||
t0 = None; src = "none"
|
t0 = None; src = "none"
|
||||||
out = {"source": src, "fps": r.get("fps"), "drift_ms": r.get("drift_ms", 0), "samples": r.get("samples", [])}
|
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:
|
if t0 is not None:
|
||||||
tc, local = timecode_string(t0, r.get("fps") or 60.0, tz)
|
tc, local = timecode_string(t0, r.get("fps") or 60.0, tz)
|
||||||
utc = datetime.fromtimestamp(t0 / 1000.0, timezone.utc)
|
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",
|
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()})
|
"timecode": tc, "timecode_tz": tz, "local_start": local.isoformat()})
|
||||||
print(f"TC_TIMECODE={tc}")
|
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_CREATION='{utc.strftime('%Y-%m-%dT%H:%M:%S')}.{t0 % 1000:03d}Z'")
|
||||||
print(f"TC_SOURCE={src}")
|
print(f"TC_SOURCE='{src}'")
|
||||||
print("TC_JSON=" + json.dumps(json.dumps(out))) # double-encoded: shell-safe single token
|
print("TC_JSON=" + json.dumps(json.dumps(out))) # double-encoded: shell-safe single token
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user