#!/usr/bin/env bash # scripts/manual/run_hardware_media_smoke.sh # Manual: local/remote hardware media smoke evidence; not part of CI. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" STAMP="$(date +%Y%m%d-%H%M%S)" ARTIFACT_DIR="${LESAVKA_HARDWARE_SMOKE_DIR:-/tmp/lesavka-hardware-media-smoke-${STAMP}}" RESULTS_TSV="${ARTIFACT_DIR}/results.tsv" SUMMARY_JSON="${ARTIFACT_DIR}/summary.json" SUMMARY_TXT="${ARTIFACT_DIR}/summary.txt" UPSTREAM_HEVC_FILE="${ARTIFACT_DIR}/upstream-hevc-nvenc.hevc" UPSTREAM_HEVC_FRAME="${ARTIFACT_DIR}/upstream-hevc-cuda-frame.png" DOWNSTREAM_H264_FILE="${ARTIFACT_DIR}/downstream-h264-nvenc.h264" DOWNSTREAM_H264_FRAME="${ARTIFACT_DIR}/downstream-h264-cuda-frame.png" AUDIO_WAV="${ARTIFACT_DIR}/audio-aac-roundtrip.wav" AUDIO_RMS_JSON="${ARTIFACT_DIR}/audio-aac-roundtrip-rms.json" SMOKE_WIDTH="${LESAVKA_HARDWARE_SMOKE_WIDTH:-1280}" SMOKE_HEIGHT="${LESAVKA_HARDWARE_SMOKE_HEIGHT:-720}" SMOKE_FPS="${LESAVKA_HARDWARE_SMOKE_FPS:-30}" SMOKE_FRAMES="${LESAVKA_HARDWARE_SMOKE_FRAMES:-90}" SMOKE_BITRATE_KBPS="${LESAVKA_HARDWARE_SMOKE_BITRATE_KBPS:-5000}" mkdir -p "${ARTIFACT_DIR}" : >"${RESULTS_TSV}" OVERALL=0 sanitize() { printf '%s' "$*" | tr '\t\r\n' ' ' } record_result() { local name="$1" local status="$2" local detail="$3" local artifact="${4:-}" printf '%s\t%s\t%s\t%s\n' \ "${name}" \ "${status}" \ "$(sanitize "${detail}")" \ "${artifact}" >>"${RESULTS_TSV}" } have_command() { command -v "$1" >/dev/null 2>&1 } gst_has() { gst-inspect-1.0 "$1" >/dev/null 2>&1 } ffmpeg_has_encoder() { local codec="$1" ffmpeg -hide_banner -encoders 2>/dev/null \ | awk -v codec="${codec}" '$2 == codec { found = 1 } END { exit found ? 0 : 1 }' } ffmpeg_has_decoder() { local codec="$1" ffmpeg -hide_banner -decoders 2>/dev/null \ | awk -v codec="${codec}" '$2 == codec { found = 1 } END { exit found ? 0 : 1 }' } run_logged() { local name="$1" local detail="$2" local rc=0 shift 2 local log="${ARTIFACT_DIR}/${name}.log" echo "==> ${name}" if "$@" >"${log}" 2>&1; then record_result "${name}" "pass" "${detail}" "${log}" echo " pass" return 0 else rc=$? fi record_result "${name}" "fail" "${detail}; exit=${rc}" "${log}" echo " fail (exit ${rc})" sed -n '1,120p' "${log}" | sed 's/^/ | /' OVERALL=1 return 0 } run_shell() { local name="$1" local detail="$2" local command_text="$3" run_logged "${name}" "${detail}" bash -o pipefail -c "${command_text}" } finish_summary() { python3 - "${RESULTS_TSV}" "${SUMMARY_JSON}" "${SUMMARY_TXT}" "${ARTIFACT_DIR}" \ "${UPSTREAM_HEVC_FILE}" "${UPSTREAM_HEVC_FRAME}" \ "${DOWNSTREAM_H264_FILE}" "${DOWNSTREAM_H264_FRAME}" \ "${AUDIO_WAV}" "${AUDIO_RMS_JSON}" <<'PY' import json import pathlib import sys results_tsv = pathlib.Path(sys.argv[1]) summary_json = pathlib.Path(sys.argv[2]) summary_txt = pathlib.Path(sys.argv[3]) artifact_dir = pathlib.Path(sys.argv[4]) artifact_paths = { "upstream_hevc_stream": sys.argv[5], "upstream_hevc_cuda_frame": sys.argv[6], "downstream_h264_stream": sys.argv[7], "downstream_h264_cuda_frame": sys.argv[8], "audio_aac_roundtrip_wav": sys.argv[9], "audio_aac_roundtrip_rms": sys.argv[10], } results = [] for line in results_tsv.read_text(encoding="utf-8").splitlines(): name, status, detail, artifact = (line.split("\t") + ["", "", "", ""])[:4] results.append( { "name": name, "status": status, "detail": detail, "artifact": artifact, } ) failed = [row for row in results if row["status"] == "fail"] summary = { "status": "fail" if failed else "pass", "artifact_dir": str(artifact_dir), "results": results, "artifacts": artifact_paths, } summary_json.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8") lines = [ "Lesavka hardware media smoke summary", f"status: {summary['status']}", f"artifact_dir: {artifact_dir}", "", ] for row in results: artifact = f" ({row['artifact']})" if row["artifact"] else "" lines.append(f"- {row['status']}: {row['name']} - {row['detail']}{artifact}") lines.extend(["", "Inspectable artifacts:"]) for name, path in artifact_paths.items(): lines.append(f"- {name}: {path}") summary_txt.write_text("\n".join(lines) + "\n", encoding="utf-8") PY echo "==> hardware media smoke summary" sed -n '1,160p' "${SUMMARY_TXT}" echo "summary_json: ${SUMMARY_JSON}" echo "summary_txt: ${SUMMARY_TXT}" } require_prereqs() { local missing=() for cmd in ffmpeg gst-inspect-1.0 gst-launch-1.0 python3 awk grep; do if ! have_command "${cmd}"; then missing+=("${cmd}") fi done if ((${#missing[@]})); then record_result "prerequisites" "fail" "missing commands: ${missing[*]}" "" OVERALL=1 return 1 fi record_result "prerequisites" "pass" "ffmpeg, GStreamer, and Python are available" "" return 0 } select_gst_h264_decoder() { local decoder for decoder in nvh264dec nvh264sldec vah264dec vaapih264dec vaapi264dec v4l2h264dec v4l2slh264dec; do if gst_has "${decoder}"; then printf '%s\n' "${decoder}" return 0 fi done if [[ ${LESAVKA_ALLOW_VULKAN_H264_DECODER:-0} == 1 ]] && gst_has vulkanh264dec; then printf '%s\n' vulkanh264dec return 0 fi return 1 } gst_h264_decode_chain() { case "$1" in vulkanh264dec) printf '%s\n' 'vulkanh264dec discard-corrupted-frames=true automatic-request-sync-points=true ! vulkandownload' ;; nvh264sldec|nvh264dec|vah264dec|vaapih264dec|vaapi264dec|v4l2h264dec|v4l2slh264dec) printf '%s\n' "$1" ;; *) return 1 ;; esac } select_gst_aac_encoder() { local encoder for encoder in fdkaacenc voaacenc avenc_aac; do if gst_has "${encoder}"; then printf '%s\n' "${encoder}" return 0 fi done return 1 } main() { echo "==> Lesavka hardware media smoke" echo " artifact_dir=${ARTIFACT_DIR}" echo " video=${SMOKE_WIDTH}x${SMOKE_HEIGHT}@${SMOKE_FPS} frames=${SMOKE_FRAMES}" echo " no sudo, no systemctl, no UVC gadget reset, and no display-manager reset are used" if ! require_prereqs; then finish_summary exit 1 fi if ! ffmpeg_has_encoder hevc_nvenc; then record_result "client_upstream_hevc_nvenc_available" "fail" "ffmpeg hevc_nvenc encoder is missing" "" OVERALL=1 else record_result "client_upstream_hevc_nvenc_available" "pass" "ffmpeg hevc_nvenc encoder is available" "" fi if ! ffmpeg_has_encoder h264_nvenc; then record_result "client_downstream_h264_nvenc_source_available" "fail" "ffmpeg h264_nvenc encoder is missing for downstream test source" "" OVERALL=1 else record_result "client_downstream_h264_nvenc_source_available" "pass" "ffmpeg h264_nvenc encoder is available for downstream test source" "" fi if ! ffmpeg_has_decoder hevc_cuvid; then record_result "client_hevc_cuvid_visual_decode_available" "fail" "ffmpeg hevc_cuvid decoder is missing for visual evidence frame" "" OVERALL=1 else record_result "client_hevc_cuvid_visual_decode_available" "pass" "ffmpeg hevc_cuvid decoder is available for visual evidence frame" "" fi if ! ffmpeg_has_decoder h264_cuvid; then record_result "client_h264_cuvid_visual_decode_available" "fail" "ffmpeg h264_cuvid decoder is missing for visual evidence frame" "" OVERALL=1 else record_result "client_h264_cuvid_visual_decode_available" "pass" "ffmpeg h264_cuvid decoder is available for visual evidence frame" "" fi local gst_h264_decoder="" local gst_h264_chain="" if gst_h264_decoder="$(select_gst_h264_decoder)"; then gst_h264_chain="$(gst_h264_decode_chain "${gst_h264_decoder}")" record_result "client_downstream_gstreamer_h264_hw_decoder_available" "pass" "selected ${gst_h264_decoder}" "" else record_result "client_downstream_gstreamer_h264_hw_decoder_available" "fail" "no GStreamer hardware H.264 decoder found" "" OVERALL=1 fi local aac_encoder="" if aac_encoder="$(select_gst_aac_encoder)" && gst_has avdec_aac && gst_has wavenc; then record_result "audio_roundtrip_elements_available" "pass" "selected ${aac_encoder} -> avdec_aac -> wavenc" "" else record_result "audio_roundtrip_elements_available" "fail" "AAC encoder/decoder/wavenc elements are missing" "" OVERALL=1 fi if [[ ${OVERALL} -eq 0 ]]; then run_shell "client_upstream_hevc_nvenc_file" \ "GPU encodes a synthetic upstream HEVC stream with hevc_nvenc" \ "ffmpeg -hide_banner -loglevel warning -y -nostdin -f lavfi -i testsrc2=size=${SMOKE_WIDTH}x${SMOKE_HEIGHT}:rate=${SMOKE_FPS} -frames:v ${SMOKE_FRAMES} -an -sn -dn -vf format=nv12 -c:v hevc_nvenc -preset p1 -tune ll -rc cbr -b:v ${SMOKE_BITRATE_KBPS}k -maxrate ${SMOKE_BITRATE_KBPS}k -bufsize ${SMOKE_BITRATE_KBPS}k -g ${SMOKE_FPS} -bf 0 -forced-idr 1 -f hevc '${UPSTREAM_HEVC_FILE}'" run_shell "client_upstream_hevc_gstreamer_parse" \ "GStreamer accepts the HEVC elementary stream shape used by the upstream bundle path" \ "gst-launch-1.0 -q filesrc location='${UPSTREAM_HEVC_FILE}' ! h265parse config-interval=-1 ! video/x-h265,stream-format=byte-stream,alignment=au ! fakesink sync=false" run_shell "client_upstream_hevc_cuvid_frame" \ "CUDA decodes one visual proof frame from the upstream HEVC stream" \ "ffmpeg -hide_banner -loglevel warning -y -nostdin -c:v hevc_cuvid -i '${UPSTREAM_HEVC_FILE}' -frames:v 1 '${UPSTREAM_HEVC_FRAME}'" run_shell "client_downstream_h264_nvenc_file" \ "GPU creates a downstream-like H.264 elementary stream for decoder verification" \ "ffmpeg -hide_banner -loglevel warning -y -nostdin -f lavfi -i testsrc2=size=${SMOKE_WIDTH}x${SMOKE_HEIGHT}:rate=${SMOKE_FPS} -frames:v ${SMOKE_FRAMES} -an -sn -dn -vf format=nv12 -c:v h264_nvenc -preset p1 -tune ll -rc cbr -b:v ${SMOKE_BITRATE_KBPS}k -maxrate ${SMOKE_BITRATE_KBPS}k -bufsize ${SMOKE_BITRATE_KBPS}k -g ${SMOKE_FPS} -bf 0 -forced-idr 1 -f h264 '${DOWNSTREAM_H264_FILE}'" run_shell "client_downstream_h264_gstreamer_hw_decode" \ "GStreamer decodes H.264 with hardware decoder ${gst_h264_decoder}" \ "gst-launch-1.0 -q filesrc location='${DOWNSTREAM_H264_FILE}' ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! ${gst_h264_chain} ! videoconvert ! fakesink sync=false" run_shell "client_downstream_h264_cuvid_frame" \ "CUDA decodes one visual proof frame from the downstream H.264 stream" \ "ffmpeg -hide_banner -loglevel warning -y -nostdin -c:v h264_cuvid -i '${DOWNSTREAM_H264_FILE}' -frames:v 1 '${DOWNSTREAM_H264_FRAME}'" run_shell "audio_aac_roundtrip_wav" \ "GStreamer encodes and decodes a 1 kHz tone to a WAV artifact for audio-path sanity" \ "gst-launch-1.0 -q audiotestsrc wave=sine freq=1000 num-buffers=120 ! audio/x-raw,format=S16LE,channels=2,rate=48000 ! audioconvert ! audioresample ! ${aac_encoder} ! aacparse ! avdec_aac ! audioconvert ! audioresample ! audio/x-raw,format=S16LE,channels=2,rate=48000 ! wavenc ! filesink location='${AUDIO_WAV}'" run_shell "audio_aac_roundtrip_rms" \ "Decoded WAV contains non-silent audio energy" \ "python3 - '${AUDIO_WAV}' '${AUDIO_RMS_JSON}' <<'PY' import json import math import pathlib import struct import sys import wave wav_path = pathlib.Path(sys.argv[1]) out_path = pathlib.Path(sys.argv[2]) with wave.open(str(wav_path), 'rb') as wav: frames = wav.readframes(wav.getnframes()) sample_count = len(frames) // 2 samples = struct.unpack('<' + 'h' * sample_count, frames) rms = math.sqrt(sum(sample * sample for sample in samples) / max(sample_count, 1)) summary = {'rms': rms, 'sample_count': sample_count, 'wav': str(wav_path)} out_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + '\\n', encoding='utf-8') if rms < 500: raise SystemExit(f'audio RMS too low: {rms:.2f}') print(json.dumps(summary, sort_keys=True)) PY" fi if [[ ${LESAVKA_RUN_REMOTE_MEDIA_SMOKE:-0} == 1 ]]; then local remote_host="${LESAVKA_SERVER_HOST:-titan-jh}" local ssh_opts="${SSH_OPTS:--o BatchMode=yes -o ConnectTimeout=5}" if ssh ${ssh_opts} "${remote_host}" "gst-inspect-1.0 v4l2slh265dec >/dev/null"; then run_shell "server_hevc_hardware_decode" \ "Remote server decodes the same upstream HEVC stream with v4l2slh265dec; no service/gadget mutation" \ "cat '${UPSTREAM_HEVC_FILE}' | ssh ${ssh_opts} '${remote_host}' \"gst-launch-1.0 -q fdsrc blocksize=1048576 ! h265parse disable-passthrough=true config-interval=-1 ! video/x-h265,stream-format=byte-stream,alignment=au ! v4l2slh265dec discard-corrupted-frames=true automatic-request-sync-points=true ! videoconvert ! fakesink sync=false\"" else record_result "server_hevc_hardware_decode" "fail" "remote ${remote_host} did not expose v4l2slh265dec over ssh" "" OVERALL=1 fi else record_result "server_hevc_hardware_decode" "skipped" "set LESAVKA_RUN_REMOTE_MEDIA_SMOKE=1 to verify Theia's hardware HEVC decoder without touching services" "" fi finish_summary exit "${OVERALL}" } main "$@"