lesavka/scripts/manual/run_local_hevc_encoder_preflight.sh

168 lines
6.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Manual: local HEVC encoder throughput preflight for upstream transport work; not part of CI.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUTPUT_DIR="${LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_OUTPUT_DIR:-/tmp/lesavka-local-hevc-encoder-preflight-${STAMP}}"
SUMMARY_JSON="${LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_JSON:-${OUTPUT_DIR}/hevc-encoder-preflight.json}"
RUN_LOG="${OUTPUT_DIR}/hevc-encoder-preflight.log"
MODES="${LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_MODES:-1280x720@20,1280x720@30,1920x1080@20,1920x1080@30}"
DURATION_SECONDS="${LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_SECONDS:-5}"
BITRATE_KBIT="${LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_KBIT:-3000}"
MIN_REALTIME_FACTOR="${LESAVKA_LOCAL_HEVC_ENCODER_PREFLIGHT_MIN_REALTIME_FACTOR:-1.05}"
ENCODER="${LESAVKA_LOCAL_HEVC_ENCODER:-auto}"
mkdir -p "${OUTPUT_DIR}"
echo "==> local HEVC encoder preflight"
echo " ↪ artifact_dir=${OUTPUT_DIR}"
echo " ↪ summary_json=${SUMMARY_JSON}"
echo " ↪ run_log=${RUN_LOG}"
echo " ↪ modes=${MODES}"
echo " ↪ duration=${DURATION_SECONDS}s bitrate=${BITRATE_KBIT}kbit min_realtime_factor=${MIN_REALTIME_FACTOR}"
echo " ↪ no remote host, sudo, tunnel, or RCT capture is used"
(
cd "${REPO_ROOT}"
python3 - "$OUTPUT_DIR" "$SUMMARY_JSON" "$MODES" "$DURATION_SECONDS" "$BITRATE_KBIT" "$MIN_REALTIME_FACTOR" "$ENCODER" <<'PY'
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
output_dir = Path(sys.argv[1])
summary_json = Path(sys.argv[2])
modes = [mode.strip() for mode in sys.argv[3].split(",") if mode.strip()]
duration_seconds = float(sys.argv[4])
bitrate_kbit = int(sys.argv[5])
min_realtime_factor = float(sys.argv[6])
encoder_override = sys.argv[7].strip()
ENCODER_ORDER = ["nvh265enc", "vah265enc", "vaapih265enc", "v4l2h265enc", "x265enc"]
MODE_RE = re.compile(r"^([0-9]+)x([0-9]+)@([0-9]+)$")
def gst_has(element: str) -> bool:
return subprocess.run(
["gst-inspect-1.0", element],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
def pick_encoder() -> str:
if encoder_override and encoder_override != "auto":
if not gst_has(encoder_override):
raise SystemExit(f"requested HEVC encoder is unavailable: {encoder_override}")
return encoder_override
for candidate in ENCODER_ORDER:
if gst_has(candidate):
return candidate
raise SystemExit("no supported HEVC encoder found")
def encoder_chain(encoder: str, fps: int) -> str:
if encoder == "x265enc":
return (
f"x265enc tune=zerolatency speed-preset=ultrafast "
f"bitrate={bitrate_kbit} key-int-max={max(fps, 1)} log-level=none"
)
# Hardware encoder property names vary by driver. Use the bare element for
# this availability/throughput preflight rather than failing on a missing
# low-latency property that the runtime selector already probes separately.
return encoder
def has_annex_b(path: Path) -> bool:
data = path.read_bytes()
return b"\x00\x00\x00\x01" in data or b"\x00\x00\x01" in data
def run_mode(encoder: str, mode: str) -> dict:
match = MODE_RE.match(mode)
if not match:
raise SystemExit(f"invalid mode: {mode}")
width, height, fps = map(int, match.groups())
frame_count = max(1, int(round(duration_seconds * fps)))
media_seconds = frame_count / fps
out_path = output_dir / f"{mode.replace('@', '_')}-{encoder}.h265"
pipeline = (
f"videotestsrc num-buffers={frame_count} is-live=false pattern=smpte ! "
f"video/x-raw,format=I420,width={width},height={height},framerate={fps}/1 ! "
f"{encoder_chain(encoder, fps)} ! "
"h265parse config-interval=-1 ! "
"video/x-h265,stream-format=byte-stream,alignment=au ! "
f"filesink location={out_path}"
)
timeout_seconds = max(10.0, media_seconds * 4.0)
start = time.monotonic()
proc = subprocess.run(
["timeout", f"{timeout_seconds:.1f}", "gst-launch-1.0", "-q", *pipeline.split()],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
elapsed = max(time.monotonic() - start, 0.001)
encoded_bytes = out_path.stat().st_size if out_path.exists() else 0
realtime_factor = media_seconds / elapsed
annex_b = encoded_bytes > 0 and has_annex_b(out_path)
status = (
"pass"
if proc.returncode == 0 and encoded_bytes > 0 and annex_b and realtime_factor >= min_realtime_factor
else "fail"
)
return {
"mode": mode,
"width": width,
"height": height,
"fps": fps,
"frames": frame_count,
"media_seconds": round(media_seconds, 3),
"elapsed_seconds": round(elapsed, 3),
"realtime_factor": round(realtime_factor, 3),
"bytes": encoded_bytes,
"annex_b": annex_b,
"status": status,
"artifact": str(out_path),
"stderr_tail": "\n".join(proc.stderr.splitlines()[-12:]),
}
encoder = pick_encoder()
results = [run_mode(encoder, mode) for mode in modes]
summary = {
"schema": "lesavka.local-hevc-encoder-preflight.v1",
"encoder": encoder,
"duration_seconds": duration_seconds,
"bitrate_kbit": bitrate_kbit,
"min_realtime_factor": min_realtime_factor,
"verdict": "pass" if all(row["status"] == "pass" for row in results) else "fail",
"results": results,
}
summary_json.parent.mkdir(parents=True, exist_ok=True)
summary_json.write_text(json.dumps(summary, indent=2) + "\n")
print(f"encoder={encoder}")
for row in results:
print(
f"{row['status'].upper()} {row['mode']}: "
f"frames={row['frames']} elapsed={row['elapsed_seconds']:.3f}s "
f"rtx={row['realtime_factor']:.3f} bytes={row['bytes']} annex_b={row['annex_b']}"
)
if summary["verdict"] != "pass":
raise SystemExit("local HEVC encoder preflight failed")
PY
) 2>&1 | tee "${RUN_LOG}"
echo "==> done"
echo "artifact_dir: ${OUTPUT_DIR}"
echo "summary_json: ${SUMMARY_JSON}"
echo "run_log: ${RUN_LOG}"