docs: create Netgrimoire/Nucking-Futz/scripts/vhs_restore
This commit is contained in:
parent
9a80b6eb12
commit
91a26faed6
1 changed files with 381 additions and 0 deletions
381
Netgrimoire/Nucking-Futz/scripts/vhs_restore.md
Normal file
381
Netgrimoire/Nucking-Futz/scripts/vhs_restore.md
Normal file
|
|
@ -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 <<EOF
|
||||
Usage: $(basename "$0") [options]
|
||||
|
||||
Options:
|
||||
-i DIR Input directory (default: ./input)
|
||||
-o DIR Output directory (default: ./output)
|
||||
-w DIR Work/scratch dir (default: ./work)
|
||||
-b PATH Path to realesrgan-ncnn-vulkan binary
|
||||
-s FACTOR Upscale factor: 2 or 4 (default: 2)
|
||||
-q CRF Output quality 0-51, lower=better (default: 16)
|
||||
--no-ai Skip Real-ESRGAN; FFmpeg cleanup only (fast)
|
||||
--keep Keep extracted frames after processing
|
||||
-h Show this help
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -i ~/Videos/VHS -o ~/Videos/Restored
|
||||
$(basename "$0") -i ~/Videos/VHS --no-ai # Quick cleanup only
|
||||
$(basename "$0") -i ~/Videos/VHS -s 4 -q 18 # 4x upscale
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-i) INPUT_DIR="$2"; shift 2 ;;
|
||||
-o) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
-w) WORK_DIR="$2"; shift 2 ;;
|
||||
-b) REALESRGAN_BIN="$2"; shift 2 ;;
|
||||
-s) UPSCALE_FACTOR="$2"; shift 2 ;;
|
||||
-q) CRF="$2"; shift 2 ;;
|
||||
--no-ai) SKIP_UPSCALE=true; shift ;;
|
||||
--keep) KEEP_FRAMES=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) error "Unknown option: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Dependency checks ────────────────────────────────────────────────────────
|
||||
header "Checking dependencies"
|
||||
|
||||
check_cmd() {
|
||||
if command -v "$1" &>/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`
|
||||
Loading…
Add table
Add a link
Reference in a new issue