diff --git a/Netgrimoire/Nucking-Futz/scripts/vhs_restore.md b/Netgrimoire/Nucking-Futz/scripts/vhs_restore.md new file mode 100644 index 0000000..4d815d1 --- /dev/null +++ b/Netgrimoire/Nucking-Futz/scripts/vhs_restore.md @@ -0,0 +1,381 @@ +--- +title: Video Restoration +description: Short Script to fix vhs captures +published: true +date: 2026-03-06T03:40:10.803Z +tags: +editor: markdown +dateCreated: 2026-03-06T03:40:10.803Z +--- + +# Header +Your content here + + + + + + + + + + + + + + + + + + + + + + + + + + +`#!/usr/bin/env bash +# ============================================================================= +# vhs_restore.sh — Automated VHS Video Restoration Pipeline +# Stages: Deinterlace → Denoise → Colour correct → AI Upscale → Reassemble +# +# Changes from v1: +# - Gentle hqdn3d (2:1:2:2) to prevent motion blocking/pixelation +# - Aggressive colour correction for washed-out VHS footage +# - Live FFmpeg progress shown in terminal (no silent hanging) +# - Logs still saved to /tmp/ for error diagnosis +# ============================================================================= +set -euo pipefail + +# ── Colour output helpers ──────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +header() { echo -e "\n${BOLD}${CYAN}══ $* ══${NC}"; } + +# ── Default configuration ──────────────────────────────────────────────────── +INPUT_DIR="./input" # Folder containing your source VHS videos +OUTPUT_DIR="./output" # Final restored videos land here +WORK_DIR="./work" # Scratch space (frames, temp files) +REALESRGAN_BIN="./realesrgan-ncnn-vulkan" # Path to Real-ESRGAN binary +REALESRGAN_MODEL="realesr-animevideov3" # Best model for home video +UPSCALE_FACTOR=2 # 2x or 4x (4x is very slow on CPU) +OUTPUT_WIDTH=1920 # Target width used in --no-ai mode +OUTPUT_HEIGHT=1080 # Target height used in --no-ai mode +CRF=16 # Output quality 0-51, lower = better +PRESET="slow" # FFmpeg encode preset +SKIP_UPSCALE=false # --no-ai flag sets this true +KEEP_FRAMES=false # --keep flag sets this true + +# ── Parse CLI flags ────────────────────────────────────────────────────────── +usage() { + cat </dev/null; then + success "$1 found" + else + error "$1 not found. Install with: $2" + exit 1 + fi +} + +check_cmd ffmpeg "sudo apt install ffmpeg" +check_cmd ffprobe "sudo apt install ffmpeg" +check_cmd bc "sudo apt install bc" + +if [[ "$SKIP_UPSCALE" == false ]]; then + if [[ ! -x "$REALESRGAN_BIN" ]]; then + warn "Real-ESRGAN binary not found at: $REALESRGAN_BIN" + echo + echo -e "${YELLOW}To install Real-ESRGAN:${NC}" + echo " 1. Download: https://github.com/xinntao/Real-ESRGAN/releases" + echo " -> realesrgan-ncnn-vulkan-*-ubuntu.zip" + echo " 2. Unzip into this directory" + echo " 3. chmod +x realesrgan-ncnn-vulkan" + echo " 4. Re-run this script" + echo + echo "Or run with --no-ai for FFmpeg-only cleanup (no upscaling)." + exit 1 + fi + success "Real-ESRGAN found" +fi + +# ── Locate input files ─────────────────────────────────────────────────────── +header "Scanning input directory: $INPUT_DIR" + +if [[ ! -d "$INPUT_DIR" ]]; then + error "Input directory not found: $INPUT_DIR" + exit 1 +fi + +mapfile -t VIDEO_FILES < <(find "$INPUT_DIR" -maxdepth 1 \ + -type f \( -iname "*.mp4" -o -iname "*.avi" -o -iname "*.mov" \ + -o -iname "*.mkv" -o -iname "*.mpg" -o -iname "*.mpeg" \ + -o -iname "*.wmv" -o -iname "*.m4v" -o -iname "*.ts" \) \ + | sort) + +if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then + error "No video files found in $INPUT_DIR" + exit 1 +fi + +info "Found ${#VIDEO_FILES[@]} video file(s):" +for f in "${VIDEO_FILES[@]}"; do echo " * $(basename "$f")"; done + +# ── Helpers ────────────────────────────────────────────────────────────────── +probe() { + ffprobe -v error -select_streams v:0 \ + -show_entries "stream=$2" -of csv=p=0 "$1" 2>/dev/null | head -1 +} + +human_time() { + local s="${1%.*}" + printf '%dh %dm %ds' $((s/3600)) $(( (s%3600)/60 )) $((s%60)) +} + +# ── Create directories ─────────────────────────────────────────────────────── +mkdir -p "$OUTPUT_DIR" "$WORK_DIR" + +# ── Overall stats ──────────────────────────────────────────────────────────── +TOTAL_FILES=${#VIDEO_FILES[@]} +PROCESSED=0 +FAILED=0 +PIPELINE_START=$(date +%s) + +# ════════════════════════════════════════════════════════════════════════════ +# MAIN LOOP +# ════════════════════════════════════════════════════════════════════════════ +for INPUT_FILE in "${VIDEO_FILES[@]}"; do + + BASENAME=$(basename "$INPUT_FILE") + STEM="${BASENAME%.*}" + CLEANED="$WORK_DIR/${STEM}_cleaned.mp4" + FRAMES_IN="$WORK_DIR/${STEM}_frames_in" + FRAMES_OUT="$WORK_DIR/${STEM}_frames_out" + FINAL_OUTPUT="$OUTPUT_DIR/${STEM}_restored.mp4" + + header "Processing: $BASENAME ($((PROCESSED+1))/$TOTAL_FILES)" + FILE_START=$(date +%s) + + # ── Probe source ────────────────────────────────────────────────────────── + FPS=$(probe "$INPUT_FILE" "r_frame_rate") + FPS_DEC=$(echo "scale=3; $FPS" | bc 2>/dev/null || echo "25") + WIDTH=$(probe "$INPUT_FILE" "width") + HEIGHT=$(probe "$INPUT_FILE" "height") + FIELD_ORDER=$(probe "$INPUT_FILE" "field_order") + DURATION=$(ffprobe -v error -show_entries format=duration \ + -of csv=p=0 "$INPUT_FILE" 2>/dev/null | head -1) + + info "Source: ${WIDTH}x${HEIGHT} ${FPS_DEC}fps $(human_time "${DURATION%.*}") field_order=${FIELD_ORDER:-unknown}" + + # Always deinterlace for VHS -- safe even if not flagged as interlaced + if [[ "$FIELD_ORDER" =~ ^(tt|tb|bt|bb)$ ]]; then + DEINTERLACE_FILTER="yadif=mode=1," + info "Interlacing detected — applying yadif deinterlacer" + else + DEINTERLACE_FILTER="yadif=mode=1," + warn "Interlacing not confirmed by probe — applying yadif anyway (safe for VHS)" + fi + + # ── Stage 1: FFmpeg cleanup ─────────────────────────────────────────────── + header "Stage 1/3 — FFmpeg cleanup & colour correction" + info "Watch fps= and speed= for live progress." + info "Corrupt frame warnings are normal for old VHS captures." + echo + + if [[ "$SKIP_UPSCALE" == true ]]; then + SCALE_FILTER="scale=${OUTPUT_WIDTH}:${OUTPUT_HEIGHT}:flags=lanczos," + else + SCALE_FILTER="" + fi + + # Filter chain notes: + # hqdn3d=2:1:2:2 -- gentle denoise; low temporal values (3rd/4th) + # prevent the motion blocking seen with higher values + # unsharp -- moderate sharpening to recover edge detail + # eq -- aggressive colour boost for washed-out VHS + # colorbalance -- corrects the green/yellow cast common in aged VHS + VFILTER="${DEINTERLACE_FILTER}\ +hqdn3d=2:1:2:2,\ +unsharp=3:3:0.5:3:3:0.3,\ +eq=contrast=1.2:brightness=0.05:saturation=1.8:gamma=1.1,\ +colorbalance=rs=0.1:gs=0.0:bs=-0.1,\ +${SCALE_FILTER}\ +format=yuv420p" + + if ! ffmpeg -y -i "$INPUT_FILE" \ + -vf "$VFILTER" \ + -c:v libx264 -crf 18 -preset medium \ + -c:a aac -b:a 192k -ac 2 \ + -stats \ + "$CLEANED" 2>&1 | tee /tmp/ffmpeg_stage1.log | \ + grep --line-buffered -E "(frame=|speed=|error|Error|Invalid)"; then + error "FFmpeg stage 1 failed. Full log: /tmp/ffmpeg_stage1.log" + FAILED=$((FAILED+1)) + continue + fi + + echo + success "Stage 1 complete -> $(du -sh "$CLEANED" | cut -f1)" + + if [[ "$SKIP_UPSCALE" == true ]]; then + cp "$CLEANED" "$FINAL_OUTPUT" + success "Output (no AI): $FINAL_OUTPUT" + PROCESSED=$((PROCESSED+1)) + [[ "$KEEP_FRAMES" == false ]] && rm -f "$CLEANED" + continue + fi + + # ── Stage 2: Extract frames ─────────────────────────────────────────────── + header "Stage 2/3 — Extracting frames for AI upscaling" + mkdir -p "$FRAMES_IN" "$FRAMES_OUT" + + FRAME_COUNT=$(ffprobe -v error -count_packets \ + -select_streams v:0 -show_entries stream=nb_read_packets \ + -of csv=p=0 "$CLEANED" 2>/dev/null | head -1) + FRAME_COUNT=${FRAME_COUNT:-0} + info "Extracting ~${FRAME_COUNT} frames..." + + if ! ffmpeg -y -i "$CLEANED" \ + -vsync 0 -stats \ + "$FRAMES_IN/frame%08d.png" 2>&1 | tee /tmp/ffmpeg_extract.log | \ + grep --line-buffered -E "(frame=|speed=|error|Error)"; then + error "Frame extraction failed. Full log: /tmp/ffmpeg_extract.log" + FAILED=$((FAILED+1)) + continue + fi + + ACTUAL_FRAMES=$(find "$FRAMES_IN" -name "*.png" | wc -l) + echo + success "Extracted $ACTUAL_FRAMES frames" + + # ── Stage 3: Real-ESRGAN ────────────────────────────────────────────────── + header "Stage 3/3 — Real-ESRGAN AI upscaling (${UPSCALE_FACTOR}x)" + warn "Slow on CPU — est. $(echo "scale=0; $ACTUAL_FRAMES * 10 / 60" | bc)-$(echo "scale=0; $ACTUAL_FRAMES * 30 / 60" | bc) minutes" + info "Upscaled frames will appear in: $FRAMES_OUT" + echo + + UPSCALE_START=$(date +%s) + if ! "$REALESRGAN_BIN" \ + -i "$FRAMES_IN" \ + -o "$FRAMES_OUT" \ + -n "$REALESRGAN_MODEL" \ + -s "$UPSCALE_FACTOR" \ + -f png 2>&1 | tee /tmp/realesrgan.log; then + error "Real-ESRGAN failed. Full log: /tmp/realesrgan.log" + FAILED=$((FAILED+1)) + continue + fi + + UPSCALE_END=$(date +%s) + UPSCALE_ELAPSED=$((UPSCALE_END - UPSCALE_START)) + success "AI upscaling complete in $(human_time $UPSCALE_ELAPSED)" + + # ── Reassemble ──────────────────────────────────────────────────────────── + REASSEMBLE_FPS=$(ffprobe -v error -select_streams v:0 \ + -show_entries stream=r_frame_rate \ + -of csv=p=0 "$CLEANED" 2>/dev/null | head -1) + + info "Reassembling video from upscaled frames..." + echo + + if ! ffmpeg -y \ + -framerate "$REASSEMBLE_FPS" \ + -i "$FRAMES_OUT/frame%08d.png" \ + -i "$CLEANED" \ + -map 0:v -map 1:a \ + -c:v libx264 -crf "$CRF" -preset "$PRESET" \ + -c:a copy \ + -movflags +faststart \ + -stats \ + "$FINAL_OUTPUT" 2>&1 | tee /tmp/ffmpeg_reassemble.log | \ + grep --line-buffered -E "(frame=|speed=|error|Error)"; then + error "Reassembly failed. Full log: /tmp/ffmpeg_reassemble.log" + FAILED=$((FAILED+1)) + continue + fi + + # ── Cleanup ─────────────────────────────────────────────────────────────── + if [[ "$KEEP_FRAMES" == false ]]; then + rm -rf "$FRAMES_IN" "$FRAMES_OUT" "$CLEANED" + info "Scratch files cleaned up" + else + info "Frames kept in: $FRAMES_IN / $FRAMES_OUT" + fi + + FILE_END=$(date +%s) + FILE_ELAPSED=$((FILE_END - FILE_START)) + PROCESSED=$((PROCESSED+1)) + + OUT_SIZE=$(du -sh "$FINAL_OUTPUT" | cut -f1) + echo + success "Done: $FINAL_OUTPUT" + info " File size : $OUT_SIZE" + info " Time taken: $(human_time $FILE_ELAPSED)" + +done + +# ════════════════════════════════════════════════════════════════════════════ +# Final summary +# ════════════════════════════════════════════════════════════════════════════ +PIPELINE_END=$(date +%s) +PIPELINE_ELAPSED=$((PIPELINE_END - PIPELINE_START)) + +header "Pipeline Complete" +echo -e " ${GREEN}Processed : $PROCESSED / $TOTAL_FILES${NC}" +[[ $FAILED -gt 0 ]] && echo -e " ${RED}Failed : $FAILED${NC}" +echo -e " Total time: $(human_time $PIPELINE_ELAPSED)" +echo -e " Output dir: $OUTPUT_DIR" +echo + +if [[ $PROCESSED -gt 0 ]]; then + echo "Restored files:" + find "$OUTPUT_DIR" -name "*_restored.mp4" | while read -r f; do + SIZE=$(du -sh "$f" | cut -f1) + echo " * $(basename "$f") ($SIZE)" + done +fi` \ No newline at end of file