Compare commits

..

7 Commits

Author SHA1 Message Date
LyAhn a4d08e9998 fix(base64-rs): print usage instead of blocking on console stdin
Running base64 with no FILE fell into the stdin branch and sat in a read
loop. That is exactly what GNU does, but on Windows it presents as a dead
terminal: the console echoes keystrokes so it looks like a prompt, output
buffers into 32K chunks so nothing comes back as you type, and there is no
hint that it is waiting for Ctrl+Z.

Guard the implicit case only. With no FILE operand and stdin attached to a
console, print the help text to stderr and exit 1. Pipes and '<' redirects
are untouched, so pipelines and the parity harness see no change -- the
harness always hands the child a stdin pipe via input=. An explicit '-'
still reads the console for anyone who does want to type input by hand.

Uses std::io::IsTerminal, so the crate stays dependency-free.
2026-07-27 22:33:13 +01:00
LyAhn 665f846dc9 test(base64-rs): add GNU differential parity harness
Runs both binaries over 154 cases and compares stdout, stderr and exit
code: encoding at every wrap width, decode round-trips, canonical-tail
rules, padding placement, concatenated streams, every prefix of a known
encoding, usage errors and file operands.

Locates a genuine GNU base64 rather than trusting /usr/bin/base64, since
distributions increasingly ship uutils there and it disagrees with GNU on
most malformed input — running against it fails 51 of the 154 cases, which
doubles as a negative control for the harness itself.

The three known differences are asserted as expected rather than ignored.
2026-07-27 21:53:26 +01:00
LyAhn 5e6b4428bb feat(base64-rs): add GNU-compatible base64 command for Windows
Windows has no base64 command; certutil is file-only, adds PEM markers
and cannot be piped. This is a real base64.exe matching GNU coreutils:
same flags (-d, -i, -w), stdin/stdout streaming, exit codes and error
messages, so existing muscle memory and scripts carry over.

Verified against coreutils 9.7 with a 153-case differential test. The
subtle behaviours are reproduced: bytes are emitted incrementally so a
stream that goes bad still writes its valid prefix, unpadded tails are
accepted only when canonically encoded, concatenated streams decode as
one, and -w0 suppresses the trailing newline.

Deliberate deviation: '\r' is skipped when decoding alongside '\n', as
CRLF base64 files are routine on Windows and GNU rejects them.

Zero dependencies, so the build works offline.
2026-07-27 21:36:01 +01:00
LyAhn 3ee7d7a3ac feat(base64-decoder): add base64 to file decoder web tool
Single-file browser tool that decodes a base64 string and saves the
result back out as a file. No server, no dependencies, nothing uploaded.

Input cleanup covers data URIs, URL-safe alphabet, missing padding,
line wrapping, percent-encoding and wrapping quotes. Type detection
runs magic bytes (~30 signatures), then the data URI's declared MIME,
then UTF-8 content sniffing for SVG/HTML/XML/JSON/PEM; unrecognised
payloads fall back to .bin with a hex dump. Images, audio, video, PDF
and text are previewed inline before download.
2026-07-27 21:08:09 +01:00
LyAhn fa0170daa9 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.
2026-07-26 18:28:40 +01:00
LyAhn ef24dbc4e5 feat(profiles): add jwtf/phok-dev helpers, fix zoxide/zinit PATH ordering
- Add jwtf to cd into the repo root (with pass-through command support,
  same pattern as phok)
- Add phok-dev alias forcing software H.264/H.265 decode and disabling
  DMA-BUF rendering, working around this laptop's Intel iHD VA-API driver
  corrupting hardware-decoded frames during Phokus dev builds
- Move nvm/bun/PATH setup above zinit init so PATH is fully resolved
  before zoxide's hook and zinit's scheduler register - fixes intermittent
  "command not found: zoxide/sleep/true" errors
- Drop zinit wait lucid turbo mode; its async scheduler was the source
  of the above errors
- Rename up()'s local path var to target to avoid shadowing zsh's
  special path array
