168 lines
6.0 KiB
Bash
Executable File
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}"
|