From f43f8154f6e97b29d4b2a02692d4826339eef8a5 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Rasmusson Date: Tue, 27 Oct 2026 02:31:13 -0400 Subject: tools/lg: v2 demo-video composition scripts (split-screen + card builder) split_screen.sh composes two guest frame streams (KVMFR PPMs from frame_reader.py) into one hstacked mp4 with last-frame padding for unequal lengths; build_video.sh wraps a title card, the main segment and an end card (drawtext, detected font) into the deliverable mp4. Both verified end-to-end with synthetic PPM sequences (ffprobe: 1280x720 h264, padding pixel-exact). These are the capture-side pieces for the 1.0.0 video; the guest-side publishers are tools/guest-diag/xshm_pub.c (X screen) and the existing frame_producer; the host reader is tools/lg/frame_reader.py. --- tools/lg/build_video.sh | 143 +++++++++++++++++++++++++++++++++++++++++++++++ tools/lg/split_screen.sh | 126 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100755 tools/lg/build_video.sh create mode 100755 tools/lg/split_screen.sh (limited to 'tools/lg') 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 [end-text] +# build_video.sh [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: [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/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 [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: " "). +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 -- cgit v1.2.3