feat: replace Textual app with base sequential CLI structure
This commit is contained in:
parent
78f3b3f91b
commit
ce211fa4be
1 changed files with 37 additions and 697 deletions
|
|
@ -1,707 +1,47 @@
|
||||||
"""PyFaceBlur TUI application."""
|
"""PyFaceBlur sequential CLI application."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import questionary
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.progress import (
|
||||||
|
BarColumn,
|
||||||
|
Progress,
|
||||||
|
SpinnerColumn,
|
||||||
|
TaskProgressColumn,
|
||||||
|
TextColumn,
|
||||||
|
TimeElapsedColumn,
|
||||||
|
)
|
||||||
|
|
||||||
# Set model cache before any uniface imports
|
# Set model cache before any uniface imports
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
"UNIFACE_CACHE_DIR",
|
"UNIFACE_CACHE_DIR",
|
||||||
str(Path(__file__).resolve().parent.parent.parent / "models"),
|
str(Path(__file__).resolve().parent.parent.parent / "models"),
|
||||||
)
|
)
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from .cluster import cluster_faces
|
||||||
from textual.binding import Binding
|
from .detect import FaceDetector
|
||||||
from textual.containers import Center, Horizontal, Middle, Vertical
|
from .encode import encode_video, find_best_encoder
|
||||||
from textual.screen import Screen
|
from .video import extract_frames
|
||||||
from textual.widgets import (
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Footer,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
ProgressBar,
|
|
||||||
Select,
|
|
||||||
Static,
|
|
||||||
)
|
|
||||||
from textual import events
|
|
||||||
import glob
|
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
class PathInput(Input):
|
|
||||||
"""Custom input for bash-like path tab completion."""
|
|
||||||
|
|
||||||
def on_paste(self, event: events.Paste) -> None:
|
|
||||||
# Schedule moving cursor to the end after the default paste handler runs
|
|
||||||
self.call_after_refresh(self._move_cursor_to_end)
|
|
||||||
|
|
||||||
def _move_cursor_to_end(self) -> None:
|
|
||||||
self.cursor_position = len(self.value)
|
|
||||||
|
|
||||||
def on_key(self, event: events.Key) -> None:
|
|
||||||
if event.key == "tab":
|
|
||||||
current = self.value
|
|
||||||
if not current:
|
|
||||||
return
|
|
||||||
|
|
||||||
path = os.path.expanduser(current)
|
|
||||||
matches = glob.glob(path + "*")
|
|
||||||
|
|
||||||
if os.path.isdir(path) and not current.endswith("/"):
|
|
||||||
matches = glob.glob(path + "/*")
|
|
||||||
if not matches:
|
|
||||||
self.value = current + "/"
|
|
||||||
self.cursor_position = len(self.value)
|
|
||||||
event.prevent_default()
|
|
||||||
event.stop()
|
|
||||||
return
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
return
|
|
||||||
|
|
||||||
if len(matches) == 1:
|
|
||||||
match = matches[0]
|
|
||||||
if os.path.isdir(match):
|
|
||||||
match += "/"
|
|
||||||
else:
|
|
||||||
match = os.path.commonprefix(matches)
|
|
||||||
|
|
||||||
if current.startswith("~"):
|
|
||||||
match = match.replace(os.path.expanduser("~"), "~", 1)
|
|
||||||
|
|
||||||
if match != current:
|
|
||||||
self.value = match
|
|
||||||
self.cursor_position = len(self.value)
|
|
||||||
event.prevent_default()
|
|
||||||
event.stop()
|
|
||||||
|
|
||||||
|
|
||||||
class WelcomeScreen(Screen):
|
|
||||||
"""Welcome screen with video path input and settings."""
|
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
|
||||||
WelcomeScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-container {
|
|
||||||
width: 50;
|
|
||||||
height: auto;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#logo {
|
|
||||||
text-align: center;
|
|
||||||
color: $accent;
|
|
||||||
text-style: bold;
|
|
||||||
margin-bottom: 2;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
margin-bottom: 1;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#start-btn {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#error-label {
|
|
||||||
color: $error;
|
|
||||||
text-align: center;
|
|
||||||
display: none;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Center():
|
|
||||||
with Middle():
|
|
||||||
with Vertical(id="app-container"):
|
|
||||||
yield Label("PyFaceBlur", id="logo")
|
|
||||||
|
|
||||||
yield PathInput(
|
|
||||||
placeholder="Path to video file...",
|
|
||||||
id="video-input",
|
|
||||||
classes="form-input",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield Input(
|
|
||||||
value="30",
|
|
||||||
placeholder="Frame interval (default 30)",
|
|
||||||
id="interval-input",
|
|
||||||
type="integer",
|
|
||||||
classes="form-input",
|
|
||||||
)
|
|
||||||
|
|
||||||
yield Label("", id="error-label")
|
|
||||||
yield Button("Start Processing", id="start-btn", variant="primary")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "start-btn":
|
|
||||||
self._validate_and_start()
|
|
||||||
|
|
||||||
def _validate_and_start(self) -> None:
|
|
||||||
video_path = self.query_one("#video-input", Input).value.strip()
|
|
||||||
interval_str = self.query_one("#interval-input", Input).value.strip()
|
|
||||||
error_label = self.query_one("#error-label", Label)
|
|
||||||
|
|
||||||
if not video_path:
|
|
||||||
error_label.update("Please enter a video file path.")
|
|
||||||
error_label.styles.display = "block"
|
|
||||||
return
|
|
||||||
|
|
||||||
path = Path(video_path)
|
|
||||||
if not path.exists():
|
|
||||||
error_label.update(f"File not found: {video_path}")
|
|
||||||
error_label.styles.display = "block"
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
interval = int(interval_str)
|
|
||||||
if interval < 1:
|
|
||||||
raise ValueError
|
|
||||||
except ValueError:
|
|
||||||
error_label.update("Frame interval must be a positive integer.")
|
|
||||||
error_label.styles.display = "block"
|
|
||||||
return
|
|
||||||
|
|
||||||
error_label.styles.display = "none"
|
|
||||||
self.app.push_screen(ProcessingScreen(video_path=path, interval=interval))
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessingScreen(Screen):
|
|
||||||
"""Processing screen — extract, detect, cluster with progress."""
|
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
|
||||||
ProcessingScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#processing-container {
|
|
||||||
width: 60;
|
|
||||||
height: auto;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#processing-title {
|
|
||||||
text-align: center;
|
|
||||||
text-style: bold;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#phase-label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-label {
|
|
||||||
color: $text-muted;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progress-bar {
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, video_path: Path, interval: int) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.video_path = video_path
|
|
||||||
self.interval = interval
|
|
||||||
self.frames = []
|
|
||||||
self.all_faces = []
|
|
||||||
self.clusters = []
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Center():
|
|
||||||
with Middle():
|
|
||||||
with Vertical(id="processing-container"):
|
|
||||||
yield Static("PyFaceBlur", id="processing-title")
|
|
||||||
yield Label("[1/3] Preparing...", id="phase-label")
|
|
||||||
yield ProgressBar(total=100, id="progress-bar")
|
|
||||||
yield Label("", id="status-label")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.run_worker(self._process, thread=True)
|
|
||||||
|
|
||||||
def _update_ui(self, phase: str, status: str, progress: float) -> None:
|
|
||||||
"""Thread-safe UI update."""
|
|
||||||
self.app.call_from_thread(self._do_update_ui, phase, status, progress)
|
|
||||||
|
|
||||||
def _do_update_ui(self, phase: str, status: str, progress: float) -> None:
|
|
||||||
self.query_one("#phase-label", Label).update(phase)
|
|
||||||
self.query_one("#status-label", Label).update(status)
|
|
||||||
bar = self.query_one("#progress-bar", ProgressBar)
|
|
||||||
bar.update(progress=progress)
|
|
||||||
|
|
||||||
def _process(self) -> None:
|
|
||||||
"""Run the full detection pipeline in a background thread."""
|
|
||||||
from .video import extract_frames
|
|
||||||
from .detect import FaceDetector
|
|
||||||
from .cluster import cluster_faces
|
|
||||||
|
|
||||||
# Phase 1: Extract frames
|
|
||||||
self._update_ui("[1/3] Extracting frames...", "Starting FFmpeg...", 0)
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
self._temp_dir = tempfile.mkdtemp(prefix="pyfaceblur_")
|
|
||||||
frames_dir = str(Path(self._temp_dir) / "frames")
|
|
||||||
|
|
||||||
self.frames = extract_frames(
|
|
||||||
str(self.video_path),
|
|
||||||
frames_dir,
|
|
||||||
self.interval,
|
|
||||||
)
|
|
||||||
self._update_ui(
|
|
||||||
"[1/3] Extracting frames...",
|
|
||||||
f"Extracted {len(self.frames)} frames",
|
|
||||||
33,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.frames:
|
|
||||||
self._update_ui("[1/3] Error", "No frames extracted from video.", 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Phase 2: Detect faces
|
|
||||||
self._update_ui("[2/3] Detecting faces...", "Initializing detector...", 33)
|
|
||||||
detector = FaceDetector()
|
|
||||||
self.all_faces = []
|
|
||||||
|
|
||||||
for i, frame in enumerate(self.frames):
|
|
||||||
try:
|
|
||||||
faces = detector.detect_faces(frame.path, frame.index)
|
|
||||||
self.all_faces.extend(faces)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
progress = 33 + (i + 1) / len(self.frames) * 34
|
|
||||||
self._update_ui(
|
|
||||||
"[2/3] Detecting faces...",
|
|
||||||
f"Frame {i + 1}/{len(self.frames)} — {len(self.all_faces)} faces found",
|
|
||||||
progress,
|
|
||||||
)
|
|
||||||
|
|
||||||
detector.close()
|
|
||||||
|
|
||||||
if not self.all_faces:
|
|
||||||
self._update_ui("[2/3] Error", "No faces detected in video.", 67)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Phase 3: Cluster
|
|
||||||
self._update_ui("[3/3] Clustering faces...", "Running DBSCAN...", 67)
|
|
||||||
self.clusters = cluster_faces(self.all_faces)
|
|
||||||
self._update_ui(
|
|
||||||
"[3/3] Clustering complete",
|
|
||||||
f"Found {len([c for c in self.clusters if c.id >= 0])} people",
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write face samples and transition to selection screen
|
|
||||||
face_samples = self._write_face_samples()
|
|
||||||
self.app.call_from_thread(
|
|
||||||
self.app.push_screen,
|
|
||||||
FaceSelectionScreen(
|
|
||||||
video_path=self.video_path,
|
|
||||||
interval=self.interval,
|
|
||||||
clusters=self.clusters,
|
|
||||||
all_faces=self.all_faces,
|
|
||||||
face_samples=face_samples,
|
|
||||||
temp_dir=self._temp_dir,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _write_face_samples(self) -> dict:
|
|
||||||
"""Write one representative face crop per cluster. Returns {cluster_id: path}."""
|
|
||||||
import cv2
|
|
||||||
|
|
||||||
samples_dir = Path(self._temp_dir) / "face_samples"
|
|
||||||
samples_dir.mkdir(exist_ok=True)
|
|
||||||
face_samples = {}
|
|
||||||
|
|
||||||
for cluster in self.clusters:
|
|
||||||
if cluster.id < 0:
|
|
||||||
continue
|
|
||||||
# Pick the face with highest confidence as representative
|
|
||||||
best_face = max(cluster.faces, key=lambda f: f.confidence)
|
|
||||||
image = cv2.imread(str(best_face.frame_path))
|
|
||||||
if image is None:
|
|
||||||
continue
|
|
||||||
x1, y1, x2, y2 = best_face.bbox
|
|
||||||
crop = image[y1:y2, x1:x2]
|
|
||||||
if crop.size == 0:
|
|
||||||
continue
|
|
||||||
sample_path = samples_dir / f"person_{cluster.id:02d}.jpg"
|
|
||||||
cv2.imwrite(str(sample_path), crop)
|
|
||||||
face_samples[cluster.id] = sample_path
|
|
||||||
|
|
||||||
return face_samples
|
|
||||||
|
|
||||||
|
|
||||||
class FaceSelectionScreen(Screen):
|
|
||||||
"""Face selection screen — choose which faces to blur."""
|
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
|
||||||
FaceSelectionScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selection-container {
|
|
||||||
width: 65;
|
|
||||||
height: auto;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#faces-list {
|
|
||||||
height: auto;
|
|
||||||
max-height: 20;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: 1;
|
|
||||||
border: solid $accent-muted;
|
|
||||||
padding: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selection-title {
|
|
||||||
text-align: center;
|
|
||||||
text-style: bold;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selection-help {
|
|
||||||
color: $text-muted;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.face-row {
|
|
||||||
height: 3;
|
|
||||||
margin-bottom: 0;
|
|
||||||
align: left middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.face-checkbox {
|
|
||||||
width: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn {
|
|
||||||
width: auto;
|
|
||||||
margin-left: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blur-method-row {
|
|
||||||
height: 3;
|
|
||||||
align: left middle;
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blur-method-label {
|
|
||||||
width: 16;
|
|
||||||
content-align: right middle;
|
|
||||||
margin-right: 1;
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blur-method {
|
|
||||||
width: 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buttons-row {
|
|
||||||
margin-top: 1;
|
|
||||||
height: 3;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#buttons-row Button {
|
|
||||||
width: 1fr;
|
|
||||||
margin: 0 1;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
video_path: Path,
|
|
||||||
interval: int,
|
|
||||||
clusters: list,
|
|
||||||
all_faces: list,
|
|
||||||
face_samples: dict,
|
|
||||||
temp_dir: str,
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.video_path = video_path
|
|
||||||
self.interval = interval
|
|
||||||
self.clusters = clusters
|
|
||||||
self.all_faces = all_faces
|
|
||||||
self.face_samples = face_samples
|
|
||||||
self.temp_dir = temp_dir
|
|
||||||
# Only show real clusters (not noise cluster -1)
|
|
||||||
self.real_clusters = [c for c in clusters if c.id >= 0]
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Center():
|
|
||||||
with Middle():
|
|
||||||
with Vertical(id="selection-container"):
|
|
||||||
yield Static("PyFaceBlur — Select Faces", id="selection-title")
|
|
||||||
yield Label(
|
|
||||||
f"Found {len(self.real_clusters)} people. "
|
|
||||||
"All faces will be blurred.\n"
|
|
||||||
"Uncheck faces you want to KEEP visible.",
|
|
||||||
id="selection-help",
|
|
||||||
)
|
|
||||||
|
|
||||||
with Vertical(id="faces-list"):
|
|
||||||
for cluster in self.real_clusters:
|
|
||||||
sample_info = ""
|
|
||||||
if cluster.id in self.face_samples:
|
|
||||||
sample_info = (
|
|
||||||
f" [dim]{self.face_samples[cluster.id]}[/dim]"
|
|
||||||
)
|
|
||||||
with Horizontal(classes="face-row"):
|
|
||||||
yield Checkbox(
|
|
||||||
f"Person {cluster.id + 1} "
|
|
||||||
f"({len(cluster.faces)} detections)"
|
|
||||||
f"{sample_info}",
|
|
||||||
value=True,
|
|
||||||
id=f"cluster-{cluster.id}",
|
|
||||||
classes="face-checkbox",
|
|
||||||
)
|
|
||||||
if cluster.id in self.face_samples:
|
|
||||||
yield Button(
|
|
||||||
"View",
|
|
||||||
id=f"view-{cluster.id}",
|
|
||||||
variant="default",
|
|
||||||
classes="view-btn",
|
|
||||||
)
|
|
||||||
|
|
||||||
with Horizontal(id="blur-method-row"):
|
|
||||||
yield Label("Blur method:", id="blur-method-label")
|
|
||||||
yield Select(
|
|
||||||
[
|
|
||||||
("Gaussian", "gaussian"),
|
|
||||||
("Pixelate", "pixelate"),
|
|
||||||
("Blackout", "blackout"),
|
|
||||||
("Elliptical", "elliptical"),
|
|
||||||
("Median", "median"),
|
|
||||||
],
|
|
||||||
value="gaussian",
|
|
||||||
id="blur-method",
|
|
||||||
)
|
|
||||||
with Horizontal(id="buttons-row"):
|
|
||||||
yield Button("Back", id="back-btn")
|
|
||||||
yield Button(
|
|
||||||
"Blur Selected",
|
|
||||||
id="blur-btn",
|
|
||||||
variant="primary",
|
|
||||||
)
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "back-btn":
|
|
||||||
self.app.switch_screen("welcome")
|
|
||||||
elif event.button.id == "blur-btn":
|
|
||||||
self._start_encoding()
|
|
||||||
elif event.button.id and event.button.id.startswith("view-"):
|
|
||||||
cluster_id = int(event.button.id.split("-")[1])
|
|
||||||
if cluster_id in self.face_samples:
|
|
||||||
self._open_image(self.face_samples[cluster_id])
|
|
||||||
|
|
||||||
def _open_image(self, path: Path) -> None:
|
|
||||||
"""Open image in default viewer."""
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
def open_directory(path: Path) -> None:
|
||||||
|
"""Open a directory in the system's default file manager."""
|
||||||
try:
|
try:
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
subprocess.run(["open", str(path)])
|
subprocess.run(["open", str(path)], check=False)
|
||||||
elif sys.platform == "win32":
|
elif sys.platform == "win32":
|
||||||
os.startfile(str(path))
|
os.startfile(str(path))
|
||||||
else:
|
else:
|
||||||
subprocess.run(["xdg-open", str(path)])
|
subprocess.run(["xdg-open", str(path)], check=False)
|
||||||
except Exception:
|
|
||||||
pass # Ignore if viewer fails to launch
|
|
||||||
|
|
||||||
def _start_encoding(self) -> None:
|
|
||||||
# Collect selected cluster IDs
|
|
||||||
selected_ids = set()
|
|
||||||
for cluster in self.real_clusters:
|
|
||||||
cb = self.query_one(f"#cluster-{cluster.id}", Checkbox)
|
|
||||||
if cb.value:
|
|
||||||
selected_ids.add(cluster.id)
|
|
||||||
|
|
||||||
# Always include noise cluster (-1) if it exists
|
|
||||||
for cluster in self.clusters:
|
|
||||||
if cluster.id == -1:
|
|
||||||
selected_ids.add(-1)
|
|
||||||
|
|
||||||
blur_method = str(self.query_one("#blur-method", Select).value)
|
|
||||||
if blur_method == "Select.NoSelection":
|
|
||||||
blur_method = "gaussian"
|
|
||||||
|
|
||||||
# Generate output path
|
|
||||||
stem = self.video_path.stem
|
|
||||||
suffix = self.video_path.suffix
|
|
||||||
output_path = self.video_path.parent / f"{stem}_blurred{suffix}"
|
|
||||||
|
|
||||||
self.app.push_screen(
|
|
||||||
EncodingScreen(
|
|
||||||
video_path=self.video_path,
|
|
||||||
output_path=output_path,
|
|
||||||
clusters=self.clusters,
|
|
||||||
selected_cluster_ids=selected_ids,
|
|
||||||
interval=self.interval,
|
|
||||||
blur_method=blur_method,
|
|
||||||
temp_dir=self.temp_dir,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EncodingScreen(Screen):
|
|
||||||
"""Encoding screen — re-encode video with face blur applied."""
|
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
|
||||||
EncodingScreen {
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#encoding-container {
|
|
||||||
width: 60;
|
|
||||||
height: auto;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#encoding-title {
|
|
||||||
text-align: center;
|
|
||||||
text-style: bold;
|
|
||||||
margin-bottom: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#encoding-status {
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
#encoding-progress {
|
|
||||||
margin: 1 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#output-label {
|
|
||||||
margin-top: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#done-btn {
|
|
||||||
margin-top: 1;
|
|
||||||
width: 100%;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
video_path: Path,
|
|
||||||
output_path: Path,
|
|
||||||
clusters: list,
|
|
||||||
selected_cluster_ids: set,
|
|
||||||
interval: int,
|
|
||||||
blur_method: str,
|
|
||||||
temp_dir: str,
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.video_path = video_path
|
|
||||||
self.output_path = output_path
|
|
||||||
self.clusters = clusters
|
|
||||||
self.selected_cluster_ids = selected_cluster_ids
|
|
||||||
self.interval = interval
|
|
||||||
self.blur_method = blur_method
|
|
||||||
self.temp_dir = temp_dir
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
with Center():
|
|
||||||
with Middle():
|
|
||||||
with Vertical(id="encoding-container"):
|
|
||||||
yield Static("PyFaceBlur", id="encoding-title")
|
|
||||||
yield Label("Encoding video with face blur...", id="encoding-phase")
|
|
||||||
yield ProgressBar(total=100, id="encoding-progress")
|
|
||||||
yield Label("", id="encoding-status")
|
|
||||||
yield Label(f"Output: {self.output_path.name}", id="output-label")
|
|
||||||
yield Button("Done — Exit", id="done-btn", variant="success")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.run_worker(self._encode, thread=True)
|
|
||||||
|
|
||||||
def _update_ui(self, status: str, progress: float) -> None:
|
|
||||||
self.app.call_from_thread(self._do_update_ui, status, progress)
|
|
||||||
|
|
||||||
def _do_update_ui(self, status: str, progress: float) -> None:
|
|
||||||
self.query_one("#encoding-status", Label).update(status)
|
|
||||||
bar = self.query_one("#encoding-progress", ProgressBar)
|
|
||||||
bar.update(progress=progress)
|
|
||||||
|
|
||||||
def _encode(self) -> None:
|
|
||||||
"""Run encoding in background thread."""
|
|
||||||
from .encode import encode_video, find_best_encoder
|
|
||||||
|
|
||||||
def on_progress(current: int, total: int) -> None:
|
|
||||||
pct = (current / total) * 100 if total > 0 else 0
|
|
||||||
self._update_ui(f"Frame {current}/{total} (Encoder: {encoder_name})", pct)
|
|
||||||
|
|
||||||
try:
|
|
||||||
best_enc = find_best_encoder()
|
|
||||||
encoder_name = best_enc[0]
|
|
||||||
self._update_ui(f"Starting encoding with {encoder_name}...", 0)
|
|
||||||
|
|
||||||
encoder_used = encode_video(
|
|
||||||
input_path=self.video_path,
|
|
||||||
output_path=self.output_path,
|
|
||||||
clusters=self.clusters,
|
|
||||||
selected_cluster_ids=self.selected_cluster_ids,
|
|
||||||
frame_interval=self.interval,
|
|
||||||
blur_method=self.blur_method,
|
|
||||||
progress_callback=on_progress,
|
|
||||||
encoder_override=best_enc,
|
|
||||||
)
|
|
||||||
self._update_ui(f"Encoding complete! (Used {encoder_used})", 100)
|
|
||||||
self.app.call_from_thread(self._show_done)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._update_ui(f"Error: {e}", 0)
|
console.print(f"[yellow]Could not automatically open directory: {e}[/yellow]")
|
||||||
|
|
||||||
def _show_done(self) -> None:
|
|
||||||
self.query_one("#encoding-phase", Label).update("Encoding complete!")
|
|
||||||
btn = self.query_one("#done-btn", Button)
|
|
||||||
btn.styles.display = "block"
|
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
||||||
if event.button.id == "done-btn":
|
|
||||||
# Cleanup temp directory
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
if self.temp_dir and Path(self.temp_dir).exists():
|
|
||||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
||||||
self.app.exit()
|
|
||||||
|
|
||||||
|
|
||||||
class PyFaceBlurApp(App):
|
|
||||||
"""PyFaceBlur TUI application."""
|
|
||||||
|
|
||||||
TITLE = "PyFaceBlur"
|
|
||||||
CSS = """
|
|
||||||
Screen {
|
|
||||||
background: $surface;
|
|
||||||
padding: 1 2;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
BINDINGS = [
|
|
||||||
Binding("tab", "focus_next", "Focus Next", show=True),
|
|
||||||
Binding("shift+tab", "focus_previous", "Focus Prev", show=True),
|
|
||||||
Binding("q", "quit", "Quit", show=True),
|
|
||||||
]
|
|
||||||
|
|
||||||
SCREENS = {"welcome": WelcomeScreen}
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.push_screen("welcome")
|
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
|
||||||
"""Entry point for the TUI app."""
|
|
||||||
app = PyFaceBlurApp()
|
|
||||||
app.run()
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue