feat: add PyFaceBlur TUI app with welcome screen and path autocomplete
This commit is contained in:
parent
ebd41ac0a4
commit
1a06093f25
1 changed files with 167 additions and 0 deletions
167
src/faceblur/app.py
Normal file
167
src/faceblur/app.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""PyFaceBlur TUI application."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Set model cache before any uniface imports
|
||||||
|
os.environ.setdefault(
|
||||||
|
"UNIFACE_CACHE_DIR",
|
||||||
|
str(Path(__file__).resolve().parent.parent.parent / "models"),
|
||||||
|
)
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Center, Middle, Vertical
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Button, Footer, Input, Label, Static
|
||||||
|
from textual_autocomplete import PathAutoComplete
|
||||||
|
|
||||||
|
|
||||||
|
LOGO = r"""
|
||||||
|
____ _____ ____ _
|
||||||
|
| _ \ _ _| ___|_ _ ___ __| _ \| |_ _ _ __
|
||||||
|
| |_) | | | | |_ / _` |/ __/ _ \ |_) | | | | | '__|
|
||||||
|
| __/| |_| | _| (_| | (_| __/ _ <| | |_| | |
|
||||||
|
|_| \__, |_| \__,_|\___\___|_| \_\_|\__,_|_|
|
||||||
|
|___/
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeScreen(Screen):
|
||||||
|
"""Welcome screen with video path input and settings."""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
WelcomeScreen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
max-height: 24;
|
||||||
|
border: round $accent;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logo {
|
||||||
|
text-align: center;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-input {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#interval-input {
|
||||||
|
width: 20;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#start-btn {
|
||||||
|
margin-top: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error-label {
|
||||||
|
color: $error;
|
||||||
|
text-align: center;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Center():
|
||||||
|
with Middle():
|
||||||
|
with Vertical(id="app-container"):
|
||||||
|
yield Static(LOGO, id="logo")
|
||||||
|
yield Label("Video file:", classes="field-label")
|
||||||
|
video_input = Input(
|
||||||
|
placeholder="Enter path to video file...",
|
||||||
|
id="video-input",
|
||||||
|
)
|
||||||
|
yield video_input
|
||||||
|
yield PathAutoComplete(target=video_input, path=".")
|
||||||
|
yield Label("Frame interval:", classes="field-label")
|
||||||
|
yield Input(
|
||||||
|
value="30",
|
||||||
|
placeholder="Frame interval",
|
||||||
|
id="interval-input",
|
||||||
|
type="integer",
|
||||||
|
)
|
||||||
|
yield Label("", id="error-label")
|
||||||
|
yield Button("Start", id="start-btn", variant="primary")
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
# Forward declaration — ProcessingScreen will be added in Task 5
|
||||||
|
class ProcessingScreen(Screen):
|
||||||
|
"""Placeholder — replaced in Task 5."""
|
||||||
|
|
||||||
|
def __init__(self, video_path: Path, interval: int) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.video_path = video_path
|
||||||
|
self.interval = interval
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Label(f"Processing {self.video_path}...")
|
||||||
|
|
||||||
|
|
||||||
|
class PyFaceBlurApp(App):
|
||||||
|
"""PyFaceBlur TUI application."""
|
||||||
|
|
||||||
|
TITLE = "PyFaceBlur"
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("q", "quit", "Quit", show=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.push_screen(WelcomeScreen())
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
"""Entry point for the TUI app."""
|
||||||
|
app = PyFaceBlurApp()
|
||||||
|
app.run()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue