summaryrefslogtreecommitdiffstats
path: root/tools/lg
diff options
context:
space:
mode:
Diffstat (limited to 'tools/lg')
-rwxr-xr-xtools/lg/build_video.sh143
-rw-r--r--tools/lg/frame_reader.py81
-rwxr-xr-xtools/lg/frame_sequence.sh33
-rwxr-xr-xtools/lg/split_screen.sh126
4 files changed, 383 insertions, 0 deletions
diff --git a/tools/lg/build_video.sh b/tools/lg/build_video.sh
new file mode 100755
index 0000000..4f80be8
--- /dev/null
+++ b/tools/lg/build_video.sh
@@ -0,0 +1,143 @@
+#!/bin/bash
+# SPDX-License-Identifier: BSD-3-Clause
+# build_video.sh - build the final open-iov 1.0.0 demo video: a drawtext
+# title card, the split-screen main segment (two GZDOOM VMs, one per pane,
+# captured through the KVMFR ivshmem path), and an optional end card,
+# concatenated into one 1280x720@30 libx264 yuv420p mp4.
+#
+# Usage:
+# build_video.sh <title-text> <main.mp4> [end-text]
+# build_video.sh <title-text> <dirA> <dirB> [end-text]
+# title-text text for the title card (required, non-empty)
+# main.mp4 pre-built split-screen video (e.g. from split_screen.sh)
+# dirA dirB two frame-%04d.ppm dirs; split_screen.sh (next to this
+# script) is invoked first at FPS to build the main segment
+# end-text optional text for the end card (omitted = no end card)
+#
+# Output: $OUT if set, else ./open-iov-1.0.0-demo.mp4 in the current dir.
+# Env knobs: FPS (default 30), CARD_SECS (default 5), FONTSIZE (default 48).
+#
+# Approach: title/end cards are generated with ffmpeg drawtext (white on
+# dark 0x101010, 1280x720); the main segment is normalized to 1280x720@30
+# (aspect-preserving fit + letterbox pad, audio dropped - the demo video is
+# video-only); the three segments are concatenated with the concat demuxer
+# followed by ONE re-encode. Stream-copy concat is brittle across
+# separately-generated mp4s (timestamp/param mismatches); the re-encode is
+# cheap at these lengths and guarantees a clean single-stream output.
+# The title font is auto-detected (DejaVuSans/NotoSans); the script fails
+# with a clear message if no TTF font exists.
+#
+# Requires: ffmpeg with libx264 + drawtext (libfreetype); split_screen.sh
+# next to this script for the two-dirs form.
+set -euo pipefail
+
+usage() {
+ sed -n '3,31p' "$0" | sed 's/^# \{0,1\}//'
+}
+
+die() { echo "build_video.sh: ERROR: $*" >&2; exit 1; }
+
+# grep -q would SIGPIPE the ffmpeg producer under pipefail; capture first.
+ffmpeg_has() { # $1 = encoder/filter token
+ local out
+ out=$(ffmpeg -hide_banner "$2" 2>/dev/null) || true
+ case "$out" in *" $1 "*) return 0 ;; *) return 1 ;; esac
+}
+
+command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
+ffmpeg_has libx264 -encoders || die "ffmpeg has no libx264 encoder"
+ffmpeg_has drawtext -filters || die "ffmpeg has no drawtext filter (needs libfreetype)"
+
+# --- locate a usable TTF font -------------------------------------------
+FONT=""
+for f in \
+ /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf \
+ /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf \
+ /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf \
+ /usr/share/fonts/dejavu/DejaVuSans.ttf; do
+ if [ -f "$f" ]; then FONT=$f; break; fi
+done
+if [ -z "$FONT" ] && command -v fc-match >/dev/null 2>&1; then
+ f=$(fc-match -f '%{file}' sans 2>/dev/null || true)
+ [ -n "$f" ] && [ -f "$f" ] && FONT=$f
+fi
+[ -n "$FONT" ] || die "no usable TTF font found (looked for DejaVuSans, NotoSans; \
+install fonts-dejavu-core or set up fontconfig)"
+echo "build_video.sh: font=$FONT" >&2
+
+# --- args ----------------------------------------------------------------
+[ "$#" -ge 1 ] || { usage; exit 2; }
+TITLE=$1; shift
+[ -n "$TITLE" ] || die "title text must be non-empty"
+MAIN="" DIR_A="" DIR_B="" END=""
+if [ "$#" -ge 1 ] && [ -d "$1" ]; then
+ DIR_A=$1
+ [ "$#" -ge 2 ] && [ -d "$2" ] || die "two-dirs form needs two directories: <dirA> <dirB> [end-text]"
+ DIR_B=$2
+ END=${3:-}
+elif [ "$#" -ge 1 ]; then
+ [ -f "$1" ] || die "main input not found: $1"
+ MAIN=$1
+ END=${2:-}
+else
+ die "missing main input (mp4 file or two frame dirs)"
+fi
+
+FPS=${FPS:-30}
+CARD_SECS=${CARD_SECS:-5}
+FONTSIZE=${FONTSIZE:-48}
+OUT=${OUT:-$PWD/open-iov-1.0.0-demo.mp4}
+HERE=$(cd "$(dirname "$0")" && pwd)
+
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/build_video.XXXXXX")
+trap 'rm -rf "$TMP"' EXIT
+
+# --- cards (drawtext; text via textfile so ':' ',' '%' etc. are literal) --
+make_card() {
+ local text=$1 out=$2 tf="$TMP/card-$$.txt"
+ printf '%s' "$text" > "$tf"
+ ffmpeg -hide_banner -loglevel warning -y \
+ -f lavfi -i "color=c=0x101010:s=1280x720:r=$FPS:d=$CARD_SECS" \
+ -vf "drawtext=fontfile=$FONT:textfile=$tf:fontcolor=white:fontsize=$FONTSIZE:expansion=none:x=(w-text_w)/2:y=(h-text_h)/2" \
+ -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p \
+ "$out"
+}
+
+echo "build_video.sh: title card (${CARD_SECS}s)..." >&2
+make_card "$TITLE" "$TMP/title.mp4"
+
+# --- main segment --------------------------------------------------------
+if [ -n "$DIR_A" ]; then
+ [ -x "$HERE/split_screen.sh" ] || die "split_screen.sh not found/executable next to build_video.sh"
+ echo "build_video.sh: building split-screen main from $DIR_A + $DIR_B..." >&2
+ "$HERE/split_screen.sh" "$DIR_A" "$DIR_B" "$TMP/split.mp4" "$FPS"
+ MAIN=$TMP/split.mp4
+fi
+echo "build_video.sh: normalizing main segment to 1280x720@$FPS..." >&2
+ffmpeg -hide_banner -loglevel warning -y -i "$MAIN" \
+ -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=$FPS" \
+ -an -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p \
+ "$TMP/main.mp4"
+
+# --- concat [title, main, end] ------------------------------------------
+printf "file '%s'\n" "$TMP/title.mp4" "$TMP/main.mp4" > "$TMP/list.txt"
+if [ -n "$END" ]; then
+ echo "build_video.sh: end card (${CARD_SECS}s)..." >&2
+ make_card "$END" "$TMP/end.mp4"
+ printf "file '%s'\n" "$TMP/end.mp4" >> "$TMP/list.txt"
+fi
+
+echo "build_video.sh: concatenating..." >&2
+ffmpeg -hide_banner -loglevel warning -y -f concat -safe 0 -i "$TMP/list.txt" \
+ -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p \
+ -r "$FPS" -movflags +faststart \
+ "$OUT"
+
+[ -s "$OUT" ] || die "ffmpeg produced no output: $OUT"
+if command -v ffprobe >/dev/null 2>&1; then
+ ffprobe -v error -select_streams v:0 \
+ -show_entries stream=width,height,r_frame_rate,duration \
+ -show_entries format=duration,size \
+ -of default=nw=1 "$OUT" | sed 's/^/build_video.sh: /' >&2
+fi
+echo "VIDEO_DONE $OUT" >&2
diff --git a/tools/lg/frame_reader.py b/tools/lg/frame_reader.py
new file mode 100644
index 0000000..783fcaf
--- /dev/null
+++ b/tools/lg/frame_reader.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# frame_reader.py - read the GPU-rendered frames the guest publishes into the
+# kvmfr ivshmem BAR (see tools/cuda-ab/frame_producer.cu) and save them on the
+# host as PPM/PNG. This is the host half of the guest frame-producer path:
+# the QEMU ivshmem-plain device is backed by /dev/shm/kvmfr-<uuid>, which the
+# guest maps through /sys/bus/pci/devices/0000:00:03.0/resource2.
+#
+# Usage: frame_reader.py [--shm /dev/shm/kvmfr-<uuid>] [--frames N] [--out DIR]
+import argparse
+import mmap
+import os
+import struct
+import sys
+import time
+
+MAGIC = 0x4B564D46
+FRAME_OFFSET = 0x100000
+# magic, version, width, height, stride, format, seq, reserved, timestamp_ns
+HDR = struct.Struct("<IIIIIIIIQ")
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--shm", default=None)
+ ap.add_argument("--frames", type=int, default=5)
+ ap.add_argument("--out", default="/tmp/opencode/frames")
+ ap.add_argument("--timeout", type=float, default=120.0)
+ args = ap.parse_args()
+
+ if not args.shm:
+ import glob
+ cands = [p for p in glob.glob("/dev/shm/kvmfr-*")
+ if os.path.getsize(p) > 0]
+ if not cands:
+ print("NO_SHM_FILE", file=sys.stderr)
+ return 1
+ args.shm = max(cands, key=os.path.getsize)
+ os.makedirs(args.out, exist_ok=True)
+ print("SHM %s" % args.shm)
+
+ fd = os.open(args.shm, os.O_RDWR)
+ size = os.fstat(fd).st_size
+ mm = mmap.mmap(fd, size, mmap.MAP_SHARED,
+ mmap.PROT_READ | mmap.PROT_WRITE)
+
+ deadline = time.time() + args.timeout
+ saved = 0
+ mm.seek(FRAME_OFFSET)
+ magic, _v, _w, _h, _s, _f, last_seq, _r, _t = HDR.unpack(mm.read(HDR.size))
+ if magic != MAGIC:
+ last_seq = 0
+ print("SEED last_seq=%d" % last_seq)
+ while saved < args.frames and time.time() < deadline:
+ mm.seek(FRAME_OFFSET)
+ magic, ver, w, h, stride, fmt, seq, _res, ts = HDR.unpack(
+ mm.read(HDR.size))
+ if magic != MAGIC or seq == 0 or seq == last_seq or w == 0:
+ time.sleep(0.05)
+ continue
+ fb = mm.read(w * h * 4)
+ last_seq = seq
+ path = os.path.join(args.out, "frame-%04d.ppm" % seq)
+ with open(path, "wb") as f:
+ f.write(b"P6\n%d %d\n255\n" % (w, h))
+ # RGBA -> RGB
+ rgb = bytearray(w * h * 3)
+ rgb[0::3] = fb[0::4]
+ rgb[1::3] = fb[1::4]
+ rgb[2::3] = fb[2::4]
+ f.write(bytes(rgb))
+ saved += 1
+ print("SAVED seq=%d %dx%d fmt=%d -> %s" % (seq, w, h, fmt, path))
+ mm.close()
+ os.close(fd)
+ print("READER_DONE saved=%d last_seq=%d" % (saved, last_seq))
+ return 0 if saved else 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/lg/frame_sequence.sh b/tools/lg/frame_sequence.sh
new file mode 100755
index 0000000..958a34a
--- /dev/null
+++ b/tools/lg/frame_sequence.sh
@@ -0,0 +1,33 @@
+#!/bin/bash
+# SPDX-License-Identifier: BSD-3-Clause
+# frame_sequence.sh - capture an animated GPU-rendered frame sequence end to
+# end: run the guest's CUDA frame producer into the kvmfr ivshmem BAR and
+# save every published frame on the host as PPM/PNG (the proven
+# CUDA -> ivshmem -> host path; see docs/05-operations/06).
+#
+# Usage: tools/lg/frame_sequence.sh [frames] [width] [height] [fps] [outdir]
+set -u
+HERE="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "$HERE/../.." && pwd)"
+FRAMES="${1:-40}"
+W="${2:-800}"
+H="${3:-600}"
+FPS="${4:-10}"
+OUT="${5:-/tmp/opencode/frames-seq}"
+
+mkdir -p "$OUT"
+rm -f "$OUT"/frame-*.ppm "$OUT"/frame-*.png
+
+# Start the host reader first so no published frame is missed.
+python3 "$REPO/tools/lg/frame_reader.py" --frames "$FRAMES" --out "$OUT" \
+ --timeout 240 > "$OUT/reader.log" 2>&1 &
+READER=$!
+
+sleep 1
+"$REPO/scripts/ssh_guest.sh" \
+ "sudo /tmp/frame_producer $FRAMES $W $H $FPS" 2>&1 | grep -v "Permanently added" \
+ | tail -5
+
+wait "$READER"
+echo "reader rc=$?"
+ls -1 "$OUT"/frame-*.ppm 2>/dev/null | wc -l
diff --git a/tools/lg/split_screen.sh b/tools/lg/split_screen.sh
new file mode 100755
index 0000000..55e11ee
--- /dev/null
+++ b/tools/lg/split_screen.sh
@@ -0,0 +1,126 @@
+#!/bin/bash
+# SPDX-License-Identifier: BSD-3-Clause
+# split_screen.sh - compose a side-by-side (split-screen) mp4 from two PPM
+# frame sequences captured through the KVMFR ivshmem path (guest publishers:
+# tools/cuda-ab/frame_producer.cu or tools/guest-diag/xshm_pub.c; host reader:
+# tools/lg/frame_reader.py, which writes frame-%04d.ppm into a directory).
+#
+# Usage: split_screen.sh <dirA> <dirB> <out.mp4> [fps] [left_w] [left_h]
+# dirA left-side frame-%04d.ppm directory (any starting index)
+# dirB right-side frame-%04d.ppm directory
+# out.mp4 output file (libx264, yuv420p)
+# fps output frame rate (default 30; pass the measured capture rate,
+# e.g. 10, for real-time playback of ~10 fps captures)
+# left_w optional left-pane width; the left pane is fitted into
+# left_w x H with letterbox padding (aspect preserved)
+# left_h optional pane height H; BOTH panes are scaled to height H
+# (default: native height of dirA's first frame). Widths follow
+# each input's aspect ratio (forced even for yuv420p).
+#
+# Unequal sequence lengths: the shorter side is padded by repeating its last
+# frame. hstack is a framesync filter whose repeatlast=1 default holds the
+# shorter input's last frame until the longer input ends; shortest=0 is
+# passed explicitly to make that the contract (verified on ffmpeg 6.1.1 for
+# both input orders). No symlink/tpad workaround is needed.
+#
+# Fails loudly (exit 1) on: missing ffmpeg/libx264, missing input dirs, or an
+# input dir with no frame-*.ppm files. Requires: ffmpeg (libx264); ffprobe is
+# used for the final summary when present.
+set -euo pipefail
+
+usage() {
+ sed -n '3,28p' "$0" | sed 's/^# \{0,1\}//'
+}
+
+die() { echo "split_screen.sh: ERROR: $*" >&2; exit 1; }
+
+# grep -q would SIGPIPE the ffmpeg producer under pipefail; capture first.
+ffmpeg_has() { # $1 = encoder/filter token
+ local out
+ out=$(ffmpeg -hide_banner "$2" 2>/dev/null) || true
+ case "$out" in *" $1 "*) return 0 ;; *) return 1 ;; esac
+}
+
+# Read width/height from the PPM P6 header (line 2: "<w> <h>").
+ppm_dims() {
+ local line w h
+ line=$(sed -n '2p' "$1")
+ read -r w h _ <<< "$line"
+ [ -n "${w:-}" ] && [ -n "${h:-}" ] || return 1
+ echo "$w $h"
+}
+
+# Scan a frame dir: sets PD_COUNT PD_FIRST PD_LAST PD_W PD_H PD_FILE
+probe_dir() {
+ local dir="$1" label="$2" files f dim
+ [ -d "$dir" ] || die "$label directory not found: $dir"
+ mapfile -t files < <(find "$dir" -maxdepth 1 -name 'frame-*.ppm' -type f \
+ -printf '%f\n' | sort)
+ PD_COUNT=${#files[@]}
+ [ "$PD_COUNT" -gt 0 ] || die "$label directory has no frame-*.ppm files: $dir"
+ f=${files[0]}
+ PD_FIRST=${f#frame-}; PD_FIRST=${PD_FIRST%.ppm}; PD_FIRST=$((10#$PD_FIRST))
+ f=${files[$((PD_COUNT - 1))]}
+ PD_LAST=${f#frame-}; PD_LAST=${PD_LAST%.ppm}; PD_LAST=$((10#$PD_LAST))
+ if [ $((PD_LAST - PD_FIRST + 1)) -ne "$PD_COUNT" ]; then
+ echo "split_screen.sh: WARN: $label sequence has gaps ($PD_COUNT files," \
+ "indices $PD_FIRST..$PD_LAST); ffmpeg stops at the first gap" >&2
+ fi
+ PD_FILE=$(printf '%s/frame-%04d.ppm' "$dir" "$PD_FIRST")
+ dim=$(ppm_dims "$PD_FILE") || die "$label: cannot read PPM header of $PD_FILE"
+ PD_W=${dim% *}; PD_H=${dim#* }
+}
+
+[ "$#" -ge 3 ] || { usage; exit 2; }
+DIR_A=$1 DIR_B=$2 OUT=$3 FPS=${4:-30} LW=${5:-} LH=${6:-}
+
+command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH"
+ffmpeg_has libx264 -encoders || die "ffmpeg has no libx264 encoder"
+
+probe_dir "$DIR_A" left; A_COUNT=$PD_COUNT A_FIRST=$PD_FIRST A_H=$PD_H
+probe_dir "$DIR_B" right; B_COUNT=$PD_COUNT B_FIRST=$PD_FIRST
+
+# Pane height H: left_h if given, else dirA's native height. Keep even.
+H=${LH:-$A_H}
+if [ $((H % 2)) -ne 0 ]; then
+ echo "split_screen.sh: WARN: pane height $H rounded down to even for yuv420p" >&2
+ H=$((H / 2 * 2))
+fi
+[ "$H" -ge 2 ] || die "pane height must be >= 2"
+
+# Left pane: fit into left_w x H (letterbox) when left_w given, else scale
+# to height H with aspect preserved. Right pane: always scale to height H.
+if [ -n "$LW" ]; then
+ if [ $((LW % 2)) -ne 0 ]; then
+ echo "split_screen.sh: WARN: left_w $LW rounded down to even for yuv420p" >&2
+ LW=$((LW / 2 * 2))
+ fi
+ [ "$LW" -ge 2 ] || die "left_w must be >= 2"
+ LEFT="[0:v]scale=${LW}:${H}:force_original_aspect_ratio=decrease,"\
+"pad=${LW}:${H}:(ow-iw)/2:(oh-ih)/2,setsar=1[l]"
+else
+ LEFT="[0:v]scale=-2:${H},setsar=1[l]"
+fi
+RIGHT="[1:v]scale=-2:${H},setsar=1[r]"
+# shortest=0 + framesync repeatlast=1 (default): shorter side's last frame is
+# repeated until the longer side ends (see header comment).
+FILTER="${LEFT};${RIGHT};[l][r]hstack=shortest=0"
+
+echo "split_screen.sh: left=$DIR_A ($A_COUNT frames from $A_FIRST)" \
+ "right=$DIR_B ($B_COUNT frames from $B_FIRST) pane_h=$H fps=$FPS" >&2
+
+ffmpeg -hide_banner -loglevel warning -y \
+ -thread_queue_size 512 -framerate "$FPS" -start_number "$A_FIRST" -i "$DIR_A/frame-%04d.ppm" \
+ -thread_queue_size 512 -framerate "$FPS" -start_number "$B_FIRST" -i "$DIR_B/frame-%04d.ppm" \
+ -filter_complex "$FILTER" \
+ -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p \
+ -r "$FPS" -movflags +faststart \
+ "$OUT"
+
+[ -s "$OUT" ] || die "ffmpeg produced no output: $OUT"
+if command -v ffprobe >/dev/null 2>&1; then
+ ffprobe -v error -count_frames -select_streams v:0 \
+ -show_entries stream=width,height,nb_read_frames \
+ -of default=nw=1 "$OUT" | sed 's/^/split_screen.sh: /' >&2
+fi
+echo "SPLIT_DONE $OUT" >&2