Python first build
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
/build/**
|
/build/**
|
||||||
/lib/yt-dlp
|
/lib/yt-dlp
|
||||||
|
/testfootage/
|
||||||
@@ -1,3 +1,31 @@
|
|||||||
# Käkisalmi proto
|
# Käkisalmi proto
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
## Video Pipeline Implementation
|
||||||
|
|
||||||
|
### FFmpeg with Go
|
||||||
|
|
||||||
|
Teknisesti monimutkaisempi implementaatio, mutta paljon suorituskyvykkäämpi.
|
||||||
|
|
||||||
GOOS=linux GOARCH=amd64 go build -o booth_linux booth_pipeline.go
|
GOOS=linux GOARCH=amd64 go build -o booth_linux booth_pipeline.go
|
||||||
|
go build -o build/booth_win.exe booth_pipeline.go
|
||||||
|
|
||||||
|
|
||||||
|
### Python with MoviePy
|
||||||
|
|
||||||
|
Helpompi muokata ( jos vain osaisi pythonia ;) ), mutta varmaankin hitain kaikista, paitsi ehkä Adobe AE pipeline.
|
||||||
|
|
||||||
|
|
||||||
|
## Big Picture- päätökset
|
||||||
|
|
||||||
|
Aiotaanko kuvauspisteeseen tehdä minkäänlaista valmistusta e.g. vihreä seinä, valaistus yms? Tarvitaanko AI taustanpoistoa, vai voidaanko hyödyntää oikeita työkaluja, kuten CorridorKey?
|
||||||
|
|
||||||
|
Onko tilaajalla jonkinlainen kuva tarkalleen minkälaista toteutusta haluavat? Onko esim. vierivä kuvatausta hyvä?
|
||||||
|
|
||||||
|
Minkälainen palvelin museolla on käytössä tällä hetkellä? Voidaanko sitä hyödyntää, vai hankitaanko paikan päälle kone tätä varten? Jos palvelinta voidaan hyödyntää, onko siinä riittävästi resursseja videon käsittelyyn?
|
||||||
|
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
Nykyinen koodi käyttää hard-coded polkuja koulukoneelta
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ const (
|
|||||||
vignetteAngle = "PI/4"
|
vignetteAngle = "PI/4"
|
||||||
|
|
||||||
defaultExhibit = "Stories from the Community"
|
defaultExhibit = "Stories from the Community"
|
||||||
fontBold = `../lib/fonts/DejaVuSans-Bold.ttf`
|
fontBold = `../../lib/fonts/DejaVuSans-Bold.ttf`
|
||||||
fontRegular = `../lib/fonts/DejaVuSans.ttf`
|
fontRegular = `../../lib/fonts/DejaVuSans.ttf`
|
||||||
)
|
)
|
||||||
|
|
||||||
// videoExtensions that trigger the watch pipeline
|
// videoExtensions that trigger the watch pipeline
|
||||||
@@ -9,6 +9,6 @@ require golang.org/x/sys v0.35.0 // indirect
|
|||||||
// Replace directives point the module resolver to your local copies.
|
// Replace directives point the module resolver to your local copies.
|
||||||
// Paths are relative to this go.mod file — adjust if your layout differs.
|
// Paths are relative to this go.mod file — adjust if your layout differs.
|
||||||
replace (
|
replace (
|
||||||
github.com/fsnotify/fsnotify => ../lib/fsnotify
|
github.com/fsnotify/fsnotify => ../../lib/fsnotify
|
||||||
golang.org/x/sys => ../lib/sys
|
golang.org/x/sys => ../../lib/sys
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
try:
|
||||||
|
# MoviePy v1.x
|
||||||
|
from moviepy.editor import ColorClip, CompositeVideoClip, ImageClip, VideoFileClip
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
# MoviePy v2.x (removed .editor submodule)
|
||||||
|
from moviepy import ColorClip, CompositeVideoClip, ImageClip, VideoFileClip
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 (0–50)
|
||||||
|
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"
|
||||||
|
|
||||||
|
FFMPEG_BIN = r"C:\Users\x119877\ffmpeg\bin\ffmpeg.exe"
|
||||||
|
FFPROBE_BIN = r"C:\Users\x119877\ffmpeg\bin\ffprobe.exe"
|
||||||
|
|
||||||
|
# Tell MoviePy where ffmpeg lives (needed when ffmpeg is not on PATH)
|
||||||
|
os.environ["FFMPEG_BINARY"] = FFMPEG_BIN
|
||||||
|
os.environ["FFPROBE_BINARY"] = FFPROBE_BIN
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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_BIN, "-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()
|
||||||
Reference in New Issue
Block a user