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:
@@ -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