feat(rembg): add RemoveBG Studio - native background removal app
C++ WebView2 host app driving a Python/rembg engine as a JSON-stdio sidecar, with live before/after preview for images and video frames. Also includes the original CLI (removebg.py) built on the same core/ engine. The WebView2 SDK is fetched on demand via native/third_party/fetch-webview2.ps1 (wired into the Makefile) rather than vendored, since it's ~15MB of prebuilt/generated Microsoft SDK content. A Makefile provides make sync/build/run/clean as the standard entry points.
This commit is contained in:
@@ -22,6 +22,10 @@ sudo python3 fake-wheel/fake_wheel_uinput.py
|
|||||||
|
|
||||||
Requires Linux with the `uinput` kernel module and either root access or a user in the `input` group.
|
Requires Linux with the `uinput` kernel module and either root access or a user in the `input` group.
|
||||||
|
|
||||||
|
### [RemoveBG Studio](./rembg/)
|
||||||
|
|
||||||
|
AI background removal for images and video, with lossless FFmpeg frame extraction/reassembly. Ships as a native Windows app (C++ host embedding WebView2, driving a Python/rembg sidecar over JSON-stdio) with a live before/after preview, plus a scriptable CLI and a legacy Tkinter GUI fallback. See [rembg/README.md](./rembg/README.md) for build/usage details.
|
||||||
|
|
||||||
### [VidBoard Setup](./vidboard-setup-rs/)
|
### [VidBoard Setup](./vidboard-setup-rs/)
|
||||||
|
|
||||||
A Rust TUI installer that copies VidBoard AI model files into existing ComfyUI and Ollama installations on Windows. Automatically detects both applications via open ports, running process command lines, environment variables, and known install locations — pre-filling the paths so you only need to confirm and press Enter.
|
A Rust TUI installer that copies VidBoard AI model files into existing ComfyUI and Ollama installations on Windows. Automatically detects both applications via open ports, running process command lines, environment variables, and known install locations — pre-filling the paths so you only need to confirm and press Enter.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
native/build/
|
||||||
|
native/.webview2/
|
||||||
|
|
||||||
|
# Fetched by native/third_party/fetch-webview2.ps1 (or `make` /
|
||||||
|
# `make configure`) — not vendored, too large/binary to track sensibly.
|
||||||
|
native/third_party/webview2/include/*.h
|
||||||
|
native/third_party/webview2/lib/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# RemoveBG Studio
|
||||||
|
#
|
||||||
|
# Common entry points for building/running the native app and keeping the
|
||||||
|
# Python engine's virtualenv in sync. Requires: CMake, MSVC (Visual Studio
|
||||||
|
# 2022 Build Tools or full VS), PowerShell, and uv (or pip) for Python deps.
|
||||||
|
|
||||||
|
GENERATOR ?= Visual Studio 17 2022
|
||||||
|
CONFIG ?= Release
|
||||||
|
BUILD_DIR := native/build
|
||||||
|
WEBVIEW2_HEADER := native/third_party/webview2/include/WebView2.h
|
||||||
|
EXE := $(BUILD_DIR)/$(CONFIG)/RemoveBGStudio.exe
|
||||||
|
|
||||||
|
.PHONY: all sync fetch-deps configure build run clean
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# --- Python engine -----------------------------------------------------
|
||||||
|
|
||||||
|
sync:
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# --- Native app ----------------------------------------------------------
|
||||||
|
|
||||||
|
$(WEBVIEW2_HEADER):
|
||||||
|
powershell -ExecutionPolicy Bypass -File native/third_party/fetch-webview2.ps1
|
||||||
|
|
||||||
|
fetch-deps: $(WEBVIEW2_HEADER)
|
||||||
|
|
||||||
|
configure: fetch-deps
|
||||||
|
cmake -S native -B $(BUILD_DIR) -G "$(GENERATOR)" -A x64
|
||||||
|
|
||||||
|
build: configure
|
||||||
|
cmake --build $(BUILD_DIR) --config $(CONFIG)
|
||||||
|
|
||||||
|
run: build
|
||||||
|
$(EXE)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
# ✂️ RemoveBG Studio (Images & Video with FFmpeg)
|
||||||
|
|
||||||
|
A powerful, high-performance background removal tool supporting single images, batch image folders, and video files with lossless frame extraction using FFmpeg. Ships as a native Windows app with a modern WebView2 UI, plus a scriptable CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Native App (Recommended)
|
||||||
|
|
||||||
|
`native/` is a small C++ host application: a native window that embeds a **WebView2** view (Chromium, via the OS-provided WebView2 Runtime) for the UI, and spawns the Python engine as a background **sidecar** process it talks to over a newline-delimited JSON protocol on stdin/stdout.
|
||||||
|
|
||||||
|
```
|
||||||
|
native/RemoveBGStudio.exe (native C++ host + WebView2 UI, no console)
|
||||||
|
│
|
||||||
|
├── web/index.html, styles.css, app.js (the actual UI)
|
||||||
|
│
|
||||||
|
└── spawns → .venv/Scripts/python.exe -u core/sidecar.py
|
||||||
|
(rembg + FFmpeg engine, JSON-over-stdio)
|
||||||
|
```
|
||||||
|
|
||||||
|
The C++ side never talks to rembg/FFmpeg directly — all of that logic still lives in `core/processor.py` and `core/ffmpeg_utils.py`, unchanged. `core/sidecar.py` is the only new piece of Python: it wraps those modules in a JSON request/response loop so the native UI can drive them.
|
||||||
|
|
||||||
|
### Building the native app
|
||||||
|
|
||||||
|
Requirements: CMake 3.20+, MSVC (Visual Studio 2022 Build Tools or full VS), PowerShell, `make`, Windows 10/11 x64. No vcpkg or NuGet install needed — `native/third_party/fetch-webview2.ps1` pulls the WebView2 SDK headers/libs straight from nuget.org the first time they're needed. The nlohmann/json single header is small enough to vendor directly and lives at `native/third_party/json/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make sync # uv sync — installs the Python engine's dependencies
|
||||||
|
make run # fetches WebView2 SDK (first run only) → configures → builds → launches
|
||||||
|
```
|
||||||
|
|
||||||
|
Other targets: `make build` (build only), `make fetch-deps` (just the WebView2 SDK fetch), `make clean` (removes `native/build/`).
|
||||||
|
|
||||||
|
The exe locates the repo root by walking up from its own path looking for `pyproject.toml`, then launches the sidecar with `.venv/Scripts/python.exe` if that venv exists, otherwise falls back to `python` on `PATH`.
|
||||||
|
|
||||||
|
For a console-attached debug variant with DevTools enabled (right-click → Inspect in the WebView2 UI) and sidecar stderr visible, configure manually with `-DRBG_CONSOLE=ON`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S native -B native/build -G "Visual Studio 17 2022" -A x64 -DRBG_CONSOLE=ON
|
||||||
|
cmake --build native/build --config Release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Key Features
|
||||||
|
|
||||||
|
- **Single & Batch Image Support**: Remove backgrounds from a single image or an entire folder of images in PNG, WebP, JPG, BMP, or TIFF.
|
||||||
|
- **FFmpeg Lossless Video Processing**:
|
||||||
|
- Extracts frames in **100% Lossless PNG** format (`-c:v png`) to ensure zero quality degradation during extraction.
|
||||||
|
- Preserves original audio streams from input videos.
|
||||||
|
- Supports exporting to QuickTime MOV (ProRes 4444 with Alpha Channel), WebM VP9 (Alpha Transparency), MP4 H.264 (Solid / Green Screen keying), or raw PNG Frame Sequences.
|
||||||
|
- **Multiple AI Models**:
|
||||||
|
- `u2net`: General purpose default model.
|
||||||
|
- `u2netp`: Lightweight and fast model.
|
||||||
|
- `u2net_human_seg`: Optimized for human portraits & people.
|
||||||
|
- `u2net_cloth_seg`: Optimized for fashion & clothing.
|
||||||
|
- `isnet-general-use`: High detail and crisp edge detection.
|
||||||
|
- `birefnet-general`: State-of-the-art high-precision background removal.
|
||||||
|
- **Background Replacement**: Replace background with transparency, green screen keying (`#00FF00`), solid white, solid black, or any custom hex color.
|
||||||
|
- **Alpha Matting**: Optional advanced alpha matting for fine details (hair, fur, transparent fabrics).
|
||||||
|
- **Dual Interface**: Use either the native **GUI** (`native/`) or the scriptable **CLI** (`removebg.py`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 1. Requirements
|
||||||
|
Ensure Python 3.8+ and [FFmpeg](https://ffmpeg.org/) are installed on your system.
|
||||||
|
|
||||||
|
Install required dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Usage
|
||||||
|
|
||||||
|
### ⌨️ Command Line Interface (CLI)
|
||||||
|
|
||||||
|
#### 1. Single Image Processing
|
||||||
|
```bash
|
||||||
|
# Output defaults to input_nobg.png
|
||||||
|
python removebg.py -i portrait.jpg
|
||||||
|
|
||||||
|
# Specify output destination & custom model
|
||||||
|
python removebg.py -i portrait.jpg -o result.png --model isnet-general-use
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Batch Image Processing
|
||||||
|
```bash
|
||||||
|
# Process all images in a directory
|
||||||
|
python removebg.py -b ./my_photos -o ./no_bg_photos
|
||||||
|
|
||||||
|
# Batch processing with green screen background replacement & WebP output
|
||||||
|
python removebg.py -b ./my_photos -o ./green_photos --bg-color green --format webp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Video Processing (FFmpeg Frame Extraction & Reassembly)
|
||||||
|
```bash
|
||||||
|
# Extract frames, remove background, reassemble as transparent MOV ProRes 4444:
|
||||||
|
python removebg.py -v input.mp4 -o output_transparent.mov --export-format mov_transparent
|
||||||
|
|
||||||
|
# Video with Green Screen background keying:
|
||||||
|
python removebg.py -v dance.mp4 -o dance_green.mp4 --export-format mp4_solid --bg-color green
|
||||||
|
|
||||||
|
# Export as WebM with alpha transparency:
|
||||||
|
python removebg.py -v video.mp4 -o video_alpha.webm --export-format webm_transparent
|
||||||
|
|
||||||
|
# Export processed frames as a PNG sequence folder:
|
||||||
|
python removebg.py -v clip.mp4 -o ./processed_frames --export-format png_sequence
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ CLI Options Reference
|
||||||
|
|
||||||
|
| Argument | Short | Description |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `--image` | `-i` | Input image file path(s) |
|
||||||
|
| `--batch` | `-b` | Input directory containing images for batch processing |
|
||||||
|
| `--video` | `-v` | Input video file path for FFmpeg frame extraction & processing |
|
||||||
|
| `--output` | `-o` | Output file path or directory |
|
||||||
|
| `--model` | `-m` | AI model selection (`u2net`, `u2netp`, `u2net_human_seg`, `isnet-general-use`, `birefnet-general`) |
|
||||||
|
| `--bg-color` | `-bg` | Background replacement (`green`, `white`, `black`, or Hex `#00FF00`) |
|
||||||
|
| `--alpha-matting` | `-am` | Enable alpha matting for finer edge detail (hair/fur) |
|
||||||
|
| `--format` | | Output image format (`png`, `webp`, `jpg`) |
|
||||||
|
| `--fps` | | Target frame rate for video frame extraction (0 = original FPS) |
|
||||||
|
| `--export-format` | | Video reassembly format (`mov_transparent`, `webm_transparent`, `mp4_solid`, `png_sequence`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Frame Extraction & Quality Guarantee
|
||||||
|
|
||||||
|
When processing video files:
|
||||||
|
1. **FFmpeg** extracts frames using `-c:v png -pred mixed` into a 24-bit/32-bit PNG sequence, ensuring zero compression loss during frame extraction.
|
||||||
|
2. The AI background removal engine processes each PNG frame cleanly.
|
||||||
|
3. **FFmpeg** reassembles frames with original audio synced into your target codec (ProRes 4444 / VP9 / H.264).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Core background removal and video processing package."""
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Dict, Any, Optional, Callable
|
||||||
|
|
||||||
|
def check_ffmpeg_installed() -> bool:
|
||||||
|
"""Check if ffmpeg is available on the system PATH."""
|
||||||
|
return shutil.which("ffmpeg") is not None
|
||||||
|
|
||||||
|
def check_ffprobe_installed() -> bool:
|
||||||
|
"""Check if ffprobe is available on the system PATH."""
|
||||||
|
return shutil.which("ffprobe") is not None
|
||||||
|
|
||||||
|
def get_video_info(video_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get video metadata using ffprobe or ffmpeg.
|
||||||
|
Returns dictionary with fps, width, height, duration, frame_count, has_audio.
|
||||||
|
"""
|
||||||
|
info = {
|
||||||
|
"fps": 30.0,
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"duration": 0.0,
|
||||||
|
"frame_count": 0,
|
||||||
|
"has_audio": False
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_ffprobe_installed():
|
||||||
|
cmd = [
|
||||||
|
"ffprobe",
|
||||||
|
"-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
video_path
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
if stream.get("codec_type") == "video" and "r_frame_rate" in stream:
|
||||||
|
# Evaluate fps string like "30/1" or "30000/1001"
|
||||||
|
r_fps = stream["r_frame_rate"]
|
||||||
|
if "/" in r_fps:
|
||||||
|
num, den = r_fps.split("/")
|
||||||
|
if float(den) > 0:
|
||||||
|
info["fps"] = float(num) / float(den)
|
||||||
|
else:
|
||||||
|
info["fps"] = float(r_fps)
|
||||||
|
|
||||||
|
info["width"] = int(stream.get("width", 1920))
|
||||||
|
info["height"] = int(stream.get("height", 1080))
|
||||||
|
|
||||||
|
if "nb_frames" in stream and stream["nb_frames"].isdigit():
|
||||||
|
info["frame_count"] = int(stream["nb_frames"])
|
||||||
|
elif stream.get("codec_type") == "audio":
|
||||||
|
info["has_audio"] = True
|
||||||
|
|
||||||
|
if "format" in data and "duration" in data["format"]:
|
||||||
|
info["duration"] = float(data["format"]["duration"])
|
||||||
|
if info["frame_count"] == 0 and info["fps"] > 0:
|
||||||
|
info["frame_count"] = int(info["duration"] * info["fps"])
|
||||||
|
return info
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback parsing via ffmpeg stderr
|
||||||
|
cmd = ["ffmpeg", "-i", video_path]
|
||||||
|
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
stderr = res.stderr
|
||||||
|
|
||||||
|
fps_match = re.search(r"(\d+(?:\.\d+)?)\s+fps", stderr)
|
||||||
|
if fps_match:
|
||||||
|
info["fps"] = float(fps_match.group(1))
|
||||||
|
|
||||||
|
res_match = re.search(r"(\d{2,5})x(\d{2,5})", stderr)
|
||||||
|
if res_match:
|
||||||
|
info["width"] = int(res_match.group(1))
|
||||||
|
info["height"] = int(res_match.group(2))
|
||||||
|
|
||||||
|
dur_match = re.search(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)", stderr)
|
||||||
|
if dur_match:
|
||||||
|
h, m, s = float(dur_match.group(1)), float(dur_match.group(2)), float(dur_match.group(3))
|
||||||
|
info["duration"] = h * 3600 + m * 60 + s
|
||||||
|
info["frame_count"] = int(info["duration"] * info["fps"])
|
||||||
|
|
||||||
|
if "Audio:" in stderr:
|
||||||
|
info["has_audio"] = True
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def extract_frames_lossless(
|
||||||
|
video_path: str,
|
||||||
|
output_dir: str,
|
||||||
|
target_fps: Optional[float] = None,
|
||||||
|
progress_callback: Optional[Callable[[str], None]] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Extract frames from a video into PNG images (100% Lossless) inside output_dir.
|
||||||
|
Returns total number of extracted frames.
|
||||||
|
"""
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
out_pattern = os.path.join(output_dir, "frame_%06d.png")
|
||||||
|
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", video_path]
|
||||||
|
|
||||||
|
if target_fps and target_fps > 0:
|
||||||
|
cmd.extend(["-vf", f"fps={target_fps}"])
|
||||||
|
|
||||||
|
# Lossless PNG output codec (-c:v png -pred mixed)
|
||||||
|
cmd.extend(["-c:v", "png", "-pred", "mixed", out_pattern])
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("Extracting video frames in lossless PNG format...")
|
||||||
|
|
||||||
|
process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg frame extraction failed: {process.stderr}")
|
||||||
|
|
||||||
|
frames = [f for f in os.listdir(output_dir) if f.startswith("frame_") and f.endswith(".png")]
|
||||||
|
frames.sort()
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"Successfully extracted {len(frames)} lossless frames.")
|
||||||
|
|
||||||
|
return len(frames)
|
||||||
|
|
||||||
|
def extract_single_frame(video_path: str, output_path: str, time_offset: float = 0.5) -> str:
|
||||||
|
"""Grab one representative frame from a video (for preview purposes)."""
|
||||||
|
cmd = ["ffmpeg", "-y", "-ss", str(time_offset), "-i", video_path, "-frames:v", "1", output_path]
|
||||||
|
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
if res.returncode != 0 or not os.path.exists(output_path):
|
||||||
|
raise RuntimeError(f"FFmpeg single-frame extraction failed: {res.stderr}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def extract_audio(video_path: str, output_audio_path: str) -> bool:
|
||||||
|
"""Extract audio stream from video file if present."""
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", video_path, "-vn", "-acodec", "aac", "-b:a", "192k", output_audio_path]
|
||||||
|
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
return res.returncode == 0 and os.path.exists(output_audio_path) and os.path.getsize(output_audio_path) > 0
|
||||||
|
|
||||||
|
def reassemble_video(
|
||||||
|
frames_dir: str,
|
||||||
|
output_video_path: str,
|
||||||
|
fps: float = 30.0,
|
||||||
|
audio_path: Optional[str] = None,
|
||||||
|
export_format: str = "mov_transparent",
|
||||||
|
bg_color: Optional[str] = None,
|
||||||
|
progress_callback: Optional[Callable[[str], None]] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Reassemble processed frame sequence back into a video file or frame folder.
|
||||||
|
|
||||||
|
export_format options:
|
||||||
|
- 'mov_transparent': QuickTime MOV with ProRes 4444 + Alpha Transparency
|
||||||
|
- 'webm_transparent': WebM with VP9 + Alpha Transparency
|
||||||
|
- 'mp4_solid': MP4 (H.264) with solid background color (e.g. Green screen #00FF00)
|
||||||
|
- 'png_sequence': Copies/Leaves processed PNG frames in output directory
|
||||||
|
"""
|
||||||
|
if export_format == "png_sequence":
|
||||||
|
out_dir = output_video_path
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
for fname in os.listdir(frames_dir):
|
||||||
|
if fname.endswith(".png"):
|
||||||
|
shutil.copy2(os.path.join(frames_dir, fname), os.path.join(out_dir, fname))
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"PNG sequence exported to {out_dir}")
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
input_pattern = os.path.join(frames_dir, "frame_%06d.png")
|
||||||
|
|
||||||
|
# Ensure parent output directory exists
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(output_video_path)), exist_ok=True)
|
||||||
|
|
||||||
|
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", input_pattern]
|
||||||
|
|
||||||
|
if audio_path and os.path.exists(audio_path):
|
||||||
|
cmd.extend(["-i", audio_path, "-c:a", "aac", "-shortest"])
|
||||||
|
|
||||||
|
if export_format == "mov_transparent":
|
||||||
|
# ProRes 4444 with Alpha Channel (Lossless/visually lossless transparent video)
|
||||||
|
cmd.extend([
|
||||||
|
"-c:v", "prores_ks",
|
||||||
|
"-profile:v", "4",
|
||||||
|
"-pix_fmt", "yuva444p10le",
|
||||||
|
output_video_path
|
||||||
|
])
|
||||||
|
elif export_format == "webm_transparent":
|
||||||
|
# VP9 WebM with Alpha Channel
|
||||||
|
cmd.extend([
|
||||||
|
"-c:v", "libvpx-vp9",
|
||||||
|
"-pix_fmt", "yuva420p",
|
||||||
|
"-b:v", "4M",
|
||||||
|
output_video_path
|
||||||
|
])
|
||||||
|
elif export_format == "mp4_solid":
|
||||||
|
# Standard MP4 H.264
|
||||||
|
cmd.extend([
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-crf", "18",
|
||||||
|
output_video_path
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
# Default MP4 fallback
|
||||||
|
cmd.extend([
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
output_video_path
|
||||||
|
])
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("Reassembling frames into output video format...")
|
||||||
|
|
||||||
|
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
# Fallback to standard libx264 if prores_ks or libvpx-vp9 isn't available
|
||||||
|
if export_format in ["mov_transparent", "webm_transparent"]:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"Codec warning: trying fallback MP4 export for {output_video_path}...")
|
||||||
|
fallback_cmd = [
|
||||||
|
"ffmpeg", "-y", "-framerate", str(fps), "-i", input_pattern
|
||||||
|
]
|
||||||
|
if audio_path and os.path.exists(audio_path):
|
||||||
|
fallback_cmd.extend(["-i", audio_path, "-c:a", "aac", "-shortest"])
|
||||||
|
fallback_cmd.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", output_video_path])
|
||||||
|
res_fb = subprocess.run(fallback_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
if res_fb.returncode != 0:
|
||||||
|
raise RuntimeError(f"FFmpeg video reassembly failed: {res_fb.stderr}")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"FFmpeg video reassembly failed: {res.stderr}")
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(f"Video created successfully: {output_video_path}")
|
||||||
|
|
||||||
|
return output_video_path
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from typing import List, Optional, Callable, Dict, Tuple
|
||||||
|
from PIL import Image, ImageColor
|
||||||
|
import rembg
|
||||||
|
from core import ffmpeg_utils
|
||||||
|
|
||||||
|
# Available model descriptions
|
||||||
|
AVAILABLE_MODELS = {
|
||||||
|
"u2net": "U2-Net (Default - General Purpose)",
|
||||||
|
"u2netp": "U2-Net Lite (Fast, Lightweight)",
|
||||||
|
"u2net_human_seg": "U2-Net Human Seg (Optimized for People & Portraits)",
|
||||||
|
"u2net_cloth_seg": "U2-Net Cloth Seg (Optimized for Clothes/Fashion)",
|
||||||
|
"isnet-general-use": "IS-Net General Use (High Accuracy & Crisp Edges)",
|
||||||
|
"birefnet-general": "BiRefNet General (State-of-the-Art High Precision)"
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackgroundRemoverEngine:
|
||||||
|
def __init__(self, model_name: str = "u2net"):
|
||||||
|
self.model_name = model_name
|
||||||
|
self.session = None
|
||||||
|
self._init_session(model_name)
|
||||||
|
|
||||||
|
def _init_session(self, model_name: str):
|
||||||
|
self.model_name = model_name
|
||||||
|
try:
|
||||||
|
self.session = rembg.new_session(model_name)
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to standard u2net if specific model fails
|
||||||
|
print(f"Warning: Failed to load model {model_name}: {e}. Falling back to u2net.")
|
||||||
|
self.model_name = "u2net"
|
||||||
|
self.session = rembg.new_session("u2net")
|
||||||
|
|
||||||
|
def change_model(self, model_name: str):
|
||||||
|
if model_name != self.model_name:
|
||||||
|
self._init_session(model_name)
|
||||||
|
|
||||||
|
def remove_background(
|
||||||
|
self,
|
||||||
|
image_input: Image.Image,
|
||||||
|
alpha_matting: bool = False,
|
||||||
|
af: int = 240,
|
||||||
|
ab: int = 10,
|
||||||
|
ae: int = 10,
|
||||||
|
bg_color: Optional[str] = None
|
||||||
|
) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Process PIL Image and return PIL Image with background removed or replaced.
|
||||||
|
"""
|
||||||
|
kwargs = {}
|
||||||
|
if alpha_matting:
|
||||||
|
kwargs.update({
|
||||||
|
"alpha_matting": True,
|
||||||
|
"alpha_matting_foreground_threshold": af,
|
||||||
|
"alpha_matting_background_threshold": ab,
|
||||||
|
"alpha_matting_erode_size": ae
|
||||||
|
})
|
||||||
|
|
||||||
|
output_img = rembg.remove(image_input, session=self.session, **kwargs)
|
||||||
|
|
||||||
|
# Ensure RGBA mode
|
||||||
|
if output_img.mode != "RGBA":
|
||||||
|
output_img = output_img.convert("RGBA")
|
||||||
|
|
||||||
|
# If solid background color requested (e.g. Green screen "#00FF00")
|
||||||
|
if bg_color and bg_color.strip() and bg_color.lower() != "transparent":
|
||||||
|
try:
|
||||||
|
rgb = ImageColor.getrgb(bg_color)
|
||||||
|
background = Image.new("RGBA", output_img.size, rgb + (255,))
|
||||||
|
background.paste(output_img, (0, 0), output_img)
|
||||||
|
return background.convert("RGB")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Invalid background color '{bg_color}': {e}")
|
||||||
|
|
||||||
|
return output_img
|
||||||
|
|
||||||
|
def process_single_image(
|
||||||
|
self,
|
||||||
|
input_path: str,
|
||||||
|
output_path: str,
|
||||||
|
alpha_matting: bool = False,
|
||||||
|
bg_color: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""Process a single image file."""
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
raise FileNotFoundError(f"Input file not found: {input_path}")
|
||||||
|
|
||||||
|
img = Image.open(input_path)
|
||||||
|
processed = self.remove_background(
|
||||||
|
img,
|
||||||
|
alpha_matting=alpha_matting,
|
||||||
|
bg_color=bg_color
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||||
|
|
||||||
|
# Save based on file extension
|
||||||
|
ext = os.path.splitext(output_path)[1].lower()
|
||||||
|
if ext in [".jpg", ".jpeg"] and processed.mode == "RGBA":
|
||||||
|
# Convert RGBA to RGB with white background for JPEG
|
||||||
|
bg = Image.new("RGB", processed.size, (255, 255, 255))
|
||||||
|
bg.paste(processed, (0, 0), processed)
|
||||||
|
bg.save(output_path, quality=95)
|
||||||
|
else:
|
||||||
|
processed.save(output_path)
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
def process_batch_images(
|
||||||
|
self,
|
||||||
|
input_paths: List[str],
|
||||||
|
output_dir: str,
|
||||||
|
alpha_matting: bool = False,
|
||||||
|
bg_color: Optional[str] = None,
|
||||||
|
output_format: str = "png",
|
||||||
|
progress_callback: Optional[Callable[[int, int, str], None]] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""Process a list of image paths or directory."""
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
results = []
|
||||||
|
total = len(input_paths)
|
||||||
|
|
||||||
|
for i, in_path in enumerate(input_paths, 1):
|
||||||
|
fname = os.path.splitext(os.path.basename(in_path))[0]
|
||||||
|
out_ext = ".png" if output_format.lower() == "png" else f".{output_format.lower()}"
|
||||||
|
out_path = os.path.join(output_dir, f"{fname}_nobg{out_ext}")
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(i, total, os.path.basename(in_path))
|
||||||
|
|
||||||
|
res = self.process_single_image(
|
||||||
|
in_path,
|
||||||
|
out_path,
|
||||||
|
alpha_matting=alpha_matting,
|
||||||
|
bg_color=bg_color
|
||||||
|
)
|
||||||
|
results.append(res)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def process_video(
|
||||||
|
self,
|
||||||
|
video_path: str,
|
||||||
|
output_path: str,
|
||||||
|
fps: Optional[float] = None,
|
||||||
|
export_format: str = "mov_transparent",
|
||||||
|
bg_color: Optional[str] = None,
|
||||||
|
alpha_matting: bool = False,
|
||||||
|
progress_callback: Optional[Callable[[int, int, str], None]] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Extract video frames in lossless PNG format via ffmpeg, process frames with rembg,
|
||||||
|
and reassemble into output video or PNG frame sequence.
|
||||||
|
"""
|
||||||
|
if not ffmpeg_utils.check_ffmpeg_installed():
|
||||||
|
raise RuntimeError("FFmpeg is not installed or not found on system PATH.")
|
||||||
|
|
||||||
|
if not os.path.exists(video_path):
|
||||||
|
raise FileNotFoundError(f"Video file not found: {video_path}")
|
||||||
|
|
||||||
|
# Get video metadata
|
||||||
|
vinfo = ffmpeg_utils.get_video_info(video_path)
|
||||||
|
actual_fps = fps if (fps and fps > 0) else vinfo.get("fps", 30.0)
|
||||||
|
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="rembg_video_")
|
||||||
|
extracted_frames_dir = os.path.join(temp_dir, "extracted")
|
||||||
|
processed_frames_dir = os.path.join(temp_dir, "processed")
|
||||||
|
audio_file = os.path.join(temp_dir, "audio.aac")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Extract frames losslessly
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 100, "Extracting frames losslessly with FFmpeg...")
|
||||||
|
num_frames = ffmpeg_utils.extract_frames_lossless(
|
||||||
|
video_path,
|
||||||
|
extracted_frames_dir,
|
||||||
|
target_fps=actual_fps
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Extract audio track if present
|
||||||
|
has_audio = False
|
||||||
|
if vinfo.get("has_audio", False):
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 100, "Extracting audio track...")
|
||||||
|
has_audio = ffmpeg_utils.extract_audio(video_path, audio_file)
|
||||||
|
|
||||||
|
# 3. Process frames with rembg
|
||||||
|
os.makedirs(processed_frames_dir, exist_ok=True)
|
||||||
|
frame_files = sorted([f for f in os.listdir(extracted_frames_dir) if f.endswith(".png")])
|
||||||
|
|
||||||
|
for idx, frame_name in enumerate(frame_files, 1):
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(idx, len(frame_files), f"Removing background from frame {idx}/{len(frame_files)}")
|
||||||
|
|
||||||
|
in_f = os.path.join(extracted_frames_dir, frame_name)
|
||||||
|
out_f = os.path.join(processed_frames_dir, frame_name)
|
||||||
|
|
||||||
|
self.process_single_image(
|
||||||
|
in_f,
|
||||||
|
out_f,
|
||||||
|
alpha_matting=alpha_matting,
|
||||||
|
bg_color=bg_color
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Reassemble output video / PNG sequence
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(len(frame_files), len(frame_files), "Reassembling video...")
|
||||||
|
|
||||||
|
final_output = ffmpeg_utils.reassemble_video(
|
||||||
|
frames_dir=processed_frames_dir,
|
||||||
|
output_video_path=output_path,
|
||||||
|
fps=actual_fps,
|
||||||
|
audio_path=audio_file if has_audio else None,
|
||||||
|
export_format=export_format,
|
||||||
|
bg_color=bg_color
|
||||||
|
)
|
||||||
|
|
||||||
|
return final_output
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup temp directory
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
RemoveBG Sidecar - JSON-over-stdio worker process for the native UI.
|
||||||
|
|
||||||
|
Protocol
|
||||||
|
--------
|
||||||
|
stdin (one JSON object per line): {"id": <int>, "cmd": <str>, "params": {...}}
|
||||||
|
stdout (one JSON object per line): {"id": <int>, "event": "progress"|"log"|"done"|"error", ...}
|
||||||
|
|
||||||
|
A single {"event": "ready"} line (no "id") is emitted once on startup once the
|
||||||
|
process is alive and reading. Model loading happens lazily on first use so
|
||||||
|
startup is instant.
|
||||||
|
|
||||||
|
Anything a dependency (rembg / onnxruntime / tqdm) writes via print() or to
|
||||||
|
sys.stdout is redirected to stderr so it can never corrupt the JSON stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import traceback
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Protect the protocol stream: keep the real stdout for our JSON lines only,
|
||||||
|
# and reroute anything the rest of the process prints to stderr.
|
||||||
|
_protocol_out = sys.stdout
|
||||||
|
sys.stdout = sys.stderr
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from core.processor import BackgroundRemoverEngine, AVAILABLE_MODELS
|
||||||
|
from core import ffmpeg_utils
|
||||||
|
|
||||||
|
PREVIEW_MAX_DIM = 1400
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_preview_pair(original: "Image.Image", processed: "Image.Image") -> Dict[str, Any]:
|
||||||
|
proc_buf = io.BytesIO()
|
||||||
|
processed.save(proc_buf, format="PNG")
|
||||||
|
|
||||||
|
orig_buf = io.BytesIO()
|
||||||
|
original.convert("RGB").save(orig_buf, format="JPEG", quality=88)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"original_b64": base64.b64encode(orig_buf.getvalue()).decode("ascii"),
|
||||||
|
"preview_b64": base64.b64encode(proc_buf.getvalue()).decode("ascii"),
|
||||||
|
"width": processed.size[0],
|
||||||
|
"height": processed.size[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _downscale_for_preview(img: "Image.Image") -> "Image.Image":
|
||||||
|
w, h = img.size
|
||||||
|
scale = min(1.0, PREVIEW_MAX_DIM / max(w, h))
|
||||||
|
if scale < 1.0:
|
||||||
|
img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def emit(payload: Dict[str, Any]) -> None:
|
||||||
|
_protocol_out.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||||
|
_protocol_out.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class Sidecar:
|
||||||
|
def __init__(self):
|
||||||
|
self.engine: Optional[BackgroundRemoverEngine] = None
|
||||||
|
self.current_model: Optional[str] = None
|
||||||
|
self.cancel_requested = False
|
||||||
|
|
||||||
|
def get_engine(self, model_name: str) -> BackgroundRemoverEngine:
|
||||||
|
if self.engine is None or self.current_model != model_name:
|
||||||
|
emit({"event": "log", "message": f"Loading model '{model_name}'..."})
|
||||||
|
self.engine = BackgroundRemoverEngine(model_name=model_name)
|
||||||
|
self.current_model = model_name
|
||||||
|
return self.engine
|
||||||
|
|
||||||
|
# ---- command handlers -------------------------------------------------
|
||||||
|
|
||||||
|
def cmd_check_ffmpeg(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {"installed": ffmpeg_utils.check_ffmpeg_installed()}
|
||||||
|
|
||||||
|
def cmd_list_models(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {"models": AVAILABLE_MODELS}
|
||||||
|
|
||||||
|
def cmd_get_video_info(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
path = params["path"]
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError(f"Video file not found: {path}")
|
||||||
|
return ffmpeg_utils.get_video_info(path)
|
||||||
|
|
||||||
|
def cmd_preview_image(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
engine = self.get_engine(params.get("model", "u2net"))
|
||||||
|
img = _downscale_for_preview(Image.open(params["input"]))
|
||||||
|
processed = engine.remove_background(
|
||||||
|
img,
|
||||||
|
alpha_matting=bool(params.get("alpha_matting", False)),
|
||||||
|
bg_color=params.get("bg_color"),
|
||||||
|
)
|
||||||
|
return _encode_preview_pair(img, processed)
|
||||||
|
|
||||||
|
def cmd_preview_video_frame(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not ffmpeg_utils.check_ffmpeg_installed():
|
||||||
|
raise RuntimeError("FFmpeg is not installed or not found on system PATH.")
|
||||||
|
|
||||||
|
engine = self.get_engine(params.get("model", "u2net"))
|
||||||
|
tmp_dir = tempfile.mkdtemp(prefix="rembg_preview_")
|
||||||
|
try:
|
||||||
|
frame_path = os.path.join(tmp_dir, "frame.png")
|
||||||
|
ffmpeg_utils.extract_single_frame(
|
||||||
|
params["video"], frame_path, time_offset=float(params.get("time", 0.5))
|
||||||
|
)
|
||||||
|
img = _downscale_for_preview(Image.open(frame_path))
|
||||||
|
processed = engine.remove_background(
|
||||||
|
img,
|
||||||
|
alpha_matting=bool(params.get("alpha_matting", False)),
|
||||||
|
bg_color=params.get("bg_color"),
|
||||||
|
)
|
||||||
|
return _encode_preview_pair(img, processed)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def cmd_process_image(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
engine = self.get_engine(params.get("model", "u2net"))
|
||||||
|
out = engine.process_single_image(
|
||||||
|
params["input"],
|
||||||
|
params["output"],
|
||||||
|
alpha_matting=bool(params.get("alpha_matting", False)),
|
||||||
|
bg_color=params.get("bg_color"),
|
||||||
|
)
|
||||||
|
return {"output": out}
|
||||||
|
|
||||||
|
def cmd_process_batch(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
engine = self.get_engine(params.get("model", "u2net"))
|
||||||
|
inputs = params["inputs"]
|
||||||
|
total = len(inputs)
|
||||||
|
|
||||||
|
def pcb(current, total_count, fname):
|
||||||
|
emit({"id": req_id, "event": "progress", "current": current, "total": total_count, "message": fname})
|
||||||
|
|
||||||
|
results = engine.process_batch_images(
|
||||||
|
input_paths=inputs,
|
||||||
|
output_dir=params["output_dir"],
|
||||||
|
alpha_matting=bool(params.get("alpha_matting", False)),
|
||||||
|
bg_color=params.get("bg_color"),
|
||||||
|
output_format=params.get("format", "png"),
|
||||||
|
progress_callback=pcb,
|
||||||
|
)
|
||||||
|
return {"results": results, "count": len(results)}
|
||||||
|
|
||||||
|
def cmd_process_video(self, req_id: int, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not ffmpeg_utils.check_ffmpeg_installed():
|
||||||
|
raise RuntimeError("FFmpeg is not installed or not found on system PATH.")
|
||||||
|
|
||||||
|
engine = self.get_engine(params.get("model", "u2net"))
|
||||||
|
fps = params.get("fps") or None
|
||||||
|
|
||||||
|
def pcb(current, total_count, message):
|
||||||
|
emit({"id": req_id, "event": "progress", "current": current, "total": total_count, "message": message})
|
||||||
|
|
||||||
|
out = engine.process_video(
|
||||||
|
video_path=params["video"],
|
||||||
|
output_path=params["output"],
|
||||||
|
fps=fps,
|
||||||
|
export_format=params.get("export_format", "mov_transparent"),
|
||||||
|
bg_color=params.get("bg_color"),
|
||||||
|
alpha_matting=bool(params.get("alpha_matting", False)),
|
||||||
|
progress_callback=pcb,
|
||||||
|
)
|
||||||
|
return {"output": out}
|
||||||
|
|
||||||
|
def dispatch(self, req: Dict[str, Any]) -> None:
|
||||||
|
req_id = req.get("id")
|
||||||
|
cmd = req.get("cmd")
|
||||||
|
params = req.get("params") or {}
|
||||||
|
|
||||||
|
handler = getattr(self, f"cmd_{cmd}", None)
|
||||||
|
if handler is None:
|
||||||
|
emit({"id": req_id, "event": "error", "message": f"Unknown command: {cmd}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = handler(req_id, params)
|
||||||
|
emit({"id": req_id, "event": "done", "result": result})
|
||||||
|
except Exception as e:
|
||||||
|
emit({
|
||||||
|
"id": req_id,
|
||||||
|
"event": "error",
|
||||||
|
"message": str(e),
|
||||||
|
"detail": traceback.format_exc(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
sidecar = Sidecar()
|
||||||
|
emit({"event": "ready"})
|
||||||
|
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
req = json.loads(line)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
emit({"event": "error", "message": f"Malformed JSON request: {e}"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if req.get("cmd") == "shutdown":
|
||||||
|
break
|
||||||
|
|
||||||
|
sidecar.dispatch(req)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(RemoveBGStudio LANGUAGES CXX)
|
||||||
|
|
||||||
|
if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||||
|
message(FATAL_ERROR "RemoveBG Studio only ships a vendored x64 WebView2 loader. Configure for x64.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
|
option(RBG_CONSOLE "Build with a console window for debug logging" OFF)
|
||||||
|
|
||||||
|
set(THIRD_PARTY_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party")
|
||||||
|
|
||||||
|
add_executable(RemoveBGStudio
|
||||||
|
src/main.cpp
|
||||||
|
src/WebViewHost.cpp
|
||||||
|
src/SidecarProcess.cpp
|
||||||
|
src/Dialogs.cpp
|
||||||
|
src/PathUtil.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
if(RBG_CONSOLE)
|
||||||
|
# Console subsystem still shows the window; stdout/stderr are visible for debugging.
|
||||||
|
set_target_properties(RemoveBGStudio PROPERTIES WIN32_EXECUTABLE FALSE)
|
||||||
|
target_compile_definitions(RemoveBGStudio PRIVATE RBG_CONSOLE=1)
|
||||||
|
else()
|
||||||
|
set_target_properties(RemoveBGStudio PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
target_include_directories(RemoveBGStudio PRIVATE
|
||||||
|
"${THIRD_PARTY_DIR}/webview2/include"
|
||||||
|
"${THIRD_PARTY_DIR}/json"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(RemoveBGStudio PRIVATE
|
||||||
|
UNICODE
|
||||||
|
_UNICODE
|
||||||
|
WIN32_LEAN_AND_MEAN
|
||||||
|
NOMINMAX
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(RemoveBGStudio PRIVATE
|
||||||
|
"${THIRD_PARTY_DIR}/webview2/lib/x64/WebView2LoaderStatic.lib"
|
||||||
|
ole32
|
||||||
|
oleaut32
|
||||||
|
shlwapi
|
||||||
|
shell32
|
||||||
|
user32
|
||||||
|
gdi32
|
||||||
|
comctl32
|
||||||
|
comdlg32
|
||||||
|
advapi32
|
||||||
|
version
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_options(RemoveBGStudio PRIVATE /W4 /permissive-)
|
||||||
|
|
||||||
|
# WebView2LoaderStatic.lib is linked statically, so no WebView2Loader.dll is
|
||||||
|
# needed at runtime. Just stage the web UI next to the exe.
|
||||||
|
add_custom_command(TARGET RemoveBGStudio POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/web"
|
||||||
|
"$<TARGET_FILE_DIR:RemoveBGStudio>/web"
|
||||||
|
COMMENT "Staging web/ assets next to RemoveBGStudio.exe"
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#include "Dialogs.h"
|
||||||
|
#include <shobjidl.h>
|
||||||
|
#include <commdlg.h>
|
||||||
|
#include <cwchar>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#pragma comment(lib, "comdlg32.lib")
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::vector<COMDLG_FILTERSPEC> ToComFilters(const std::vector<FilterSpec>& filters) {
|
||||||
|
std::vector<COMDLG_FILTERSPEC> out;
|
||||||
|
out.reserve(filters.size());
|
||||||
|
for (const auto& f : filters) {
|
||||||
|
out.push_back({ f.name.c_str(), f.pattern.c_str() });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring GetResultPath(IShellItem* item) {
|
||||||
|
PWSTR path = nullptr;
|
||||||
|
std::wstring result;
|
||||||
|
if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) {
|
||||||
|
result = path;
|
||||||
|
CoTaskMemFree(path);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<std::wstring> ShowOpenFileDialog(HWND owner, const std::wstring& title, const std::vector<FilterSpec>& filters) {
|
||||||
|
IFileOpenDialog* dialog = nullptr;
|
||||||
|
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto comFilters = ToComFilters(filters);
|
||||||
|
if (!comFilters.empty()) dialog->SetFileTypes((UINT)comFilters.size(), comFilters.data());
|
||||||
|
dialog->SetTitle(title.c_str());
|
||||||
|
|
||||||
|
std::optional<std::wstring> result;
|
||||||
|
if (SUCCEEDED(dialog->Show(owner))) {
|
||||||
|
IShellItem* item = nullptr;
|
||||||
|
if (SUCCEEDED(dialog->GetResult(&item))) {
|
||||||
|
std::wstring path = GetResultPath(item);
|
||||||
|
if (!path.empty()) result = path;
|
||||||
|
item->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog->Release();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::wstring> ShowOpenMultiFileDialog(HWND owner, const std::wstring& title, const std::vector<FilterSpec>& filters) {
|
||||||
|
std::vector<std::wstring> results;
|
||||||
|
|
||||||
|
IFileOpenDialog* dialog = nullptr;
|
||||||
|
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD opts = 0;
|
||||||
|
dialog->GetOptions(&opts);
|
||||||
|
dialog->SetOptions(opts | FOS_ALLOWMULTISELECT | FOS_FORCEFILESYSTEM);
|
||||||
|
|
||||||
|
auto comFilters = ToComFilters(filters);
|
||||||
|
if (!comFilters.empty()) dialog->SetFileTypes((UINT)comFilters.size(), comFilters.data());
|
||||||
|
dialog->SetTitle(title.c_str());
|
||||||
|
|
||||||
|
if (SUCCEEDED(dialog->Show(owner))) {
|
||||||
|
IShellItemArray* items = nullptr;
|
||||||
|
if (SUCCEEDED(dialog->GetResults(&items))) {
|
||||||
|
DWORD count = 0;
|
||||||
|
items->GetCount(&count);
|
||||||
|
for (DWORD i = 0; i < count; ++i) {
|
||||||
|
IShellItem* item = nullptr;
|
||||||
|
if (SUCCEEDED(items->GetItemAt(i, &item))) {
|
||||||
|
std::wstring path = GetResultPath(item);
|
||||||
|
if (!path.empty()) results.push_back(path);
|
||||||
|
item->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog->Release();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::wstring> ShowOpenFolderDialog(HWND owner, const std::wstring& title) {
|
||||||
|
IFileOpenDialog* dialog = nullptr;
|
||||||
|
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD opts = 0;
|
||||||
|
dialog->GetOptions(&opts);
|
||||||
|
dialog->SetOptions(opts | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM);
|
||||||
|
dialog->SetTitle(title.c_str());
|
||||||
|
|
||||||
|
std::optional<std::wstring> result;
|
||||||
|
if (SUCCEEDED(dialog->Show(owner))) {
|
||||||
|
IShellItem* item = nullptr;
|
||||||
|
if (SUCCEEDED(dialog->GetResult(&item))) {
|
||||||
|
std::wstring path = GetResultPath(item);
|
||||||
|
if (!path.empty()) result = path;
|
||||||
|
item->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog->Release();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::wstring> ShowSaveFileDialog(HWND owner, const std::wstring& title, const std::vector<FilterSpec>& filters, const std::wstring& defaultExt, const std::wstring& suggestedName) {
|
||||||
|
IFileSaveDialog* dialog = nullptr;
|
||||||
|
if (FAILED(CoCreateInstance(CLSID_FileSaveDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto comFilters = ToComFilters(filters);
|
||||||
|
if (!comFilters.empty()) dialog->SetFileTypes((UINT)comFilters.size(), comFilters.data());
|
||||||
|
if (!defaultExt.empty()) dialog->SetDefaultExtension(defaultExt.c_str());
|
||||||
|
if (!suggestedName.empty()) dialog->SetFileName(suggestedName.c_str());
|
||||||
|
dialog->SetTitle(title.c_str());
|
||||||
|
|
||||||
|
std::optional<std::wstring> result;
|
||||||
|
if (SUCCEEDED(dialog->Show(owner))) {
|
||||||
|
IShellItem* item = nullptr;
|
||||||
|
if (SUCCEEDED(dialog->GetResult(&item))) {
|
||||||
|
std::wstring path = GetResultPath(item);
|
||||||
|
if (!path.empty()) result = path;
|
||||||
|
item->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog->Release();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::wstring> ShowColorPickerDialog(HWND owner, const std::wstring& initialHex) {
|
||||||
|
static COLORREF customColors[16] = {};
|
||||||
|
|
||||||
|
COLORREF initial = RGB(0, 255, 0);
|
||||||
|
unsigned int r = 0, g = 0, b = 0;
|
||||||
|
std::wstring hex = initialHex;
|
||||||
|
if (!hex.empty() && hex[0] == L'#') hex = hex.substr(1);
|
||||||
|
if (hex.size() == 6 && swscanf_s(hex.c_str(), L"%02x%02x%02x", &r, &g, &b) == 3) {
|
||||||
|
initial = RGB(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
CHOOSECOLORW cc{};
|
||||||
|
cc.lStructSize = sizeof(cc);
|
||||||
|
cc.hwndOwner = owner;
|
||||||
|
cc.rgbResult = initial;
|
||||||
|
cc.lpCustColors = customColors;
|
||||||
|
cc.Flags = CC_FULLOPEN | CC_RGBINIT;
|
||||||
|
|
||||||
|
if (!ChooseColorW(&cc)) return std::nullopt;
|
||||||
|
|
||||||
|
wchar_t buf[8];
|
||||||
|
swprintf_s(buf, L"#%02X%02X%02X", GetRValue(cc.rgbResult), GetGValue(cc.rgbResult), GetBValue(cc.rgbResult));
|
||||||
|
return std::wstring(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <windows.h>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
struct FilterSpec {
|
||||||
|
std::wstring name; // e.g. L"Image Files"
|
||||||
|
std::wstring pattern; // e.g. L"*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.tiff"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single image / video file picker.
|
||||||
|
std::optional<std::wstring> ShowOpenFileDialog(HWND owner, const std::wstring& title, const std::vector<FilterSpec>& filters);
|
||||||
|
|
||||||
|
// Multi-select file picker (batch image mode).
|
||||||
|
std::vector<std::wstring> ShowOpenMultiFileDialog(HWND owner, const std::wstring& title, const std::vector<FilterSpec>& filters);
|
||||||
|
|
||||||
|
// Folder picker (batch input dir / output dir).
|
||||||
|
std::optional<std::wstring> ShowOpenFolderDialog(HWND owner, const std::wstring& title);
|
||||||
|
|
||||||
|
// Save-as picker; returns the chosen path (extension enforced by caller if needed).
|
||||||
|
std::optional<std::wstring> ShowSaveFileDialog(HWND owner, const std::wstring& title, const std::vector<FilterSpec>& filters, const std::wstring& defaultExt, const std::wstring& suggestedName);
|
||||||
|
|
||||||
|
// Returns a "#RRGGBB" hex string, or nullopt if cancelled.
|
||||||
|
std::optional<std::wstring> ShowColorPickerDialog(HWND owner, const std::wstring& initialHex);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#include "PathUtil.h"
|
||||||
|
#include <windows.h>
|
||||||
|
#include <shlwapi.h>
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
bool FileExists(const std::wstring& path) {
|
||||||
|
DWORD attrs = GetFileAttributesW(path.c_str());
|
||||||
|
return attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool DirExists(const std::wstring& path) {
|
||||||
|
DWORD attrs = GetFileAttributesW(path.c_str());
|
||||||
|
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring GetExeDir() {
|
||||||
|
wchar_t buf[MAX_PATH];
|
||||||
|
DWORD len = GetModuleFileNameW(nullptr, buf, MAX_PATH);
|
||||||
|
std::wstring path(buf, len);
|
||||||
|
size_t pos = path.find_last_of(L"\\/");
|
||||||
|
return pos == std::wstring::npos ? L"." : path.substr(0, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring FindRepoRoot() {
|
||||||
|
std::wstring dir = GetExeDir();
|
||||||
|
|
||||||
|
for (int i = 0; i < 8; ++i) {
|
||||||
|
if (FileExists(dir + L"\\pyproject.toml") && DirExists(dir + L"\\core")) {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
size_t pos = dir.find_last_of(L"\\/");
|
||||||
|
if (pos == std::wstring::npos) break;
|
||||||
|
dir = dir.substr(0, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: treat the exe directory itself as root (packaged layout).
|
||||||
|
return GetExeDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring ResolvePythonInterpreter(const std::wstring& repoRoot) {
|
||||||
|
std::wstring venvPython = repoRoot + L"\\.venv\\Scripts\\python.exe";
|
||||||
|
if (FileExists(venvPython)) {
|
||||||
|
return venvPython;
|
||||||
|
}
|
||||||
|
return L"python";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring Utf8ToWide(const std::string& s) {
|
||||||
|
if (s.empty()) return {};
|
||||||
|
int len = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0);
|
||||||
|
std::wstring w(len, L'\0');
|
||||||
|
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), w.data(), len);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string WideToUtf8(const std::wstring& w) {
|
||||||
|
if (w.empty()) return {};
|
||||||
|
int len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr);
|
||||||
|
std::string s(len, '\0');
|
||||||
|
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), s.data(), len, nullptr, nullptr);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
// Directory containing the running executable.
|
||||||
|
std::wstring GetExeDir();
|
||||||
|
|
||||||
|
// Walks upward from the exe directory looking for a "pyproject.toml"
|
||||||
|
// marker to locate the RemoveBG Studio repo root (core/, .venv/, etc.).
|
||||||
|
// Falls back to the exe directory if the marker can't be found (e.g. an
|
||||||
|
// installed/packaged build where core/ is staged next to the exe instead).
|
||||||
|
std::wstring FindRepoRoot();
|
||||||
|
|
||||||
|
// Picks the python interpreter to launch the sidecar with: prefers
|
||||||
|
// "<repo>/.venv/Scripts/python.exe" if present, otherwise falls back to
|
||||||
|
// "python" resolved from PATH.
|
||||||
|
std::wstring ResolvePythonInterpreter(const std::wstring& repoRoot);
|
||||||
|
|
||||||
|
bool FileExists(const std::wstring& path);
|
||||||
|
|
||||||
|
std::wstring Utf8ToWide(const std::string& s);
|
||||||
|
std::string WideToUtf8(const std::wstring& w);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#include "SidecarProcess.h"
|
||||||
|
#include <vector>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Minimal, dependency-free JSON string escaping for the synthetic
|
||||||
|
// log/error events we build locally (stderr lines, spawn failures).
|
||||||
|
std::string JsonEscape(const std::string& s) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(s.size() + 8);
|
||||||
|
for (char c : s) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
char buf[8];
|
||||||
|
snprintf(buf, sizeof(buf), "\\u%04x", c);
|
||||||
|
out += buf;
|
||||||
|
} else {
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SidecarProcess::SidecarProcess(std::wstring pythonExe, std::wstring scriptPath, std::wstring workDir, EventCallback onEvent)
|
||||||
|
: m_pythonExe(std::move(pythonExe))
|
||||||
|
, m_scriptPath(std::move(scriptPath))
|
||||||
|
, m_workDir(std::move(workDir))
|
||||||
|
, m_onEvent(std::move(onEvent)) {
|
||||||
|
}
|
||||||
|
|
||||||
|
SidecarProcess::~SidecarProcess() {
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SidecarProcess::EmitLocal(const std::string& event, const std::string& message) {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << R"({"event":")" << event << R"(","message":")" << JsonEscape(message) << R"("})";
|
||||||
|
if (m_onEvent) m_onEvent(oss.str());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SidecarProcess::Start() {
|
||||||
|
SECURITY_ATTRIBUTES sa{};
|
||||||
|
sa.nLength = sizeof(sa);
|
||||||
|
sa.bInheritHandle = TRUE;
|
||||||
|
|
||||||
|
HANDLE stdinRead = nullptr, stdinWrite = nullptr;
|
||||||
|
HANDLE stdoutRead = nullptr, stdoutWrite = nullptr;
|
||||||
|
HANDLE stderrRead = nullptr, stderrWrite = nullptr;
|
||||||
|
|
||||||
|
if (!CreatePipe(&stdinRead, &stdinWrite, &sa, 0) ||
|
||||||
|
!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0) ||
|
||||||
|
!CreatePipe(&stderrRead, &stderrWrite, &sa, 0)) {
|
||||||
|
EmitLocal("error", "Failed to create pipes for sidecar process.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the ends the *child* uses should be inheritable.
|
||||||
|
SetHandleInformation(stdinWrite, HANDLE_FLAG_INHERIT, 0);
|
||||||
|
SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
|
||||||
|
SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0);
|
||||||
|
|
||||||
|
STARTUPINFOW si{};
|
||||||
|
si.cb = sizeof(si);
|
||||||
|
si.dwFlags = STARTF_USESTDHANDLES;
|
||||||
|
si.hStdInput = stdinRead;
|
||||||
|
si.hStdOutput = stdoutWrite;
|
||||||
|
si.hStdError = stderrWrite;
|
||||||
|
|
||||||
|
// -u: force unbuffered stdio so the line-based JSON protocol is
|
||||||
|
// delivered promptly in both directions.
|
||||||
|
std::wstring cmdLine = L"\"" + m_pythonExe + L"\" -u \"" + m_scriptPath + L"\"";
|
||||||
|
std::vector<wchar_t> cmdBuf(cmdLine.begin(), cmdLine.end());
|
||||||
|
cmdBuf.push_back(L'\0');
|
||||||
|
|
||||||
|
BOOL ok = CreateProcessW(
|
||||||
|
nullptr,
|
||||||
|
cmdBuf.data(),
|
||||||
|
nullptr, nullptr,
|
||||||
|
TRUE, // inherit handles
|
||||||
|
CREATE_NO_WINDOW,
|
||||||
|
nullptr,
|
||||||
|
m_workDir.empty() ? nullptr : m_workDir.c_str(),
|
||||||
|
&si,
|
||||||
|
&m_pi
|
||||||
|
);
|
||||||
|
|
||||||
|
CloseHandle(stdinRead);
|
||||||
|
CloseHandle(stdoutWrite);
|
||||||
|
CloseHandle(stderrWrite);
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
DWORD err = GetLastError();
|
||||||
|
CloseHandle(stdinWrite);
|
||||||
|
CloseHandle(stdoutRead);
|
||||||
|
CloseHandle(stderrRead);
|
||||||
|
EmitLocal("error", "Failed to launch sidecar process (Win32 error " + std::to_string(err) + "). Is Python installed?");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_stdinWrite = stdinWrite;
|
||||||
|
m_stdoutRead = stdoutRead;
|
||||||
|
m_stderrRead = stderrRead;
|
||||||
|
m_running = true;
|
||||||
|
|
||||||
|
m_stdoutReader = std::thread(&SidecarProcess::StdoutReaderThreadProc, this);
|
||||||
|
m_stderrReader = std::thread(&SidecarProcess::StderrReaderThreadProc, this);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SidecarProcess::StdoutReaderThreadProc() {
|
||||||
|
std::string buffer;
|
||||||
|
char chunk[4096];
|
||||||
|
DWORD bytesRead = 0;
|
||||||
|
|
||||||
|
while (m_running) {
|
||||||
|
BOOL ok = ReadFile(m_stdoutRead, chunk, sizeof(chunk), &bytesRead, nullptr);
|
||||||
|
if (!ok || bytesRead == 0) break;
|
||||||
|
|
||||||
|
buffer.append(chunk, bytesRead);
|
||||||
|
|
||||||
|
size_t pos;
|
||||||
|
while ((pos = buffer.find('\n')) != std::string::npos) {
|
||||||
|
std::string line = buffer.substr(0, pos);
|
||||||
|
buffer.erase(0, pos + 1);
|
||||||
|
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||||
|
if (!line.empty() && m_onEvent) m_onEvent(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_running.load()) {
|
||||||
|
EmitLocal("error", "Sidecar process stdout closed unexpectedly (process may have crashed).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SidecarProcess::StderrReaderThreadProc() {
|
||||||
|
std::string buffer;
|
||||||
|
char chunk[4096];
|
||||||
|
DWORD bytesRead = 0;
|
||||||
|
|
||||||
|
while (m_running) {
|
||||||
|
BOOL ok = ReadFile(m_stderrRead, chunk, sizeof(chunk), &bytesRead, nullptr);
|
||||||
|
if (!ok || bytesRead == 0) break;
|
||||||
|
|
||||||
|
buffer.append(chunk, bytesRead);
|
||||||
|
|
||||||
|
// Some dependencies (tqdm-style download progress bars) redraw a
|
||||||
|
// single line in place using '\r' with no '\n' in between. Treat
|
||||||
|
// either as a line boundary, or every one of those redraws piles
|
||||||
|
// up in the buffer and gets emitted as one giant garbled line
|
||||||
|
// once a real '\n' finally arrives.
|
||||||
|
for (;;) {
|
||||||
|
size_t nl = buffer.find('\n');
|
||||||
|
size_t cr = buffer.find('\r');
|
||||||
|
if (nl == std::string::npos && cr == std::string::npos) break;
|
||||||
|
|
||||||
|
size_t pos = (cr == std::string::npos) ? nl : (nl == std::string::npos ? cr : std::min(nl, cr));
|
||||||
|
size_t skip = 1;
|
||||||
|
if (buffer[pos] == '\r' && pos + 1 < buffer.size() && buffer[pos + 1] == '\n') skip = 2;
|
||||||
|
|
||||||
|
std::string line = buffer.substr(0, pos);
|
||||||
|
buffer.erase(0, pos + skip);
|
||||||
|
if (!line.empty()) EmitLocal("log", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SidecarProcess::SendCommand(const std::string& jsonLine) {
|
||||||
|
if (!m_stdinWrite) return;
|
||||||
|
std::lock_guard<std::mutex> lock(m_writeMutex);
|
||||||
|
std::string payload = jsonLine;
|
||||||
|
payload += '\n';
|
||||||
|
DWORD written = 0;
|
||||||
|
WriteFile(m_stdinWrite, payload.data(), (DWORD)payload.size(), &written, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SidecarProcess::Shutdown() {
|
||||||
|
if (!m_running.exchange(false)) return;
|
||||||
|
|
||||||
|
SendCommand(R"({"cmd":"shutdown"})");
|
||||||
|
|
||||||
|
if (m_stdinWrite) { CloseHandle(m_stdinWrite); m_stdinWrite = nullptr; }
|
||||||
|
|
||||||
|
if (m_pi.hProcess) {
|
||||||
|
WaitForSingleObject(m_pi.hProcess, 2000);
|
||||||
|
TerminateProcess(m_pi.hProcess, 0);
|
||||||
|
CloseHandle(m_pi.hProcess);
|
||||||
|
CloseHandle(m_pi.hThread);
|
||||||
|
m_pi = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_stdoutRead) { CloseHandle(m_stdoutRead); m_stdoutRead = nullptr; }
|
||||||
|
if (m_stderrRead) { CloseHandle(m_stderrRead); m_stderrRead = nullptr; }
|
||||||
|
|
||||||
|
if (m_stdoutReader.joinable()) m_stdoutReader.join();
|
||||||
|
if (m_stderrReader.joinable()) m_stderrReader.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <windows.h>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <mutex>
|
||||||
|
#include <atomic>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
// Owns the child "python core/sidecar.py" process and speaks the
|
||||||
|
// newline-delimited JSON protocol over its stdin/stdout pipes.
|
||||||
|
class SidecarProcess {
|
||||||
|
public:
|
||||||
|
// Called (from a background thread) once per complete line the sidecar
|
||||||
|
// writes to stdout. Also synthesized for stderr lines (library
|
||||||
|
// warnings, tracebacks) so nothing is silently lost.
|
||||||
|
using EventCallback = std::function<void(const std::string& jsonLine)>;
|
||||||
|
|
||||||
|
SidecarProcess(std::wstring pythonExe, std::wstring scriptPath, std::wstring workDir, EventCallback onEvent);
|
||||||
|
~SidecarProcess();
|
||||||
|
|
||||||
|
SidecarProcess(const SidecarProcess&) = delete;
|
||||||
|
SidecarProcess& operator=(const SidecarProcess&) = delete;
|
||||||
|
|
||||||
|
// Launches the child process. Returns false (with a human-readable
|
||||||
|
// error via onEvent) on failure.
|
||||||
|
bool Start();
|
||||||
|
|
||||||
|
// Sends one already-serialized JSON object (no trailing newline needed).
|
||||||
|
void SendCommand(const std::string& jsonLine);
|
||||||
|
|
||||||
|
void Shutdown();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void StdoutReaderThreadProc();
|
||||||
|
void StderrReaderThreadProc();
|
||||||
|
void EmitLocal(const std::string& event, const std::string& message);
|
||||||
|
|
||||||
|
std::wstring m_pythonExe;
|
||||||
|
std::wstring m_scriptPath;
|
||||||
|
std::wstring m_workDir;
|
||||||
|
EventCallback m_onEvent;
|
||||||
|
|
||||||
|
PROCESS_INFORMATION m_pi{};
|
||||||
|
HANDLE m_stdinWrite = nullptr;
|
||||||
|
HANDLE m_stdoutRead = nullptr;
|
||||||
|
HANDLE m_stderrRead = nullptr;
|
||||||
|
|
||||||
|
std::thread m_stdoutReader;
|
||||||
|
std::thread m_stderrReader;
|
||||||
|
std::mutex m_writeMutex;
|
||||||
|
std::atomic<bool> m_running{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#include "WebViewHost.h"
|
||||||
|
|
||||||
|
using namespace Microsoft::WRL;
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
WebViewHost::WebViewHost(HWND hwnd, std::wstring userDataFolder, bool devToolsEnabled)
|
||||||
|
: m_hwnd(hwnd)
|
||||||
|
, m_userDataFolder(std::move(userDataFolder))
|
||||||
|
, m_devToolsEnabled(devToolsEnabled) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebViewHost::Initialize(std::function<void()> onReady) {
|
||||||
|
m_onReady = std::move(onReady);
|
||||||
|
|
||||||
|
HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(
|
||||||
|
nullptr,
|
||||||
|
m_userDataFolder.c_str(),
|
||||||
|
nullptr,
|
||||||
|
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
|
||||||
|
[this](HRESULT result, ICoreWebView2Environment* env) -> HRESULT {
|
||||||
|
if (FAILED(result) || !env) {
|
||||||
|
MessageBoxW(m_hwnd,
|
||||||
|
L"Failed to create the WebView2 environment.\n\n"
|
||||||
|
L"Make sure the WebView2 Runtime is installed "
|
||||||
|
L"(it ships with Windows 11 and current Windows 10 builds).",
|
||||||
|
L"RemoveBG Studio", MB_ICONERROR);
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
env->CreateCoreWebView2Controller(
|
||||||
|
m_hwnd,
|
||||||
|
Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
|
||||||
|
[this](HRESULT ctrlResult, ICoreWebView2Controller* controller) -> HRESULT {
|
||||||
|
if (FAILED(ctrlResult) || !controller) {
|
||||||
|
MessageBoxW(m_hwnd, L"Failed to create the WebView2 controller.", L"RemoveBG Studio", MB_ICONERROR);
|
||||||
|
return S_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_controller = controller;
|
||||||
|
m_controller->get_CoreWebView2(&m_webview);
|
||||||
|
|
||||||
|
ComPtr<ICoreWebView2Settings> settings;
|
||||||
|
m_webview->get_Settings(&settings);
|
||||||
|
if (settings) {
|
||||||
|
settings->put_IsScriptEnabled(TRUE);
|
||||||
|
settings->put_IsWebMessageEnabled(TRUE);
|
||||||
|
settings->put_AreDefaultScriptDialogsEnabled(TRUE);
|
||||||
|
settings->put_AreDevToolsEnabled(m_devToolsEnabled ? TRUE : FALSE);
|
||||||
|
settings->put_AreDefaultContextMenusEnabled(m_devToolsEnabled ? TRUE : FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
RECT bounds;
|
||||||
|
GetClientRect(m_hwnd, &bounds);
|
||||||
|
m_controller->put_Bounds(bounds);
|
||||||
|
|
||||||
|
EventRegistrationToken token;
|
||||||
|
m_webview->add_WebMessageReceived(
|
||||||
|
Callback<ICoreWebView2WebMessageReceivedEventHandler>(
|
||||||
|
[this](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {
|
||||||
|
LPWSTR json = nullptr;
|
||||||
|
if (SUCCEEDED(args->get_WebMessageAsJson(&json)) && json) {
|
||||||
|
if (m_messageHandler) m_messageHandler(json);
|
||||||
|
CoTaskMemFree(json);
|
||||||
|
}
|
||||||
|
return S_OK;
|
||||||
|
}).Get(),
|
||||||
|
&token);
|
||||||
|
|
||||||
|
if (m_onReady) m_onReady();
|
||||||
|
return S_OK;
|
||||||
|
}).Get());
|
||||||
|
|
||||||
|
return S_OK;
|
||||||
|
}).Get());
|
||||||
|
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
MessageBoxW(m_hwnd, L"Failed to initialize WebView2 (CreateCoreWebView2EnvironmentWithOptions failed).", L"RemoveBG Studio", MB_ICONERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebViewHost::Navigate(const std::wstring& url) {
|
||||||
|
if (m_webview) m_webview->Navigate(url.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebViewHost::PostMessageToJS(const std::wstring& json) {
|
||||||
|
if (m_webview) m_webview->PostWebMessageAsJson(json.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebViewHost::Resize() {
|
||||||
|
if (!m_controller) return;
|
||||||
|
RECT bounds;
|
||||||
|
GetClientRect(m_hwnd, &bounds);
|
||||||
|
m_controller->put_Bounds(bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <windows.h>
|
||||||
|
#include <wrl.h>
|
||||||
|
#include <WebView2.h>
|
||||||
|
#include <string>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace rbg {
|
||||||
|
|
||||||
|
// Thin wrapper around the WebView2 environment/controller/webview trio.
|
||||||
|
// Owns the browser surface embedded in the host window and exposes the
|
||||||
|
// two things the rest of the app needs: navigate, and a bidirectional
|
||||||
|
// JSON message channel with the page.
|
||||||
|
class WebViewHost {
|
||||||
|
public:
|
||||||
|
using MessageHandler = std::function<void(const std::wstring& json)>;
|
||||||
|
|
||||||
|
WebViewHost(HWND hwnd, std::wstring userDataFolder, bool devToolsEnabled);
|
||||||
|
|
||||||
|
// Async: environment/controller creation happens off-thread internally
|
||||||
|
// via WebView2's own callback machinery; onReady fires once navigation
|
||||||
|
// is possible.
|
||||||
|
void Initialize(std::function<void()> onReady);
|
||||||
|
|
||||||
|
void Navigate(const std::wstring& url);
|
||||||
|
void PostMessageToJS(const std::wstring& json);
|
||||||
|
void Resize();
|
||||||
|
void SetMessageHandler(MessageHandler handler) { m_messageHandler = std::move(handler); }
|
||||||
|
|
||||||
|
bool IsReady() const { return m_webview != nullptr; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
HWND m_hwnd;
|
||||||
|
std::wstring m_userDataFolder;
|
||||||
|
bool m_devToolsEnabled;
|
||||||
|
|
||||||
|
Microsoft::WRL::ComPtr<ICoreWebView2Controller> m_controller;
|
||||||
|
Microsoft::WRL::ComPtr<ICoreWebView2> m_webview;
|
||||||
|
|
||||||
|
MessageHandler m_messageHandler;
|
||||||
|
std::function<void()> m_onReady;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#include <windows.h>
|
||||||
|
#include <dwmapi.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include "WebViewHost.h"
|
||||||
|
#include "SidecarProcess.h"
|
||||||
|
#include "Dialogs.h"
|
||||||
|
#include "PathUtil.h"
|
||||||
|
|
||||||
|
#pragma comment(lib, "dwmapi.lib")
|
||||||
|
|
||||||
|
using json = nlohmann::json;
|
||||||
|
using namespace rbg;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr wchar_t kWindowClass[] = L"RemoveBGStudioWindow";
|
||||||
|
constexpr wchar_t kWindowTitle[] = L"RemoveBG Studio";
|
||||||
|
constexpr UINT WM_APP_SIDECAR_EVENT = WM_APP + 1;
|
||||||
|
|
||||||
|
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||||
|
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
HWND hwnd = nullptr;
|
||||||
|
std::unique_ptr<WebViewHost> webView;
|
||||||
|
std::unique_ptr<SidecarProcess> sidecar;
|
||||||
|
};
|
||||||
|
|
||||||
|
AppState* g_app = nullptr;
|
||||||
|
|
||||||
|
std::vector<FilterSpec> ImageFilters() {
|
||||||
|
return { { L"Image Files", L"*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.tiff" }, { L"All Files", L"*.*" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<FilterSpec> VideoFilters() {
|
||||||
|
return { { L"Video Files", L"*.mp4;*.mov;*.avi;*.mkv;*.webm;*.flv" }, { L"All Files", L"*.*" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles JS messages addressed to the native host directly (file/folder/
|
||||||
|
// color dialogs) rather than the Python sidecar. Replies synchronously via
|
||||||
|
// PostWebMessageAsJson since we're already on the UI thread here.
|
||||||
|
void HandleNativeCommand(AppState& app, const json& req) {
|
||||||
|
int id = req.value("id", 0);
|
||||||
|
std::string cmd = req.value("cmd", "");
|
||||||
|
json params = req.value("params", json::object());
|
||||||
|
|
||||||
|
json reply = { {"id", id} };
|
||||||
|
|
||||||
|
auto ok = [&](json result) {
|
||||||
|
reply["event"] = "done";
|
||||||
|
reply["result"] = std::move(result);
|
||||||
|
};
|
||||||
|
auto fail = [&](const std::string& msg) {
|
||||||
|
reply["event"] = "error";
|
||||||
|
reply["message"] = msg;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cmd == "dialog-open-image") {
|
||||||
|
auto path = ShowOpenFileDialog(app.hwnd, L"Select Image File", ImageFilters());
|
||||||
|
ok(path ? json{ {"path", WideToUtf8(*path)} } : json{ {"path", nullptr} });
|
||||||
|
} else if (cmd == "dialog-open-images") {
|
||||||
|
auto paths = ShowOpenMultiFileDialog(app.hwnd, L"Select Images", ImageFilters());
|
||||||
|
json arr = json::array();
|
||||||
|
for (auto& p : paths) arr.push_back(WideToUtf8(p));
|
||||||
|
ok(json{ {"paths", arr} });
|
||||||
|
} else if (cmd == "dialog-open-folder") {
|
||||||
|
std::wstring title = Utf8ToWide(params.value("title", std::string("Select Folder")));
|
||||||
|
auto path = ShowOpenFolderDialog(app.hwnd, title);
|
||||||
|
ok(path ? json{ {"path", WideToUtf8(*path)} } : json{ {"path", nullptr} });
|
||||||
|
} else if (cmd == "dialog-open-video") {
|
||||||
|
auto path = ShowOpenFileDialog(app.hwnd, L"Select Video File", VideoFilters());
|
||||||
|
ok(path ? json{ {"path", WideToUtf8(*path)} } : json{ {"path", nullptr} });
|
||||||
|
} else if (cmd == "dialog-save-image") {
|
||||||
|
std::wstring suggested = Utf8ToWide(params.value("suggestedName", std::string("output.png")));
|
||||||
|
auto path = ShowSaveFileDialog(app.hwnd, L"Save Processed Image As",
|
||||||
|
{ {L"PNG Image", L"*.png"}, {L"WebP Image", L"*.webp"}, {L"JPEG Image", L"*.jpg"} },
|
||||||
|
L"png", suggested);
|
||||||
|
ok(path ? json{ {"path", WideToUtf8(*path)} } : json{ {"path", nullptr} });
|
||||||
|
} else if (cmd == "dialog-save-video") {
|
||||||
|
std::wstring suggested = Utf8ToWide(params.value("suggestedName", std::string("output.mov")));
|
||||||
|
auto path = ShowSaveFileDialog(app.hwnd, L"Save Output Video As",
|
||||||
|
{ {L"QuickTime MOV", L"*.mov"}, {L"WebM Video", L"*.webm"}, {L"MP4 Video", L"*.mp4"} },
|
||||||
|
L"mov", suggested);
|
||||||
|
ok(path ? json{ {"path", WideToUtf8(*path)} } : json{ {"path", nullptr} });
|
||||||
|
} else if (cmd == "dialog-pick-color") {
|
||||||
|
std::wstring initial = Utf8ToWide(params.value("initial", std::string("#00FF00")));
|
||||||
|
auto hex = ShowColorPickerDialog(app.hwnd, initial);
|
||||||
|
ok(hex ? json{ {"hex", WideToUtf8(*hex)} } : json{ {"hex", nullptr} });
|
||||||
|
} else {
|
||||||
|
fail("Unknown native command: " + cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.webView->PostMessageToJS(Utf8ToWide(reply.dump()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnWebMessage(AppState& app, const std::wstring& jsonStr) {
|
||||||
|
json req;
|
||||||
|
try {
|
||||||
|
req = json::parse(WideToUtf8(jsonStr));
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.value("target", "") == "native") {
|
||||||
|
HandleNativeCommand(app, req);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else is a sidecar command; forward the raw JSON line as-is.
|
||||||
|
if (app.sidecar) {
|
||||||
|
app.sidecar->SendCommand(req.dump());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnSidecarEvent(AppState& app, const std::string& jsonLine) {
|
||||||
|
// Marshal from the SidecarProcess background thread to the UI thread;
|
||||||
|
// WebView2's COM objects are single-threaded apartment and must only
|
||||||
|
// be touched from the thread that created them.
|
||||||
|
auto* payload = new std::wstring(Utf8ToWide(jsonLine));
|
||||||
|
PostMessageW(app.hwnd, WM_APP_SIDECAR_EVENT, 0, reinterpret_cast<LPARAM>(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||||
|
AppState* app = g_app;
|
||||||
|
|
||||||
|
switch (msg) {
|
||||||
|
case WM_SIZE:
|
||||||
|
if (app && app->webView) app->webView->Resize();
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case WM_GETMINMAXINFO: {
|
||||||
|
auto* mmi = reinterpret_cast<MINMAXINFO*>(lParam);
|
||||||
|
mmi->ptMinTrackSize = { 760, 560 };
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
case WM_APP_SIDECAR_EVENT: {
|
||||||
|
std::unique_ptr<std::wstring> payload(reinterpret_cast<std::wstring*>(lParam));
|
||||||
|
if (app && app->webView && app->webView->IsReady()) {
|
||||||
|
app->webView->PostMessageToJS(*payload);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
case WM_DESTROY:
|
||||||
|
PostQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplyDarkTitleBar(HWND hwnd) {
|
||||||
|
BOOL dark = TRUE;
|
||||||
|
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE, PWSTR, int nCmdShow) {
|
||||||
|
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||||
|
|
||||||
|
WNDCLASSEXW wc{};
|
||||||
|
wc.cbSize = sizeof(wc);
|
||||||
|
wc.style = CS_HREDRAW | CS_VREDRAW;
|
||||||
|
wc.lpfnWndProc = WndProc;
|
||||||
|
wc.hInstance = hInstance;
|
||||||
|
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||||
|
wc.hbrBackground = CreateSolidBrush(RGB(0x11, 0x11, 0x14));
|
||||||
|
wc.lpszClassName = kWindowClass;
|
||||||
|
RegisterClassExW(&wc);
|
||||||
|
|
||||||
|
HWND hwnd = CreateWindowExW(
|
||||||
|
0, kWindowClass, kWindowTitle,
|
||||||
|
WS_OVERLAPPEDWINDOW,
|
||||||
|
CW_USEDEFAULT, CW_USEDEFAULT, 1180, 820,
|
||||||
|
nullptr, nullptr, hInstance, nullptr);
|
||||||
|
|
||||||
|
if (!hwnd) return 1;
|
||||||
|
|
||||||
|
ApplyDarkTitleBar(hwnd);
|
||||||
|
|
||||||
|
AppState app;
|
||||||
|
app.hwnd = hwnd;
|
||||||
|
g_app = &app;
|
||||||
|
|
||||||
|
ShowWindow(hwnd, nCmdShow);
|
||||||
|
UpdateWindow(hwnd);
|
||||||
|
|
||||||
|
std::wstring repoRoot = FindRepoRoot();
|
||||||
|
std::wstring pythonExe = ResolvePythonInterpreter(repoRoot);
|
||||||
|
std::wstring sidecarScript = repoRoot + L"\\core\\sidecar.py";
|
||||||
|
|
||||||
|
app.sidecar = std::make_unique<SidecarProcess>(
|
||||||
|
pythonExe, sidecarScript, repoRoot,
|
||||||
|
[&app](const std::string& line) { OnSidecarEvent(app, line); });
|
||||||
|
|
||||||
|
if (!app.sidecar->Start()) {
|
||||||
|
MessageBoxW(hwnd,
|
||||||
|
L"Could not start the RemoveBG sidecar process (Python).\n"
|
||||||
|
L"Make sure Python 3.10+ and the project dependencies are installed\n"
|
||||||
|
L"(see README.md), then relaunch.",
|
||||||
|
L"RemoveBG Studio", MB_ICONWARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring userDataFolder = repoRoot + L"\\native\\.webview2";
|
||||||
|
CreateDirectoryW(userDataFolder.c_str(), nullptr);
|
||||||
|
|
||||||
|
#ifdef RBG_CONSOLE
|
||||||
|
bool devTools = true;
|
||||||
|
#else
|
||||||
|
bool devTools = false;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
app.webView = std::make_unique<WebViewHost>(hwnd, userDataFolder, devTools);
|
||||||
|
app.webView->SetMessageHandler([&app](const std::wstring& j) { OnWebMessage(app, j); });
|
||||||
|
|
||||||
|
std::wstring indexPath = GetExeDir() + L"\\web\\index.html";
|
||||||
|
app.webView->Initialize([&app, indexPath]() {
|
||||||
|
app.webView->Navigate(L"file:///" + indexPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
MSG msg;
|
||||||
|
while (GetMessageW(&msg, nullptr, 0, 0)) {
|
||||||
|
TranslateMessage(&msg);
|
||||||
|
DispatchMessageW(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.sidecar.reset();
|
||||||
|
CoUninitialize();
|
||||||
|
return (int)msg.wParam;
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Fetches the Microsoft WebView2 SDK (headers + x64 loader libs) into
|
||||||
|
native/third_party/webview2/, without needing NuGet or vcpkg installed.
|
||||||
|
|
||||||
|
The SDK is downloaded directly from nuget.org (same package vcpkg/NuGet
|
||||||
|
would fetch), unpacked, and only the pieces the CMake build needs are
|
||||||
|
copied out. Safe to re-run — skips work if already present unless -Force
|
||||||
|
is passed.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Version = "1.0.4078.44",
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Join-Path $PSScriptRoot "webview2"
|
||||||
|
$includeDir = Join-Path $root "include"
|
||||||
|
$libDir = Join-Path $root "lib\x64"
|
||||||
|
$marker = Join-Path $includeDir "WebView2.h"
|
||||||
|
|
||||||
|
if ((Test-Path $marker) -and -not $Force) {
|
||||||
|
Write-Host "WebView2 SDK already present at $root (use -Force to re-fetch)."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("webview2-fetch-" + [guid]::NewGuid())
|
||||||
|
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||||
|
$zipPath = Join-Path $tempDir "webview2.zip"
|
||||||
|
$extractPath = Join-Path $tempDir "pkg"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$url = "https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/$Version"
|
||||||
|
Write-Host "Downloading Microsoft.Web.WebView2 $Version from nuget.org..."
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||||
|
|
||||||
|
Write-Host "Extracting..."
|
||||||
|
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $includeDir -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $libDir -Force | Out-Null
|
||||||
|
|
||||||
|
Copy-Item (Join-Path $extractPath "build\native\include\*.h") $includeDir -Force
|
||||||
|
Copy-Item (Join-Path $extractPath "build\native\x64\WebView2LoaderStatic.lib") $libDir -Force
|
||||||
|
Copy-Item (Join-Path $extractPath "build\native\x64\WebView2Loader.dll.lib") $libDir -Force
|
||||||
|
Copy-Item (Join-Path $extractPath "build\native\x64\WebView2Loader.dll") $libDir -Force
|
||||||
|
|
||||||
|
Write-Host "WebView2 SDK $Version installed to $root"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
+24765
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,591 @@
|
|||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const host = window.chrome && window.chrome.webview;
|
||||||
|
if (!host) {
|
||||||
|
document.body.insertAdjacentHTML(
|
||||||
|
"afterbegin",
|
||||||
|
'<div style="position:fixed;inset:0;z-index:99;display:flex;align-items:center;justify-content:center;background:#16161a;color:#e9e8ee;font-family:sans-serif;">This UI must be run inside the RemoveBG Studio native host.</div>'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- IPC
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
function call(cmd, params, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++seq;
|
||||||
|
pending.set(id, { resolve, reject, onProgress });
|
||||||
|
host.postMessage({ id, cmd, params: params || {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function callNative(cmd, params) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = ++seq;
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
host.postMessage({ target: "native", id, cmd, params: params || {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
host.addEventListener("message", (e) => {
|
||||||
|
const data = e.data;
|
||||||
|
if (!data || typeof data !== "object") return;
|
||||||
|
|
||||||
|
if (data.event === "ready" && data.id === undefined) {
|
||||||
|
setEngineStatus("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.event === "log") {
|
||||||
|
appendLog(data.message, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = pending.get(data.id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (data.event === "progress") {
|
||||||
|
entry.onProgress && entry.onProgress(data);
|
||||||
|
if (data.message) setStatusOnly(data.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.event === "done") {
|
||||||
|
pending.delete(data.id);
|
||||||
|
entry.resolve(data.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.event === "error") {
|
||||||
|
pending.delete(data.id);
|
||||||
|
appendLog(data.message, true);
|
||||||
|
entry.reject(new Error(data.message || "Unknown error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- chrome / status
|
||||||
|
|
||||||
|
const engineStatusEl = document.getElementById("engineStatus");
|
||||||
|
function setEngineStatus(state) { engineStatusEl.className = "rail-status " + state; }
|
||||||
|
|
||||||
|
const statusText = document.getElementById("statusText");
|
||||||
|
const progressFill = document.getElementById("progressFill");
|
||||||
|
const logPanel = document.getElementById("logPanel");
|
||||||
|
const logToggle = document.getElementById("logToggle");
|
||||||
|
|
||||||
|
logToggle.addEventListener("click", () => {
|
||||||
|
logPanel.classList.toggle("is-open");
|
||||||
|
logToggle.classList.toggle("is-open");
|
||||||
|
});
|
||||||
|
|
||||||
|
function setStatusOnly(message) { if (message) statusText.textContent = message; }
|
||||||
|
|
||||||
|
function appendLog(message, isError) {
|
||||||
|
if (!message) return;
|
||||||
|
const line = document.createElement("div");
|
||||||
|
if (isError) line.classList.add("is-error");
|
||||||
|
line.textContent = message;
|
||||||
|
logPanel.appendChild(line);
|
||||||
|
logPanel.scrollTop = logPanel.scrollHeight;
|
||||||
|
statusText.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(current, total) {
|
||||||
|
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
progressFill.style.width = pct + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- nav
|
||||||
|
|
||||||
|
const railButtons = document.querySelectorAll(".rail-btn");
|
||||||
|
const viewTitle = document.getElementById("viewTitle");
|
||||||
|
const imgModeSegWrap = document.getElementById("imgModeSeg");
|
||||||
|
|
||||||
|
const stages = { image: document.getElementById("stage-image"), video: document.getElementById("stage-video") };
|
||||||
|
const panels = { image: document.getElementById("panel-image"), video: document.getElementById("panel-video") };
|
||||||
|
const titles = { image: "Image Processor", video: "Video Processor" };
|
||||||
|
|
||||||
|
railButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const view = btn.dataset.view;
|
||||||
|
railButtons.forEach((b) => b.classList.toggle("is-active", b === btn));
|
||||||
|
Object.keys(stages).forEach((k) => {
|
||||||
|
stages[k].hidden = k !== view;
|
||||||
|
panels[k].hidden = k !== view;
|
||||||
|
});
|
||||||
|
viewTitle.textContent = titles[view];
|
||||||
|
imgModeSegWrap.style.visibility = view === "image" ? "visible" : "hidden";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- models
|
||||||
|
|
||||||
|
const MODEL_DESCRIPTIONS = {};
|
||||||
|
|
||||||
|
function populateModelSelect(select) {
|
||||||
|
select.innerHTML = "";
|
||||||
|
for (const id of Object.keys(MODEL_DESCRIPTIONS)) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = id;
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
const result = await call("list_models", {});
|
||||||
|
Object.assign(MODEL_DESCRIPTIONS, result.models);
|
||||||
|
populateModelSelect(imgModel);
|
||||||
|
populateModelSelect(vidModel);
|
||||||
|
imgModel.value = "u2net";
|
||||||
|
vidModel.value = "u2net";
|
||||||
|
updateModelDesc();
|
||||||
|
} catch (err) {
|
||||||
|
appendLog("Failed to load model list: " + err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
if (!p) return "";
|
||||||
|
const parts = p.split(/[\\/]/);
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPathLine(el, path, emptyText) {
|
||||||
|
if (path) { el.textContent = path; el.title = path; el.classList.add("has-value"); }
|
||||||
|
else { el.textContent = emptyText; el.title = ""; el.classList.remove("has-value"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultImageOutput(inputPath, forBatch) {
|
||||||
|
if (!inputPath) return "";
|
||||||
|
if (forBatch) {
|
||||||
|
const dir = inputPath.substring(0, inputPath.lastIndexOf("\\"));
|
||||||
|
return (dir || inputPath) + "\\output_nobg";
|
||||||
|
}
|
||||||
|
const dot = inputPath.lastIndexOf(".");
|
||||||
|
const base = dot > -1 ? inputPath.substring(0, dot) : inputPath;
|
||||||
|
return base + "_nobg.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultVideoOutput(inputPath, exportFormat) {
|
||||||
|
if (!inputPath) return "";
|
||||||
|
const dot = inputPath.lastIndexOf(".");
|
||||||
|
const base = dot > -1 ? inputPath.substring(0, dot) : inputPath;
|
||||||
|
if (exportFormat === "png_sequence") return base + "_frames";
|
||||||
|
const ext = exportFormat === "webm_transparent" ? "webm" : exportFormat === "mp4_solid" ? "mp4" : "mov";
|
||||||
|
return base + "_nobg." + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounce(fn, ms) {
|
||||||
|
let t = null;
|
||||||
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDropTarget(el, onFiles) {
|
||||||
|
el.addEventListener("dragover", (e) => { e.preventDefault(); });
|
||||||
|
el.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const files = Array.from(e.dataTransfer.files || []);
|
||||||
|
const paths = files.map((f) => f.path).filter(Boolean);
|
||||||
|
onFiles(paths.length ? paths : null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindSwatchRow(container, onChange) {
|
||||||
|
container.querySelectorAll(".swatch-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
container.querySelectorAll(".swatch-btn").forEach((b) => b.classList.remove("is-active"));
|
||||||
|
btn.classList.add("is-active");
|
||||||
|
onChange(btn.dataset.bg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindToggle(el, onChange) {
|
||||||
|
let on = false;
|
||||||
|
el.addEventListener("click", () => { on = !on; el.classList.toggle("is-on", on); onChange(on); });
|
||||||
|
return () => on;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCompareHandle(stageEl, handleEl) {
|
||||||
|
// Dragging can start anywhere on the image, not just the thin divider
|
||||||
|
// line — the handle is just the visual affordance. This mirrors the
|
||||||
|
// usual before/after slider UX and avoids requiring pixel-precise aim.
|
||||||
|
let dragging = false;
|
||||||
|
const setSplit = (clientX) => {
|
||||||
|
const rect = stageEl.getBoundingClientRect();
|
||||||
|
const pct = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
|
||||||
|
stageEl.style.setProperty("--split", pct.toFixed(2));
|
||||||
|
};
|
||||||
|
const start = (e) => {
|
||||||
|
dragging = true;
|
||||||
|
handleEl.classList.add("is-dragging");
|
||||||
|
stageEl.setPointerCapture(e.pointerId);
|
||||||
|
setSplit(e.clientX);
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
const move = (e) => { if (dragging) setSplit(e.clientX); };
|
||||||
|
const end = (e) => {
|
||||||
|
dragging = false;
|
||||||
|
handleEl.classList.remove("is-dragging");
|
||||||
|
try { stageEl.releasePointerCapture(e.pointerId); } catch (err) { /* already released */ }
|
||||||
|
};
|
||||||
|
stageEl.addEventListener("pointerdown", start);
|
||||||
|
stageEl.addEventListener("pointermove", move);
|
||||||
|
stageEl.addEventListener("pointerup", end);
|
||||||
|
stageEl.addEventListener("pointercancel", end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bgColorFor(kind, customHex) {
|
||||||
|
return kind === "transparent" ? null
|
||||||
|
: kind === "green" ? "#00FF00"
|
||||||
|
: kind === "white" ? "#FFFFFF"
|
||||||
|
: kind === "black" ? "#000000"
|
||||||
|
: customHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// IMAGE VIEW
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
const imgModel = document.getElementById("imgModel");
|
||||||
|
const imgModelDesc = document.getElementById("imgModelDesc");
|
||||||
|
const imgEmpty = document.getElementById("imgEmpty");
|
||||||
|
const imgOpenBtn = document.getElementById("imgOpenBtn");
|
||||||
|
const imgBrowseInput = document.getElementById("imgBrowseInput");
|
||||||
|
const imgCompare = document.getElementById("imgCompare");
|
||||||
|
const imgCompareStage = document.getElementById("imgCompareStage");
|
||||||
|
const imgOriginalImg = document.getElementById("imgOriginalImg");
|
||||||
|
const imgProcessedImg = document.getElementById("imgProcessedImg");
|
||||||
|
const imgHandle = document.getElementById("imgHandle");
|
||||||
|
const imgLoading = document.getElementById("imgLoading");
|
||||||
|
const imgBatchList = document.getElementById("imgBatchList");
|
||||||
|
const imgBatchListBody = document.getElementById("imgBatchListBody");
|
||||||
|
const imgInputPath = document.getElementById("imgInputPath");
|
||||||
|
const imgOutputPath = document.getElementById("imgOutputPath");
|
||||||
|
const imgBrowseOutput = document.getElementById("imgBrowseOutput");
|
||||||
|
const imgFormat = document.getElementById("imgFormat");
|
||||||
|
const imgFormatField = document.getElementById("imgFormatField");
|
||||||
|
const imgBgSeg = document.getElementById("imgBgSeg");
|
||||||
|
const imgSwatch = document.getElementById("imgSwatch");
|
||||||
|
const imgAlphaToggle = document.getElementById("imgAlphaToggle");
|
||||||
|
const imgStartBtn = document.getElementById("imgStartBtn");
|
||||||
|
const stageImageEl = document.getElementById("stage-image");
|
||||||
|
|
||||||
|
let imgMode = "single";
|
||||||
|
let imgInputs = [];
|
||||||
|
let imgOutput = "";
|
||||||
|
let imgBg = "transparent";
|
||||||
|
let imgCustomHex = "#00FF00";
|
||||||
|
const getImgAlpha = bindToggle(imgAlphaToggle, () => queuePreview());
|
||||||
|
|
||||||
|
bindCompareHandle(imgCompareStage, imgHandle);
|
||||||
|
|
||||||
|
document.getElementById("imgModeSeg").querySelectorAll(".seg-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll("#imgModeSeg .seg-btn").forEach((b) => b.classList.remove("is-active"));
|
||||||
|
btn.classList.add("is-active");
|
||||||
|
imgMode = btn.dataset.mode;
|
||||||
|
imgFormatField.style.display = imgMode === "batch" ? "flex" : "flex";
|
||||||
|
resetImageInputState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetImageInputState() {
|
||||||
|
imgInputs = [];
|
||||||
|
imgOutput = "";
|
||||||
|
setPathLine(imgInputPath, "", "No input selected");
|
||||||
|
setPathLine(imgOutputPath, "", "Not set");
|
||||||
|
showImageEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showImageEmptyState() {
|
||||||
|
imgEmpty.hidden = false;
|
||||||
|
imgCompare.hidden = true;
|
||||||
|
imgLoading.hidden = true;
|
||||||
|
imgBatchList.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindSwatchRow(imgBgSeg, async (bg) => {
|
||||||
|
imgBg = bg;
|
||||||
|
if (bg === "custom") {
|
||||||
|
const res = await callNative("dialog-pick-color", { initial: imgCustomHex });
|
||||||
|
if (res.hex) { imgCustomHex = res.hex; imgSwatch.style.background = res.hex; }
|
||||||
|
}
|
||||||
|
queuePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
imgModel.addEventListener("change", () => { updateModelDesc(); queuePreview(); });
|
||||||
|
function updateModelDesc() { imgModelDesc.textContent = MODEL_DESCRIPTIONS[imgModel.value] || ""; }
|
||||||
|
|
||||||
|
async function pickImageInput() {
|
||||||
|
if (imgMode === "single") {
|
||||||
|
const res = await callNative("dialog-open-image", {});
|
||||||
|
return res.path ? [res.path] : [];
|
||||||
|
}
|
||||||
|
const res = await callNative("dialog-open-images", {});
|
||||||
|
return res.paths || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setImageInputs(paths) {
|
||||||
|
if (!paths || !paths.length) return;
|
||||||
|
imgInputs = imgMode === "single" ? [paths[0]] : paths;
|
||||||
|
setPathLine(imgInputPath, imgMode === "single" ? imgInputs[0] : `${imgInputs.length} file(s) selected`, "No input selected");
|
||||||
|
|
||||||
|
if (!imgOutput) {
|
||||||
|
imgOutput = defaultImageOutput(imgInputs[0], imgMode === "batch");
|
||||||
|
setPathLine(imgOutputPath, imgOutput, "Not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imgMode === "batch") {
|
||||||
|
imgEmpty.hidden = true;
|
||||||
|
imgCompare.hidden = true;
|
||||||
|
imgLoading.hidden = true;
|
||||||
|
imgBatchList.hidden = false;
|
||||||
|
imgBatchListBody.innerHTML = "";
|
||||||
|
imgInputs.forEach((p) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "batch-row";
|
||||||
|
row.textContent = basename(p);
|
||||||
|
imgBatchListBody.appendChild(row);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
imgBatchList.hidden = true;
|
||||||
|
runImagePreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[imgOpenBtn, imgEmpty, imgBrowseInput].forEach((el) => {
|
||||||
|
el.addEventListener("click", async () => setImageInputs(await pickImageInput()));
|
||||||
|
});
|
||||||
|
setupDropTarget(stageImageEl, (paths) => { if (paths) setImageInputs(paths); else pickImageInput().then(setImageInputs); });
|
||||||
|
|
||||||
|
async function runImagePreview() {
|
||||||
|
if (imgMode !== "single" || !imgInputs.length) return;
|
||||||
|
imgEmpty.hidden = true;
|
||||||
|
imgBatchList.hidden = true;
|
||||||
|
imgCompare.hidden = true;
|
||||||
|
imgLoading.hidden = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await call("preview_image", {
|
||||||
|
input: imgInputs[0],
|
||||||
|
model: imgModel.value,
|
||||||
|
alpha_matting: getImgAlpha(),
|
||||||
|
bg_color: bgColorFor(imgBg, imgCustomHex),
|
||||||
|
});
|
||||||
|
imgOriginalImg.src = "data:image/jpeg;base64," + result.original_b64;
|
||||||
|
imgProcessedImg.src = "data:image/png;base64," + result.preview_b64;
|
||||||
|
imgLoading.hidden = true;
|
||||||
|
imgCompare.hidden = false;
|
||||||
|
} catch (err) {
|
||||||
|
imgLoading.hidden = true;
|
||||||
|
imgEmpty.hidden = false;
|
||||||
|
appendLog("Preview failed: " + err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const queuePreview = debounce(runImagePreview, 260);
|
||||||
|
|
||||||
|
imgBrowseOutput.addEventListener("click", async () => {
|
||||||
|
if (imgMode === "single") {
|
||||||
|
const suggested = imgInputs[0] ? basename(defaultImageOutput(imgInputs[0], false)) : "output.png";
|
||||||
|
const res = await callNative("dialog-save-image", { suggestedName: suggested });
|
||||||
|
if (res.path) { imgOutput = res.path; setPathLine(imgOutputPath, imgOutput, "Not set"); }
|
||||||
|
} else {
|
||||||
|
const res = await callNative("dialog-open-folder", { title: "Select Output Folder" });
|
||||||
|
if (res.path) { imgOutput = res.path; setPathLine(imgOutputPath, imgOutput, "Not set"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
imgStartBtn.addEventListener("click", async () => {
|
||||||
|
if (!imgInputs.length) { appendLog("Select an input image first.", true); return; }
|
||||||
|
if (!imgOutput) { appendLog("Choose an output destination first.", true); return; }
|
||||||
|
|
||||||
|
const bgColor = bgColorFor(imgBg, imgCustomHex);
|
||||||
|
imgStartBtn.disabled = true;
|
||||||
|
setEngineStatus("busy");
|
||||||
|
setProgress(0, 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (imgMode === "single") {
|
||||||
|
appendLog(`Saving ${basename(imgInputs[0])}…`, false);
|
||||||
|
const result = await call("process_image", {
|
||||||
|
input: imgInputs[0], output: imgOutput, model: imgModel.value,
|
||||||
|
alpha_matting: getImgAlpha(), bg_color: bgColor,
|
||||||
|
});
|
||||||
|
setProgress(1, 1);
|
||||||
|
appendLog(`Saved → ${result.output}`, false);
|
||||||
|
} else {
|
||||||
|
const result = await call(
|
||||||
|
"process_batch",
|
||||||
|
{ inputs: imgInputs, output_dir: imgOutput, model: imgModel.value, alpha_matting: getImgAlpha(), bg_color: bgColor, format: imgFormat.value },
|
||||||
|
(p) => setProgress(p.current, p.total)
|
||||||
|
);
|
||||||
|
appendLog(`Batch complete: ${result.count} image(s) saved to ${imgOutput}`, false);
|
||||||
|
}
|
||||||
|
setEngineStatus("ready");
|
||||||
|
} catch (err) {
|
||||||
|
setEngineStatus("error");
|
||||||
|
appendLog("Failed: " + err.message, true);
|
||||||
|
} finally {
|
||||||
|
imgStartBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// VIDEO VIEW
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
const vidModel = document.getElementById("vidModel");
|
||||||
|
const vidEmpty = document.getElementById("vidEmpty");
|
||||||
|
const vidOpenBtn = document.getElementById("vidOpenBtn");
|
||||||
|
const vidBrowseInput = document.getElementById("vidBrowseInput");
|
||||||
|
const vidCompare = document.getElementById("vidCompare");
|
||||||
|
const vidCompareStage = document.getElementById("vidCompareStage");
|
||||||
|
const vidOriginalImg = document.getElementById("vidOriginalImg");
|
||||||
|
const vidProcessedImg = document.getElementById("vidProcessedImg");
|
||||||
|
const vidHandle = document.getElementById("vidHandle");
|
||||||
|
const vidLoading = document.getElementById("vidLoading");
|
||||||
|
const vidMeta = document.getElementById("vidMeta");
|
||||||
|
const vidRefreshPreview = document.getElementById("vidRefreshPreview");
|
||||||
|
const vidInputPath = document.getElementById("vidInputPath");
|
||||||
|
const vidOutputPath = document.getElementById("vidOutputPath");
|
||||||
|
const vidBrowseOutput = document.getElementById("vidBrowseOutput");
|
||||||
|
const vidExportFormat = document.getElementById("vidExportFormat");
|
||||||
|
const vidBgSeg = document.getElementById("vidBgSeg");
|
||||||
|
const vidFps = document.getElementById("vidFps");
|
||||||
|
const vidAlphaToggle = document.getElementById("vidAlphaToggle");
|
||||||
|
const vidStartBtn = document.getElementById("vidStartBtn");
|
||||||
|
const stageVideoEl = document.getElementById("stage-video");
|
||||||
|
|
||||||
|
let vidInput = "";
|
||||||
|
let vidOutput = "";
|
||||||
|
let vidOutputIsDefault = true; // false once the user explicitly browses for output
|
||||||
|
let vidBg = "transparent";
|
||||||
|
const getVidAlpha = bindToggle(vidAlphaToggle, () => queueVideoPreview());
|
||||||
|
|
||||||
|
bindCompareHandle(vidCompareStage, vidHandle);
|
||||||
|
bindSwatchRow(vidBgSeg, (bg) => { vidBg = bg; queueVideoPreview(); });
|
||||||
|
vidModel.addEventListener("change", () => queueVideoPreview());
|
||||||
|
vidRefreshPreview.addEventListener("click", () => runVideoPreview());
|
||||||
|
|
||||||
|
vidExportFormat.addEventListener("change", () => {
|
||||||
|
// The default suggested output's extension (or folder-vs-file shape)
|
||||||
|
// depends on the export format, so keep it in sync unless the user
|
||||||
|
// has already picked their own destination.
|
||||||
|
if (vidOutputIsDefault && vidInput) {
|
||||||
|
vidOutput = defaultVideoOutput(vidInput, vidExportFormat.value);
|
||||||
|
setPathLine(vidOutputPath, vidOutput, "Not set");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function pickVideoInput() {
|
||||||
|
const res = await callNative("dialog-open-video", {});
|
||||||
|
return res.path || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setVideoInput(path) {
|
||||||
|
if (!path) return;
|
||||||
|
vidInput = path;
|
||||||
|
setPathLine(vidInputPath, vidInput, "No video selected");
|
||||||
|
|
||||||
|
if (!vidOutput || vidOutputIsDefault) {
|
||||||
|
vidOutput = defaultVideoOutput(vidInput, vidExportFormat.value);
|
||||||
|
vidOutputIsDefault = true;
|
||||||
|
setPathLine(vidOutputPath, vidOutput, "Not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
vidMeta.textContent = "Analyzing…";
|
||||||
|
try {
|
||||||
|
const info = await call("get_video_info", { path: vidInput });
|
||||||
|
vidMeta.textContent = `${info.width}×${info.height} · ${info.fps.toFixed(2)} fps · ~${info.frame_count} frames · Audio: ${info.has_audio ? "Yes" : "No"}`;
|
||||||
|
} catch (err) {
|
||||||
|
vidMeta.textContent = "Could not analyze video: " + err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
runVideoPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
[vidOpenBtn, vidEmpty, vidBrowseInput].forEach((el) => {
|
||||||
|
el.addEventListener("click", async () => setVideoInput(await pickVideoInput()));
|
||||||
|
});
|
||||||
|
setupDropTarget(stageVideoEl, (paths) => { if (paths && paths[0]) setVideoInput(paths[0]); else pickVideoInput().then(setVideoInput); });
|
||||||
|
|
||||||
|
async function runVideoPreview() {
|
||||||
|
if (!vidInput) return;
|
||||||
|
vidEmpty.hidden = true;
|
||||||
|
vidCompare.hidden = true;
|
||||||
|
vidLoading.hidden = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await call("preview_video_frame", {
|
||||||
|
video: vidInput,
|
||||||
|
model: vidModel.value,
|
||||||
|
alpha_matting: getVidAlpha(),
|
||||||
|
bg_color: bgColorFor(vidBg, null),
|
||||||
|
});
|
||||||
|
vidOriginalImg.src = "data:image/jpeg;base64," + result.original_b64;
|
||||||
|
vidProcessedImg.src = "data:image/png;base64," + result.preview_b64;
|
||||||
|
vidLoading.hidden = true;
|
||||||
|
vidCompare.hidden = false;
|
||||||
|
} catch (err) {
|
||||||
|
vidLoading.hidden = true;
|
||||||
|
vidEmpty.hidden = false;
|
||||||
|
appendLog("Video preview failed: " + err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const queueVideoPreview = debounce(runVideoPreview, 260);
|
||||||
|
|
||||||
|
vidBrowseOutput.addEventListener("click", async () => {
|
||||||
|
let res;
|
||||||
|
if (vidExportFormat.value === "png_sequence") {
|
||||||
|
res = await callNative("dialog-open-folder", { title: "Select Output Folder for PNG Sequence" });
|
||||||
|
} else {
|
||||||
|
const suggested = vidInput ? basename(defaultVideoOutput(vidInput, vidExportFormat.value)) : "output.mov";
|
||||||
|
res = await callNative("dialog-save-video", { suggestedName: suggested });
|
||||||
|
}
|
||||||
|
if (res.path) {
|
||||||
|
vidOutput = res.path;
|
||||||
|
vidOutputIsDefault = false;
|
||||||
|
setPathLine(vidOutputPath, vidOutput, "Not set");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vidStartBtn.addEventListener("click", async () => {
|
||||||
|
if (!vidInput) { appendLog("Select a video file first.", true); return; }
|
||||||
|
if (!vidOutput) { appendLog("Choose an output destination first.", true); return; }
|
||||||
|
|
||||||
|
const bgColor = bgColorFor(vidBg, null);
|
||||||
|
const fps = parseFloat(vidFps.value) || 0;
|
||||||
|
|
||||||
|
vidStartBtn.disabled = true;
|
||||||
|
setEngineStatus("busy");
|
||||||
|
setProgress(0, 1);
|
||||||
|
appendLog(`Starting video processing: ${basename(vidInput)}`, false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await call(
|
||||||
|
"process_video",
|
||||||
|
{ video: vidInput, output: vidOutput, model: vidModel.value, fps: fps > 0 ? fps : null, export_format: vidExportFormat.value, bg_color: bgColor, alpha_matting: getVidAlpha() },
|
||||||
|
(p) => setProgress(p.current, p.total)
|
||||||
|
);
|
||||||
|
setEngineStatus("ready");
|
||||||
|
appendLog(`Video complete → ${result.output}`, false);
|
||||||
|
} catch (err) {
|
||||||
|
setEngineStatus("error");
|
||||||
|
appendLog("Failed: " + err.message, true);
|
||||||
|
} finally {
|
||||||
|
vidStartBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- boot
|
||||||
|
|
||||||
|
setEngineStatus("");
|
||||||
|
loadModels();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>RemoveBG Studio</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<nav class="rail">
|
||||||
|
<div class="rail-mark">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 2 L21 7 L21 17 L12 22 L3 17 L3 7 Z" stroke="currentColor" stroke-width="1.3"/><path d="M3 7 L12 12 L21 7 M12 12 L12 22" stroke="currentColor" stroke-width="1.3"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="rail-btn is-active" data-view="image" title="Image Processor">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5" stroke="currentColor" stroke-width="1.3"/><circle cx="9" cy="10" r="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5 17 L10 12.5 L13 15 L16.5 11 L19.5 15" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="rail-btn" data-view="video" title="Video Processor">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><rect x="3.5" y="6" width="12.5" height="12" rx="2.5" stroke="currentColor" stroke-width="1.3"/><path d="M16 10.5 L20.5 7.8 V16.2 L16 13.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="rail-spacer"></div>
|
||||||
|
<div class="rail-status" id="engineStatus" title="Sidecar status"><span class="dot"></span></div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<header class="toolbar">
|
||||||
|
<h1 id="viewTitle">Image Processor</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<div class="seg" id="imgModeSeg">
|
||||||
|
<button class="seg-btn is-active" data-mode="single">Single</button>
|
||||||
|
<button class="seg-btn" data-mode="batch">Batch</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
|
||||||
|
<!-- ============================= IMAGE STAGE ============================= -->
|
||||||
|
<section class="stage" id="stage-image">
|
||||||
|
<div class="stage-empty" id="imgEmpty">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5" stroke="currentColor" stroke-width="1.2"/><circle cx="9" cy="10" r="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M5 17 L10 12.5 L13 15 L16.5 11 L19.5 15" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-btn" id="imgOpenBtn">Open Image…</button>
|
||||||
|
<span class="empty-hint">or drop a file here</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compare" id="imgCompare" hidden>
|
||||||
|
<div class="compare-stage" id="imgCompareStage">
|
||||||
|
<div class="checker"></div>
|
||||||
|
<img class="layer layer-original" id="imgOriginalImg" alt="" />
|
||||||
|
<img class="layer layer-processed" id="imgProcessedImg" alt="" />
|
||||||
|
<div class="handle" id="imgHandle"><span class="handle-grip"></span></div>
|
||||||
|
<span class="tag tag-before">Original</span>
|
||||||
|
<span class="tag tag-after">Result</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage-loading" id="imgLoading" hidden><span class="spinner"></span>Generating preview…</div>
|
||||||
|
|
||||||
|
<div class="batch-list" id="imgBatchList" hidden>
|
||||||
|
<div class="batch-list-head">Selected files</div>
|
||||||
|
<div class="batch-list-body" id="imgBatchListBody"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================= IMAGE PANEL ============================= -->
|
||||||
|
<aside class="panel" id="panel-image">
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Input</div>
|
||||||
|
<button class="row-btn" id="imgBrowseInput">Choose file(s)…</button>
|
||||||
|
<div class="path-line" id="imgInputPath">No input selected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Model</div>
|
||||||
|
<select class="full-select" id="imgModel"></select>
|
||||||
|
<p class="panel-hint" id="imgModelDesc"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Background</div>
|
||||||
|
<div class="swatch-row" id="imgBgSeg">
|
||||||
|
<button class="swatch-btn is-active" data-bg="transparent" title="Transparent"><span class="swatch checker-swatch"></span></button>
|
||||||
|
<button class="swatch-btn" data-bg="green" title="Green screen"><span class="swatch" style="background:#00ff00"></span></button>
|
||||||
|
<button class="swatch-btn" data-bg="white" title="White"><span class="swatch" style="background:#ffffff"></span></button>
|
||||||
|
<button class="swatch-btn" data-bg="black" title="Black"><span class="swatch" style="background:#000000"></span></button>
|
||||||
|
<button class="swatch-btn" data-bg="custom" title="Custom colour"><span class="swatch" id="imgSwatch" style="background:#00ff00"></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<label class="switch-row">
|
||||||
|
<span>Alpha matting</span>
|
||||||
|
<span class="switch" id="imgAlphaToggle"><span class="knob"></span></span>
|
||||||
|
</label>
|
||||||
|
<p class="panel-hint">Refines hair & fine edges. Slower.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-spacer"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Output</div>
|
||||||
|
<button class="row-btn" id="imgBrowseOutput">Choose destination…</button>
|
||||||
|
<div class="path-line" id="imgOutputPath">Not set</div>
|
||||||
|
<div class="panel-field" id="imgFormatField">
|
||||||
|
<span>Format</span>
|
||||||
|
<select id="imgFormat">
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
<option value="webp">WEBP</option>
|
||||||
|
<option value="jpg">JPG</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary-btn" id="imgStartBtn">Save Result</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ============================= VIDEO STAGE ============================= -->
|
||||||
|
<section class="stage" id="stage-video" hidden>
|
||||||
|
<div class="stage-empty" id="vidEmpty">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><rect x="3.5" y="6" width="12.5" height="12" rx="2.5" stroke="currentColor" stroke-width="1.2"/><path d="M16 10.5 L20.5 7.8 V16.2 L16 13.5" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-btn" id="vidOpenBtn">Open Video…</button>
|
||||||
|
<span class="empty-hint">or drop a file here</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compare" id="vidCompare" hidden>
|
||||||
|
<div class="compare-stage" id="vidCompareStage">
|
||||||
|
<div class="checker"></div>
|
||||||
|
<img class="layer layer-original" id="vidOriginalImg" alt="" />
|
||||||
|
<img class="layer layer-processed" id="vidProcessedImg" alt="" />
|
||||||
|
<div class="handle" id="vidHandle"><span class="handle-grip"></span></div>
|
||||||
|
<span class="tag tag-before">Original frame</span>
|
||||||
|
<span class="tag tag-after">Result</span>
|
||||||
|
</div>
|
||||||
|
<div class="stage-toolbar">
|
||||||
|
<span id="vidMeta"></span>
|
||||||
|
<button class="mini-btn" id="vidRefreshPreview">Refresh preview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage-loading" id="vidLoading" hidden><span class="spinner"></span>Extracting & processing frame…</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================= VIDEO PANEL ============================= -->
|
||||||
|
<aside class="panel" id="panel-video" hidden>
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Input</div>
|
||||||
|
<button class="row-btn" id="vidBrowseInput">Choose video…</button>
|
||||||
|
<div class="path-line" id="vidInputPath">No video selected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Model</div>
|
||||||
|
<select class="full-select" id="vidModel"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Background</div>
|
||||||
|
<div class="swatch-row" id="vidBgSeg">
|
||||||
|
<button class="swatch-btn is-active" data-bg="transparent" title="Transparent"><span class="swatch checker-swatch"></span></button>
|
||||||
|
<button class="swatch-btn" data-bg="green" title="Green screen"><span class="swatch" style="background:#00ff00"></span></button>
|
||||||
|
<button class="swatch-btn" data-bg="white" title="White"><span class="swatch" style="background:#ffffff"></span></button>
|
||||||
|
<button class="swatch-btn" data-bg="black" title="Black"><span class="swatch" style="background:#000000"></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-field">
|
||||||
|
<span>Target FPS</span>
|
||||||
|
<input class="glass-input" id="vidFps" type="number" min="0" step="0.1" value="0" placeholder="0 = original" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<label class="switch-row">
|
||||||
|
<span>Alpha matting</span>
|
||||||
|
<span class="switch" id="vidAlphaToggle"><span class="knob"></span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-spacer"></div>
|
||||||
|
|
||||||
|
<div class="panel-section">
|
||||||
|
<div class="panel-label">Output</div>
|
||||||
|
<button class="row-btn" id="vidBrowseOutput">Choose destination…</button>
|
||||||
|
<div class="path-line" id="vidOutputPath">Not set</div>
|
||||||
|
<div class="panel-field">
|
||||||
|
<span>Export</span>
|
||||||
|
<select id="vidExportFormat">
|
||||||
|
<option value="mov_transparent">MOV · ProRes 4444</option>
|
||||||
|
<option value="webm_transparent">WebM · VP9 Alpha</option>
|
||||||
|
<option value="mp4_solid">MP4 · H.264 Solid</option>
|
||||||
|
<option value="png_sequence">PNG Sequence</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="primary-btn" id="vidStartBtn">Process Video</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<div class="statusbar-track"><div class="statusbar-fill" id="progressFill"></div></div>
|
||||||
|
<span class="statusbar-text" id="statusText">Ready</span>
|
||||||
|
<button class="statusbar-log" id="logToggle">Log <svg viewBox="0 0 24 24" fill="none"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div class="log-drawer" id="logPanel"></div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
:root {
|
||||||
|
--bg-app: #16161a;
|
||||||
|
--bg-rail: #101013;
|
||||||
|
--bg-toolbar: #191a1e;
|
||||||
|
--bg-stage: #0d0d10;
|
||||||
|
--bg-panel: #191a1e;
|
||||||
|
--bg-field: #0f0f12;
|
||||||
|
--bg-statusbar: #101013;
|
||||||
|
--line: rgba(255, 255, 255, 0.07);
|
||||||
|
--line-strong: rgba(255, 255, 255, 0.13);
|
||||||
|
--ink: #e9e8ee;
|
||||||
|
--ink-dim: #97959f;
|
||||||
|
--ink-faint: #66646d;
|
||||||
|
--accent: #7c6bff;
|
||||||
|
--accent-dim: rgba(124, 107, 255, 0.16);
|
||||||
|
--good: #3fd692;
|
||||||
|
--bad: #ff6b6b;
|
||||||
|
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
--radius: 8px;
|
||||||
|
font-family: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* The [hidden] attribute must always win over component display rules —
|
||||||
|
several components below (.stage-empty, .compare, etc.) set their own
|
||||||
|
`display` unconditionally, which otherwise silences the UA default. */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-app);
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { font-size: 13px; line-height: 1.5; display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
|
||||||
|
button, select, input { font-family: inherit; }
|
||||||
|
|
||||||
|
::selection { background: var(--accent-dim); }
|
||||||
|
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- app shell */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
width: 52px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 0;
|
||||||
|
background: var(--bg-rail);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-mark {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-btn {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s var(--ease), background 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
.rail-btn svg { width: 18px; height: 18px; }
|
||||||
|
.rail-btn:hover { color: var(--ink-dim); background: rgba(255,255,255,0.03); }
|
||||||
|
.rail-btn.is-active { color: var(--ink); background: rgba(255,255,255,0.045); border-left-color: var(--accent); }
|
||||||
|
|
||||||
|
.rail-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.rail-status { -webkit-app-region: no-drag; padding-bottom: 2px; }
|
||||||
|
.rail-status .dot {
|
||||||
|
display: block; width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: #4a4a52;
|
||||||
|
transition: background 0.3s var(--ease), box-shadow 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
.rail-status.ready .dot { background: var(--good); box-shadow: 0 0 6px 0 rgba(63, 214, 146, 0.5); }
|
||||||
|
.rail-status.busy .dot { background: var(--accent); box-shadow: 0 0 6px 0 rgba(124,107,255,0.6); animation: pulse 1.3s ease-in-out infinite; }
|
||||||
|
.rail-status.error .dot { background: var(--bad); box-shadow: 0 0 6px 0 rgba(255,107,107,0.5); }
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- main / toolbar */
|
||||||
|
|
||||||
|
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 46px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--bg-toolbar);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar h1 { font-size: 13px; font-weight: 600; margin: 0; letter-spacing: 0.01em; }
|
||||||
|
.toolbar-actions { -webkit-app-region: no-drag; }
|
||||||
|
|
||||||
|
.seg { display: inline-flex; padding: 2px; border-radius: 7px; background: rgba(0,0,0,0.3); border: 1px solid var(--line); }
|
||||||
|
.seg-btn {
|
||||||
|
border: none; background: transparent; color: var(--ink-dim);
|
||||||
|
padding: 5px 12px; border-radius: 5px; font-size: 12px; cursor: pointer;
|
||||||
|
transition: background 0.2s var(--ease), color 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
.seg-btn:hover { color: var(--ink); }
|
||||||
|
.seg-btn.is-active { background: rgba(255,255,255,0.09); color: var(--ink); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- workspace */
|
||||||
|
|
||||||
|
.workspace { flex: 1; min-height: 0; display: flex; }
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-stage);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--line) 1px, transparent 1px);
|
||||||
|
background-size: 28px 28px;
|
||||||
|
background-position: -1px -1px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage[hidden] { display: none; }
|
||||||
|
|
||||||
|
.stage-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
.empty-icon svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
.empty-hint { color: var(--ink-faint); font-size: 11.5px; }
|
||||||
|
|
||||||
|
.ghost-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
.ghost-btn:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
.ghost-btn:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
/* compare / before-after */
|
||||||
|
|
||||||
|
.compare { flex: 1; min-height: 0; padding: 18px; display: flex; flex-direction: column; }
|
||||||
|
.compare[hidden] { display: none; }
|
||||||
|
|
||||||
|
.compare-stage {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
cursor: ew-resize;
|
||||||
|
touch-action: none;
|
||||||
|
--split: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checker {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #2a2a30 25%, transparent 25%, transparent 75%, #2a2a30 75%),
|
||||||
|
linear-gradient(45deg, #2a2a30 25%, #1c1c20 25%, #1c1c20 75%, #2a2a30 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position: 0 0, 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
/* Left of the handle shows the original ("Before"), right shows the result
|
||||||
|
("After") — matching the .tag-before / .tag-after label positions. */
|
||||||
|
.layer-original { clip-path: inset(0 calc(100% - var(--split) * 1%) 0 0); }
|
||||||
|
.layer-processed { clip-path: inset(0 0 0 calc(var(--split) * 1%)); }
|
||||||
|
|
||||||
|
/* The visible line is 2px, but the actual pointer hit-zone is much wider
|
||||||
|
(and centered on the line via translateX) so the divider is easy to grab
|
||||||
|
without pixel-precise aim. Dragging can also start anywhere on the image
|
||||||
|
(see .compare-stage pointerdown in app.js) — the handle itself is just
|
||||||
|
the visual affordance. */
|
||||||
|
.handle {
|
||||||
|
position: absolute; top: 0; bottom: 0;
|
||||||
|
left: calc(var(--split) * 1%);
|
||||||
|
width: 40px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: ew-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.handle::before {
|
||||||
|
content: ""; position: absolute; top: 0; bottom: 0; left: 50%; width: 2px;
|
||||||
|
transform: translateX(-1px);
|
||||||
|
background: rgba(255,255,255,0.7);
|
||||||
|
transition: width 0.15s var(--ease), background 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
.handle:hover::before, .handle.is-dragging::before {
|
||||||
|
width: 3px;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
}
|
||||||
|
.handle-grip {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(20,20,24,0.85);
|
||||||
|
border: 1px solid rgba(255,255,255,0.35);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: transform 0.15s var(--ease), background 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
.handle:hover .handle-grip, .handle.is-dragging .handle-grip {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background: rgba(30,26,50,0.95);
|
||||||
|
border-color: rgba(124,107,255,0.7);
|
||||||
|
}
|
||||||
|
.handle-grip::before, .handle-grip::after {
|
||||||
|
content: ""; position: absolute; top: 50%; width: 5px; height: 5px;
|
||||||
|
border-top: 1.4px solid rgba(255,255,255,0.75);
|
||||||
|
border-right: 1.4px solid rgba(255,255,255,0.75);
|
||||||
|
transform: translateY(-50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
.handle-grip::before { left: 7px; }
|
||||||
|
.handle-grip::after { right: 7px; transform: translateY(-50%) rotate(135deg); }
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
position: absolute; top: 10px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(10,10,12,0.65);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.tag-before { left: 10px; }
|
||||||
|
.tag-after { right: 10px; }
|
||||||
|
|
||||||
|
/* loading */
|
||||||
|
|
||||||
|
.stage-loading {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.stage-loading[hidden] { display: none; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255,255,255,0.15);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.stage-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding-top: 10px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink-dim);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s var(--ease), color 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
.mini-btn:hover { background: rgba(255,255,255,0.09); color: var(--ink); }
|
||||||
|
|
||||||
|
/* batch list */
|
||||||
|
|
||||||
|
.batch-list { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 18px; }
|
||||||
|
.batch-list[hidden] { display: none; }
|
||||||
|
.batch-list-head { color: var(--ink-faint); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 10px; }
|
||||||
|
.batch-list-body { flex: 1; overflow-y: auto; border: 1px solid var(--line); border-radius: 6px; background: rgba(0,0,0,0.15); }
|
||||||
|
.batch-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-family: "Cascadia Code", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
.batch-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- panel */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 280px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.panel[hidden] { display: none; }
|
||||||
|
|
||||||
|
.panel-section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.panel-spacer { flex: 1; min-height: 12px; }
|
||||||
|
|
||||||
|
.divider { height: 1px; background: var(--line); margin: 14px 0; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
.panel-label { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-faint); }
|
||||||
|
.panel-hint { margin: 0; color: var(--ink-faint); font-size: 11px; }
|
||||||
|
|
||||||
|
.row-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.045);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s var(--ease);
|
||||||
|
}
|
||||||
|
.row-btn:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
|
||||||
|
.path-line {
|
||||||
|
font-family: "Cascadia Code", "Consolas", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.path-line.has-value { color: var(--ink-dim); }
|
||||||
|
|
||||||
|
.full-select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-field);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-field { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.panel-field span { color: var(--ink-dim); font-size: 12px; }
|
||||||
|
.panel-field select, .glass-input {
|
||||||
|
background: var(--bg-field);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 118px;
|
||||||
|
}
|
||||||
|
.glass-input { min-width: 90px; }
|
||||||
|
.panel-field select:focus, .full-select:focus, .glass-input:focus { border-color: rgba(124,107,255,0.55); }
|
||||||
|
|
||||||
|
.swatch-row { display: flex; gap: 8px; }
|
||||||
|
.swatch-btn {
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 3px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: border-color 0.2s var(--ease), transform 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
.swatch-btn:hover { transform: translateY(-1px); }
|
||||||
|
.swatch-btn.is-active { border-color: var(--accent); }
|
||||||
|
.swatch {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.checker-swatch {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #3a3a42 25%, transparent 25%, transparent 75%, #3a3a42 75%),
|
||||||
|
linear-gradient(45deg, #3a3a42 25%, #24242a 25%, #24242a 75%, #3a3a42 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.switch {
|
||||||
|
width: 32px; height: 19px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: background 0.25s var(--ease), border-color 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
.switch .knob {
|
||||||
|
position: absolute; top: 1px; left: 1px;
|
||||||
|
width: 15px; height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-faint);
|
||||||
|
transition: transform 0.25s var(--ease), background 0.25s var(--ease);
|
||||||
|
}
|
||||||
|
.switch.is-on { background: rgba(124,107,255,0.5); border-color: rgba(124,107,255,0.65); }
|
||||||
|
.switch.is-on .knob { transform: translateX(13px); background: #fff; }
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
margin-top: 14px;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 10px 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.2s var(--ease), transform 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
.primary-btn:hover { filter: brightness(1.08); }
|
||||||
|
.primary-btn:active { transform: scale(0.985); }
|
||||||
|
.primary-btn:disabled { opacity: 0.45; cursor: not-allowed; filter: none; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- statusbar */
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-statusbar);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar-track {
|
||||||
|
width: 90px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.statusbar-fill {
|
||||||
|
height: 100%; width: 0%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.4s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.statusbar-log {
|
||||||
|
background: none; border: none; color: var(--ink-faint);
|
||||||
|
font-size: 11px; display: flex; align-items: center; gap: 4px;
|
||||||
|
cursor: pointer; padding: 3px;
|
||||||
|
}
|
||||||
|
.statusbar-log svg { width: 10px; height: 10px; transition: transform 0.25s var(--ease); }
|
||||||
|
.statusbar-log.is-open svg { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.log-drawer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; right: 0; bottom: 26px;
|
||||||
|
max-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(10,10,12,0.97);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
transition: max-height 0.3s var(--ease);
|
||||||
|
font-family: "Cascadia Code", "Consolas", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.log-drawer.is-open { max-height: 180px; }
|
||||||
|
.log-drawer div { padding: 3px 14px; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.log-drawer div.is-error { color: var(--bad); }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "removebg-studio"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AI Background Removal for Images and Videos powered by rembg & FFmpeg"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"rembg>=2.0.50",
|
||||||
|
"pillow>=10.0.0",
|
||||||
|
"tqdm>=4.65.0",
|
||||||
|
"onnxruntime>=1.15.0",
|
||||||
|
"numba>=0.60.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
removebg = "removebg:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["core*"]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["removebg"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = true
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
RemoveBG Tool - Command Line & Graphical Interface for Image & Video Background Removal
|
||||||
|
Powered by rembg and FFmpeg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
# Add parent directory to path if running directly
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from core.processor import BackgroundRemoverEngine, AVAILABLE_MODELS
|
||||||
|
from core.ffmpeg_utils import check_ffmpeg_installed
|
||||||
|
|
||||||
|
def run_cli(args):
|
||||||
|
"""Execute command-line processing mode."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("✨ RemoveBG CLI Processor")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
model_name = args.model
|
||||||
|
if model_name not in AVAILABLE_MODELS:
|
||||||
|
print(f"Warning: Unknown model '{model_name}'. Available options: {list(AVAILABLE_MODELS.keys())}")
|
||||||
|
model_name = "u2net"
|
||||||
|
|
||||||
|
print(f"Loading Model: {model_name} ({AVAILABLE_MODELS.get(model_name, '')})")
|
||||||
|
engine = BackgroundRemoverEngine(model_name=model_name)
|
||||||
|
|
||||||
|
bg_color = args.bg_color
|
||||||
|
if bg_color:
|
||||||
|
if bg_color.lower() == "green":
|
||||||
|
bg_color = "#00FF00"
|
||||||
|
elif bg_color.lower() == "white":
|
||||||
|
bg_color = "#FFFFFF"
|
||||||
|
elif bg_color.lower() == "black":
|
||||||
|
bg_color = "#000000"
|
||||||
|
|
||||||
|
# 1. Video Processing Mode
|
||||||
|
if args.video:
|
||||||
|
if not check_ffmpeg_installed():
|
||||||
|
print("❌ Error: FFmpeg is not installed or not found in system PATH. Video processing requires FFmpeg.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
video_path = args.video
|
||||||
|
output_path = args.output
|
||||||
|
if not output_path:
|
||||||
|
dir_name, base_name = os.path.split(video_path)
|
||||||
|
fname, _ = os.path.splitext(base_name)
|
||||||
|
output_path = os.path.join(dir_name, f"{fname}_nobg.mov")
|
||||||
|
|
||||||
|
print(f"\n🎬 Input Video: {video_path}")
|
||||||
|
print(f"🎯 Output Destination: {output_path}")
|
||||||
|
print(f"⚙️ Export Format: {args.export_format}")
|
||||||
|
if bg_color:
|
||||||
|
print(f"🎨 Background Color: {bg_color}")
|
||||||
|
print(f"✂️ Alpha Matting: {'Enabled' if args.alpha_matting else 'Disabled'}")
|
||||||
|
|
||||||
|
from tqdm import tqdm
|
||||||
|
pbar = None
|
||||||
|
|
||||||
|
def pcb(current, total_count, msg):
|
||||||
|
nonlocal pbar
|
||||||
|
if total_count > 0:
|
||||||
|
if pbar is None or pbar.total != total_count:
|
||||||
|
if pbar:
|
||||||
|
pbar.close()
|
||||||
|
pbar = tqdm(total=total_count, desc="Processing Video Frames", unit="frame")
|
||||||
|
pbar.n = current
|
||||||
|
pbar.refresh()
|
||||||
|
else:
|
||||||
|
print(f"[Status] {msg}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = engine.process_video(
|
||||||
|
video_path=video_path,
|
||||||
|
output_path=output_path,
|
||||||
|
fps=args.fps if args.fps > 0 else None,
|
||||||
|
export_format=args.export_format,
|
||||||
|
bg_color=bg_color,
|
||||||
|
alpha_matting=args.alpha_matting,
|
||||||
|
progress_callback=pcb
|
||||||
|
)
|
||||||
|
if pbar:
|
||||||
|
pbar.close()
|
||||||
|
print(f"\n✅ Video background removal complete! File saved to: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
if pbar:
|
||||||
|
pbar.close()
|
||||||
|
print(f"\n❌ Error processing video: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 2. Batch Directory Mode
|
||||||
|
elif args.batch or (args.image and os.path.isdir(args.image[0])):
|
||||||
|
batch_dir = args.batch or args.image[0]
|
||||||
|
output_dir = args.output or os.path.join(batch_dir, "output_nobg")
|
||||||
|
|
||||||
|
image_files = []
|
||||||
|
for root, _, files in os.walk(batch_dir):
|
||||||
|
for f in files:
|
||||||
|
if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff")):
|
||||||
|
image_files.append(os.path.join(root, f))
|
||||||
|
|
||||||
|
if not image_files:
|
||||||
|
print(f"❌ No valid image files found in directory: {batch_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n📁 Batch Input Directory: {batch_dir} ({len(image_files)} images found)")
|
||||||
|
print(f"🎯 Output Directory: {output_dir}")
|
||||||
|
|
||||||
|
from tqdm import tqdm
|
||||||
|
pbar = tqdm(total=len(image_files), desc="Removing Backgrounds", unit="img")
|
||||||
|
|
||||||
|
def pcb(current, total_count, fname):
|
||||||
|
pbar.n = current
|
||||||
|
pbar.set_postfix_str(fname)
|
||||||
|
pbar.refresh()
|
||||||
|
|
||||||
|
results = engine.process_batch_images(
|
||||||
|
input_paths=image_files,
|
||||||
|
output_dir=output_dir,
|
||||||
|
alpha_matting=args.alpha_matting,
|
||||||
|
bg_color=bg_color,
|
||||||
|
output_format=args.format,
|
||||||
|
progress_callback=pcb
|
||||||
|
)
|
||||||
|
pbar.close()
|
||||||
|
print(f"\n✅ Batch processing complete! Saved {len(results)} images to: {output_dir}")
|
||||||
|
|
||||||
|
# 3. Single / Multiple Specific Images
|
||||||
|
elif args.image:
|
||||||
|
input_paths = args.image
|
||||||
|
if len(input_paths) == 1:
|
||||||
|
in_file = input_paths[0]
|
||||||
|
out_file = args.output
|
||||||
|
if not out_file:
|
||||||
|
dir_name, base_name = os.path.split(in_file)
|
||||||
|
fname, _ = os.path.splitext(base_name)
|
||||||
|
out_file = os.path.join(dir_name, f"{fname}_nobg.{args.format.lower()}")
|
||||||
|
|
||||||
|
print(f"\n🖼️ Input Image: {in_file}")
|
||||||
|
print(f"🎯 Output File: {out_file}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = engine.process_single_image(
|
||||||
|
in_file,
|
||||||
|
out_file,
|
||||||
|
alpha_matting=args.alpha_matting,
|
||||||
|
bg_color=bg_color
|
||||||
|
)
|
||||||
|
print(f"✅ Success! Image saved to: {res}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
out_dir = args.output or os.path.dirname(input_paths[0])
|
||||||
|
print(f"\n🖼️ Processing {len(input_paths)} images...")
|
||||||
|
from tqdm import tqdm
|
||||||
|
pbar = tqdm(total=len(input_paths), desc="Removing Backgrounds", unit="img")
|
||||||
|
|
||||||
|
def pcb(current, total_count, fname):
|
||||||
|
pbar.n = current
|
||||||
|
pbar.set_postfix_str(fname)
|
||||||
|
pbar.refresh()
|
||||||
|
|
||||||
|
results = engine.process_batch_images(
|
||||||
|
input_paths=input_paths,
|
||||||
|
output_dir=out_dir,
|
||||||
|
alpha_matting=args.alpha_matting,
|
||||||
|
bg_color=bg_color,
|
||||||
|
output_format=args.format,
|
||||||
|
progress_callback=pcb
|
||||||
|
)
|
||||||
|
pbar.close()
|
||||||
|
print(f"✅ Success! Processed {len(results)} images.")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="RemoveBG Studio - AI Background Removal for Images & Video Files (FFmpeg)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# For a graphical UI, build and run native/RemoveBGStudio.exe instead (see README.md).
|
||||||
|
|
||||||
|
# Single Image Background Removal:
|
||||||
|
python removebg.py -i input.jpg -o output.png
|
||||||
|
|
||||||
|
# Batch Image Directory Background Removal:
|
||||||
|
python removebg.py -b ./my_images -o ./no_bg_images --model birefnet-general
|
||||||
|
|
||||||
|
# Video Background Removal with FFmpeg:
|
||||||
|
python removebg.py -v input.mp4 -o transparent.mov --export-format mov_transparent
|
||||||
|
|
||||||
|
# Video Background Removal with Green Screen Replacement:
|
||||||
|
python removebg.py -v input.mp4 -o greenscreen.mp4 --export-format mp4_solid --bg-color green
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("-i", "--image", nargs="+", help="Input image file path(s)")
|
||||||
|
parser.add_argument("-b", "--batch", help="Input directory containing images for batch processing")
|
||||||
|
parser.add_argument("-v", "--video", help="Input video file path to extract and process frames via FFmpeg")
|
||||||
|
parser.add_argument("-o", "--output", help="Output file path (single image/video) or output directory (batch)")
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-m", "--model",
|
||||||
|
default="u2net",
|
||||||
|
choices=list(AVAILABLE_MODELS.keys()),
|
||||||
|
help="AI Model to use for background removal (default: u2net)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-bg", "--bg-color",
|
||||||
|
help="Background replacement color (e.g. 'green', 'white', 'black', or Hex '#00FF00'). Default: transparent"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-am", "--alpha-matting",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable alpha matting for finer edge detail (hair, fur)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--format",
|
||||||
|
default="png",
|
||||||
|
choices=["png", "webp", "jpg"],
|
||||||
|
help="Output image format for single/batch processing (default: png)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--fps",
|
||||||
|
type=float,
|
||||||
|
default=0.0,
|
||||||
|
help="Target frame rate for video frame extraction (0 = original video FPS)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--export-format",
|
||||||
|
default="mov_transparent",
|
||||||
|
choices=["mov_transparent", "webm_transparent", "mp4_solid", "png_sequence"],
|
||||||
|
help="Export format for video reassembly (default: mov_transparent)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.image and not args.batch and not args.video:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run_cli(args)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
rembg>=2.0.50
|
||||||
|
pillow>=10.0.0
|
||||||
|
tqdm>=4.65.0
|
||||||
|
onnxruntime>=1.15.0
|
||||||
|
numba>=0.60.0
|
||||||
Generated
+1444
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user