Compare commits
5 Commits
3fcb8fbe99
...
f1d19ba32d
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d19ba32d | |||
| 9dc81ae5aa | |||
| 4101498ad7 | |||
| 473247e710 | |||
| f36487d4b5 |
@@ -357,6 +357,17 @@ Checklist if adopted:
|
|||||||
- [ ] per-user clips volume in `copyparty.conf` once forward-auth is live (Phase 5)
|
- [ ] per-user clips volume in `copyparty.conf` once forward-auth is live (Phase 5)
|
||||||
|
|
||||||
### Later — when it hurts
|
### Later — when it hurts
|
||||||
|
|
||||||
|
- [ ] **Off-site gap filling** (instead of burning the SYS-1's CPU): streams whose sidecar reports
|
||||||
|
`gaps.total_missing_ms` ≥ threshold are re-encoded elsewhere with a hardware encoder.
|
||||||
|
- the sweep keeps the raw recording in `.src/` and the sidecar flags it (`needs_fill: true`)
|
||||||
|
- a nightly job on the home box (RTX 2080 Ti, NVENC) or a Spot GPU instance pulls flagged raws over
|
||||||
|
SFTP, re-encodes to CFR with duplicated frames (same editing standard, `tmcd` + `creation_time`
|
||||||
|
carried from the sidecar), pushes the result back over the published file, clears the flag
|
||||||
|
- Jellyfin keeps the same path → item identity and watched state survive; the plugin re-reads metadata
|
||||||
|
- until then: `FILL_GAPS_MS` stays off; players hold frames across gaps, editors see the gap list
|
||||||
|
- [ ] **SYS-3 (E-2288G, Quick Sync)** — the 2×6 TB disk option is currently unavailable; re-check and swap when
|
||||||
|
it returns. Then: Jellyfin transcoding on, sweep re-encodes in minutes, `FILL_GAPS_MS` can simply be on.
|
||||||
- [ ] **SYS-3 (E-2288G)**: Quick Sync → Jellyfin transcoding on, sweep re-encodes in minutes.
|
- [ ] **SYS-3 (E-2288G)**: Quick Sync → Jellyfin transcoding on, sweep re-encodes in minutes.
|
||||||
Swap, not redesign: same compose, same `/srv` layout; restore `/srv/appdata` + `/srv/media`.
|
Swap, not redesign: same compose, same `/srv` layout; restore `/srv/appdata` + `/srv/media`.
|
||||||
- [ ] home archive puller (`home/rclone-archive.sh`) on the home box, nightly cron
|
- [ ] home archive puller (`home/rclone-archive.sh`) on the home box, nightly cron
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -315,6 +315,24 @@ 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 has a hole and every
|
||||||
|
frame that exists is still at the right time — the timecode stays correct, and players (Jellyfin, the portal)
|
||||||
|
hold the last frame across the hole with audio in sync. By default the sweep does **not** re-encode a stream to
|
||||||
|
fill holes (hours of CPU on this box); the sidecar lists them (`gaps.count`, `total_missing_ms`, first 50
|
||||||
|
positions). Premiere preserves timing on such files; in Resolve check the clip's frame-rate interpretation.
|
||||||
|
**Gap filling (available, off by default).** The sweep can re-encode a stream whose holes total at least
|
||||||
|
`FILL_GAPS_MS` milliseconds to constant frame rate with duplicated frames (raw kept in `.src/`, timecode carried
|
||||||
|
over). It costs ~2× the stream length in CPU on the E-2136, so it is off until the box has a hardware encoder.
|
||||||
|
Turn it on (threshold 1 s) / off, on the server:
|
||||||
|
|
||||||
|
sudo sed -i 's|^#* *Environment=FILL_GAPS_MS=.*|Environment=FILL_GAPS_MS=1000|' /etc/systemd/system/normalize-sweep.service && sudo systemctl daemon-reload
|
||||||
|
sudo sed -i 's|^Environment=FILL_GAPS_MS=.*|# Environment=FILL_GAPS_MS=1000|' /etc/systemd/system/normalize-sweep.service && sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
Check: `systemctl show normalize-sweep -p Environment`. Takes effect on the next sweep run; already-published
|
||||||
|
files are not revisited. Clips are always converted to CFR regardless.
|
||||||
|
Planned alternative: re-encode flagged streams off-site on a machine with a hardware encoder (home box NVENC or a
|
||||||
|
Spot GPU) and push the result back — see CommunityDiscordMedia.md → Later.
|
||||||
|
|
||||||
## 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)
|
||||||
|
|||||||
@@ -76,13 +76,14 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
8. Plugin repository served by Caddy so Jellyfin updates from its catalog.
|
8. Plugin repository served by Caddy so Jellyfin updates from its catalog.
|
||||||
9. Clip submissions (belongs to the bot). Bot code from the other dev.
|
9. Clip submissions (belongs to the bot). Bot code from the other dev.
|
||||||
10. Authentik blueprint from `.env` (Discord source, groups, policies, provider) for reproducible installs.
|
10. Authentik blueprint from `.env` (Discord source, groups, policies, provider) for reproducible installs.
|
||||||
11. SYS-3 when transcoding or sweep speed hurts.
|
11. SYS-3 when transcoding or sweep speed hurts (2×6 TB option currently unavailable; watching for it). Interim option documented: off-site gap filling on the home NVENC box.
|
||||||
|
|
||||||
## Verified behaviours worth remembering
|
## Verified behaviours worth remembering
|
||||||
- **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only.
|
- **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only.
|
||||||
- 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 not re-encoded for VFR/gaps by default (dropped frames are legitimate; a 3 h re-encode is hours of CPU); players hold the last frame across a gap. The timecode sidecar lists gaps. **Gap filling is available** (`FILL_GAPS_MS` in `normalize-sweep.service`, README "Timecode") — turn on when the box has a hardware encoder. Clips always 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).
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ set -uo pipefail
|
|||||||
|
|
||||||
ROOT="${MEDIA_ROOT:-/srv/media}"
|
ROOT="${MEDIA_ROOT:-/srv/media}"
|
||||||
MINS_IDLE=3
|
MINS_IDLE=3
|
||||||
|
# Gap filling for STREAMS: re-encode to constant frame rate (duplicating frames across dropped-frame holes) when
|
||||||
|
# the total missing time is >= this many ms. 0 = never (default; a 3 h re-encode is ~6 h of CPU on the E-2136).
|
||||||
|
# Set via systemd Environment=FILL_GAPS_MS=... ; viewers already see held frames either way.
|
||||||
|
FILL_GAPS_MS="${FILL_GAPS_MS:-0}"
|
||||||
MAX_W=2560; MAX_H=1440; MAX_FPS=60
|
MAX_W=2560; MAX_H=1440; MAX_FPS=60
|
||||||
exec 9>"${LOCK_FILE:-/run/lock/normalize-sweep.lock}"
|
exec 9>"${LOCK_FILE:-/run/lock/normalize-sweep.lock}"
|
||||||
flock -n 9 || exit 0
|
flock -n 9 || exit 0
|
||||||
@@ -61,7 +65,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"
|
||||||
@@ -126,6 +132,13 @@ PYEOF
|
|||||||
tcjson="${TC_JSON:-}"
|
tcjson="${TC_JSON:-}"
|
||||||
log "tc $src source=$TC_SOURCE timecode=$TC_TIMECODE"
|
log "tc $src source=$TC_SOURCE timecode=$TC_TIMECODE"
|
||||||
fi
|
fi
|
||||||
|
# opt-in gap filling: switch this stream from remux to a CFR re-encode when the holes add up to enough
|
||||||
|
if (( FILL_GAPS_MS > 0 )) && [[ $verdict == remux && -n $tcjson ]]; then
|
||||||
|
missing=$(python3 -c 'import json,sys; d=json.loads(sys.argv[1]); print(d.get("gaps",{}).get("total_missing_ms",0))' "$tcjson" 2>/dev/null || echo 0)
|
||||||
|
if (( missing >= FILL_GAPS_MS )); then
|
||||||
|
verdict=encode; reason="fill-gaps ${missing}ms"; log "gaps $src ${missing} ms missing -> CFR re-encode (FILL_GAPS_MS=$FILL_GAPS_MS)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ $verdict == remux ]]; then
|
if [[ $verdict == remux ]]; then
|
||||||
nice -n 15 ionice -c 3 ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \
|
nice -n 15 ionice -c 3 ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \
|
||||||
@@ -150,7 +163,13 @@ PYEOF
|
|||||||
if [[ -n $tcjson ]]; then # sidecar: exact capture time (read by the Jellyfin plugin) + drift samples
|
if [[ -n $tcjson ]]; then # sidecar: exact capture time (read by the Jellyfin plugin) + drift samples
|
||||||
printf '%s\n' "$tcjson" > "${out%.*}.json"; chown 1000:1000 "${out%.*}.json" 2>/dev/null
|
printf '%s\n' "$tcjson" > "${out%.*}.json"; chown 1000:1000 "${out%.*}.json" 2>/dev/null
|
||||||
fi
|
fi
|
||||||
rm -f "$src"; rmdir "$(dirname "$src")" "$titledir" 2>/dev/null || true # drop .live and the empty title folder
|
if [[ $verdict == encode ]]; then # gap-filled re-encode: keep the raw recording in the hidden .src/
|
||||||
|
srcdir="$(dirname "$out")/.src"; mkdir -p "$srcdir"; chown 1000:1000 "$srcdir" 2>/dev/null
|
||||||
|
mv -f "$src" "$srcdir/$(basename "$out" .mp4).raw.mp4"
|
||||||
|
else
|
||||||
|
rm -f "$src"
|
||||||
|
fi
|
||||||
|
rmdir "$(dirname "$src")" "$titledir" 2>/dev/null || true # drop .live and the empty title folder
|
||||||
log "done $out"; continue
|
log "done $out"; continue
|
||||||
elif (( synced )); then
|
elif (( synced )); then
|
||||||
rel_src="${src#"$stroot"/}"; rel_out="${out#"$stroot"/}"
|
rel_src="${src#"$stroot"/}"; rel_out="${out#"$stroot"/}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ Type=oneshot
|
|||||||
ExecStart=/usr/local/bin/normalize-sweep.sh
|
ExecStart=/usr/local/bin/normalize-sweep.sh
|
||||||
Nice=15
|
Nice=15
|
||||||
IOSchedulingClass=idle
|
IOSchedulingClass=idle
|
||||||
|
# Environment=FILL_GAPS_MS=1000 # opt-in: CFR re-encode streams whose dropped-frame holes total >= 1 s (hours of CPU on this box)
|
||||||
|
|||||||
Reference in New Issue
Block a user