Merge branch 'dev'

Fix branching
This commit is contained in:
Uula Ilola (LAB)
2026-05-21 14:59:52 +03:00
12 changed files with 1117 additions and 15 deletions
+3
View File
@@ -0,0 +1,3 @@
/build/**
/lib/yt-dlp
/testfootage/
+6
View File
@@ -0,0 +1,6 @@
[submodule "lib/sys"]
path = lib/sys
url = https://go.googlesource.com/sys
[submodule "lib/fsnotify"]
path = lib/fsnotify
url = https://github.com/fsnotify/fsnotify.git
Binary file not shown.
Binary file not shown.
Submodule
+1
Submodule lib/fsnotify added at 20b1e15ef3
Submodule
+1
Submodule lib/sys added at 99666ae32e
-7
View File
@@ -1,7 +0,0 @@
module booth
go 1.26
require github.com/fsnotify/fsnotify v1.10.1
require golang.org/x/sys v0.44.0 // indirect
@@ -39,6 +39,7 @@ import (
// ── Configuration ────────────────────────────────────────────────────────────── // ── Configuration ──────────────────────────────────────────────────────────────
const ( const (
frameInterval = 5 // Extract one key frame every N seconds frameInterval = 5 // Extract one key frame every N seconds
panelWidth = 320 // Each filmstrip panel width in px panelWidth = 320 // Each filmstrip panel width in px
panelHeight = 240 // Each filmstrip panel height in px panelHeight = 240 // Each filmstrip panel height in px
@@ -56,8 +57,9 @@ const (
vignetteAngle = "PI/4" vignetteAngle = "PI/4"
defaultExhibit = "Stories from the Community" defaultExhibit = "Stories from the Community"
fontBold = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" fontBold = `../../lib/fonts/DejaVuSans-Bold.ttf`
fontRegular = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" fontRegular = `../../lib/fonts/DejaVuSans.ttf`
) )
// videoExtensions that trigger the watch pipeline // videoExtensions that trigger the watch pipeline
@@ -78,6 +80,18 @@ type Metadata struct {
// ── Helpers ──────────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────────
// find ffmpeg and ffprobe
func loadEnv() {
ffmpeg := os.Getenv("FFMPEG_PATH")
if ffmpeg == "" {
ffmpeg = "ffmpeg"
}
ffprobe := os.Getenv("FFPROBE_PATH")
if ffprobe == "" {
ffprobe = "ffprobe"
}
}
// run executes a command, streaming its stderr to our log. Fatal on error. // run executes a command, streaming its stderr to our log. Fatal on error.
func run(label string, name string, args ...string) { func run(label string, name string, args ...string) {
if label != "" { if label != "" {
@@ -99,7 +113,7 @@ func run(label string, name string, args ...string) {
// probeFormat returns the "format" section from ffprobe as a map. // probeFormat returns the "format" section from ffprobe as a map.
func probeFormat(path string) map[string]interface{} { func probeFormat(path string) map[string]interface{} {
out, err := exec.Command( out, err := exec.Command(
"ffprobe", "-v", "quiet", "-print_format", "json", ffprobe, "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", path, "-show_format", "-show_streams", path,
).Output() ).Output()
if err != nil { if err != nil {
@@ -167,7 +181,7 @@ func extractFrames(inputPath, framesDir string) []string {
panelWidth, panelHeight, panelWidth, panelHeight,
panelWidth, panelHeight, panelWidth, panelHeight,
) )
run("", "ffmpeg", "-y", "-i", inputPath, run("", ffmpeg, "-y", "-i", inputPath,
"-vf", vf, "-q:v", "2", "-vf", vf, "-q:v", "2",
filepath.Join(framesDir, "frame_%04d.jpg"), filepath.Join(framesDir, "frame_%04d.jpg"),
) )
@@ -245,7 +259,7 @@ func buildFilmstrip(frames []string, tmpDir string) (stripPath string, stripW, s
stripPath, stripPath,
) )
run("Stitching panels with hstack…", "ffmpeg", args...) run("Stitching panels with hstack…", ffmpeg, args...)
return stripPath, stripW, stripH return stripPath, stripW, stripH
} }
@@ -309,7 +323,7 @@ func composite(
}, ";") }, ";")
run("Rendering composite (this may take a while)…", run("Rendering composite (this may take a while)…",
"ffmpeg", "-y", ffmpeg, "-y",
"-i", inputPath, "-i", inputPath,
"-i", stripPath, "-i", stripPath,
"-filter_complex", filterComplex, "-filter_complex", filterComplex,
@@ -328,7 +342,7 @@ func composite(
func archiveCopy(displayPath, archivePath string) { func archiveCopy(displayPath, archivePath string) {
log.Println("\n[4/5] Writing archive copy (ProRes HQ)...") log.Println("\n[4/5] Writing archive copy (ProRes HQ)...")
run("", run("",
"ffmpeg", "-y", ffmpeg, "-y",
"-i", displayPath, "-i", displayPath,
"-c:v", "prores_ks", "-profile:v", "3", "-c:v", "prores_ks", "-profile:v", "3",
"-c:a", "pcm_s16le", "-c:a", "pcm_s16le",
@@ -467,6 +481,13 @@ func watch(watchDir, outputDir, defaultTitle string) {
func main() { func main() {
log.SetFlags(0) // cleaner output without timestamps log.SetFlags(0) // cleaner output without timestamps
exe, _ := os.Executable()
envPath := filepath.Join(filepath.Dir(exe), "..", "..", ".env")
if err := godotenv.Load(envPath); err != nil {
log.Fatal("Error loading .env file")
}
loadEnv()
processCmd := flag.NewFlagSet("process", flag.ExitOnError) processCmd := flag.NewFlagSet("process", flag.ExitOnError)
pTitle := processCmd.String("title", defaultExhibit, "Exhibit name") pTitle := processCmd.String("title", defaultExhibit, "Exhibit name")
pSpeaker := processCmd.String("speaker", "", "Speaker name (optional)") pSpeaker := processCmd.String("speaker", "", "Speaker name (optional)")
+14
View File
@@ -0,0 +1,14 @@
module booth
go 1.25.0
require github.com/fsnotify/fsnotify v1.7.0
require golang.org/x/sys v0.35.0 // indirect
// Replace directives point the module resolver to your local copies.
// Paths are relative to this go.mod file adjust if your layout differs.
replace (
github.com/fsnotify/fsnotify => ../../lib/fsnotify
golang.org/x/sys => ../../lib/sys
)
+465
View File
@@ -0,0 +1,465 @@
#!/usr/bin/env python3
"""
Museum Story Booth — Video Processing Pipeline (MoviePy)
=========================================================
Produces a panning photo-reel composite from a raw booth recording.
This version uses MoviePy + Pillow for compositing, which means all
the pipeline logic is readable Python rather than FFmpeg filter strings.
Install
-------
pip install moviepy pillow numpy watchdog python-dotenv
MoviePy wraps FFmpeg under the hood for encoding; FFmpeg must be on PATH.
Unlike the pure-FFmpeg version, there is no ImageMagick dependency —
all text rendering uses Pillow directly.
Usage
-----
python booth_moviepy.py process recording.mp4 ./output --speaker "Jane Smith"
python booth_moviepy.py watch ./watch_folder ./output --title "Community Voices"
"""
import argparse
import json
import os
import sys
import time
import tempfile
import subprocess
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
import numpy as np
from moviepy import ColorClip, CompositeVideoClip, ImageClip, VideoFileClip
from PIL import Image, ImageDraw, ImageFont
# ── Configuration ──────────────────────────────────────────────────────────────
FRAME_INTERVAL = 5 # Extract one key frame every N seconds
PANEL_WIDTH = 320 # Each filmstrip panel width in px
PANEL_HEIGHT = 240 # Each filmstrip panel height in px
BORDER = 8 # White border around each panel in px
PANEL_GAP = 10 # Horizontal gap between panels in px
OUTPUT_WIDTH = 1920
OUTPUT_HEIGHT = 1080
OUTPUT_FPS = 25
PAN_SPEED = 55 # Pixels/second the reel scrolls
SUBJECT_SCALE = 0.65 # Subject video height as fraction of OUTPUT_HEIGHT
GRAIN_STRENGTH = 16 # Standard deviation of noise (050)
VIGNETTE_POWER = 2.5 # Higher = stronger vignette falloff
EXHIBIT_NAME = "Stories from the Community"
# System fonts — Pillow will fall back to its built-in if these are absent
FONT_PATH_BOLD = "../../lib/fonts/DejaVuSans-Bold.ttf"
FONT_PATH_REGULAR = "../../lib/fonts/DejaVuSans.ttf"
env_path = Path(__file__).parent / ".." / ".." / ".env"
load_dotenv(env_path)
ffmpeg = os.getenv("FFMPEG_PATH", "ffmpeg")
ffprobe = os.getenv("FFPROBE_PATH", "ffprobe")
# ── Font loader ────────────────────────────────────────────────────────────────
def load_font(path: str, size: int) -> ImageFont.FreeTypeFont:
try:
return ImageFont.truetype(path, size)
except (IOError, OSError):
return ImageFont.load_default()
# ── Sepia ──────────────────────────────────────────────────────────────────────
def apply_sepia(img: Image.Image) -> Image.Image:
"""Apply a classic sepia tone to a PIL image."""
arr = np.array(img.convert("RGB"), dtype=np.float32)
r = arr[:, :, 0]
g = arr[:, :, 1]
b = arr[:, :, 2]
arr[:, :, 0] = np.clip(r * 0.393 + g * 0.769 + b * 0.189, 0, 255)
arr[:, :, 1] = np.clip(r * 0.349 + g * 0.686 + b * 0.168, 0, 255)
arr[:, :, 2] = np.clip(r * 0.272 + g * 0.534 + b * 0.131, 0, 255)
return Image.fromarray(arr.astype(np.uint8))
# ── Grain ──────────────────────────────────────────────────────────────────────
def make_grain_effect(strength: int = GRAIN_STRENGTH):
"""
Returns a MoviePy fl_image function that adds temporal film grain.
Using a closure lets us precompute the RNG once and vary per-frame.
"""
rng = np.random.default_rng()
def add_grain(frame: np.ndarray) -> np.ndarray:
noise = rng.normal(0, strength, frame.shape).astype(np.int16)
return np.clip(frame.astype(np.int16) + noise, 0, 255).astype(np.uint8)
return add_grain
# ── Vignette ──────────────────────────────────────────────────────────────────
def make_vignette_mask(width: int, height: int, power: float = VIGNETTE_POWER) -> np.ndarray:
"""
Returns a (H, W, 1) float32 mask where 1.0 = centre (no darkening)
and 0.0 = corners (fully dark). Applied once, reused every frame.
"""
cx, cy = width / 2, height / 2
y, x = np.ogrid[:height, :width]
dist = np.sqrt(((x - cx) / cx) ** 2 + ((y - cy) / cy) ** 2)
mask = np.clip(1.0 - (dist ** power) * 0.6, 0.0, 1.0)
return mask[:, :, np.newaxis] # broadcast over RGB channels
def make_vignette_effect(width: int, height: int):
"""Returns a MoviePy fl_image function that applies a vignette."""
mask = make_vignette_mask(width, height)
def apply_vignette(frame: np.ndarray) -> np.ndarray:
return (frame.astype(np.float32) * mask).clip(0, 255).astype(np.uint8)
return apply_vignette
# ── Stage 1: Extract key frames ───────────────────────────────────────────────
def extract_frames(input_path: Path, frames_dir: Path) -> list[Path]:
"""
Pull frames from the video at FRAME_INTERVAL seconds using MoviePy.
MoviePy gives us direct numpy array access — no FFmpeg subprocess needed.
"""
print("\n[1/5] Extracting key frames...")
frames_dir.mkdir(parents=True, exist_ok=True)
with VideoFileClip(str(input_path)) as clip:
duration = clip.duration
times = list(range(0, int(duration), FRAME_INTERVAL))
out_paths = []
for i, t in enumerate(times):
frame = clip.get_frame(t) # numpy (H, W, 3)
img = Image.fromarray(frame)
img = img.resize(
(PANEL_WIDTH, PANEL_HEIGHT), Image.LANCZOS
)
out_path = frames_dir / f"frame_{i:04d}.jpg"
img.save(out_path, quality=92)
out_paths.append(out_path)
print(f"{len(out_paths)} frames extracted")
return out_paths
# ── Stage 2: Build filmstrip image ────────────────────────────────────────────
def build_filmstrip(frames: list[Path]) -> tuple[Image.Image, int, int]:
"""
Stitch frames into a single wide sepia filmstrip PIL image.
Returns (image, strip_width, strip_height).
Doing this in Pillow is far more readable than the equivalent
FFmpeg hstack filter chain.
"""
print("\n[2/5] Building filmstrip image...")
if not frames:
raise RuntimeError("No frames to stitch — check your input video.")
panel_w = PANEL_WIDTH + BORDER * 2
panel_h = PANEL_HEIGHT + BORDER * 2
n = len(frames)
strip_w = n * panel_w + (n - 1) * PANEL_GAP
strip_h = panel_h
strip = Image.new("RGB", (strip_w, strip_h), color=(26, 26, 26))
for i, frame_path in enumerate(frames):
img = Image.open(frame_path).convert("RGB")
img = img.resize((PANEL_WIDTH, PANEL_HEIGHT), Image.LANCZOS)
img = apply_sepia(img)
# White-bordered panel
panel = Image.new("RGB", (panel_w, panel_h), color=(255, 255, 255))
panel.paste(img, (BORDER, BORDER))
x = i * (panel_w + PANEL_GAP)
strip.paste(panel, (x, 0))
print(f" → Filmstrip: {strip_w} × {strip_h} px ({n} panels)")
return strip, strip_w, strip_h
# ── Stage 3: Composite ────────────────────────────────────────────────────────
def composite(
input_path: Path,
strip_img: Image.Image,
strip_w: int,
strip_h: int,
output_path: Path,
metadata: dict,
) -> None:
"""
The main creative stage — pure MoviePy compositing:
• Dark background
• Filmstrip ImageClip panning behind the speaker
• Speaker video centred and scaled
• Film grain + vignette applied as per-frame functions
• Title card burned in via Pillow (no ImageMagick needed)
"""
print("\n[3/5] Compositing panning reel + subject video...")
clip = VideoFileClip(str(input_path))
duration = clip.duration
# ── Subject clip (centred) ────────────────────────────────────────────────
subj_h = int(OUTPUT_HEIGHT * SUBJECT_SCALE)
subj_w = int(subj_h * clip.w / clip.h)
subj_x = (OUTPUT_WIDTH - subj_w) // 2
subj_y = (OUTPUT_HEIGHT - subj_h) // 2
subject = clip.resized((subj_w, subj_h))
# ── Tiled filmstrip (wide enough to cover full pan travel) ────────────────
total_travel = int(duration * PAN_SPEED) + OUTPUT_WIDTH
tile_copies = max(2, total_travel // strip_w + 2)
tiled_w = tile_copies * strip_w
strip_y = (OUTPUT_HEIGHT - strip_h) // 2
tiled = Image.new("RGB", (tiled_w, strip_h), color=(26, 26, 26))
for i in range(tile_copies):
tiled.paste(strip_img, (i * strip_w, 0))
tiled_np = np.array(tiled)
reel_clip = (
ImageClip(tiled_np)
.with_duration(duration)
# Pan: x moves left over time; y stays fixed at strip_y
.with_position(lambda t: (int(-t * PAN_SPEED), strip_y))
)
# ── Title card (Pillow-rendered, overlaid as ImageClip) ───────────────────
title_img = render_title_card(OUTPUT_WIDTH, OUTPUT_HEIGHT, metadata)
title_clip = (
ImageClip(np.array(title_img))
.with_duration(duration)
.with_opacity(1.0)
.with_position((0, 0))
)
# ── Background ────────────────────────────────────────────────────────────
background = ColorClip(
size=(OUTPUT_WIDTH, OUTPUT_HEIGHT),
color=(17, 17, 17),
duration=duration,
)
# ── Composite layers (bottom → top) ──────────────────────────────────────
composite_clip = CompositeVideoClip([
background,
reel_clip,
subject.with_position((subj_x, subj_y)),
title_clip,
], size=(OUTPUT_WIDTH, OUTPUT_HEIGHT))
# ── Per-frame effects: grain then vignette ────────────────────────────────
grain = make_grain_effect(GRAIN_STRENGTH)
vignette = make_vignette_effect(OUTPUT_WIDTH, OUTPUT_HEIGHT)
final = composite_clip.image_transform(lambda f: vignette(grain(f)))
# ── Render ────────────────────────────────────────────────────────────────
print(" Rendering… (this may take a while)")
final.write_videofile(
str(output_path),
fps=OUTPUT_FPS,
codec="libx264",
audio_codec="aac",
bitrate="8000k",
audio=True,
)
clip.close()
def render_title_card(width: int, height: int, metadata: dict) -> Image.Image:
"""
Render a transparent title card as a PIL RGBA image using Pillow.
Returns an image that can be composited directly — no ImageMagick needed.
"""
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
title = metadata.get("title", EXHIBIT_NAME)
date = metadata.get("date", datetime.now().strftime("%B %d, %Y"))
speaker = metadata.get("speaker", "")
font_title = load_font(FONT_PATH_BOLD, 28)
font_sub = load_font(FONT_PATH_REGULAR, 20)
font_speaker = load_font(FONT_PATH_REGULAR, 22)
def centred_text(text, y, font, color):
bbox = draw.textbbox((0, 0), text, font=font)
tw = bbox[2] - bbox[0]
x = (width - tw) // 2
# Shadow
draw.text((x + 1, y + 1), text, font=font, fill=(0, 0, 0, 160))
draw.text((x, y ), text, font=font, fill=color)
centred_text(title, height - 70, font_title, (255, 255, 255, 217))
centred_text(date, height - 36, font_sub, (221, 221, 221, 178))
if speaker:
centred_text(speaker, 30, font_speaker, (255, 213, 128, 230))
return img
# ── Stage 4: Archive copy ─────────────────────────────────────────────────────
def archive_copy(display_path: Path, archive_path: Path) -> None:
"""Transcode the display MP4 to a lossless ProRes HQ archive."""
print("\n[4/5] Writing archive copy (ProRes HQ)...")
subprocess.run([
ffmpeg, "-y",
"-i", str(display_path),
"-c:v", "prores_ks", "-profile:v", "3",
"-c:a", "pcm_s16le",
str(archive_path),
], check=True, stderr=subprocess.DEVNULL)
# ── Stage 5: Sidecar metadata ─────────────────────────────────────────────────
def write_sidecar(output_path: Path, metadata: dict, duration: float) -> None:
print("\n[5/5] Writing sidecar metadata...")
data = {
"content_id": output_path.stem,
"exhibit": metadata.get("title", EXHIBIT_NAME),
"speaker": metadata.get("speaker", ""),
"date_recorded": metadata.get("date", datetime.now().isoformat()),
"duration_seconds": round(duration, 2),
"pipeline_version": "1.0 (moviepy)",
}
sidecar = output_path.with_suffix(".json")
sidecar.write_text(json.dumps(data, indent=2))
print(f"{sidecar}")
# ── Orchestrator ──────────────────────────────────────────────────────────────
def process(input_path: str, output_dir: str, metadata: dict = None) -> tuple[Path, Path]:
input_path = Path(input_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if metadata is None:
metadata = {}
metadata.setdefault("title", EXHIBIT_NAME)
metadata.setdefault("date", datetime.now().strftime("%B %d, %Y"))
stem = input_path.stem
display_out = output_dir / f"{stem}_display.mp4"
archive_out = output_dir / f"{stem}_archive.mov"
print(f"\n{'='*60}")
print(f" Processing: {input_path.name}")
print(f"{'='*60}")
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
frames = extract_frames(input_path, tmp / "frames")
strip_img, sw, sh = build_filmstrip(frames)
composite(input_path, strip_img, sw, sh, display_out, metadata)
with VideoFileClip(str(input_path)) as c:
duration = c.duration
write_sidecar(display_out, metadata, duration)
archive_copy(display_out, archive_out)
print(f"\n✓ Display copy → {display_out}")
print(f"✓ Archive copy → {archive_out}")
return display_out, archive_out
# ── Watchdog automation ───────────────────────────────────────────────────────
def run_watchdog(watch_dir: str, output_dir: str, default_title: str = EXHIBIT_NAME):
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
except ImportError:
sys.exit("watchdog not installed — run: pip install watchdog")
EXTENSIONS = {".mp4", ".mov", ".mxf", ".avi"}
class BoothHandler(FileSystemEventHandler):
def on_created(self, event):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix.lower() not in EXTENSIONS:
return
time.sleep(4)
print(f"\n★ New recording detected: {path.name}")
try:
process(str(path), output_dir, {
"title": default_title,
"date": datetime.now().strftime("%B %d, %Y"),
})
except Exception as exc:
print(f"\n✗ Pipeline failed for {path.name}: {exc}")
observer = Observer()
observer.schedule(BoothHandler(), watch_dir, recursive=False)
observer.start()
print(f"Watching {watch_dir!r} for new recordings… (Ctrl+C to stop)")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Museum story booth — MoviePy pipeline",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("process", help="Process a single recording")
p.add_argument("input")
p.add_argument("output_dir")
p.add_argument("--title", default=EXHIBIT_NAME)
p.add_argument("--speaker", default="")
w = sub.add_parser("watch", help="Watch a folder and auto-process")
w.add_argument("watch_dir")
w.add_argument("output_dir")
w.add_argument("--title", default=EXHIBIT_NAME)
args = parser.parse_args()
if args.cmd == "process":
process(args.input, args.output_dir, {
"title": args.title,
"speaker": args.speaker,
"date": datetime.now().strftime("%B %d, %Y"),
})
elif args.cmd == "watch":
run_watchdog(args.watch_dir, args.output_dir, args.title)
if __name__ == "__main__":
main()
+573
View File
@@ -0,0 +1,573 @@
//! Museum Story Booth — Video Processing Pipeline (Rust)
//! =======================================================
//! Produces a panning photo-reel composite from a raw booth recording.
//!
//! Build
//! -----
//! cargo build --release
//! Cross-compile for Windows from Linux/macOS:
//! cargo build --release --target x86_64-pc-windows-gnu
//!
//! Usage
//! -----
//! booth process recording.mp4 ./output --speaker "Jane Smith"
//! booth watch ./watch_folder ./output --title "Community Voices"
//!
//! Dependencies
//! ------------
//! ffmpeg + ffprobe must be available (configure paths in Config below)
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use notify::{EventKind, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
// ── Configuration ─────────────────────────────────────────────────────────────
const FRAME_INTERVAL: u32 = 5; // Extract one key frame every N seconds
const PANEL_WIDTH: u32 = 320;
const PANEL_HEIGHT: u32 = 240;
const BORDER: u32 = 8; // White border around each panel in px
const PANEL_GAP: u32 = 10; // Gap between panels in px
const OUTPUT_WIDTH: u32 = 1920;
const OUTPUT_HEIGHT: u32 = 1080;
const OUTPUT_FPS: u32 = 25;
const PAN_SPEED: u32 = 55; // Pixels/second the reel scrolls
const SUBJECT_SCALE: f64 = 0.65; // Subject height as fraction of OUTPUT_HEIGHT
const GRAIN_STRENGTH: u32 = 16;
const VIGNETTE_ANGLE: &str = "PI/4";
const DEFAULT_EXHIBIT: &str = "Stories from the Community";
const VIDEO_EXTENSIONS: &[&str] = &[".mp4", ".mov", ".mxf", ".avi"];
// ── Runtime config (paths resolved relative to the executable) ────────────────
struct Config {
ffmpeg: PathBuf,
ffprobe: PathBuf,
font_bold: PathBuf,
font_regular: PathBuf,
}
impl Config {
fn new() -> Self {
// Executable lives in build/; repo root is one level up
let exe = env::current_exe().expect("cannot locate executable");
let repo = exe.parent().unwrap().parent().unwrap();
let fonts = repo.join("lib").join("fonts");
// ffmpeg lives at lib/ffmpeg/bin/ in the repo,
// matching the school machine layout where it was installed from source
let ffmpeg_bin = repo.join("lib").join("ffmpeg").join("bin");
Config {
ffmpeg: ffmpeg_bin.join("ffmpeg.exe"),
ffprobe: ffmpeg_bin.join("ffprobe.exe"),
font_bold: fonts.join("DejaVuSans-Bold.ttf"),
font_regular: fonts.join("DejaVuSans.ttf"),
}
}
}
// ── Metadata ──────────────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
struct Sidecar {
content_id: String,
exhibit: String,
speaker: String,
date_recorded: String,
duration_seconds: f64,
pipeline_version: String,
}
// ── Job (what gets processed) ─────────────────────────────────────────────────
#[derive(Clone)]
struct Job {
title: String,
speaker: String,
date: String,
}
impl Job {
fn new(title: &str, speaker: &str) -> Self {
Job {
title: title.to_string(),
speaker: speaker.to_string(),
date: chrono_date(),
}
}
}
fn chrono_date() -> String {
// std only; no chrono crate needed for a simple formatted date
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Days since epoch → approximate date string
let days = secs / 86400;
let (y, m, d) = days_to_ymd(days);
let months = ["January","February","March","April","May","June",
"July","August","September","October","November","December"];
format!("{} {:02}, {}", months[(m - 1) as usize], d, y)
}
/// Naive Gregorian conversion (no external crate needed).
fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
let mut year = 1970u64;
loop {
let leap = is_leap(year);
let days_in_year = if leap { 366 } else { 365 };
if days < days_in_year { break; }
days -= days_in_year;
year += 1;
}
let leap = is_leap(year);
let month_days: &[u64] = if leap {
&[31,29,31,30,31,30,31,31,30,31,30,31]
} else {
&[31,28,31,30,31,30,31,31,30,31,30,31]
};
let mut month = 1u64;
for &md in month_days {
if days < md { break; }
days -= md;
month += 1;
}
(year, month, days + 1)
}
fn is_leap(y: u64) -> bool { y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) }
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Run a command; stream stderr to our stdout; panic on non-zero exit.
fn run(label: &str, program: &Path, args: &[&str]) {
if !label.is_empty() {
println!(" {label}");
}
let preview = format!("{} {}", program.display(), args.join(" "));
println!(" $ {:.120}", preview);
let status = Command::new(program)
.args(args)
.stderr(Stdio::inherit())
.status()
.unwrap_or_else(|e| panic!("failed to spawn {}: {e}", program.display()));
if !status.success() {
panic!("command failed with status {status}");
}
}
/// Run ffprobe and return its JSON output as a parsed value.
fn probe(ffprobe: &Path, path: &Path) -> serde_json::Value {
let out = Command::new(ffprobe)
.args(["-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams",
path.to_str().unwrap()])
.output()
.expect("ffprobe failed");
serde_json::from_slice(&out.stdout).expect("ffprobe JSON parse error")
}
fn get_duration(ffprobe: &Path, path: &Path) -> f64 {
let v = probe(ffprobe, path);
v["format"]["duration"]
.as_str()
.unwrap()
.parse::<f64>()
.unwrap()
}
fn get_video_size(ffprobe: &Path, path: &Path) -> (u32, u32) {
let v = probe(ffprobe, path);
let streams = v["streams"].as_array().unwrap();
for s in streams {
if s["codec_type"] == "video" {
let w = s["width"].as_u64().unwrap() as u32;
let h = s["height"].as_u64().unwrap() as u32;
return (w, h);
}
}
panic!("no video stream in {}", path.display());
}
/// Escape characters special to FFmpeg's drawtext filter.
fn esc(s: &str) -> String {
s.replace('\\', r"\\")
.replace('\'', r"\'")
.replace(':', r"\:")
}
// ── Stage 1: Extract key frames ───────────────────────────────────────────────
fn extract_frames(cfg: &Config, input: &Path, frames_dir: &Path) -> Vec<PathBuf> {
println!("\n[1/5] Extracting key frames...");
fs::create_dir_all(frames_dir).unwrap();
let vf = format!(
"fps=1/{FRAME_INTERVAL},\
scale={PANEL_WIDTH}:{PANEL_HEIGHT}:force_original_aspect_ratio=increase,\
crop={PANEL_WIDTH}:{PANEL_HEIGHT}"
);
let out_pattern = frames_dir.join("frame_%04d.jpg");
run("", &cfg.ffmpeg, &[
"-y", "-i", input.to_str().unwrap(),
"-vf", &vf,
"-q:v", "2",
out_pattern.to_str().unwrap(),
]);
let mut frames: Vec<PathBuf> = fs::read_dir(frames_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == "jpg").unwrap_or(false))
.collect();
frames.sort();
println!("{} frames extracted", frames.len());
frames
}
// ── Stage 2: Build filmstrip ──────────────────────────────────────────────────
fn build_filmstrip(
cfg: &Config,
frames: &[PathBuf],
tmp_dir: &Path,
) -> (PathBuf, u32, u32) {
println!("\n[2/5] Building filmstrip image...");
assert!(!frames.is_empty(), "no frames to stitch");
let n = frames.len() as u32;
let panel_w = PANEL_WIDTH + BORDER * 2;
let panel_h = PANEL_HEIGHT + BORDER * 2;
let strip_w = n * panel_w + (n - 1) * PANEL_GAP;
let strip_h = panel_h;
let out_path = tmp_dir.join("filmstrip.png");
// Build inputs list
let mut args: Vec<String> = vec!["-y".into()];
for f in frames {
args.push("-i".into());
args.push(f.to_str().unwrap().into());
}
let sepia =
"colorchannelmixer=\
rr=0.393:rg=0.769:rb=0.189:\
gr=0.349:gg=0.686:gb=0.168:\
br=0.272:bg=0.534:bb=0.131";
let mut filter_parts: Vec<String> = Vec::new();
// Per-frame: scale → white border pad → sepia
for i in 0..frames.len() {
filter_parts.push(format!(
"[{i}:v]scale={PANEL_WIDTH}:{PANEL_HEIGHT}:\
force_original_aspect_ratio=increase,\
crop={PANEL_WIDTH}:{PANEL_HEIGHT},\
pad={panel_w}:{panel_h}:{BORDER}:{BORDER}:color=white,\
{sepia}[p{i}]"
));
}
// Add right-side gap then hstack
for i in 0..frames.len() {
let extra = if i < frames.len() - 1 { PANEL_GAP } else { 0 };
filter_parts.push(format!(
"[p{i}]pad={}:{panel_h}:0:0:color=0x1a1a1a[g{i}]",
panel_w + extra
));
}
let stack_inputs: String = (0..frames.len()).map(|i| format!("[g{i}]")).collect();
filter_parts.push(format!("{stack_inputs}hstack=inputs={}[strip]", frames.len()));
args.extend([
"-filter_complex".into(), filter_parts.join(";"),
"-map".into(), "[strip]".into(),
"-frames:v".into(), "1".into(),
out_path.to_str().unwrap().into(),
]);
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
run("Stitching panels with hstack…", &cfg.ffmpeg, &arg_refs);
(out_path, strip_w, strip_h)
}
// ── Stage 3: Composite ────────────────────────────────────────────────────────
fn composite(
cfg: &Config,
input: &Path,
strip_path: &Path,
strip_w: u32,
strip_h: u32,
output: &Path,
job: &Job,
) {
println!("\n[3/5] Compositing panning reel + subject video...");
let duration = get_duration(&cfg.ffprobe, input);
let (src_w, src_h) = get_video_size(&cfg.ffprobe, input);
// Subject dimensions
let subj_h = (OUTPUT_HEIGHT as f64 * SUBJECT_SCALE) as u32;
let subj_w = (subj_h as f64 * src_w as f64 / src_h as f64) as u32;
let subj_x = (OUTPUT_WIDTH - subj_w) / 2;
let subj_y = (OUTPUT_HEIGHT - subj_h) / 2;
// Tile copies
let total_travel = (duration * PAN_SPEED as f64) as u32 + OUTPUT_WIDTH;
let tile_copies = (total_travel / strip_w + 2).max(2);
let strip_y = (OUTPUT_HEIGHT - strip_h) / 2;
let pan_x = format!("-(t*{PAN_SPEED})");
// Drawtext
let title = esc(&job.title);
let date = esc(&job.date);
let speaker = esc(&job.speaker);
let fb = cfg.font_bold.to_str().unwrap();
let fr = cfg.font_regular.to_str().unwrap();
let mut drawtext = format!(
"drawtext=fontfile='{fb}':text='{title}':\
fontcolor=white:fontsize=28:alpha=0.85:\
x=(w-text_w)/2:y=h-70:\
shadowcolor=black:shadowx=1:shadowy=1,\
drawtext=fontfile='{fr}':text='{date}':\
fontcolor=0xdddddd:fontsize=20:alpha=0.7:\
x=(w-text_w)/2:y=h-36:\
shadowcolor=black:shadowx=1:shadowy=1"
);
if !speaker.is_empty() {
drawtext.push_str(&format!(
",drawtext=fontfile='{fr}':text='{speaker}':\
fontcolor=0xffd580:fontsize=22:alpha=0.90:\
x=(w-text_w)/2:y=30:\
shadowcolor=black:shadowx=1:shadowy=1"
));
}
let filter_complex = [
format!("[1:v]tile={tile_copies}x1[strip_tiled]"),
format!("[strip_tiled]crop={OUTPUT_WIDTH}:{strip_h}:'{pan_x}':0[reel]"),
format!("color=c=0x111111:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:r={OUTPUT_FPS}[bg]"),
format!("[bg][reel]overlay=0:{strip_y}[bg_reel]"),
format!("[0:v]scale={subj_w}:{subj_h}[subject]"),
format!("[bg_reel][subject]overlay={subj_x}:{subj_y}[with_subject]"),
format!("[with_subject]noise=alls={GRAIN_STRENGTH}:allf=t+u[grainy]"),
format!("[grainy]vignette=angle={VIGNETTE_ANGLE}:mode=forward[vignetted]"),
format!("[vignetted]{drawtext}[out]"),
].join(";");
let duration_str = format!("{:.3}", duration);
let fps_str = OUTPUT_FPS.to_string();
run("Rendering composite (this may take a while)…", &cfg.ffmpeg, &[
"-y",
"-i", input.to_str().unwrap(),
"-i", strip_path.to_str().unwrap(),
"-filter_complex", &filter_complex,
"-map", "[out]",
"-map", "0:a",
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-c:a", "aac", "-b:a", "192k",
"-t", &duration_str,
"-r", &fps_str,
output.to_str().unwrap(),
]);
}
// ── Stage 4: Archive copy ─────────────────────────────────────────────────────
fn archive_copy(cfg: &Config, display: &Path, archive: &Path) {
println!("\n[4/5] Writing archive copy (ProRes HQ)...");
run("", &cfg.ffmpeg, &[
"-y",
"-i", display.to_str().unwrap(),
"-c:v", "prores_ks",
"-profile:v", "3",
"-c:a", "pcm_s16le",
archive.to_str().unwrap(),
]);
}
// ── Stage 5: Sidecar metadata ─────────────────────────────────────────────────
fn write_sidecar(output: &Path, job: &Job, duration: f64) {
println!("\n[5/5] Writing sidecar metadata...");
let stem = output.file_stem().unwrap().to_str().unwrap();
let exhibit = if job.title.is_empty() { DEFAULT_EXHIBIT.into() } else { job.title.clone() };
let data = Sidecar {
content_id: stem.into(),
exhibit,
speaker: job.speaker.clone(),
date_recorded: job.date.clone(),
duration_seconds: (duration * 100.0).round() / 100.0,
pipeline_version: "1.0 (rust)".into(),
};
let sidecar = output.with_extension("json");
let json = serde_json::to_string_pretty(&data).unwrap();
fs::write(&sidecar, json).unwrap();
println!("{}", sidecar.display());
}
// ── Orchestrator ──────────────────────────────────────────────────────────────
fn process(cfg: &Config, input: &Path, output_dir: &Path, job: &Job) {
fs::create_dir_all(output_dir).unwrap();
let stem = input.file_stem().unwrap().to_str().unwrap();
let display_out = output_dir.join(format!("{stem}_display.mp4"));
let archive_out = output_dir.join(format!("{stem}_archive.mov"));
println!("\n{}", "=".repeat(60));
println!(" Processing: {}", input.display());
println!("{}", "=".repeat(60));
let tmp_dir = tempfile::tempdir().expect("failed to create temp dir");
let tmp = tmp_dir.path();
let frames = extract_frames(cfg, input, &tmp.join("frames"));
let (strip_path, strip_w, strip_h) = build_filmstrip(cfg, &frames, tmp);
composite(cfg, input, &strip_path, strip_w, strip_h, &display_out, job);
let duration = get_duration(&cfg.ffprobe, input);
write_sidecar(&display_out, job, duration);
archive_copy(cfg, &display_out, &archive_out);
println!("\n✓ Display copy → {}", display_out.display());
println!("✓ Archive copy → {}", archive_out.display());
}
// ── Watch ─────────────────────────────────────────────────────────────────────
fn watch(cfg: &Config, watch_dir: &Path, output_dir: &Path, default_title: &str) {
use notify::event::CreateKind;
use std::sync::mpsc;
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(tx)
.expect("failed to create watcher");
watcher.watch(watch_dir, RecursiveMode::NonRecursive)
.expect("failed to watch directory");
println!("Watching {:?} for new recordings… (Ctrl+C to stop)", watch_dir);
for res in rx {
match res {
Ok(event) => {
if !matches!(event.kind, EventKind::Create(CreateKind::File)) {
continue;
}
for path in event.paths {
let ext = path.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}").to_lowercase())
.unwrap_or_default();
if !VIDEO_EXTENSIONS.contains(&ext.as_str()) {
continue;
}
// Wait for camera software to finish writing
thread::sleep(Duration::from_secs(4));
println!("\n★ New recording detected: {}", path.display());
let job = Job::new(default_title, "");
let output_dir = output_dir.to_path_buf();
let cfg_ffmpeg = cfg.ffmpeg.clone();
let cfg_ffprobe = cfg.ffprobe.clone();
let cfg_fb = cfg.font_bold.clone();
let cfg_fr = cfg.font_regular.clone();
thread::spawn(move || {
let cfg = Config {
ffmpeg: cfg_ffmpeg,
ffprobe: cfg_ffprobe,
font_bold: cfg_fb,
font_regular: cfg_fr,
};
process(&cfg, &path, &output_dir, &job);
});
}
}
Err(e) => eprintln!("watcher error: {e}"),
}
}
}
// ── CLI ───────────────────────────────────────────────────────────────────────
fn main() {
let args: Vec<String> = env::args().collect();
let cfg = Config::new();
if args.len() < 2 {
eprintln!("Usage:");
eprintln!(" booth process <input> <output_dir> [--title ...] [--speaker ...]");
eprintln!(" booth watch <watch_dir> <output_dir> [--title ...]");
std::process::exit(1);
}
match args[1].as_str() {
"process" => {
if args.len() < 4 {
eprintln!("Usage: booth process <input> <output_dir>");
std::process::exit(1);
}
let input = PathBuf::from(&args[2]);
let output_dir = PathBuf::from(&args[3]);
let title = flag_value(&args, "--title")
.unwrap_or_else(|| DEFAULT_EXHIBIT.into());
let speaker = flag_value(&args, "--speaker")
.unwrap_or_default();
let job = Job::new(&title, &speaker);
process(&cfg, &input, &output_dir, &job);
}
"watch" => {
if args.len() < 4 {
eprintln!("Usage: booth watch <watch_dir> <output_dir>");
std::process::exit(1);
}
let watch_dir = PathBuf::from(&args[2]);
let output_dir = PathBuf::from(&args[3]);
let title = flag_value(&args, "--title")
.unwrap_or_else(|| DEFAULT_EXHIBIT.into());
watch(&cfg, &watch_dir, &output_dir, &title);
}
cmd => {
eprintln!("Unknown command {cmd:?}. Use 'process' or 'watch'.");
std::process::exit(1);
}
}
}
/// Pull the value after a named flag, e.g. --speaker "Jane" → Some("Jane")
fn flag_value(args: &[String], flag: &str) -> Option<String> {
args.windows(2)
.find(|w| w[0] == flag)
.map(|w| w[1].clone())
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "booth-process"
version = "0.1.0"
edition = "2026"
[[bin]]
name = "boothVideoProcess"
path = "src/rust/booth_process.rs"
[dependencies]
# File system event watching (watch mode)
notify = "6.1"
# JSON serialisation for sidecar files + ffprobe parsing
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Temporary directory (cleaned up automatically on drop)
tempfile = "3"
[profile.release]
# Smaller binary, good for deployment on the museum machine
opt-level = 3
lto = true
strip = true