diff --git a/pyproject.toml b/pyproject.toml index 55a5537..664c05f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "opencv-python", "scikit-learn", "textual>=2.0.0", - "textual-autocomplete>=4.0.0", ] [project.scripts] diff --git a/src/faceblur/app.py b/src/faceblur/app.py index 4215cf8..10392de 100644 --- a/src/faceblur/app.py +++ b/src/faceblur/app.py @@ -23,7 +23,48 @@ from textual.widgets import ( Select, Static, ) -from textual_autocomplete import PathAutoComplete +from textual.events import Key +import glob + + +class PathInput(Input): + """Custom input for bash-like path tab completion.""" + + def on_key(self, event: Key) -> None: + if event.key == "tab": + current = self.value + if not current: + return + + event.prevent_default() + event.stop() + + 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) + 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) LOGO = r""" @@ -47,7 +88,6 @@ class WelcomeScreen(Screen): #app-container { width: 60; height: auto; - border: heavy $accent; padding: 1 2; } @@ -108,12 +148,10 @@ class WelcomeScreen(Screen): with Horizontal(classes="form-row"): yield Label("Video file:", classes="form-label") - video_input = Input( + yield PathInput( placeholder="Enter path to video...", id="video-input", ) - yield video_input - yield PathAutoComplete(target=video_input, path=".") with Horizontal(classes="form-row"): yield Label("Frame interval:", classes="form-label") @@ -172,7 +210,6 @@ class ProcessingScreen(Screen): #processing-container { width: 60; height: auto; - border: heavy $accent; padding: 1 2; } @@ -339,11 +376,18 @@ class FaceSelectionScreen(Screen): #selection-container { width: 65; height: auto; - max-height: 30; - border: heavy $accent; 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; @@ -428,28 +472,31 @@ class FaceSelectionScreen(Screen): "Uncheck faces you want to KEEP visible.", id="selection-help", ) - 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", - ) + + with Vertical(id="faces-list"): + for cluster in self.real_clusters: + sample_info = "" if cluster.id in self.face_samples: - yield Button( - "View", - id=f"view-{cluster.id}", - variant="default", - classes="view-btn", + 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( @@ -542,7 +589,6 @@ class EncodingScreen(Screen): #encoding-container { width: 60; height: auto; - border: heavy $accent; padding: 1 2; } @@ -614,14 +660,18 @@ class EncodingScreen(Screen): def _encode(self) -> None: """Run encoding in background thread.""" - from .encode import encode_video + 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}", pct) + self._update_ui(f"Frame {current}/{total} (Encoder: {encoder_name})", pct) try: - encode_video( + 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, @@ -629,8 +679,9 @@ class EncodingScreen(Screen): frame_interval=self.interval, blur_method=self.blur_method, progress_callback=on_progress, + encoder_override=best_enc, ) - self._update_ui("Encoding complete!", 100) + self._update_ui(f"Encoding complete! (Used {encoder_used})", 100) self.app.call_from_thread(self._show_done) except Exception as e: self._update_ui(f"Error: {e}", 0) @@ -657,6 +708,8 @@ class PyFaceBlurApp(App): CSS = """ Screen { background: $surface; + border: heavy $accent; + padding: 1 2; } """ BINDINGS = [ diff --git a/src/faceblur/blur.py b/src/faceblur/blur.py index 58e9dbe..b3e985d 100644 --- a/src/faceblur/blur.py +++ b/src/faceblur/blur.py @@ -149,20 +149,10 @@ def get_bboxes_for_frame( bbox = interpolate_bboxes(prev_by_cluster[cid], next_by_cluster[cid], t) result.append((cid, bbox)) elif cid in prev_by_cluster: - # Face leaving: shrink to center of previous bbox - x1, y1, x2, y2 = prev_by_cluster[cid] - cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 - center_bbox = (cx, cy, cx, cy) - # Interpolate from full bbox (t=0) to center point (t=1) - bbox = interpolate_bboxes(prev_by_cluster[cid], center_bbox, t) - result.append((cid, bbox)) + # Face leaving: hold previous bbox static to ensure privacy until next keyframe + result.append((cid, prev_by_cluster[cid])) else: - # Face entering: grow from center of next bbox - x1, y1, x2, y2 = next_by_cluster[cid] - cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 - center_bbox = (cx, cy, cx, cy) - # Interpolate from center point (t=0) to full bbox (t=1) - bbox = interpolate_bboxes(center_bbox, next_by_cluster[cid], t) - result.append((cid, bbox)) + # Face entering: hold next bbox static to ensure privacy from previous keyframe + result.append((cid, next_by_cluster[cid])) return result diff --git a/src/faceblur/encode.py b/src/faceblur/encode.py index f9d2da9..7524139 100644 --- a/src/faceblur/encode.py +++ b/src/faceblur/encode.py @@ -92,35 +92,129 @@ def probe_video(video_path: Path) -> dict: return info -def find_best_encoder() -> str: +def find_best_encoder() -> Tuple[str, List[str], List[str]]: """Find the best available H.264 encoder by testing each in priority order. Returns: - Name of the best available encoder + (encoder_name, input_args, output_args) """ - for encoder in ENCODER_PRIORITY: - cmd = [ - "ffmpeg", - "-v", - "quiet", - "-f", - "lavfi", - "-i", - "nullsrc=s=64x64:d=0.1", - "-c:v", - encoder, - "-f", - "null", - "-", - ] - result = subprocess.run(cmd, capture_output=True, timeout=10) - if result.returncode == 0: - return encoder + # 1. NVIDIA NVENC + cmd = [ + "ffmpeg", + "-v", + "quiet", + "-f", + "lavfi", + "-i", + "nullsrc=s=64x64:d=0.1", + "-c:v", + "h264_nvenc", + "-f", + "null", + "-", + ] + if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0: + return "h264_nvenc", [], [] - raise RuntimeError( - "No H.264 encoder found. Available encoders checked: " - + ", ".join(ENCODER_PRIORITY) - ) + # 2. Linux VA-API (AMD/Intel) + cmd = [ + "ffmpeg", + "-v", + "quiet", + "-vaapi_device", + "/dev/dri/renderD128", + "-f", + "lavfi", + "-i", + "nullsrc=s=64x64:d=0.1", + "-vf", + "format=nv12,hwupload", + "-c:v", + "h264_vaapi", + "-f", + "null", + "-", + ] + if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0: + return ( + "h264_vaapi", + ["-vaapi_device", "/dev/dri/renderD128"], + ["-vf", "format=nv12,hwupload"], + ) + + # 3. AMD AMF (Windows/Proprietary Linux) + cmd = [ + "ffmpeg", + "-v", + "quiet", + "-f", + "lavfi", + "-i", + "nullsrc=s=64x64:d=0.1", + "-c:v", + "h264_amf", + "-f", + "null", + "-", + ] + if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0: + return "h264_amf", [], [] + + # 4. Intel QSV + cmd = [ + "ffmpeg", + "-v", + "quiet", + "-f", + "lavfi", + "-i", + "nullsrc=s=64x64:d=0.1", + "-c:v", + "h264_qsv", + "-f", + "null", + "-", + ] + if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0: + return "h264_qsv", [], [] + + # 5. Software fallback (libx264 if installed) + cmd = [ + "ffmpeg", + "-v", + "quiet", + "-f", + "lavfi", + "-i", + "nullsrc=s=64x64:d=0.1", + "-c:v", + "libx264", + "-f", + "null", + "-", + ] + if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0: + return "libx264", [], [] + + # 6. Software fallback (libopenh264) + cmd = [ + "ffmpeg", + "-v", + "quiet", + "-f", + "lavfi", + "-i", + "nullsrc=s=64x64:d=0.1", + "-c:v", + "libopenh264", + "-f", + "null", + "-", + ] + if subprocess.run(cmd, capture_output=True, timeout=5).returncode == 0: + return "libopenh264", [], [] + + raise RuntimeError("No suitable H.264 encoder found on this system.") def build_keyframe_bboxes( @@ -162,7 +256,8 @@ def encode_video( frame_interval: int, blur_method: BlurMethod = "gaussian", progress_callback: Optional[Callable[[int, int], None]] = None, -) -> None: + encoder_override: Optional[Tuple[str, List[str], List[str]]] = None, +) -> str: """Re-encode video with face blur applied to selected clusters. Args: @@ -173,9 +268,17 @@ def encode_video( frame_interval: Frame interval used during detection blur_method: Blur method to use progress_callback: Called with (current_frame, total_frames) + encoder_override: Optional tuple of (encoder_name, enc_in_args, enc_out_args) + + Returns: + String description of the encoder used (for UI reporting) """ video_info = probe_video(input_path) - encoder = find_best_encoder() + + if encoder_override: + encoder_name, enc_in_args, enc_out_args = encoder_override + else: + encoder_name, enc_in_args, enc_out_args = find_best_encoder() keyframe_bboxes, keyframe_indices = build_keyframe_bboxes( clusters, @@ -203,37 +306,38 @@ def encode_video( height, width = first_frame.shape[:2] # Build FFmpeg encode command - ffmpeg_cmd = [ - "ffmpeg", - "-y", - "-f", - "rawvideo", - "-pix_fmt", - "bgr24", - "-s", - f"{width}x{height}", - "-r", - str(fps), - "-i", - "pipe:0", - "-i", - str(input_path), - "-map", - "0:v:0", - ] + ffmpeg_cmd = ["ffmpeg", "-y"] + ffmpeg_cmd.extend(enc_in_args) + ffmpeg_cmd.extend( + [ + "-f", + "rawvideo", + "-pix_fmt", + "bgr24", + "-s", + f"{width}x{height}", + "-r", + str(fps), + "-i", + "pipe:0", + "-i", + str(input_path), + "-map", + "0:v:0", + ] + ) # Map audio from original if present if video_info["audio_codec"]: ffmpeg_cmd.extend(["-map", "1:a:0", "-c:a", "copy"]) + ffmpeg_cmd.extend(enc_out_args) ffmpeg_cmd.extend( [ "-c:v", - encoder, + encoder_name, "-b:v", str(bitrate), - "-pix_fmt", - "yuv420p", str(output_path), ] ) @@ -283,3 +387,5 @@ def encode_video( if proc.returncode != 0: stderr = proc.stderr.read().decode() if proc.stderr else "" raise RuntimeError(f"FFmpeg encoding failed: {stderr}") + + return encoder_name diff --git a/uv.lock b/uv.lock index 368d3d2..e95b025 100644 --- a/uv.lock +++ b/uv.lock @@ -102,7 +102,6 @@ dependencies = [ { name = "opencv-python" }, { name = "scikit-learn" }, { name = "textual" }, - { name = "textual-autocomplete" }, { name = "uniface" }, ] @@ -112,7 +111,6 @@ requires-dist = [ { name = "opencv-python" }, { name = "scikit-learn" }, { name = "textual", specifier = ">=2.0.0" }, - { name = "textual-autocomplete", specifier = ">=4.0.0" }, { name = "uniface" }, ] @@ -743,19 +741,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" }, ] -[[package]] -name = "textual-autocomplete" -version = "4.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "textual" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/3a/80411bc7b94969eb116ad1b18db90f8dce8a1de441278c4a81fee55a27ca/textual_autocomplete-4.0.6.tar.gz", hash = "sha256:2ba2f0d767be4480ecacb3e4b130cf07340e033c3500fc424fed9125d27a4586", size = 97967, upload-time = "2025-09-24T21:19:20.213Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/66/ebe744d79c87f25a42d2654dddbd09462edd595f2ded715245a51a546461/textual_autocomplete-4.0.6-py3-none-any.whl", hash = "sha256:bff69c19386e2cbb4a007503b058dc37671d480a4fa2ddb3959c15ceb4aff9b5", size = 16499, upload-time = "2025-09-24T21:19:18.489Z" }, -] - [[package]] name = "threadpoolctl" version = "3.6.0"