2026-07-26 07:12:36 +01:00
LyAhn ee503a225c feat(profiles): add up to .zshrc 2026-07-26 06:47:21 +01:00
37 changed files with 31250 additions and 26 deletions
+22
View File
@@ -4,6 +4,24 @@ A collection of utility tools for development and testing.
## Tools
### [Base64 → File](./base64-decoder/)
A single-file web tool that decodes a base64 string and saves it back out as a real file. Handles data URIs, URL-safe base64, missing padding and line-wrapped input; detects the file type from magic bytes and previews images, audio, video, PDFs and text before you download. Runs entirely in the browser — no server, no dependencies, nothing uploaded.
```bash
xdg-open base64-decoder/base64-decoder.html
```
### [base64 for Windows](./base64-rs/)
A real `base64.exe` for Windows, which otherwise has no `base64` command — only `certutil`, which is file-only, adds PEM markers and can't be piped. Matches GNU coreutils flag for flag (`-d`, `-i`, `-w`, stdin/stdout, exit codes, error messages), verified against coreutils 9.7 with a 153-case differential test. Zero dependencies, single static binary, streams its input.
```powershell
cargo build --release # then put target\release\base64.exe on your PATH
base64 image.png > image.b64
base64 -d image.b64 > image.png
```
### [Fake Media](./fake-media/)
Generates large collections of placeholder PNG images for testing without downloading real files. Supports a terminal UI and a non-interactive CLI mode. Configurable image sizes, folder structure, file counts, and naming prefixes — uses only Python's standard library.
@@ -22,6 +40,10 @@ sudo python3 fake-wheel/fake_wheel_uinput.py
Requires Linux with the `uinput` kernel module and either root access or a user in the `input` group.
### [RemoveBG Studio](./rembg/)
AI background removal for images and video, with lossless FFmpeg frame extraction/reassembly. Ships as a native Windows app (C++ host embedding WebView2, driving a Python/rembg sidecar over JSON-stdio) with a live before/after preview, plus a scriptable CLI and a legacy Tkinter GUI fallback. See [rembg/README.md](./rembg/README.md) for build/usage details.
### [VidBoard Setup](./vidboard-setup-rs/)
A Rust TUI installer that copies VidBoard AI model files into existing ComfyUI and Ollama installations on Windows. Automatically detects both applications via open ports, running process command lines, environment variables, and known install locations — pre-filling the paths so you only need to confirm and press Enter.
+60
View File
@@ -0,0 +1,60 @@
# Base64 → File
A single-file web tool that decodes a base64 string and saves the result back out as a real file. Useful for pulling attachments out of API responses, log dumps, JSON payloads, config blobs and `data:` URIs without hunting for a working online decoder — and without pasting anything sensitive into someone else's website.
## What it does
Open `base64-decoder.html` in any browser, paste the base64, and the decoded file appears on the right — type detected, previewed, ready to download.
Everything runs locally in the page. There is no server, no build step, no dependencies, and nothing is ever uploaded.
```bash
xdg-open base64-decoder/base64-decoder.html
```
## Input handling
The input is cleaned up before decoding, so most real-world paste jobs work as-is:
| Input | Handled |
|---|---|
| `data:image/png;base64,iVBORw0...` | Prefix stripped, MIME type read from the header |
| Line-wrapped / indented base64 | All whitespace removed |
| URL-safe base64 (`-` and `_`) | Converted to standard `+` and `/` |
| Missing `=` padding | Restored |
| Percent-encoded (`%2B`) | URL-decoded first |
| Wrapping quotes from a JSON value | Trimmed |
Invalid input gives a specific reason — the offending character, or a truncated length — rather than a generic failure.
## Type detection
The file type is worked out in three passes, in order of reliability:
1. **Magic bytes** — ~30 signatures covering PNG, JPEG, GIF, WEBP, BMP, ICO, PDF, ZIP, GZIP, BZ2, XZ, 7z, RAR, TAR, MP3, MP4, MKV, OGG, FLAC, WAV, AVI, SQLite, TTF/OTF/WOFF/WOFF2, ELF, EXE and Java class files
2. **Data URI header** — if the input was a `data:` URI, its declared MIME type supplies the extension
3. **Content sniffing** — payloads that decode as valid UTF-8 are checked for SVG, HTML, XML, JSON, PEM and shell scripts
Anything unrecognised falls back to `application/octet-stream` and a `.bin` extension. The suggested filename is always editable before saving.
## Preview
| Decoded type | Preview |
|---|---|
| Images | Rendered inline |
| Audio / video | Playable with controls |
| PDF | Embedded viewer |
| Text-based | Syntax-free text view, plus a **Copy text** button |
| Anything else | Hex dump of the first 192 bytes with an ASCII column |
## Shortcuts
- **Drag and drop** a `.txt` / `.b64` file onto the input to load its contents
- **Ctrl/Cmd + S** downloads the decoded file
- **Enter** in the filename field downloads immediately
## Requirements
Any modern browser. Works straight from `file://` — no web server needed.
The **Paste** button uses the clipboard API, which some browsers restrict on `file://` pages; if it does nothing, use Ctrl+V in the input instead.
+572
View File
@@ -0,0 +1,572 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Base64 &rarr; File</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
font-family:"Segoe UI",Calibri,Arial,sans-serif;
background:#0f1316;color:#c8d8ea;display:flex;flex-direction:column;overflow:hidden
}
/* ── Header ── */
.header{
flex-shrink:0;height:54px;display:flex;align-items:center;gap:14px;
padding:0 22px;
background:linear-gradient(135deg,#14191b 0%,#0e1e28 60%,#0b2020 100%);
border-bottom:2px solid transparent;
border-image:linear-gradient(to right,#3eafff,#2acc94) 1;
}
.brand-name{font-size:18px;font-weight:800;letter-spacing:.3px;color:#fff}
.brand-name em{
font-style:normal;
background:linear-gradient(90deg,#3eafff,#2acc94);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text
}
.brand-pipe{color:#2a3a3a;font-size:13px}
.brand-sub{font-size:12px;color:#4a6878}
.header-right{margin-left:auto;display:flex;align-items:center;gap:10px}
.stat-chip{
background:rgba(62,175,255,.08);border:1px solid rgba(62,175,255,.18);
border-radius:20px;padding:3px 12px;font-size:11px;color:#5a8898;white-space:nowrap
}
.stat-chip strong{color:#2acc94}
/* ── Layout ── */
.layout{display:flex;flex:1;min-height:0}
.pane{display:flex;flex-direction:column;min-width:0;min-height:0}
.pane-in{flex:1 1 50%;border-right:1px solid #1a2428}
.pane-out{flex:1 1 50%;background:#0c1014}
.pane-head{
flex-shrink:0;display:flex;align-items:center;gap:10px;
padding:9px 16px;border-bottom:1px solid #1a2428;background:#0c1014
}
.pane-out .pane-head{background:#0a0e11}
.pane-title{
font-size:9px;font-weight:700;letter-spacing:2px;
text-transform:uppercase;color:#2a4048
}
.pane-tools{margin-left:auto;display:flex;align-items:center;gap:6px}
/* ── Input ── */
.drop{position:relative;flex:1;display:flex;min-height:0}
textarea{
flex:1;resize:none;border:0;outline:0;padding:14px 16px;
background:#0f1316;color:#a8c4d8;
font-family:"Cascadia Mono",Consolas,"Courier New",monospace;
font-size:12px;line-height:1.55;word-break:break-all
}
textarea::placeholder{color:#2f4450}
.drop.over::after{
content:"Drop file to load its contents";
position:absolute;inset:8px;border:2px dashed #3eafff;border-radius:8px;
background:rgba(62,175,255,.07);display:flex;align-items:center;justify-content:center;
font-size:13px;color:#3eafff;pointer-events:none
}
/* ── Buttons ── */
button{
font-family:inherit;font-size:11px;cursor:pointer;
background:#141e24;color:#6a8898;border:1px solid #1e2c34;
border-radius:5px;padding:5px 12px;transition:background .1s,color .1s,border-color .1s
}
button:hover:not(:disabled){background:#18242c;color:#a0c0d0;border-color:#2a3c46}
button:disabled{opacity:.35;cursor:not-allowed}
button.primary{
background:linear-gradient(90deg,#3eafff,#2acc94);color:#08191f;
border:0;font-weight:700;padding:7px 18px
}
button.primary:hover:not(:disabled){filter:brightness(1.12)}
/* ── Output ── */
.out-body{flex:1;overflow-y:auto;min-height:0;padding:16px}
.empty{
height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:8px;color:#2f4450;font-size:13px;text-align:center;padding:20px
}
.empty .big{font-size:30px;opacity:.5}
.card{
background:#0f151a;border:1px solid #1a2428;border-radius:8px;
padding:14px 16px;margin-bottom:14px
}
.card.err{border-color:#5a2430;background:#1a1013}
.card.err .card-label{color:#e06a7a}
.card-label{
font-size:9px;font-weight:700;letter-spacing:2px;text-transform:uppercase;
color:#2a4048;margin-bottom:10px
}
.err-msg{font-size:13px;color:#e8a0aa;line-height:1.5}
.err-hint{font-size:11px;color:#8a5a64;margin-top:8px;line-height:1.5}
.meta{display:grid;grid-template-columns:auto 1fr;gap:7px 16px;font-size:12px}
.meta dt{color:#4a6878;white-space:nowrap}
.meta dd{color:#c8d8ea;word-break:break-all}
.meta dd code{
font-family:"Cascadia Mono",Consolas,monospace;font-size:11px;
color:#2acc94;background:#0a1418;border-radius:3px;padding:1px 5px
}
.save-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
input[type=text]{
flex:1;min-width:150px;font-family:"Cascadia Mono",Consolas,monospace;font-size:12px;
background:#0a0e11;color:#c8d8ea;border:1px solid #1e2c34;border-radius:5px;
padding:7px 10px;outline:0
}
input[type=text]:focus{border-color:#3eafff}
.preview img,.preview video{
max-width:100%;max-height:340px;display:block;margin:0 auto;
border-radius:5px;background:#0a0e11
}
.preview audio{width:100%}
.preview iframe{width:100%;height:420px;border:0;border-radius:5px;background:#fff}
.preview pre{
max-height:340px;overflow:auto;
font-family:"Cascadia Mono",Consolas,monospace;font-size:11.5px;line-height:1.55;
color:#a8c4d8;white-space:pre-wrap;word-break:break-word
}
.hex{
font-family:"Cascadia Mono",Consolas,monospace;font-size:11.5px;line-height:1.6;
color:#5a8898;white-space:pre;overflow-x:auto
}
.hex b{color:#2acc94;font-weight:400}
.hex i{color:#2a4048;font-style:normal}
@media (max-width:820px){
body{overflow:auto}
.layout{flex-direction:column}
.pane-in{border-right:0;border-bottom:1px solid #1a2428}
.pane-in .drop{min-height:200px}
}
</style>
</head>
<body>
<div class="header">
<span class="brand-name">Base<em>64</em> &rarr; File</span>
<span class="brand-pipe">|</span>
<span class="brand-sub">decode base64 and save it back out as a file</span>
<div class="header-right">
<span class="stat-chip">In <strong id="statIn">0</strong></span>
<span class="stat-chip">Out <strong id="statOut">0 B</strong></span>
</div>
</div>
<div class="layout">
<div class="pane pane-in">
<div class="pane-head">
<span class="pane-title">Base64 input</span>
<div class="pane-tools">
<button id="btnPaste">Paste</button>
<button id="btnLoad">Load file</button>
<button id="btnClear">Clear</button>
</div>
</div>
<div class="drop" id="drop">
<textarea id="input" spellcheck="false" autocomplete="off"
placeholder="Paste base64 here.&#10;&#10;Data URIs (data:image/png;base64,...), URL-safe base64, missing padding and&#10;wrapped lines are all handled automatically."></textarea>
</div>
</div>
<div class="pane pane-out">
<div class="pane-head">
<span class="pane-title">Decoded file</span>
<div class="pane-tools">
<button id="btnCopy" disabled>Copy text</button>
<button class="primary" id="btnSave" disabled>Download</button>
</div>
</div>
<div class="out-body" id="out">
<div class="empty">
<div class="big">&#8681;</div>
<div>Paste or drop some base64 to get started</div>
</div>
</div>
</div>
</div>
<input type="file" id="filePicker" hidden>
<script>
(function(){
"use strict";
var input = document.getElementById('input');
var out = document.getElementById('out');
var drop = document.getElementById('drop');
var filePicker = document.getElementById('filePicker');
var statIn = document.getElementById('statIn');
var statOut = document.getElementById('statOut');
var btnSave = document.getElementById('btnSave');
var btnCopy = document.getElementById('btnCopy');
var state = { bytes:null, name:'', mime:'', text:null, url:null };
/* ── Magic-byte signatures ──────────────────────────────────────────
Checked in order; first match wins. `at` is the byte offset of the
signature, `sig` is a byte array (null = wildcard for that byte). */
var SIGS = [
{ ext:'png', mime:'image/png', at:0, sig:[0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A] },
{ ext:'jpg', mime:'image/jpeg', at:0, sig:[0xFF,0xD8,0xFF] },
{ ext:'gif', mime:'image/gif', at:0, sig:[0x47,0x49,0x46,0x38] },
{ ext:'webp', mime:'image/webp', at:8, sig:[0x57,0x45,0x42,0x50] },
{ ext:'bmp', mime:'image/bmp', at:0, sig:[0x42,0x4D] },
{ ext:'ico', mime:'image/x-icon', at:0, sig:[0x00,0x00,0x01,0x00] },
{ ext:'pdf', mime:'application/pdf', at:0, sig:[0x25,0x50,0x44,0x46] },
{ ext:'wav', mime:'audio/wav', at:8, sig:[0x57,0x41,0x56,0x45] },
{ ext:'avi', mime:'video/x-msvideo', at:8, sig:[0x41,0x56,0x49,0x20] },
{ ext:'mp4', mime:'video/mp4', at:4, sig:[0x66,0x74,0x79,0x70] },
{ ext:'mkv', mime:'video/x-matroska', at:0, sig:[0x1A,0x45,0xDF,0xA3] },
{ ext:'ogg', mime:'audio/ogg', at:0, sig:[0x4F,0x67,0x67,0x53] },
{ ext:'flac', mime:'audio/flac', at:0, sig:[0x66,0x4C,0x61,0x43] },
{ ext:'mp3', mime:'audio/mpeg', at:0, sig:[0x49,0x44,0x33] },
{ ext:'mp3', mime:'audio/mpeg', at:0, sig:[0xFF,0xFB] },
{ ext:'zip', mime:'application/zip', at:0, sig:[0x50,0x4B,0x03,0x04] },
{ ext:'gz', mime:'application/gzip', at:0, sig:[0x1F,0x8B] },
{ ext:'bz2', mime:'application/x-bzip2', at:0, sig:[0x42,0x5A,0x68] },
{ ext:'xz', mime:'application/x-xz', at:0, sig:[0xFD,0x37,0x7A,0x58,0x5A,0x00] },
{ ext:'7z', mime:'application/x-7z-compressed', at:0, sig:[0x37,0x7A,0xBC,0xAF,0x27,0x1C] },
{ ext:'rar', mime:'application/vnd.rar', at:0, sig:[0x52,0x61,0x72,0x21,0x1A,0x07] },
{ ext:'tar', mime:'application/x-tar', at:257, sig:[0x75,0x73,0x74,0x61,0x72] },
{ ext:'sqlite', mime:'application/vnd.sqlite3', at:0,
sig:[0x53,0x51,0x4C,0x69,0x74,0x65,0x20,0x66,0x6F,0x72,0x6D,0x61,0x74,0x20,0x33,0x00] },
{ ext:'ttf', mime:'font/ttf', at:0, sig:[0x00,0x01,0x00,0x00,0x00] },
{ ext:'otf', mime:'font/otf', at:0, sig:[0x4F,0x54,0x54,0x4F] },
{ ext:'woff', mime:'font/woff', at:0, sig:[0x77,0x4F,0x46,0x46] },
{ ext:'woff2',mime:'font/woff2', at:0, sig:[0x77,0x4F,0x46,0x32] },
{ ext:'exe', mime:'application/vnd.microsoft.portable-executable', at:0, sig:[0x4D,0x5A] },
{ ext:'elf', mime:'application/x-elf', at:0, sig:[0x7F,0x45,0x4C,0x46] },
{ ext:'class',mime:'application/java-vm', at:0, sig:[0xCA,0xFE,0xBA,0xBE] }
];
function matches(bytes, s){
if (bytes.length < s.at + s.sig.length) return false;
for (var i = 0; i < s.sig.length; i++){
if (s.sig[i] !== null && bytes[s.at + i] !== s.sig[i]) return false;
}
return true;
}
function sniff(bytes){
for (var i = 0; i < SIGS.length; i++){
if (matches(bytes, SIGS[i])) return { ext:SIGS[i].ext, mime:SIGS[i].mime, sure:true };
}
return null;
}
/* Text-based formats have no magic bytes, so classify by content once
we know the payload decodes as valid UTF-8. */
function sniffText(txt){
var t = txt.replace(/^/, '').trimStart();
var head = t.slice(0, 400).toLowerCase();
if (/^<svg[\s>]/.test(head) || (head.indexOf('<?xml') === 0 && head.indexOf('<svg') > -1))
return { ext:'svg', mime:'image/svg+xml' };
if (head.indexOf('<!doctype html') === 0 || head.indexOf('<html') === 0)
return { ext:'html', mime:'text/html' };
if (head.indexOf('<?xml') === 0)
return { ext:'xml', mime:'application/xml' };
if (/^[{\[]/.test(t)){
try { JSON.parse(t); return { ext:'json', mime:'application/json' }; } catch(e){}
}
if (head.indexOf('-----begin ') === 0)
return { ext:'pem', mime:'application/x-pem-file' };
if (head.indexOf('#!') === 0)
return { ext:'sh', mime:'text/x-shellscript' };
if (/^%pdf/.test(head))
return { ext:'pdf', mime:'application/pdf' };
return { ext:'txt', mime:'text/plain' };
}
/* Last resort for a data URI whose payload has no magic bytes — the
declared MIME type usually names the extension we want. */
var MIME_EXT = {
'plain':'txt', 'jpeg':'jpg', 'javascript':'js', 'markdown':'md',
'octet-stream':'bin', 'msword':'doc', 'mpeg':'mp3', 'quicktime':'mov'
};
function extFromMime(mime){
var sub = (mime.split('/')[1] || '').split(';')[0].split('+')[0].trim().toLowerCase();
sub = sub.replace(/^(x-|vnd\.)/, '');
if (MIME_EXT[sub]) return MIME_EXT[sub];
return /^[a-z0-9]{1,8}$/.test(sub) ? sub : null;
}
function asUtf8(bytes){
try { return new TextDecoder('utf-8', {fatal:true}).decode(bytes); }
catch(e){ return null; }
}
function humanSize(n){
if (n < 1024) return n + ' B';
var u = ['KB','MB','GB'], i = -1, v = n;
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
return v.toFixed(v < 10 ? 1 : 0) + ' ' + u[i];
}
function esc(s){
return String(s).replace(/[&<>"]/g, function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];
});
}
/* ── Decode ──────────────────────────────────────────────────────── */
// Strips a data URI prefix, whitespace and quotes; converts URL-safe
// base64 to standard and restores missing '=' padding.
function normalise(raw){
var mime = '';
var s = raw.trim().replace(/^["'`]|["'`]$/g, '');
var m = /^data:([^;,]*)(;[^,]*)?,/i.exec(s);
if (m){
if (!/;base64/i.test(m[2] || '')) throw new Error('That data URI is not base64-encoded.');
mime = m[1] || '';
s = s.slice(m[0].length);
}
s = s.replace(/\s+/g, '');
if (/%[0-9a-f]{2}/i.test(s)){
try { s = decodeURIComponent(s); } catch(e){}
}
s = s.replace(/-/g, '+').replace(/_/g, '/');
s = s.replace(/=+$/, '');
var bad = s.match(/[^A-Za-z0-9+/]/);
if (bad) throw new Error('Not valid base64 — unexpected character "' + bad[0] + '".');
if (s.length % 4 === 1) throw new Error('Not valid base64 — the input length is truncated.');
while (s.length % 4) s += '=';
return { data:s, mime:mime };
}
function toBytes(b64){
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function hexDump(bytes, rows){
var lines = [], limit = Math.min(bytes.length, rows * 16);
for (var o = 0; o < limit; o += 16){
var hex = '', txt = '';
for (var i = 0; i < 16; i++){
if (o + i < limit){
var b = bytes[o + i];
hex += (b < 16 ? '0' : '') + b.toString(16) + ' ';
txt += (b >= 32 && b < 127) ? esc(String.fromCharCode(b)) : '.';
} else {
hex += ' '; txt += ' ';
}
if (i === 7) hex += ' ';
}
lines.push('<i>' + ('0000000' + o.toString(16)).slice(-8) + '</i> ' + hex + ' <b>' + txt + '</b>');
}
if (bytes.length > limit) lines.push('<i>' + humanSize(bytes.length - limit) + ' more…</i>');
return lines.join('\n');
}
function reset(){
if (state.url) URL.revokeObjectURL(state.url);
state = { bytes:null, name:'', mime:'', text:null, url:null };
btnSave.disabled = true;
btnCopy.disabled = true;
statOut.textContent = '0 B';
}
function showEmpty(){
reset();
out.innerHTML = '<div class="empty"><div class="big">&#8681;</div>' +
'<div>Paste or drop some base64 to get started</div></div>';
}
function showError(msg, hint){
reset();
out.innerHTML = '<div class="card err"><div class="card-label">Could not decode</div>' +
'<div class="err-msg">' + esc(msg) + '</div>' +
(hint ? '<div class="err-hint">' + esc(hint) + '</div>' : '') + '</div>';
}
function decode(){
var raw = input.value;
statIn.textContent = raw.replace(/\s+/g, '').length.toLocaleString();
if (!raw.trim()){ showEmpty(); return; }
var norm, bytes;
try {
norm = normalise(raw);
bytes = toBytes(norm.data);
} catch(e){
showError(e.message, 'Check that the whole string was copied and that nothing ' +
'was truncated or line-wrapped with stray characters.');
return;
}
if (!bytes.length){ showError('The input decoded to zero bytes.'); return; }
var text = asUtf8(bytes);
var hit = sniff(bytes);
var kind;
if (hit) kind = hit;
else if (text !== null) kind = sniffText(text);
else kind = { ext:'bin', mime:'application/octet-stream' };
var mime = norm.mime || kind.mime;
if (!hit && norm.mime) kind.ext = extFromMime(norm.mime) || kind.ext;
reset();
state.bytes = bytes;
state.mime = mime;
state.text = (kind.ext === 'txt' || /^text\/|json|xml|svg|javascript|pem|shellscript/.test(kind.mime)) ? text : null;
state.name = 'decoded.' + kind.ext;
state.url = URL.createObjectURL(new Blob([bytes], {type:mime}));
statOut.textContent = humanSize(bytes.length);
btnSave.disabled = false;
btnCopy.disabled = state.text === null;
render(bytes, mime, kind, hit, norm.mime);
}
function render(bytes, mime, kind, hit, uriMime){
var detected = hit ? 'magic bytes'
: uriMime ? 'data URI header'
: kind.ext === 'bin' ? 'unrecognised — treated as binary'
: 'content sniffing';
var html =
'<div class="card"><div class="card-label">Save as</div>' +
'<div class="save-row">' +
'<input type="text" id="nameField" value="' + esc(state.name) + '" spellcheck="false">' +
'<button class="primary" id="btnSave2">Download</button>' +
'</div>' +
'</div>' +
'<div class="card"><div class="card-label">File</div>' +
'<dl class="meta">' +
'<dt>Type</dt><dd><code>' + esc(mime) + '</code></dd>' +
'<dt>Size</dt><dd>' + humanSize(bytes.length) +
' <span style="color:#4a6878">(' + bytes.length.toLocaleString() + ' bytes)</span></dd>' +
'<dt>Detected via</dt><dd style="color:#4a6878">' + detected + '</dd>' +
'</dl>' +
'</div>';
var p = '';
if (/^image\//.test(mime)){
p = '<img src="' + state.url + '" alt="decoded image">';
} else if (/^video\//.test(mime)){
p = '<video src="' + state.url + '" controls></video>';
} else if (/^audio\//.test(mime)){
p = '<audio src="' + state.url + '" controls></audio>';
} else if (mime === 'application/pdf'){
p = '<iframe src="' + state.url + '"></iframe>';
} else if (state.text !== null){
p = '<pre>' + esc(state.text.slice(0, 20000)) +
(state.text.length > 20000 ? '\n…' : '') + '</pre>';
} else {
p = '<div class="hex">' + hexDump(bytes, 12) + '</div>';
}
html += '<div class="card preview"><div class="card-label">Preview</div>' + p + '</div>';
out.innerHTML = html;
var nameField = document.getElementById('nameField');
nameField.addEventListener('input', function(){ state.name = nameField.value; });
nameField.addEventListener('keydown', function(e){ if (e.key === 'Enter') save(); });
document.getElementById('btnSave2').addEventListener('click', save);
}
function save(){
if (!state.bytes) return;
var a = document.createElement('a');
a.href = state.url;
a.download = (state.name || 'decoded.bin').trim() || 'decoded.bin';
document.body.appendChild(a);
a.click();
a.remove();
}
/* ── Events ──────────────────────────────────────────────────────── */
var timer;
input.addEventListener('input', function(){
clearTimeout(timer);
timer = setTimeout(decode, 120);
});
btnSave.addEventListener('click', save);
btnCopy.addEventListener('click', function(){
if (state.text === null) return;
navigator.clipboard.writeText(state.text).then(function(){
btnCopy.textContent = 'Copied';
setTimeout(function(){ btnCopy.textContent = 'Copy text'; }, 1200);
});
});
document.getElementById('btnClear').addEventListener('click', function(){
input.value = '';
input.focus();
decode();
});
document.getElementById('btnPaste').addEventListener('click', function(){
navigator.clipboard.readText().then(function(t){
input.value = t;
decode();
}).catch(function(){
input.focus();
});
});
document.getElementById('btnLoad').addEventListener('click', function(){
filePicker.click();
});
filePicker.addEventListener('change', function(){
if (filePicker.files[0]) readAsText(filePicker.files[0]);
filePicker.value = '';
});
function readAsText(file){
var r = new FileReader();
r.onload = function(){ input.value = r.result; decode(); };
r.readAsText(file);
}
['dragenter','dragover'].forEach(function(ev){
drop.addEventListener(ev, function(e){
e.preventDefault();
drop.classList.add('over');
});
});
['dragleave','drop'].forEach(function(ev){
drop.addEventListener(ev, function(e){
e.preventDefault();
if (ev === 'dragleave' && drop.contains(e.relatedTarget)) return;
drop.classList.remove('over');
});
});
drop.addEventListener('drop', function(e){
var f = e.dataTransfer.files[0];
if (f) readAsText(f);
});
window.addEventListener('keydown', function(e){
if ((e.ctrlKey || e.metaKey) && e.key === 's' && state.bytes){
e.preventDefault();
save();
}
});
input.focus();
})();
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
/target
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "base64"
version = "1.0.0"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "base64"
version = "1.0.0"
edition = "2021"
[dependencies]
[[bin]]
name = "base64"
path = "src/main.rs"
[profile.release]
opt-level = 3
lto = true
strip = true
panic = "abort"
+82
View File
@@ -0,0 +1,82 @@
# base64 for Windows
Windows has no `base64` command. `certutil -encode` is the usual substitute, but it only works on files, wraps output in `-----BEGIN CERTIFICATE-----` markers, and can't be piped. This is a real `base64.exe` that behaves like the one on a Linux box, so the commands and scripts you already know carry over.
```powershell
base64 image.png > image.b64
base64 -d image.b64 > image.png
type secrets.json | base64 -w0
base64 -di messy.txt > payload.bin
```
## Build and install
```powershell
cargo build --release
```
Copy `target\release\base64.exe` somewhere on your PATH — e.g. `%LOCALAPPDATA%\Microsoft\WindowsApps`, or a `C:\tools\bin` folder you've added yourself:
```powershell
mkdir C:\tools\bin
copy target\release\base64.exe C:\tools\bin\
[Environment]::SetEnvironmentVariable(
'Path',
[Environment]::GetEnvironmentVariable('Path','User') + ';C:\tools\bin',
'User')
```
Open a new terminal afterwards for the PATH change to take effect. No runtime dependencies — it's a single static binary, and the crate itself has zero dependencies, so the build works offline.
## Usage
```
Usage: base64 [OPTION]... [FILE]
Base64 encode or decode FILE, or standard input, to standard output.
With no FILE, or when FILE is -, read standard input.
-d, --decode decode data
-i, --ignore-garbage when decoding, ignore non-alphabet characters
-w, --wrap=COLS wrap encoded lines after COLS character (default 76).
Use 0 to disable line wrapping
--help display this help and exit
--version output version information and exit
```
Long options accept unambiguous abbreviations (`--dec`), short options cluster (`-di`), `-w` takes its value joined or separate (`-w0`, `-w 0`, `--wrap=0`), and `--` ends option parsing.
## Behaviour
Verified against GNU coreutils 9.7 with a 154-case differential test ([tests/parity.py](tests/parity.py)) covering encoding at every wrap width, decode round-trips, malformed padding, truncated input, garbage handling, all the usage errors and file operands. Output, exit codes and error messages match, including the fiddly parts:
- **Partial output on failure.** Bytes are emitted as soon as enough bits are available, so a stream that goes bad halfway still writes the valid prefix before the error — same as coreutils.
- **Unpadded input is accepted** when the encoding is canonical, i.e. the leftover bits are zero. `aGVsbG8` decodes to `hello`; `aGV` is rejected because `V` carries stray bits.
- **Concatenated streams** decode as one (`aGVsbG8=aGVsbG8=``hellohello`).
- **`-w0` emits no trailing newline**; wrapped output always ends with one. Empty input produces no output at all.
- **Exit codes**: `0` success, `1` on any error.
### Deliberate differences
| | Why |
|---|---|
| `\r` is skipped when decoding, as `\n` already is | GNU rejects CRLF input as invalid. On Windows a base64 file will routinely have CRLF line endings, and erroring on those would make the tool useless for its main job. Everything else is still rejected unless you pass `-i`. |
| Running it with no FILE at an interactive console prints the usage text and exits `1` | GNU would read stdin, which looks exactly like a hung terminal — no prompt, no output, no hint that it wants Ctrl+Z. Only applies when stdin is a console: pipes and `<` redirects read stdin as usual, and `base64 -` still reads the console if you actually want to type input. |
| `--help` omits GNU's support URLs | This isn't GNU coreutils; pointing at their bug tracker would be wrong. |
| Error messages quote with `'ASCII'` | GNU uses `'…'` in a UTF-8 locale. Windows consoles are frequently cp437/cp1252, where those bytes render as mojibake. |
## Running the tests
```bash
cargo build --release
python3 tests/parity.py
```
Linux-only — it diffs against a real GNU `base64`, so it can't run on the Windows box this tool targets. Note that many distributions now ship **uutils** coreutils as `/usr/bin/base64`; it disagrees with GNU on most malformed-input cases, so the script hunts for a genuine GNU build and refuses to run against anything else. On Ubuntu with `coreutils-from-uutils` installed, the GNU one is `gnubase64`.
## Notes
- Roughly 2× slower than GNU on large inputs (100 MB encodes in ~0.35s vs ~0.18s). GNU uses hand-tuned routines; this is plain portable Rust and fast enough that the difference is invisible for normal use.
- Input is streamed, not buffered whole, so encoding a multi-gigabyte file doesn't blow up memory.
- Piping to a program that exits early (`base64 big.bin | head`) is handled quietly rather than reporting a broken pipe.
- Writing raw binary to a Windows console fails with a clear message telling you to redirect, because consoles take UTF-16 text and would corrupt the bytes. Redirecting to a file or piping is unaffected.
+464
View File
@@ -0,0 +1,464 @@
//! A drop-in `base64` for Windows, matching GNU coreutils behaviour.
//!
//! Same flags, same output, same error messages and exit codes as the
//! `base64` on a Linux box, so muscle memory and scripts carry over.
use std::env;
use std::fs::File;
use std::io::{self, BufWriter, ErrorKind, IsTerminal, Read, Write};
use std::process::ExitCode;
const PROGRAM: &str = "base64";
const VERSION: &str = concat!("base64 (jwtf tools) ", env!("CARGO_PKG_VERSION"));
const DEFAULT_WRAP: usize = 76;
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const PAD: u8 = b'=';
/// Reverse lookup for the alphabet; -1 marks a byte that is not a base64 digit.
const DECODE: [i8; 256] = {
let mut table = [-1i8; 256];
let mut i = 0;
while i < 64 {
table[ALPHABET[i] as usize] = i as i8;
i += 1;
}
table
};
const HELP: &str = "\
Usage: base64 [OPTION]... [FILE]
Base64 encode or decode FILE, or standard input, to standard output.
With no FILE, or when FILE is -, read standard input.
Mandatory arguments to long options are mandatory for short options too.
-d, --decode decode data
-i, --ignore-garbage when decoding, ignore non-alphabet characters
-w, --wrap=COLS wrap encoded lines after COLS character (default 76).
Use 0 to disable line wrapping
--help display this help and exit
--version output version information and exit
The data are encoded as described for the base64 alphabet in RFC 4648.
When decoding, the input may contain newlines in addition to the bytes of
the formal base64 alphabet. Use --ignore-garbage to attempt to recover
from any other non-alphabet bytes in the encoded stream.";
const LONG_OPTS: [&str; 5] = ["decode", "ignore-garbage", "wrap", "help", "version"];
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(Fail::Quiet) => ExitCode::SUCCESS,
Err(Fail::Usage(msg)) => {
eprintln!("{PROGRAM}: {msg}");
eprintln!("Try '{PROGRAM} --help' for more information.");
ExitCode::FAILURE
}
Err(Fail::NoInput) => {
eprintln!("{HELP}");
ExitCode::FAILURE
}
Err(Fail::Error(msg)) => {
eprintln!("{PROGRAM}: {msg}");
ExitCode::FAILURE
}
}
}
enum Fail {
/// Usage problem: prints the "Try --help" hint after the message.
Usage(String),
/// Runtime problem: message only.
Error(String),
/// Bare invocation at a console: show the usage text rather than sit on
/// stdin looking hung.
NoInput,
/// Downstream closed the pipe (`base64 f | head`); nothing to report.
Quiet,
}
struct Options {
decode: bool,
ignore_garbage: bool,
wrap: usize,
file: Option<String>,
}
fn run() -> Result<(), Fail> {
let opts = match parse_args(env::args().skip(1).collect())? {
Parsed::Run(o) => o,
Parsed::Exit(text) => {
println!("{text}");
return Ok(());
}
};
// GNU reads stdin when given no FILE, which at an interactive prompt is
// indistinguishable from a hang. Only the implicit case is caught: an
// explicit `-` is someone asking for stdin on purpose.
if opts.file.is_none() && io::stdin().is_terminal() {
return Err(Fail::NoInput);
}
// Reading is buffered by the encode/decode loops, which pull in large
// chunks; stdout is wrapped so small writes do not hit the OS each time.
let stdout = io::stdout();
let mut out = BufWriter::with_capacity(64 * 1024, stdout.lock());
let result = match opts.file.as_deref() {
None | Some("-") => {
let stdin = io::stdin();
process(stdin.lock(), &mut out, &opts)
}
Some(path) => match File::open(path) {
Ok(f) => process(f, &mut out, &opts),
Err(e) => return Err(Fail::Error(format!("{path}: {}", describe(&e)))),
},
};
// Flush even on failure: a partial decode still writes its valid prefix,
// and it must land before main() prints the error to stderr.
let flushed = out.flush().map_err(write_failure);
result.and(flushed)
}
fn process<R: Read, W: Write>(input: R, out: &mut W, opts: &Options) -> Result<(), Fail> {
if opts.decode {
decode(input, out, opts.ignore_garbage)
} else {
encode(input, out, opts.wrap)
}
}
/* ── Argument parsing ────────────────────────────────────────────────── */
enum Parsed {
Run(Options),
/// --help / --version: print this and stop.
Exit(&'static str),
}
fn parse_args(args: Vec<String>) -> Result<Parsed, Fail> {
let mut opts = Options {
decode: false,
ignore_garbage: false,
wrap: DEFAULT_WRAP,
file: None,
};
let mut operands: Vec<String> = Vec::new();
let mut only_operands = false;
let mut it = args.into_iter().peekable();
while let Some(arg) = it.next() {
if only_operands {
operands.push(arg);
} else if arg == "--" {
only_operands = true;
} else if let Some(long) = arg.strip_prefix("--") {
// Split --wrap=76 into name and inline value.
let (name, inline) = match long.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (long, None),
};
match resolve_long(name)? {
"help" => return Ok(Parsed::Exit(HELP)),
"version" => return Ok(Parsed::Exit(VERSION)),
"decode" => opts.decode = true,
"ignore-garbage" => opts.ignore_garbage = true,
"wrap" => {
let value = match inline.or_else(|| it.next()) {
Some(v) => v,
None => {
return Err(Fail::Usage(format!("option '--{name}' requires an argument")))
}
};
opts.wrap = parse_wrap(&value)?;
}
_ => unreachable!(),
}
} else if arg.len() > 1 && arg.starts_with('-') {
// Short flags, clusterable: -di, -dw0, -w76
let chars: Vec<char> = arg.chars().collect();
let mut idx = 1;
while idx < chars.len() {
match chars[idx] {
'd' => opts.decode = true,
'i' => opts.ignore_garbage = true,
'w' => {
// Rest of this argument is the value, else the next one.
let rest: String = chars[idx + 1..].iter().collect();
let value = if rest.is_empty() {
match it.next() {
Some(v) => v,
None => {
return Err(Fail::Usage(
"option requires an argument -- 'w'".to_string(),
))
}
}
} else {
rest
};
opts.wrap = parse_wrap(&value)?;
idx = chars.len();
continue;
}
c => return Err(Fail::Usage(format!("invalid option -- '{c}'"))),
}
idx += 1;
}
} else {
operands.push(arg);
}
}
if operands.len() > 1 {
return Err(Fail::Usage(format!("extra operand '{}'", operands[1])));
}
opts.file = operands.into_iter().next();
Ok(Parsed::Run(opts))
}
/// GNU accepts any unambiguous abbreviation of a long option.
fn resolve_long(name: &str) -> Result<&'static str, Fail> {
let hits: Vec<&'static str> = LONG_OPTS
.iter()
.copied()
.filter(|o| o.starts_with(name))
.collect();
match hits.len() {
1 => Ok(hits[0]),
0 => Err(Fail::Usage(format!("unrecognized option '--{name}'"))),
_ => Err(Fail::Usage(format!(
"option '--{name}' is ambiguous; possibilities: {}",
hits.iter()
.map(|o| format!("'--{o}'"))
.collect::<Vec<_>>()
.join(" ")
))),
}
}
/// Unlike the other usage errors, coreutils prints this one without the
/// "Try --help" hint.
fn parse_wrap(value: &str) -> Result<usize, Fail> {
value
.parse::<usize>()
.map_err(|_| Fail::Error(format!("invalid wrap size: '{value}'")))
}
/* ── Encode ──────────────────────────────────────────────────────────── */
fn encode<R: Read, W: Write>(mut input: R, out: &mut W, wrap: usize) -> Result<(), Fail> {
let mut inbuf = vec![0u8; 48 * 1024];
let mut pending: Vec<u8> = Vec::with_capacity(3);
let mut line: Vec<u8> = Vec::with_capacity(64 * 1024);
let mut col = 0usize;
let mut any = false;
loop {
let n = input.read(&mut inbuf).map_err(read_failure)?;
if n == 0 {
break;
}
any = true;
pending.extend_from_slice(&inbuf[..n]);
let whole = pending.len() - pending.len() % 3;
for group in pending[..whole].chunks_exact(3) {
emit(&mut line, encode_triple(group[0], group[1], group[2]), wrap, &mut col);
}
pending.drain(..whole);
if line.len() >= 32 * 1024 {
out.write_all(&line).map_err(write_failure)?;
line.clear();
}
}
// Tail of 1 or 2 bytes gets padded out to a full quad.
match pending.len() {
1 => {
let q = encode_triple(pending[0], 0, 0);
emit(&mut line, [q[0], q[1], PAD, PAD], wrap, &mut col);
}
2 => {
let q = encode_triple(pending[0], pending[1], 0);
emit(&mut line, [q[0], q[1], q[2], PAD], wrap, &mut col);
}
_ => {}
}
// The trailing newline is a line terminator, so it appears only when
// wrapping is on and something was actually written.
if any && col != 0 && wrap != 0 {
line.push(b'\n');
}
out.write_all(&line).map_err(write_failure)?;
Ok(())
}
fn encode_triple(a: u8, b: u8, c: u8) -> [u8; 4] {
let n = ((a as u32) << 16) | ((b as u32) << 8) | c as u32;
[
ALPHABET[(n >> 18) as usize & 63],
ALPHABET[(n >> 12) as usize & 63],
ALPHABET[(n >> 6) as usize & 63],
ALPHABET[n as usize & 63],
]
}
fn emit(line: &mut Vec<u8>, quad: [u8; 4], wrap: usize, col: &mut usize) {
for ch in quad {
if wrap != 0 && *col == wrap {
line.push(b'\n');
*col = 0;
}
line.push(ch);
*col += 1;
}
}
/* ── Decode ──────────────────────────────────────────────────────────── */
fn decode<R: Read, W: Write>(mut input: R, out: &mut W, ignore_garbage: bool) -> Result<(), Fail> {
let mut obuf: Vec<u8> = Vec::with_capacity(48 * 1024);
let outcome = decode_stream(&mut input, out, &mut obuf, ignore_garbage);
// Whatever decoded cleanly before the failure still gets written, which
// is what coreutils does — the error only stops further output.
let flushed = out.write_all(&obuf).map_err(write_failure);
outcome.and(flushed)
}
/// Bytes are emitted as soon as enough bits are in hand (one byte after two
/// digits, another after three, the last on a full quad), so a stream that
/// turns bad part-way still produces the valid prefix.
fn decode_stream<R: Read, W: Write>(
input: &mut R,
out: &mut W,
obuf: &mut Vec<u8>,
ignore_garbage: bool,
) -> Result<(), Fail> {
let mut inbuf = vec![0u8; 64 * 1024];
let mut d = [0u8; 3];
let mut have = 0usize;
// A quad holding two digits needs '=' twice; this tracks the second.
let mut need_pad = false;
loop {
let n = input.read(&mut inbuf).map_err(read_failure)?;
if n == 0 {
break;
}
for &byte in &inbuf[..n] {
if byte == PAD {
if need_pad {
need_pad = false;
have = 0;
continue;
}
match have {
2 if d[1] & 0x0F == 0 => need_pad = true,
3 if d[2] & 0x03 == 0 => have = 0,
_ => return Err(invalid()),
}
continue;
}
let digit = DECODE[byte as usize];
if digit < 0 {
// Line endings are always skipped so CRLF files work; other
// junk is only tolerated under --ignore-garbage.
if byte == b'\n' || byte == b'\r' || ignore_garbage {
continue;
}
return Err(invalid());
}
if need_pad {
return Err(invalid());
}
let v = digit as u8;
match have {
0 => {
d[0] = v;
have = 1;
}
1 => {
d[1] = v;
have = 2;
obuf.push((d[0] << 2) | (d[1] >> 4));
}
2 => {
d[2] = v;
have = 3;
obuf.push(((d[1] & 0x0F) << 4) | (d[2] >> 2));
}
_ => {
obuf.push(((d[2] & 0x03) << 6) | v);
have = 0;
}
}
}
if obuf.len() >= 32 * 1024 {
out.write_all(obuf).map_err(write_failure)?;
obuf.clear();
}
}
if need_pad {
return Err(invalid());
}
// An unpadded tail is only legal if the bits that would have been
// dropped are zero, i.e. the input was canonically encoded.
match have {
0 => Ok(()),
2 if d[1] & 0x0F == 0 => Ok(()),
3 if d[2] & 0x03 == 0 => Ok(()),
_ => Err(invalid()),
}
}
fn invalid() -> Fail {
Fail::Error("invalid input".to_string())
}
/* ── I/O error mapping ───────────────────────────────────────────────── */
fn read_failure(e: io::Error) -> Fail {
Fail::Error(format!("read error: {}", describe(&e)))
}
fn write_failure(e: io::Error) -> Fail {
match e.kind() {
// `base64 big.bin | head` closes the pipe under us; exit quietly.
ErrorKind::BrokenPipe => Fail::Quiet,
// Windows consoles take UTF-16 text, so raw bytes cannot be printed
// to one. Redirecting to a file or piping sidesteps it entirely.
ErrorKind::InvalidData => Fail::Error(
"cannot write binary output to the console; redirect to a file (base64 -d in.txt > out.bin)"
.to_string(),
),
_ => Fail::Error(format!("write error: {}", describe(&e))),
}
}
/// Trims Rust's "(os error N)" suffix so messages read like coreutils'.
fn describe(e: &io::Error) -> String {
match e.kind() {
ErrorKind::NotFound => "No such file or directory".to_string(),
ErrorKind::PermissionDenied => "Permission denied".to_string(),
ErrorKind::IsADirectory => "Is a directory".to_string(),
_ => {
let s = e.to_string();
match s.find(" (os error ") {
Some(i) => s[..i].to_string(),
None => s,
}
}
}
}
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Differential test: this base64 against GNU coreutils base64.
Runs both binaries over the same inputs and compares stdout, stderr and
exit code. Linux-only — it needs a real GNU base64 to diff against, so it
cannot run on the Windows machine the tool is actually built for.
cargo build --release && python3 tests/parity.py
Note that many distributions now ship uutils coreutils as /usr/bin/base64,
which differs from GNU on malformed input. This script looks for a genuine
GNU build and refuses to run against anything else.
"""
import os
import random
import shutil
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MINE = os.path.join(ROOT, "target", "release", "base64")
# Differences that are intentional; see README.md.
EXPECTED_DIFF = {
# We skip '\r' alongside '\n' so CRLF base64 files work on Windows.
"decode CRLF wrapped",
# Our --help omits GNU's own support URLs.
"help",
"help after other flags",
}
def find_gnu():
"""Locate a genuine GNU base64, ignoring uutils drop-in replacements."""
for name in ("gnubase64", "base64", "gbase64"):
path = shutil.which(name)
if not path:
continue
try:
banner = subprocess.run(
[path, "--version"], capture_output=True, text=True, timeout=10
).stdout
except OSError:
continue
if "GNU coreutils" in banner:
return path, banner.splitlines()[0]
return None, None
GNU, GNU_BANNER = find_gnu()
def norm(b, gnu_name):
"""Strip argv[0] and locale noise so the two are comparable."""
b = b.replace(gnu_name.encode(), b"base64")
b = b.replace(os.path.basename(gnu_name).encode(), b"base64")
# GNU quotes with U+2018/2019 outside the C locale; we always use ASCII.
b = b.replace("".encode(), b"'").replace("".encode(), b"'")
return b
def run(exe, args, data):
# C locale: GNU otherwise uses en_GB spelling and Unicode quotes, neither
# of which a Windows console would produce.
env = dict(os.environ, LC_ALL="C")
p = subprocess.run([exe] + args, input=data, capture_output=True, env=env)
return p.returncode, norm(p.stdout, GNU), norm(p.stderr, GNU)
results = []
def t(name, args, data=b""):
mine = run(MINE, args, data)
gnu = run(GNU, args, data)
ok = mine == gnu
if not ok:
tag = "DIFF(expected)" if name in EXPECTED_DIFF else "FAIL"
print(f"[{tag}] {name} args={args}")
if mine[0] != gnu[0]:
print(f" exit: mine={mine[0]} gnu={gnu[0]}")
if mine[1] != gnu[1]:
print(f" out : mine={mine[1][:90]!r}")
print(f" gnu ={gnu[1][:90]!r}")
if mine[2] != gnu[2]:
print(f" err : mine={mine[2][:120]!r}")
print(f" gnu ={gnu[2][:120]!r}")
results.append((name, ok or name in EXPECTED_DIFF))
def main():
random.seed(7)
big = bytes(random.getrandbits(8) for _ in range(200_000))
# ── encoding ──────────────────────────────────────────────────────
t("encode empty", [], b"")
for n in (1, 2, 3, 4, 5, 56, 57, 58, 76, 1000):
t(f"encode {n} bytes", [], (bytes(range(256)) * 10)[:n])
t("encode all byte values", [], bytes(range(256)))
t("encode 200KB binary", [], big)
sample = b"hello world, this is a longer sample string for wrapping"
for w in ("0", "1", "2", "3", "4", "5", "76", "77", "1000"):
t(f"encode wrap {w}", ["-w", w], sample)
t(f"encode wrap {w} joined", [f"-w{w}"], sample)
t(f"encode wrap {w} long", ["--wrap=" + w], big[:5000])
# ── decoding ──────────────────────────────────────────────────────
for n in (0, 1, 2, 3, 4, 5, 100, 1000):
enc = subprocess.run([GNU], input=big[:n], capture_output=True).stdout
t(f"decode roundtrip {n}", ["-d"], enc)
t("decode 200KB roundtrip", ["-d"],
subprocess.run([GNU], input=big, capture_output=True).stdout)
t("decode unwrapped", ["-d"], b"aGVsbG8gd29ybGQ=")
t("decode no trailing newline", ["-d"], b"aGVsbG8=")
t("decode with newlines", ["-d"], b"aGVs\nbG8=\n")
t("decode CRLF wrapped", ["-d"], b"aGVs\r\nbG8=\r\n")
t("decode empty", ["-d"], b"")
t("decode only newline", ["-d"], b"\n")
t("decode spaces garbage", ["-d"], b"aGVs bG8=")
t("decode spaces garbage -i", ["-di"], b"aGVs bG8=")
t("decode junk", ["-d"], b"aG!!Vs*bG8=")
t("decode junk -i", ["-di"], b"aG!!Vs*bG8=")
t("decode junk only -i", ["-di"], b"!!!!")
t("decode nul bytes -i", ["-di"], b"aGVs\x00bG8=")
t("decode high bytes", ["-d"], b"aGVs\xffbG8=")
t("decode high bytes -i", ["-di"], b"aGVs\xffbG8=")
t("decode urlsafe chars", ["-d"], b"_-_-")
# Unpadded tails are legal only when the leftover bits are zero.
for s in (b"a", b"aA", b"aQ", b"ag", b"aG", b"aGA", b"aGE", b"aGV",
b"aB", b"aC", b"aGB", b"aGC", b"aGVs", b"aGVsb", b"aGVsbG",
b"aGVsbG8"):
t(f"decode tail {s.decode()}", ["-d"], s)
# Padding placement, and streams concatenated after padding.
for s in (b"=", b"====", b"=aGVsbG8=", b"aG=sbG8=", b"aGVsbG8===",
b"aA==", b"aQ==", b"aG==", b"aGA=", b"aGV=", b"aA=", b"aA=x",
b"aG=x", b"aGA=x", b"aA==x", b"aA==aGVs", b"aGVsbG8=x",
b"aGVsbG8=aGVs", b"aGVsbG8=\naGVs", b"aGVsbG8=aGVsbG8="):
t(f"decode pad {s.decode()}", ["-d"], s)
# Every prefix of a known-good encoding, strict and lenient.
enc = subprocess.run([GNU], input=b"hello world!", capture_output=True).stdout.strip()
for i in range(1, len(enc) + 1):
t(f"decode prefix {i}", ["-d"], enc[:i])
t(f"decode prefix {i} -i", ["-di"], enc[:i])
# ── options and usage errors ──────────────────────────────────────
t("help", ["--help"])
t("help after other flags", ["--decode", "--help"])
t("abbrev --dec", ["--dec"], b"aGVsbG8=")
t("abbrev --ign", ["--ign", "-d"], b"aGV s bG8=")
t("abbrev --w", ["--w", "4"], b"hello world")
t("bare --", ["--"], b"hello")
t("cluster -di", ["-di"], b"aG!Vs bG8=")
t("cluster -dw0", ["-dw0"], b"aGVsbG8=")
t("wrap missing arg", ["-w"])
t("wrap bad value", ["-w", "abc"])
t("wrap negative", ["-w", "-5"])
t("unknown short", ["-z"])
t("unknown long", ["--nope"])
t("ambiguous long", ["--i"])
t("extra operand", ["a.txt", "b.txt"])
t("missing file", ["/nonexistent/nope.txt"])
t("directory as file", ["/tmp"])
t("dash is stdin", ["-"], b"hello")
t("double dash then dash", ["--", "-"], b"hello")
# ── file operands ─────────────────────────────────────────────────
with tempfile.TemporaryDirectory() as tmp:
raw = os.path.join(tmp, "sample.bin")
with open(raw, "wb") as fh:
fh.write(big[:9999])
t("encode from file", [raw])
t("encode from file wrap 0", ["-w0", raw])
encoded = os.path.join(tmp, "sample.b64")
with open(encoded, "wb") as fh:
fh.write(subprocess.run([GNU], input=big[:9999], capture_output=True).stdout)
t("decode from file", ["-d", encoded])
passed = sum(1 for _, ok in results if ok)
print(f"\n{passed}/{len(results)} cases match GNU base64")
failed = [n for n, ok in results if not ok]
if failed:
print("failing:", ", ".join(failed))
return 1
print("all parity checks passed")
return 0
if __name__ == "__main__":
if not os.path.exists(MINE):
sys.exit(f"build it first: cargo build --release (looked for {MINE})")
if GNU is None:
sys.exit(
"no GNU coreutils base64 found. Distributions increasingly ship "
"uutils as /usr/bin/base64, which differs on malformed input; "
"install GNU coreutils to run this comparison."
)
print(f"comparing {MINE}\n against {GNU} ({GNU_BANNER})\n")
sys.exit(main())
+43 -26
View File
@@ -33,6 +33,27 @@ _compinit_cached() {
fi
}
# ---------- nvm ----------
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"
# ---------- bun ----------
export BUN_INSTALL="$HOME/.bun"
[ -s "$BUN_INSTALL/_bun" ] && source "$BUN_INSTALL/_bun"
# ---------- PATH ----------
export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}"
export PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:$PATH"
case ":$PATH:" in
*":$PNPM_HOME/bin:"*) ;;
*) export PATH="$PNPM_HOME/bin:$PATH" ;;
esac
case ":$PATH:" in
*":$BUN_INSTALL/bin:"*) ;;
*) export PATH="$BUN_INSTALL/bin:$PATH" ;;
esac
# ---------- zinit (plugin manager, auto-install) ----------
export ZINIT_HOME=${XDG_DATA_HOME:-$HOME/.local/share}/zinit/zinit.git
if [[ ! -d $ZINIT_HOME ]]; then
@@ -61,16 +82,14 @@ zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list '' 'm:{a-zA-Z}={A-Za-z}' 'r:|[._-]=* r:|=*' 'l:|=* r:|=*'
zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}
# ---------- plugins (deferred via turbo mode for faster startup) ----------
# ---------- plugins ----------
# fzf-tab needs compinit to have already run, and needs to load before
# anything that wraps completion widgets (autosuggestions, syntax-highlighting).
zinit ice wait lucid atinit'_compinit_cached'
zinit ice atinit'_compinit_cached'
zinit light Aloxaf/fzf-tab
zinit ice wait lucid
zinit light zsh-users/zsh-autosuggestions
zinit ice wait lucid
zinit light ajeetdsouza/zoxide
eval "$(zoxide init zsh)"
@@ -84,11 +103,9 @@ else
fi
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND="$FZF_DEFAULT_COMMAND"
zinit ice wait lucid
zinit light junegunn/fzf
# syntax-highlighting must be loaded last, after any plugin that wraps widgets
zinit ice wait lucid
zinit light zsh-users/zsh-syntax-highlighting
# ---------- aliases with smart fallbacks ----------
@@ -115,8 +132,28 @@ phok() {
fi
}
jwtf() {
cd "$HOME/Coding/jwtf" || return
if [ "$#" -gt 0 ]; then
"$@"
fi
}
# This laptop's Intel iHD VA-API driver corrupts hardware-decoded video
# frames (dmabuf allocator size-mismatch bug), and WebKitGTK's DMA-BUF
# compositing path is separately broken on this GPU/driver combo too — force
# software H.264/H.265 decode and disable DMA-BUF rendering for Phokus dev
# builds so video playback is smooth.
alias phok-dev='WEBKIT_DISABLE_DMABUF_RENDERER=1 GST_PLUGIN_FEATURE_RANK="vah264dec:0,vah265dec:0,vaapih264dec:0,vaapih265dec:0" pnpm --dir "$HOME/Coding/jwtf/apps/phokus" dev:app:cpu'
# ---------- misc helpers ----------
mkcd(){ mkdir -p "$1" && cd "$1"; }
up(){
local n="${1:-1}"
[[ "$n" =~ ^[0-9]+$ ]] || { echo "up: expects a number"; return 1; }
local target=""
for ((i=0; i<n; i++)); do target="../$target"; done
cd "$target" || return
}
extract(){
case "$1" in
*.tar.gz|*.tgz) tar xzf "$1";;
@@ -131,23 +168,3 @@ extract(){
esac
}
# ---------- nvm ----------
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"
# ---------- bun ----------
export BUN_INSTALL="$HOME/.bun"
[ -s "$BUN_INSTALL/_bun" ] && source "$BUN_INSTALL/_bun"
# ---------- PATH ----------
export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}"
export PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:$PATH"
case ":$PATH:" in
*":$PNPM_HOME/bin:"*) ;;
*) export PATH="$PNPM_HOME/bin:$PATH" ;;
esac
case ":$PATH:" in
*":$BUN_INSTALL/bin:"*) ;;
*) export PATH="$BUN_INSTALL/bin:$PATH" ;;
esac
+10
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
3.12
+39
View File
@@ -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
View File
@@ -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).
+1
View File
@@ -0,0 +1 @@
"""Core background removal and video processing package."""
+239
View File
@@ -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
+223
View File
@@ -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)
+220
View File
@@ -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()
+67
View File
@@ -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"
)
+167
View File
@@ -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);
}
}
+29
View File
@@ -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);
}
+65
View File
@@ -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;
}
}
+25
View File
@@ -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);
}
+210
View File
@@ -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();
}
}
+56
View File
@@ -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};
};
}
+97
View File
@@ -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);
}
}
+44
View File
@@ -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;
};
}
+237
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+591
View File
@@ -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();
})();
+235
View File
@@ -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 &amp; 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 &amp; 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>
+535
View File
@@ -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); }
+30
View File
@@ -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
+254
View File
@@ -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()
+5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff