Spaces:
Runtime error
Dockerize stack, strip watermarking, add navigator monitor
Browse filesCompose the headless-Chrome monitor with the three backend servers and
make the live monitor a full in-browser navigator.
Infra:
- Add docker-compose.yml wiring chrome + chatgpt + gemini + flow on a
shared loopback (network_mode: service:chrome); only chrome publishes.
- Add Dockerfiles for chatgpt-free-api and flow-agent.
- flow: run the FastAPI API on 8101 so it no longer collides with the
ExtensionBridge HTTP callback on 8100 (was crash-looping).
- Update extension cookie-WS ports: gemini 9226, flow 9227.
Cleanup:
- Remove watermark code from flow-agent (omniflash/watermark.py) and
gemini (gemini/watermark.go); drop ffmpeg usage.
Monitor navigator:
- Tabs are now discovered live from CDP (known services first) instead of
being hardcoded.
- Add address bar + back/forward/reload and new/close-tab, backed by new
/api/navigate, /api/reload, /api/back, /api/forward, /api/newtab and
/api/closetab endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Dockerfile +11 -2
- README.md +160 -1
- chatgpt-free-api/Dockerfile +17 -0
- chatgpt-free-api/config.json +1 -1
- docker-compose.yml +49 -0
- flow-agent/Dockerfile +16 -0
- flow-agent/Flow-extension/background.js +1 -1
- flow-agent/cli/__init__.py +0 -0
- flow-agent/cli/api.py +6 -26
- flow-agent/cli/edit.py +2 -15
- flow-agent/cli/generate.py +2 -13
- flow-agent/omniflash/watermark.py +0 -375
- flow-agent/requirements.txt +0 -3
- free-gemini-api/Dockerfile +3 -3
- free-gemini-api/gemini-extension/background.js +1 -1
- free-gemini-api/gemini/client.go +0 -18
- free-gemini-api/gemini/watermark.go +0 -424
- monitor/go.mod +5 -0
- monitor/go.sum +2 -0
- monitor/index.html +261 -0
- monitor/main.go +543 -0
- monitor/stats.go +99 -0
- start_hf.sh +4 -83
|
@@ -1,3 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
FROM akashyadav758/chrome:latest
|
| 2 |
|
| 3 |
USER root
|
|
@@ -7,6 +13,9 @@ COPY chatgpt-free-api/gpt-extension /opt/gpt-extension
|
|
| 7 |
COPY free-gemini-api/gemini-extension /opt/gemini-extension
|
| 8 |
COPY flow-agent/Flow-extension /opt/flow-extension
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
# Install Chrome for Testing (unbranded). Branded google-chrome-stable (>=128)
|
| 11 |
# silently ignores --load-extension, so unpacked extensions never load. CfT is the
|
| 12 |
# unbranded build of the same Chrome version where --load-extension still works.
|
|
@@ -20,8 +29,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends unzip && \
|
|
| 20 |
|
| 21 |
# Set correct permissions and ownership
|
| 22 |
RUN rm -rf /opt/flow-extension/_metadata && \
|
| 23 |
-
chown -R 1000:1000 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64 && \
|
| 24 |
-
chmod -R 755 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64
|
| 25 |
|
| 26 |
# Replace the start script with the non-root version
|
| 27 |
COPY --chmod=755 start_hf.sh /start.sh
|
|
|
|
| 1 |
+
# Build the 3-tab monitor server (GPT / Gemini / Flow live view) from source.
|
| 2 |
+
FROM golang:1.22-bookworm AS monitor-build
|
| 3 |
+
WORKDIR /src
|
| 4 |
+
COPY monitor/ ./
|
| 5 |
+
RUN go mod tidy && CGO_ENABLED=0 go build -ldflags="-s -w" -o /monitor .
|
| 6 |
+
|
| 7 |
FROM akashyadav758/chrome:latest
|
| 8 |
|
| 9 |
USER root
|
|
|
|
| 13 |
COPY free-gemini-api/gemini-extension /opt/gemini-extension
|
| 14 |
COPY flow-agent/Flow-extension /opt/flow-extension
|
| 15 |
|
| 16 |
+
# 3-tab monitor binary (replaces the single-view base monitor)
|
| 17 |
+
COPY --from=monitor-build /monitor /opt/monitor3/monitor
|
| 18 |
+
|
| 19 |
# Install Chrome for Testing (unbranded). Branded google-chrome-stable (>=128)
|
| 20 |
# silently ignores --load-extension, so unpacked extensions never load. CfT is the
|
| 21 |
# unbranded build of the same Chrome version where --load-extension still works.
|
|
|
|
| 29 |
|
| 30 |
# Set correct permissions and ownership
|
| 31 |
RUN rm -rf /opt/flow-extension/_metadata && \
|
| 32 |
+
chown -R 1000:1000 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64 /opt/monitor3 && \
|
| 33 |
+
chmod -R 755 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension /opt/chrome-linux64 /opt/monitor3
|
| 34 |
|
| 35 |
# Replace the start script with the non-root version
|
| 36 |
COPY --chmod=755 start_hf.sh /start.sh
|
|
@@ -8,4 +8,163 @@ app_port: 3001
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Chrome Space — Headless Chrome + Unpacked Extensions
|
| 12 |
+
|
| 13 |
+
A [Hugging Face Space](https://huggingface.co/spaces/akash1313/chrome) that runs a
|
| 14 |
+
persistent, headless **Chrome for Testing** browser inside Docker with three custom
|
| 15 |
+
unpacked extensions auto-loaded, plus a live web monitor to watch the browser in real time.
|
| 16 |
+
|
| 17 |
+
The browser stays logged in across restarts (persistent profile), and the bundled
|
| 18 |
+
extensions sync cookies / automate workflows for ChatGPT, Google Gemini, and Google Flow.
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## Bundled extensions
|
| 23 |
+
|
| 24 |
+
| Extension | Source dir | Loaded at | What it does |
|
| 25 |
+
|-----------|-----------|-----------|--------------|
|
| 26 |
+
| **Gpt Agent Api** | `chatgpt-free-api/gpt-extension` | `/opt/gpt-extension` | Auto-syncs ChatGPT cookies to a local ChatGPT Free API server |
|
| 27 |
+
| **Gemini Api Agent** | `free-gemini-api/gemini-extension` | `/opt/gemini-extension` | Auto-syncs Google Gemini cookies to a local Free Gemini API server |
|
| 28 |
+
| **Flow Agent** | `flow-agent/Flow-extension` | `/opt/flow-extension` | Automates Google Flow — T2V / V2V / I2V video + T2I / I2I image generation |
|
| 29 |
+
|
| 30 |
+
All three are Manifest V3 extensions with background service workers. Each subfolder
|
| 31 |
+
also contains the matching backend server and its own README.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## Backend servers
|
| 36 |
+
|
| 37 |
+
Each extension is the browser half of a standalone project. The extension uses your
|
| 38 |
+
authenticated browser session and forwards requests to a **local** API server (which you
|
| 39 |
+
run yourself — see each subproject's README for setup):
|
| 40 |
+
|
| 41 |
+
| Project | Server | Talks to | Purpose |
|
| 42 |
+
|---------|--------|----------|---------|
|
| 43 |
+
| [`chatgpt-free-api`](chatgpt-free-api/README.md) | Go | `127.0.0.1:9225` | ChatGPT backend API from your logged-in session |
|
| 44 |
+
| [`free-gemini-api`](free-gemini-api/README.md) | Go reverse-proxy | local server | Gemini text / Imagen 3 images / Gemini video |
|
| 45 |
+
| [`flow-agent`](flow-agent/README.md) | Python FastAPI + CLI | `127.0.0.1:8100` | Google Flow T2V / V2V / I2V video + T2I / I2I images |
|
| 46 |
+
|
| 47 |
+
> ⚠️ This Space runs **only the browser + the 3 extensions**. It does **not** start the
|
| 48 |
+
> backend servers — run those locally (or in their own containers) and the extensions will
|
| 49 |
+
> connect once the matching port is reachable.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## How it works
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
HF Space (port 3001)
|
| 57 |
+
│
|
| 58 |
+
▼
|
| 59 |
+
┌─────────────────────────────────────────────────────────┐
|
| 60 |
+
│ Docker container (Debian 12) │
|
| 61 |
+
│ │
|
| 62 |
+
│ monitor-server ──┐ serves the live UI + /chrome.log │
|
| 63 |
+
│ (Go, :3001) │ │
|
| 64 |
+
│ ▼ │
|
| 65 |
+
│ Chrome for Testing 145 (--headless=new) │
|
| 66 |
+
│ --load-extension=/opt/gpt,/opt/gemini,/opt/flow │
|
| 67 |
+
│ --remote-debugging-port=9222 (CDP) │
|
| 68 |
+
│ --user-data-dir=/home/chrome/data (persistent) │
|
| 69 |
+
│ ▲ │
|
| 70 |
+
│ socat :9223 ─────┘ forwards external CDP → 127.0.0.1 │
|
| 71 |
+
└─────────────────────────────────────────────────────────┘
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
`start_hf.sh` is the boot sequence:
|
| 75 |
+
|
| 76 |
+
1. **Profile repair & cleanup** — fixes a nested `data/data` layout and removes cache/junk
|
| 77 |
+
to keep the persistent profile small.
|
| 78 |
+
2. **Lock removal** — clears `Singleton*` / `LOCK` files left by an unclean shutdown.
|
| 79 |
+
3. **Preferences injection** — enables `extensions.ui.developer_mode` and sets a clean
|
| 80 |
+
`exit_type` so the "restore pages?" popup never appears.
|
| 81 |
+
4. **Monitor server** — starts the Go monitor on `:3001` and symlinks `chrome.log` into it.
|
| 82 |
+
5. **CDP proxy** — `socat` exposes the DevTools port (9223 → 127.0.0.1:9222).
|
| 83 |
+
6. **Auto-navigate** — a tiny WebSocket client drives the first tab to `chrome://extensions/`.
|
| 84 |
+
7. **Launch Chrome** — Chrome for Testing in `--headless=new` with all three extensions.
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Why Chrome for Testing (not regular Chrome)
|
| 89 |
+
|
| 90 |
+
Branded **Google Chrome ≥ 128 silently ignores `--load-extension`** (removed by the
|
| 91 |
+
[March 2025 Chrome RFC](https://developer.chrome.com/blog/remote-debugging-port) to curb
|
| 92 |
+
abuse). On a branded build the extensions never load and `chrome://extensions/` shows an
|
| 93 |
+
empty list with **no error in the log**.
|
| 94 |
+
|
| 95 |
+
The fix is to run **[Chrome for Testing](https://googlechromelabs.github.io/chrome-for-testing/)**
|
| 96 |
+
— the unbranded build of the same Chrome version where `--load-extension` still works. The
|
| 97 |
+
Dockerfile downloads CfT `145.0.7632.117` to `/opt/chrome-linux64` and `start_hf.sh` launches
|
| 98 |
+
that binary. Everything else (monitor, CDP proxy, flags) is unchanged.
|
| 99 |
+
|
| 100 |
+
> Alternatives if you ever upgrade: use the CDP `Extensions.loadUnpacked` method
|
| 101 |
+
> (requires `--remote-debugging-pipe` + `--enable-unsafe-extension-debugging`), or plain
|
| 102 |
+
> Chromium. Both also keep `--load-extension`-style loading working.
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Endpoints
|
| 107 |
+
|
| 108 |
+
| URL | Description |
|
| 109 |
+
|-----|-------------|
|
| 110 |
+
| `https://akash1313-chrome.hf.space/` | Live Chrome monitor UI |
|
| 111 |
+
| `https://akash1313-chrome.hf.space/chrome.log` | Streaming Chrome log (debugging) |
|
| 112 |
+
| `127.0.0.1:9222` (in-container) | Chrome DevTools Protocol |
|
| 113 |
+
| `:9223` | CDP proxied for external tools (via socat) |
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## Project layout
|
| 118 |
+
|
| 119 |
+
```
|
| 120 |
+
chrome-space/
|
| 121 |
+
├── Dockerfile # builds on akashyadav758/chrome, installs CfT, copies extensions
|
| 122 |
+
├── start_hf.sh # container entrypoint / Chrome boot sequence
|
| 123 |
+
├── chatgpt-free-api/ # ChatGPT Free API server + gpt-extension
|
| 124 |
+
├── free-gemini-api/ # Free Gemini API server + gemini-extension
|
| 125 |
+
└── flow-agent/ # Flow automation CLI + Flow-extension
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## Local development
|
| 131 |
+
|
| 132 |
+
Requires Docker. The base image is `linux/amd64` only.
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
# Build
|
| 136 |
+
docker build --platform linux/amd64 -t chrome-space .
|
| 137 |
+
|
| 138 |
+
# Run
|
| 139 |
+
docker run -d --name chrome-space -p 3001:3001 --platform linux/amd64 chrome-space
|
| 140 |
+
|
| 141 |
+
# Verify the 3 extensions loaded (look for service_worker targets)
|
| 142 |
+
docker exec chrome-space curl -s http://127.0.0.1:9222/json \
|
| 143 |
+
| python3 -c 'import json,sys;[print(t["type"],t["url"]) for t in json.load(sys.stdin)]'
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
Open <http://localhost:3001> to watch the browser.
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## Deploy
|
| 151 |
+
|
| 152 |
+
This repo **is** the HF Space — pushing to `main` triggers an automatic rebuild:
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
git push origin main
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
> `hf-token` is gitignored — keep your Hugging Face token out of commits.
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## Troubleshooting
|
| 163 |
+
|
| 164 |
+
| Symptom | Cause / fix |
|
| 165 |
+
|---------|-------------|
|
| 166 |
+
| `chrome://extensions/` empty, no error in log | Branded Chrome ignoring `--load-extension` → use Chrome for Testing (see above) |
|
| 167 |
+
| `COPY failed: ... not found` at build | Extension source dir name in the Dockerfile doesn't match disk |
|
| 168 |
+
| Extension listed but service worker idle | MV3 headless quirk — open the extension's toggle or hit **Update** on the extensions page |
|
| 169 |
+
| `Failed to connect to the bus` spam in log | Harmless D-Bus warnings in a container with no system bus |
|
| 170 |
+
| "Restore pages?" popup | Handled by the `exit_type=Normal` Preferences injection in `start_hf.sh` |
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ChatGPT Free API agent — talks to the gpt-extension over ws/http on 127.0.0.1:9225.
|
| 2 |
+
FROM golang:1.26-bookworm AS builder
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY go.mod go.sum ./
|
| 5 |
+
RUN go mod download
|
| 6 |
+
COPY . .
|
| 7 |
+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o agent .
|
| 8 |
+
|
| 9 |
+
FROM debian:12-slim
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
|
| 11 |
+
rm -rf /var/lib/apt/lists/*
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
COPY --from=builder /app/agent .
|
| 14 |
+
COPY config.json .
|
| 15 |
+
|
| 16 |
+
EXPOSE 9225
|
| 17 |
+
CMD ["./agent"]
|
|
@@ -10,5 +10,5 @@
|
|
| 10 |
"bulk_delay_seconds": 2,
|
| 11 |
"browser_wake_cooldown_seconds": 60,
|
| 12 |
"timezone_offset_min": -330,
|
| 13 |
-
"raw_sse_output_path": "
|
| 14 |
}
|
|
|
|
| 10 |
"bulk_delay_seconds": 2,
|
| 11 |
"browser_wake_cooldown_seconds": 60,
|
| 12 |
"timezone_offset_min": -330,
|
| 13 |
+
"raw_sse_output_path": ""
|
| 14 |
}
|
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runs the headless Chrome + 3-tab monitor together with the three backend servers.
|
| 2 |
+
#
|
| 3 |
+
# The server containers join the chrome container's network namespace
|
| 4 |
+
# (network_mode: "service:chrome"), so they all share one 127.0.0.1 — exactly what the
|
| 5 |
+
# extensions expect. Only the chrome service publishes ports.
|
| 6 |
+
#
|
| 7 |
+
# Port map (shared loopback):
|
| 8 |
+
# chrome : 9222 CDP, 3001 monitor UI
|
| 9 |
+
# chatgpt : 9225 (ws+http)
|
| 10 |
+
# gemini : 8000 http, 9226 cookie-WS
|
| 11 |
+
# flow : 8101 http API, 8100 ext-callback, 9227 cookie-WS
|
| 12 |
+
|
| 13 |
+
services:
|
| 14 |
+
chrome:
|
| 15 |
+
build: .
|
| 16 |
+
platform: linux/amd64
|
| 17 |
+
ports:
|
| 18 |
+
- "3001:3001" # 3-tab monitor UI (also the only published port)
|
| 19 |
+
restart: unless-stopped
|
| 20 |
+
|
| 21 |
+
chatgpt:
|
| 22 |
+
build: ./chatgpt-free-api
|
| 23 |
+
platform: linux/amd64
|
| 24 |
+
network_mode: "service:chrome"
|
| 25 |
+
depends_on:
|
| 26 |
+
- chrome
|
| 27 |
+
restart: unless-stopped
|
| 28 |
+
|
| 29 |
+
gemini:
|
| 30 |
+
build: ./free-gemini-api
|
| 31 |
+
platform: linux/amd64
|
| 32 |
+
environment:
|
| 33 |
+
WS_PORT: "9226"
|
| 34 |
+
PORT: "8000"
|
| 35 |
+
network_mode: "service:chrome"
|
| 36 |
+
depends_on:
|
| 37 |
+
- chrome
|
| 38 |
+
restart: unless-stopped
|
| 39 |
+
|
| 40 |
+
flow:
|
| 41 |
+
build: ./flow-agent
|
| 42 |
+
platform: linux/amd64
|
| 43 |
+
environment:
|
| 44 |
+
WS_PORT: "9227"
|
| 45 |
+
HTTP_PORT: "8100"
|
| 46 |
+
network_mode: "service:chrome"
|
| 47 |
+
depends_on:
|
| 48 |
+
- chrome
|
| 49 |
+
restart: unless-stopped
|
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Flow Agent FastAPI server — talks to the Flow extension.
|
| 2 |
+
# Public HTTP API : 127.0.0.1:8101 (uvicorn / FastAPI)
|
| 3 |
+
# Extension callback HTTP : 127.0.0.1:8100 (HTTP_PORT, ExtensionBridge — hardcoded in extension)
|
| 4 |
+
# cookie/token WebSocket : 127.0.0.1:9227 (WS_PORT)
|
| 5 |
+
FROM python:3.11-slim
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
ENV WS_PORT=9227 \
|
| 13 |
+
HTTP_PORT=8100
|
| 14 |
+
|
| 15 |
+
EXPOSE 8100 8101 9227
|
| 16 |
+
CMD ["uvicorn", "cli.api:app", "--host", "127.0.0.1", "--port", "8101"]
|
|
@@ -5,7 +5,7 @@
|
|
| 5 |
* Captures bearer token, solves reCAPTCHA, proxies API calls through browser.
|
| 6 |
*/
|
| 7 |
|
| 8 |
-
const AGENT_WS_URL = 'ws://127.0.0.1:
|
| 9 |
// NOTE: This is a browser-restricted public API key — safe to ship in extension bundles.
|
| 10 |
const API_KEY = 'AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY';
|
| 11 |
|
|
|
|
| 5 |
* Captures bearer token, solves reCAPTCHA, proxies API calls through browser.
|
| 6 |
*/
|
| 7 |
|
| 8 |
+
const AGENT_WS_URL = 'ws://127.0.0.1:9227';
|
| 9 |
// NOTE: This is a browser-restricted public API key — safe to ship in extension bundles.
|
| 10 |
const API_KEY = 'AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY';
|
| 11 |
|
|
File without changes
|
|
@@ -352,20 +352,9 @@ async def api_generate_video(req: VideoGenerationRequest, download: bool = Query
|
|
| 352 |
temp_path = os.path.join(TEMP_DIR, filename)
|
| 353 |
|
| 354 |
if await download_video(active_bridge, media_id, temp_path):
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
remove_watermark_video(temp_path, out_path)
|
| 359 |
-
try:
|
| 360 |
-
os.remove(temp_path)
|
| 361 |
-
except OSError:
|
| 362 |
-
pass
|
| 363 |
-
except Exception as e:
|
| 364 |
-
log.warning(f"Watermark removal failed: {e}. Fallback to raw video.")
|
| 365 |
-
os.replace(temp_path, out_path)
|
| 366 |
-
else:
|
| 367 |
-
os.replace(temp_path, out_path)
|
| 368 |
-
|
| 369 |
outputs.append({
|
| 370 |
"media_id": media_id,
|
| 371 |
"filename": filename,
|
|
@@ -482,18 +471,9 @@ async def api_edit_video(req: VideoEditRequest):
|
|
| 482 |
temp_path = os.path.join(TEMP_DIR, filename)
|
| 483 |
|
| 484 |
if await download_video(active_bridge, media_id, temp_path):
|
| 485 |
-
#
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
remove_watermark_video(temp_path, out_path)
|
| 489 |
-
try:
|
| 490 |
-
os.remove(temp_path)
|
| 491 |
-
except OSError:
|
| 492 |
-
pass
|
| 493 |
-
except Exception as e:
|
| 494 |
-
log.warning(f"Watermark removal failed: {e}. Fallback to raw video.")
|
| 495 |
-
os.replace(temp_path, out_path)
|
| 496 |
-
|
| 497 |
outputs.append({
|
| 498 |
"media_id": media_id,
|
| 499 |
"filename": filename,
|
|
|
|
| 352 |
temp_path = os.path.join(TEMP_DIR, filename)
|
| 353 |
|
| 354 |
if await download_video(active_bridge, media_id, temp_path):
|
| 355 |
+
# Watermark removal disabled — save the raw downloaded video as-is.
|
| 356 |
+
os.replace(temp_path, out_path)
|
| 357 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
outputs.append({
|
| 359 |
"media_id": media_id,
|
| 360 |
"filename": filename,
|
|
|
|
| 471 |
temp_path = os.path.join(TEMP_DIR, filename)
|
| 472 |
|
| 473 |
if await download_video(active_bridge, media_id, temp_path):
|
| 474 |
+
# Watermark removal disabled — save the raw downloaded video as-is.
|
| 475 |
+
os.replace(temp_path, out_path)
|
| 476 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
outputs.append({
|
| 478 |
"media_id": media_id,
|
| 479 |
"filename": filename,
|
|
@@ -115,14 +115,8 @@ async def edit_segment(bridge, prompt, aspect, project_id, media_id,
|
|
| 115 |
temp_path = os.path.join(temp_dir, f"segment_{segment_num:03d}.mp4")
|
| 116 |
|
| 117 |
if await download_video(bridge, result_media_id, temp_path):
|
| 118 |
-
#
|
| 119 |
-
|
| 120 |
-
from omniflash.watermark import remove_watermark_video
|
| 121 |
-
remove_watermark_video(temp_path, out_path, show_progress=False)
|
| 122 |
-
os.remove(temp_path)
|
| 123 |
-
except Exception as e:
|
| 124 |
-
os.replace(temp_path, out_path)
|
| 125 |
-
log.warning("⚠️ Watermark removal failed for segment %d: %s", segment_num, e)
|
| 126 |
# Cleanup empty .temp dir
|
| 127 |
try:
|
| 128 |
os.rmdir(temp_dir)
|
|
@@ -213,13 +207,6 @@ async def run(args):
|
|
| 213 |
"-i", concat_file, "-c", "copy", merge_path
|
| 214 |
], capture_output=True)
|
| 215 |
os.remove(concat_file)
|
| 216 |
-
# Auto-remove watermark from merged output
|
| 217 |
-
try:
|
| 218 |
-
from omniflash.watermark import remove_watermark_video
|
| 219 |
-
clean_path = remove_watermark_video(merge_path, show_progress=False)
|
| 220 |
-
os.replace(clean_path, merge_path)
|
| 221 |
-
except Exception:
|
| 222 |
-
pass
|
| 223 |
log.info("✅ Merged: %s", merge_path)
|
| 224 |
except Exception as e:
|
| 225 |
log.warning("⚠️ Merge failed (ffmpeg needed): %s", e)
|
|
|
|
| 115 |
temp_path = os.path.join(temp_dir, f"segment_{segment_num:03d}.mp4")
|
| 116 |
|
| 117 |
if await download_video(bridge, result_media_id, temp_path):
|
| 118 |
+
# Watermark removal disabled — save the raw downloaded segment as-is.
|
| 119 |
+
os.replace(temp_path, out_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
# Cleanup empty .temp dir
|
| 121 |
try:
|
| 122 |
os.rmdir(temp_dir)
|
|
|
|
| 207 |
"-i", concat_file, "-c", "copy", merge_path
|
| 208 |
], capture_output=True)
|
| 209 |
os.remove(concat_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
log.info("✅ Merged: %s", merge_path)
|
| 211 |
except Exception as e:
|
| 212 |
log.warning("⚠️ Merge failed (ffmpeg needed): %s", e)
|
|
@@ -99,19 +99,8 @@ async def run(args):
|
|
| 99 |
temp_path = os.path.join(temp_dir, os.path.basename(out_path))
|
| 100 |
|
| 101 |
if await download_video(bridge, media_id, temp_path):
|
| 102 |
-
#
|
| 103 |
-
|
| 104 |
-
try:
|
| 105 |
-
from omniflash.watermark import remove_watermark_video
|
| 106 |
-
remove_watermark_video(temp_path, out_path)
|
| 107 |
-
os.remove(temp_path)
|
| 108 |
-
print(f"🧹 Watermark removed!")
|
| 109 |
-
except Exception as e:
|
| 110 |
-
# Fallback: move temp to output as-is
|
| 111 |
-
os.replace(temp_path, out_path)
|
| 112 |
-
print(f"⚠️ Watermark removal failed: {e}")
|
| 113 |
-
else:
|
| 114 |
-
os.replace(temp_path, out_path)
|
| 115 |
|
| 116 |
# Cleanup empty .temp dir
|
| 117 |
try:
|
|
|
|
| 99 |
temp_path = os.path.join(temp_dir, os.path.basename(out_path))
|
| 100 |
|
| 101 |
if await download_video(bridge, media_id, temp_path):
|
| 102 |
+
# Watermark removal disabled — save the raw downloaded video as-is.
|
| 103 |
+
os.replace(temp_path, out_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# Cleanup empty .temp dir
|
| 106 |
try:
|
|
@@ -1,375 +0,0 @@
|
|
| 1 |
-
"""Omni Flash — Video Watermark Remover.
|
| 2 |
-
|
| 3 |
-
Removes Gemini/Flow watermark from generated videos using reverse alpha blending.
|
| 4 |
-
Ported from Gemini-Watermark-Remover-UI (TypeScript → Python).
|
| 5 |
-
|
| 6 |
-
Algorithm:
|
| 7 |
-
Gemini adds watermark: watermarked = α × logo + (1 - α) × original
|
| 8 |
-
Reverse solve: original = (watermarked - α × logo) / (1 - α)
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
import logging
|
| 12 |
-
import os
|
| 13 |
-
import cv2
|
| 14 |
-
import numpy as np
|
| 15 |
-
import subprocess
|
| 16 |
-
import json
|
| 17 |
-
import sys
|
| 18 |
-
|
| 19 |
-
log = logging.getLogger("omniflash.watermark")
|
| 20 |
-
|
| 21 |
-
# ── Constants ──────────────────────────────────────────────
|
| 22 |
-
ALPHA_THRESHOLD = 0.002 # Ignore very small alpha values (noise)
|
| 23 |
-
MAX_ALPHA = 0.99 # Avoid division by near-zero values
|
| 24 |
-
LOGO_VALUE = 255 # White watermark color value
|
| 25 |
-
VIDEO_ALPHA_SCALE = 0.6 # Gemini videos use 60% opacity watermark
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
# ── Embedded watermark assets (base64-encoded PNGs) ──
|
| 29 |
-
_BG_48_B64 = "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"
|
| 30 |
-
|
| 31 |
-
_BG_96_B64 = "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"
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def _load_alpha_map(size: int) -> np.ndarray:
|
| 35 |
-
"""Load alpha map from embedded base64 PNG data.
|
| 36 |
-
|
| 37 |
-
Alpha map is the max RGB channel of the watermark on black background,
|
| 38 |
-
normalized to [0, 1].
|
| 39 |
-
"""
|
| 40 |
-
import base64
|
| 41 |
-
|
| 42 |
-
b64 = _BG_48_B64 if size == 48 else _BG_96_B64
|
| 43 |
-
png_bytes = base64.b64decode(b64)
|
| 44 |
-
arr = np.frombuffer(png_bytes, dtype=np.uint8)
|
| 45 |
-
bg = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
| 46 |
-
|
| 47 |
-
if bg is None:
|
| 48 |
-
raise ValueError(f"Failed to decode embedded bg_{size}.png")
|
| 49 |
-
|
| 50 |
-
# Resize to exact size if needed
|
| 51 |
-
if bg.shape[0] != size or bg.shape[1] != size:
|
| 52 |
-
bg = cv2.resize(bg, (size, size))
|
| 53 |
-
|
| 54 |
-
# Max of RGB channels, normalized to [0, 1]
|
| 55 |
-
alpha_map = bg.max(axis=2).astype(np.float32) / 255.0
|
| 56 |
-
return alpha_map
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
# Cache alpha maps
|
| 60 |
-
_alpha_cache: dict[int, np.ndarray] = {}
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def get_alpha_map(size: int) -> np.ndarray:
|
| 64 |
-
"""Get cached alpha map for given watermark size."""
|
| 65 |
-
if size not in _alpha_cache:
|
| 66 |
-
_alpha_cache[size] = _load_alpha_map(size)
|
| 67 |
-
return _alpha_cache[size]
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def detect_watermark_config(width: int, height: int, is_video: bool = True):
|
| 71 |
-
"""Detect watermark size and position based on dimensions."""
|
| 72 |
-
if is_video:
|
| 73 |
-
short_dim = min(width, height)
|
| 74 |
-
if short_dim >= 1080:
|
| 75 |
-
logo_size = 96
|
| 76 |
-
margin_right = 64
|
| 77 |
-
margin_bottom = 64
|
| 78 |
-
else:
|
| 79 |
-
logo_size = 48
|
| 80 |
-
margin_right = 72
|
| 81 |
-
margin_bottom = 72
|
| 82 |
-
else:
|
| 83 |
-
if width == 720 and height == 1280:
|
| 84 |
-
logo_size = 48
|
| 85 |
-
margin_right = 72
|
| 86 |
-
margin_bottom = 72
|
| 87 |
-
else:
|
| 88 |
-
short_dim = min(width, height)
|
| 89 |
-
if short_dim > 800:
|
| 90 |
-
logo_size = 96
|
| 91 |
-
margin_right = 64
|
| 92 |
-
margin_bottom = 64
|
| 93 |
-
else:
|
| 94 |
-
logo_size = 48
|
| 95 |
-
margin_right = 32
|
| 96 |
-
margin_bottom = 32
|
| 97 |
-
|
| 98 |
-
x = width - margin_right - logo_size
|
| 99 |
-
y = height - margin_bottom - logo_size
|
| 100 |
-
|
| 101 |
-
return {
|
| 102 |
-
"logo_size": logo_size,
|
| 103 |
-
"x": x,
|
| 104 |
-
"y": y,
|
| 105 |
-
"width": logo_size,
|
| 106 |
-
"height": logo_size,
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
def remove_watermark_frame(frame: np.ndarray, is_video: bool = True) -> np.ndarray:
|
| 111 |
-
"""Remove watermark from a single frame (BGR numpy array).
|
| 112 |
-
|
| 113 |
-
Uses reverse alpha blending:
|
| 114 |
-
original = (watermarked - α × logo) / (1 - α)
|
| 115 |
-
"""
|
| 116 |
-
h, w = frame.shape[:2]
|
| 117 |
-
config = detect_watermark_config(w, h, is_video)
|
| 118 |
-
alpha_map = get_alpha_map(config["logo_size"])
|
| 119 |
-
|
| 120 |
-
x, y = config["x"], config["y"]
|
| 121 |
-
size = config["logo_size"]
|
| 122 |
-
|
| 123 |
-
# Extract watermark region
|
| 124 |
-
region = frame[y:y+size, x:x+size].astype(np.float32)
|
| 125 |
-
|
| 126 |
-
# Scale alpha for video
|
| 127 |
-
alpha = alpha_map.copy()
|
| 128 |
-
if is_video:
|
| 129 |
-
alpha = alpha * VIDEO_ALPHA_SCALE
|
| 130 |
-
|
| 131 |
-
# Create mask for significant alpha values
|
| 132 |
-
mask = alpha >= ALPHA_THRESHOLD
|
| 133 |
-
alpha_clamped = np.clip(alpha, 0, MAX_ALPHA)
|
| 134 |
-
one_minus_alpha = 1.0 - alpha_clamped
|
| 135 |
-
|
| 136 |
-
# Apply reverse alpha blending to each channel
|
| 137 |
-
for c in range(3): # BGR
|
| 138 |
-
channel = region[:, :, c]
|
| 139 |
-
# original = (watermarked - α × LOGO_VALUE) / (1 - α)
|
| 140 |
-
original = (channel - alpha_clamped * LOGO_VALUE) / one_minus_alpha
|
| 141 |
-
# Only modify pixels where alpha is significant
|
| 142 |
-
channel[mask] = original[mask]
|
| 143 |
-
region[:, :, c] = np.clip(channel, 0, 255)
|
| 144 |
-
|
| 145 |
-
# Write back
|
| 146 |
-
frame[y:y+size, x:x+size] = region.astype(np.uint8)
|
| 147 |
-
return frame
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
def remove_watermark_yuv_frame(frame_bytes: bytes, width: int, height: int, config: dict) -> bytes:
|
| 151 |
-
"""Apply YUV watermark reverse blending directly in raw yuv420p buffer using NumPy."""
|
| 152 |
-
frame_arr = np.frombuffer(frame_bytes, dtype=np.uint8).copy()
|
| 153 |
-
|
| 154 |
-
y_size = width * height
|
| 155 |
-
uv_size = (width // 2) * (height // 2)
|
| 156 |
-
|
| 157 |
-
# Reshape planes into 2D views
|
| 158 |
-
Y = frame_arr[0:y_size].reshape((height, width))
|
| 159 |
-
U = frame_arr[y_size:y_size + uv_size].reshape((height // 2, width // 2))
|
| 160 |
-
V = frame_arr[y_size + uv_size:].reshape((height // 2, width // 2))
|
| 161 |
-
|
| 162 |
-
x, y = config["x"], config["y"]
|
| 163 |
-
size = config["logo_size"]
|
| 164 |
-
|
| 165 |
-
# Align coordinates to even numbers for YUV420p subsampling compatibility
|
| 166 |
-
x0 = (x // 2) * 2
|
| 167 |
-
y0 = (y // 2) * 2
|
| 168 |
-
x1 = ((x + size + 1) // 2) * 2
|
| 169 |
-
y1 = ((y + size + 1) // 2) * 2
|
| 170 |
-
|
| 171 |
-
# Ensure bounds are within image dimensions (which must also be even)
|
| 172 |
-
x0 = max(0, min(x0, width))
|
| 173 |
-
y0 = max(0, min(y0, height))
|
| 174 |
-
x1 = max(0, min(x1, width))
|
| 175 |
-
y1 = max(0, min(y1, height))
|
| 176 |
-
|
| 177 |
-
# Intersect aligned bounds with actual watermark bounding box
|
| 178 |
-
w_x0, w_y0 = x, y
|
| 179 |
-
w_x1, w_y1 = x + size, y + size
|
| 180 |
-
|
| 181 |
-
int_x0 = max(x0, w_x0)
|
| 182 |
-
int_y0 = max(y0, w_y0)
|
| 183 |
-
int_x1 = min(x1, w_x1)
|
| 184 |
-
int_y1 = min(y1, w_y1)
|
| 185 |
-
|
| 186 |
-
# Create target alpha map for coordinate-aligned region
|
| 187 |
-
alpha_map_full = np.zeros((y1 - y0, x1 - x0), dtype=np.float32)
|
| 188 |
-
|
| 189 |
-
if int_x1 > int_x0 and int_y1 > int_y0:
|
| 190 |
-
src_x0 = int_x0 - w_x0
|
| 191 |
-
src_y0 = int_y0 - w_y0
|
| 192 |
-
src_x1 = int_x1 - w_x0
|
| 193 |
-
src_y1 = int_y1 - w_y0
|
| 194 |
-
|
| 195 |
-
dst_x0 = int_x0 - x0
|
| 196 |
-
dst_y0 = int_y0 - y0
|
| 197 |
-
dst_x1 = int_x1 - x0
|
| 198 |
-
dst_y1 = int_y1 - y0
|
| 199 |
-
|
| 200 |
-
alpha_map_full[dst_y0:dst_y1, dst_x0:dst_x1] = get_alpha_map(size)[src_y0:src_y1, src_x0:src_x1]
|
| 201 |
-
|
| 202 |
-
scaled_alpha = alpha_map_full * VIDEO_ALPHA_SCALE
|
| 203 |
-
scaled_alpha = np.clip(scaled_alpha, 0, MAX_ALPHA)
|
| 204 |
-
one_minus_alpha = 1.0 - scaled_alpha
|
| 205 |
-
mask = alpha_map_full >= ALPHA_THRESHOLD
|
| 206 |
-
|
| 207 |
-
# 1. Process Y channel (Luminance)
|
| 208 |
-
Y_roi = Y[y0:y1, x0:x1].astype(np.float32)
|
| 209 |
-
new_Y = (Y_roi - scaled_alpha * 235.0) / one_minus_alpha
|
| 210 |
-
Y[y0:y1, x0:x1] = np.where(mask, np.clip(new_Y, 0, 255).astype(np.uint8), Y[y0:y1, x0:x1])
|
| 211 |
-
|
| 212 |
-
# 2. Process U & V channels (Chroma, subsampled 2x2)
|
| 213 |
-
x0_sub, y0_sub = x0 // 2, y0 // 2
|
| 214 |
-
x1_sub, y1_sub = x1 // 2, y1 // 2
|
| 215 |
-
|
| 216 |
-
scaled_alpha_sub = scaled_alpha[::2, ::2]
|
| 217 |
-
one_minus_alpha_sub = one_minus_alpha[::2, ::2]
|
| 218 |
-
mask_sub = mask[::2, ::2]
|
| 219 |
-
|
| 220 |
-
# U Channel
|
| 221 |
-
U_roi = U[y0_sub:y1_sub, x0_sub:x1_sub].astype(np.float32)
|
| 222 |
-
new_U = (U_roi - 128.0) / one_minus_alpha_sub + 128.0
|
| 223 |
-
U[y0_sub:y1_sub, x0_sub:x1_sub] = np.where(mask_sub, np.clip(new_U, 0, 255).astype(np.uint8), U[y0_sub:y1_sub, x0_sub:x1_sub])
|
| 224 |
-
|
| 225 |
-
# V Channel
|
| 226 |
-
V_roi = V[y0_sub:y1_sub, x0_sub:x1_sub].astype(np.float32)
|
| 227 |
-
new_V = (V_roi - 128.0) / one_minus_alpha_sub + 128.0
|
| 228 |
-
V[y0_sub:y1_sub, x0_sub:x1_sub] = np.where(mask_sub, np.clip(new_V, 0, 255).astype(np.uint8), V[y0_sub:y1_sub, x0_sub:x1_sub])
|
| 229 |
-
|
| 230 |
-
return frame_arr.tobytes()
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def remove_watermark_video(input_path: str, output_path: str = None,
|
| 234 |
-
show_progress: bool = True) -> str:
|
| 235 |
-
"""Remove watermark from a video file using ffmpeg YUV420p pipes.
|
| 236 |
-
|
| 237 |
-
Args:
|
| 238 |
-
input_path: Path to input video file
|
| 239 |
-
output_path: Path for output video (default: adds _clean suffix)
|
| 240 |
-
show_progress: Show progress log
|
| 241 |
-
|
| 242 |
-
Returns:
|
| 243 |
-
Path to cleaned video file
|
| 244 |
-
"""
|
| 245 |
-
input_path = str(input_path)
|
| 246 |
-
if output_path is None:
|
| 247 |
-
base, ext = os.path.splitext(input_path)
|
| 248 |
-
output_path = f"{base}_clean{ext}"
|
| 249 |
-
|
| 250 |
-
abs_input = os.path.abspath(input_path)
|
| 251 |
-
abs_output = os.path.abspath(output_path)
|
| 252 |
-
|
| 253 |
-
# Get video info via ffprobe
|
| 254 |
-
probe = subprocess.run(
|
| 255 |
-
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", abs_input],
|
| 256 |
-
capture_output=True, text=True
|
| 257 |
-
)
|
| 258 |
-
info = json.loads(probe.stdout)
|
| 259 |
-
|
| 260 |
-
video_stream = next(s for s in info["streams"] if s["codec_type"] == "video")
|
| 261 |
-
width = int(video_stream["width"])
|
| 262 |
-
height = int(video_stream["height"])
|
| 263 |
-
|
| 264 |
-
# Parse fps
|
| 265 |
-
fps_str = video_stream.get("r_frame_rate", "24/1")
|
| 266 |
-
if "/" in fps_str:
|
| 267 |
-
num, den = fps_str.split("/")
|
| 268 |
-
fps = float(num) / float(den)
|
| 269 |
-
else:
|
| 270 |
-
fps = float(fps_str)
|
| 271 |
-
|
| 272 |
-
duration = float(info.get("format", {}).get("duration", 0))
|
| 273 |
-
total_frames = int(duration * fps) if duration > 0 else 0
|
| 274 |
-
|
| 275 |
-
log.info("🎬 Processing YUV video: %dx%d @ %.1f fps (%d frames)", width, height, fps, total_frames)
|
| 276 |
-
|
| 277 |
-
config = detect_watermark_config(width, height, is_video=True)
|
| 278 |
-
|
| 279 |
-
# Temp file for safe rewrite
|
| 280 |
-
temp_out = abs_output + ".tmp.mp4"
|
| 281 |
-
if os.path.exists(temp_out):
|
| 282 |
-
os.remove(temp_out)
|
| 283 |
-
|
| 284 |
-
# Dynamic macOS Hardware Acceleration vs standard CPU fallback
|
| 285 |
-
if sys.platform == "darwin":
|
| 286 |
-
read_cmd = [
|
| 287 |
-
"ffmpeg", "-hwaccel", "videotoolbox", "-i", abs_input,
|
| 288 |
-
"-f", "rawvideo", "-pix_fmt", "yuv420p",
|
| 289 |
-
"-v", "quiet", "-"
|
| 290 |
-
]
|
| 291 |
-
encoder_args = ["-c:v", "h264_videotoolbox", "-b:v", "4000k"]
|
| 292 |
-
else:
|
| 293 |
-
read_cmd = [
|
| 294 |
-
"ffmpeg", "-i", abs_input,
|
| 295 |
-
"-f", "rawvideo", "-pix_fmt", "yuv420p",
|
| 296 |
-
"-v", "quiet", "-"
|
| 297 |
-
]
|
| 298 |
-
encoder_args = ["-c:v", "libx264", "-preset", "fast", "-crf", "18"]
|
| 299 |
-
|
| 300 |
-
write_cmd = [
|
| 301 |
-
"ffmpeg", "-y",
|
| 302 |
-
"-f", "rawvideo", "-pix_fmt", "yuv420p",
|
| 303 |
-
"-s", f"{width}x{height}", "-r", str(fps_str),
|
| 304 |
-
"-i", "-",
|
| 305 |
-
"-i", abs_input,
|
| 306 |
-
"-map", "0:v", "-map", "1:a?",
|
| 307 |
-
] + encoder_args + [
|
| 308 |
-
"-c:a", "copy",
|
| 309 |
-
"-movflags", "+faststart",
|
| 310 |
-
"-v", "quiet",
|
| 311 |
-
temp_out
|
| 312 |
-
]
|
| 313 |
-
|
| 314 |
-
reader = subprocess.Popen(read_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
| 315 |
-
writer = subprocess.Popen(write_cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
| 316 |
-
|
| 317 |
-
y_size = width * height
|
| 318 |
-
uv_size = (width // 2) * (height // 2)
|
| 319 |
-
frame_size = y_size + 2 * uv_size
|
| 320 |
-
frame_count = 0
|
| 321 |
-
|
| 322 |
-
try:
|
| 323 |
-
while True:
|
| 324 |
-
raw = reader.stdout.read(frame_size)
|
| 325 |
-
if len(raw) < frame_size:
|
| 326 |
-
break
|
| 327 |
-
|
| 328 |
-
clean_raw = remove_watermark_yuv_frame(raw, width, height, config)
|
| 329 |
-
writer.stdin.write(clean_raw)
|
| 330 |
-
|
| 331 |
-
frame_count += 1
|
| 332 |
-
if show_progress and frame_count % 30 == 0:
|
| 333 |
-
pct = int(frame_count / total_frames * 100) if total_frames > 0 else 0
|
| 334 |
-
log.info(" Processing: %d%% (%d/%d frames)", pct, frame_count, total_frames)
|
| 335 |
-
finally:
|
| 336 |
-
writer.stdin.close()
|
| 337 |
-
writer.wait()
|
| 338 |
-
reader.wait()
|
| 339 |
-
|
| 340 |
-
if os.path.exists(temp_out):
|
| 341 |
-
if os.path.exists(abs_output):
|
| 342 |
-
os.remove(abs_output)
|
| 343 |
-
os.rename(temp_out, abs_output)
|
| 344 |
-
else:
|
| 345 |
-
raise RuntimeError("Failed to compile clean watermark-free video")
|
| 346 |
-
|
| 347 |
-
size_mb = os.path.getsize(abs_output) / (1024 * 1024)
|
| 348 |
-
log.info("✅ Watermark removed: %s (%.1f MB, %d frames)", abs_output, size_mb, frame_count)
|
| 349 |
-
return abs_output
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
def remove_watermark_image(input_path: str, output_path: str = None) -> str:
|
| 353 |
-
"""Remove watermark from a single image file.
|
| 354 |
-
|
| 355 |
-
Args:
|
| 356 |
-
input_path: Path to input image
|
| 357 |
-
output_path: Path for output (default: adds _clean suffix)
|
| 358 |
-
|
| 359 |
-
Returns:
|
| 360 |
-
Path to cleaned image
|
| 361 |
-
"""
|
| 362 |
-
input_path = str(input_path)
|
| 363 |
-
if output_path is None:
|
| 364 |
-
base, ext = os.path.splitext(input_path)
|
| 365 |
-
output_path = f"{base}_clean{ext}"
|
| 366 |
-
|
| 367 |
-
img = cv2.imread(input_path)
|
| 368 |
-
if img is None:
|
| 369 |
-
raise ValueError(f"Cannot read image: {input_path}")
|
| 370 |
-
|
| 371 |
-
clean = remove_watermark_frame(img, is_video=False)
|
| 372 |
-
cv2.imwrite(output_path, clean)
|
| 373 |
-
|
| 374 |
-
log.info("✅ Watermark removed: %s", output_path)
|
| 375 |
-
return output_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,7 +1,4 @@
|
|
| 1 |
websockets>=12.0
|
| 2 |
-
opencv-python-headless>=4.8
|
| 3 |
-
numpy>=1.24
|
| 4 |
fastapi>=0.100.0
|
| 5 |
uvicorn>=0.22.0
|
| 6 |
python-multipart>=0.0.6
|
| 7 |
-
|
|
|
|
| 1 |
websockets>=12.0
|
|
|
|
|
|
|
| 2 |
fastapi>=0.100.0
|
| 3 |
uvicorn>=0.22.0
|
| 4 |
python-multipart>=0.0.6
|
|
|
|
@@ -8,14 +8,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o free-gemini-api .
|
|
| 8 |
|
| 9 |
# Runtime stage
|
| 10 |
FROM alpine:3.19
|
| 11 |
-
RUN apk --no-cache add ca-certificates curl
|
| 12 |
WORKDIR /app
|
| 13 |
COPY --from=builder /app/free-gemini-api .
|
| 14 |
RUN mkdir -p /app/output
|
| 15 |
|
| 16 |
-
EXPOSE 8000
|
| 17 |
|
| 18 |
-
ENV WS_PORT="
|
| 19 |
PORT="8000"
|
| 20 |
|
| 21 |
CMD ["./free-gemini-api"]
|
|
|
|
| 8 |
|
| 9 |
# Runtime stage
|
| 10 |
FROM alpine:3.19
|
| 11 |
+
RUN apk --no-cache add ca-certificates curl
|
| 12 |
WORKDIR /app
|
| 13 |
COPY --from=builder /app/free-gemini-api .
|
| 14 |
RUN mkdir -p /app/output
|
| 15 |
|
| 16 |
+
EXPOSE 8000 9226
|
| 17 |
|
| 18 |
+
ENV WS_PORT="9226" \
|
| 19 |
PORT="8000"
|
| 20 |
|
| 21 |
CMD ["./free-gemini-api"]
|
|
@@ -3,7 +3,7 @@
|
|
| 3 |
* Proactively keeps Google Gemini cookies fresh and auto-syncs them on change.
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
const LOCAL_WS_URL = 'ws://127.0.0.1:
|
| 7 |
let ws = null;
|
| 8 |
let lastSyncTime = null;
|
| 9 |
let hasSyncedOnce = false;
|
|
|
|
| 3 |
* Proactively keeps Google Gemini cookies fresh and auto-syncs them on change.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
const LOCAL_WS_URL = 'ws://127.0.0.1:9226';
|
| 7 |
let ws = null;
|
| 8 |
let lastSyncTime = null;
|
| 9 |
let hasSyncedOnce = false;
|
|
@@ -1166,24 +1166,6 @@ func (c *GeminiClient) DownloadFile(urlStr, savePath string) error {
|
|
| 1166 |
return fmt.Errorf("file too small (%d bytes), likely failed download", info.Size())
|
| 1167 |
}
|
| 1168 |
|
| 1169 |
-
// Run watermark remover if file is image or video
|
| 1170 |
-
ext := strings.ToLower(filepath.Ext(savePath))
|
| 1171 |
-
var fileType string
|
| 1172 |
-
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" {
|
| 1173 |
-
fileType = "image"
|
| 1174 |
-
} else if ext == ".mp4" {
|
| 1175 |
-
fileType = "video"
|
| 1176 |
-
}
|
| 1177 |
-
|
| 1178 |
-
if fileType != "" {
|
| 1179 |
-
log.Printf("🧹 Removing watermark from %s (%s) natively in Go...", savePath, fileType)
|
| 1180 |
-
if err := RemoveWatermark(savePath, fileType); err != nil {
|
| 1181 |
-
log.Printf("⚠️ Failed to remove watermark natively: %v", err)
|
| 1182 |
-
} else {
|
| 1183 |
-
log.Printf("✨ Watermark removed successfully natively in Go: %s", savePath)
|
| 1184 |
-
}
|
| 1185 |
-
}
|
| 1186 |
-
|
| 1187 |
return nil
|
| 1188 |
}
|
| 1189 |
|
|
|
|
| 1166 |
return fmt.Errorf("file too small (%d bytes), likely failed download", info.Size())
|
| 1167 |
}
|
| 1168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1169 |
return nil
|
| 1170 |
}
|
| 1171 |
|
|
@@ -1,424 +0,0 @@
|
|
| 1 |
-
package gemini
|
| 2 |
-
|
| 3 |
-
import (
|
| 4 |
-
"bytes"
|
| 5 |
-
"encoding/base64"
|
| 6 |
-
"encoding/json"
|
| 7 |
-
"fmt"
|
| 8 |
-
"image"
|
| 9 |
-
"image/color"
|
| 10 |
-
"image/draw"
|
| 11 |
-
"image/jpeg"
|
| 12 |
-
"image/png"
|
| 13 |
-
"io"
|
| 14 |
-
"log"
|
| 15 |
-
"math"
|
| 16 |
-
"os"
|
| 17 |
-
"os/exec"
|
| 18 |
-
"path/filepath"
|
| 19 |
-
"strings"
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
// Constants for mathematical reverse alpha blending
|
| 23 |
-
const (
|
| 24 |
-
alphaThreshold = 0.002
|
| 25 |
-
maxAlpha = 0.99
|
| 26 |
-
logoValue = 255.0
|
| 27 |
-
videoAlphaScale = 0.6
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
// Embedded base64-encoded PNG assets for the 48px and 96px watermarks
|
| 31 |
-
const (
|
| 32 |
-
bg48B64 = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAGVElEQVR4nMVYvXIbNxD+FvKMWInXmd2dK7MTO7sj9QKWS7qy/Ab2o/gNmCp0JyZ9dHaldJcqTHfnSSF1R7kwlYmwKRYA93BHmkrseMcjgzgA++HbH2BBxhhmBiB/RYgo+hkGSFv/ZOY3b94w89u3b6HEL8JEYCYATCAi2JYiQ8xMDADGWsvMbfVagm6ZLxKGPXr0qN/vJ0mSpqn0RzuU//Wu9MoyPqxmtqmXJYwxxpiAQzBF4x8/fiyN4XDYoZLA5LfEhtg0+glMIGZY6wABMMbs4CaiR8brkYIDwGg00uuEMUTQ1MYqPBRRYZjZ+q42nxEsaYiV5VOapkmSSLvX62VZprUyM0DiQACIGLCAESIAEINAAAEOcQdD4a+2FJqmhDd/YEVkMpmEtrU2igCocNHW13swRBQYcl0enxbHpzEhKo0xSZJEgLIsC4Q5HJaJ2Qg7kKBjwMJyCDciBBcw7fjSO4tQapdi5vF43IZ+cnISdh9Y0At2RoZWFNtLsxr8N6CUTgCaHq3g+Pg4TVO1FACSaDLmgMhYC8sEQzCu3/mQjNEMSTvoDs4b+nXny5cvo4lBJpNJmKj9z81VrtNhikCgTsRRfAklmurxeKx9JZIsy548eeITKJgAQwzXJlhDTAwDgrXkxxCD2GfqgEPa4rnBOlApFUC/39fR1CmTyWQwGAQrR8TonMRNjjYpTmPSmUnC8ODgQHqSJDk7O9uNBkCv15tOp4eHh8SQgBICiCGu49YnSUJOiLGJcG2ydmdwnRcvXuwwlpYkSabTaZS1vyimc7R2Se16z58/f/jw4Z5LA8iy7NmzZ8J76CQ25F2UGsEAJjxo5194q0fn9unp6fHx8f5oRCQ1nJ+fbxtA3HAjAmCMCaGuAQWgh4eH0+k0y7LGvPiU3CVXV1fz+by+WQkCJYaImKzL6SEN6uMpjBVMg8FgOp3GfnNPQADqup79MLv59AlWn75E/vAlf20ibmWg0Pn06dPJZNLr9e6nfLu8//Ahv/gFAEdcWEsgZnYpR3uM9KRpOplMGmb6SlLX9Ww2q29WyjH8+SI+pD0GQJIkJycn/8J/I4mWjaQoijzPb25uJJsjmAwqprIsG4/HbVZ2L/1fpCiKoijKqgTRBlCWZcPhcDQafUVfuZfUdb1cLpfL5cePf9Lr16/3zLz/g9T1quNy+F2FiYjSNB0Oh8Ph8HtRtV6vi6JYLpdVVbmb8t3dnSAbjUbRNfmbSlmWeZ6XHytEUQafEo0xR0dHUdjvG2X3Sd/Fb0We56t6BX8l2mTq6BCVnqOjo7Ozs29hRGGlqqrOr40CIKqeiGg8Hn/xcri/rG/XeZ7/evnrjjGbC3V05YC/BSRJ8urVq36/3zX7Hjaq63o+n19fX/upUqe5VxFok7UBtQ+T6XQ6GAz2Vd6Ssizn8/nt7a3ay1ZAYbMN520XkKenpx0B2E2SLOo+FEWxWPwMgMnC3/adejZMYLLS42r7oH4LGodpsVgURdHQuIcURbFYLDYlVKg9sCk5wpWNiHym9pUAEQGG6EAqSxhilRQWi0VZVmrz23yI5cPV1dX5TwsmWGYrb2TW36OJGjdXhryKxEeHvjR2Fgzz+bu6XnVgaHEmXhytEK0W1aUADJPjAL6CtPZv5rsGSvUKtv7r8/zdj+v1uoOUpsxms7qunT6+g1/TvTQCxE6XR2kBqxjyZo6K66gsAXB1fZ3neQdJSvI8X61WpNaMWCFuKNrkGuGGmMm95fhpvPkn/f6lAgAuLy/LstyGpq7r9+8d4rAr443qaln/ehHt1siv3dvt2B/RDpJms5lGE62gEy9az0XGcQCK3DL4DTPr0pPZEjPAZVlusoCSoihWqzpCHy7ODRXhbUTJly9oDr4fKDaV9NZJUrszPOjsI0a/FzfwNt4eHH+BSyICqK7rqqo0u0VRrFYridyN87L3pBYf7qvq3wqc3DMldJmiK06pgi8uLqQjAAorRG+p+zLUxks+z7rOkOzlIUy8yrAcQFVV3a4/ywBPmJsVMcTM3l/h9xDlLga4I1PDGaD7UNBPuCKBleUfy2gd+DOrPWubGHJJyD+L+LCTjEXEgH//2uSxhu1/Xzocy+VSL+2cUhrqLVZ/jTYL0IMtQEklT3/iWCutzUljDDNXVSVHRFWW7SOtccHag6V/AF1/slVRyOkZAAAAAElFTkSuQmCC"
|
| 33 |
-
bg96B64 = "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"
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
// Global cache for decoded alpha maps
|
| 37 |
-
var (
|
| 38 |
-
alphaCache48 []float32
|
| 39 |
-
alphaCache96 []float32
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
func init() {
|
| 43 |
-
// Pre-decode and cache the watermark grids
|
| 44 |
-
var err error
|
| 45 |
-
alphaCache48, err = loadAlphaMap(48)
|
| 46 |
-
if err != nil {
|
| 47 |
-
log.Printf("⚠️ Failed to load 48px alpha map: %v", err)
|
| 48 |
-
}
|
| 49 |
-
alphaCache96, err = loadAlphaMap(96)
|
| 50 |
-
if err != nil {
|
| 51 |
-
log.Printf("⚠️ Failed to load 96px alpha map: %v", err)
|
| 52 |
-
}
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
// loadAlphaMap decodes base64 PNGs and extracts the maximum RGB channel as alpha mapping
|
| 56 |
-
func loadAlphaMap(size int) ([]float32, error) {
|
| 57 |
-
b64Str := bg48B64
|
| 58 |
-
if size == 96 {
|
| 59 |
-
b64Str = bg96B64
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
data, err := base64.StdEncoding.DecodeString(b64Str)
|
| 63 |
-
if err != nil {
|
| 64 |
-
return nil, err
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
img, _, err := image.Decode(bytes.NewReader(data))
|
| 68 |
-
if err != nil {
|
| 69 |
-
return nil, err
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
bounds := img.Bounds()
|
| 73 |
-
w, h := bounds.Dx(), bounds.Dy()
|
| 74 |
-
|
| 75 |
-
// Resize using simple nearest-neighbor if dimensions mismatch
|
| 76 |
-
alphaMap := make([]float32, size*size)
|
| 77 |
-
for y := 0; y < size; y++ {
|
| 78 |
-
srcY := int(float64(y) * float64(h) / float64(size))
|
| 79 |
-
for x := 0; x < size; x++ {
|
| 80 |
-
srcX := int(float64(x) * float64(w) / float64(size))
|
| 81 |
-
r, g, b, _ := img.At(bounds.Min.X+srcX, bounds.Min.Y+srcY).RGBA()
|
| 82 |
-
|
| 83 |
-
// Max of RGB, scale [0, 65535] down to [0.0, 1.0]
|
| 84 |
-
maxVal := r
|
| 85 |
-
if g > maxVal {
|
| 86 |
-
maxVal = g
|
| 87 |
-
}
|
| 88 |
-
if b > maxVal {
|
| 89 |
-
maxVal = b
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
alphaMap[y*size+x] = float32(maxVal) / 65535.0
|
| 93 |
-
}
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
return alphaMap, nil
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
// WatermarkConfig holds dimensions and offset coordinates
|
| 100 |
-
type WatermarkConfig struct {
|
| 101 |
-
LogoSize int
|
| 102 |
-
X int
|
| 103 |
-
Y int
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
// DetectWatermarkConfig returns position configurations based on dimensions
|
| 107 |
-
func DetectWatermarkConfig(width, height int, isVideo bool) WatermarkConfig {
|
| 108 |
-
var logoSize, marginRight, marginBottom int
|
| 109 |
-
|
| 110 |
-
if isVideo {
|
| 111 |
-
shortDim := width
|
| 112 |
-
if height < shortDim {
|
| 113 |
-
shortDim = height
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
if shortDim >= 1080 {
|
| 117 |
-
logoSize = 96
|
| 118 |
-
marginRight = 64
|
| 119 |
-
marginBottom = 64
|
| 120 |
-
} else {
|
| 121 |
-
logoSize = 48
|
| 122 |
-
marginRight = 72
|
| 123 |
-
marginBottom = 72
|
| 124 |
-
}
|
| 125 |
-
} else {
|
| 126 |
-
// Portrait standard matching
|
| 127 |
-
if width == 720 && height == 1280 {
|
| 128 |
-
logoSize = 48
|
| 129 |
-
marginRight = 72
|
| 130 |
-
marginBottom = 72
|
| 131 |
-
} else {
|
| 132 |
-
shortDim := width
|
| 133 |
-
if height < shortDim {
|
| 134 |
-
shortDim = height
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
if shortDim > 800 {
|
| 138 |
-
logoSize = 96
|
| 139 |
-
marginRight = 64
|
| 140 |
-
marginBottom = 64
|
| 141 |
-
} else {
|
| 142 |
-
logoSize = 48
|
| 143 |
-
marginRight = 32
|
| 144 |
-
marginBottom = 32
|
| 145 |
-
}
|
| 146 |
-
}
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
x := width - marginRight - logoSize
|
| 150 |
-
y := height - marginBottom - logoSize
|
| 151 |
-
|
| 152 |
-
return WatermarkConfig{
|
| 153 |
-
LogoSize: logoSize,
|
| 154 |
-
X: x,
|
| 155 |
-
Y: y,
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
// RemoveWatermark native hub. Dispatches based on file extensions.
|
| 160 |
-
func RemoveWatermark(savePath string, fileType string) error {
|
| 161 |
-
ext := strings.ToLower(filepath.Ext(savePath))
|
| 162 |
-
if ext == ".png" || ext == ".jpg" || ext == ".jpeg" {
|
| 163 |
-
return removeWatermarkFromImage(savePath)
|
| 164 |
-
} else if ext == ".mp4" {
|
| 165 |
-
return removeWatermarkFromVideo(savePath)
|
| 166 |
-
}
|
| 167 |
-
return fmt.Errorf("unsupported watermark file type: %s", ext)
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
// removeWatermarkFromImage performs native Go reverse-alpha editing on static images
|
| 171 |
-
func removeWatermarkFromImage(imagePath string) error {
|
| 172 |
-
file, err := os.Open(imagePath)
|
| 173 |
-
if err != nil {
|
| 174 |
-
return err
|
| 175 |
-
}
|
| 176 |
-
defer file.Close()
|
| 177 |
-
|
| 178 |
-
img, format, err := image.Decode(file)
|
| 179 |
-
if err != nil {
|
| 180 |
-
return err
|
| 181 |
-
}
|
| 182 |
-
file.Close() // Close early for in-place write
|
| 183 |
-
|
| 184 |
-
bounds := img.Bounds()
|
| 185 |
-
width, height := bounds.Dx(), bounds.Dy()
|
| 186 |
-
config := DetectWatermarkConfig(width, height, false)
|
| 187 |
-
|
| 188 |
-
// Fetch cached alpha map
|
| 189 |
-
alphaMap := alphaCache48
|
| 190 |
-
if config.LogoSize == 96 {
|
| 191 |
-
alphaMap = alphaCache96
|
| 192 |
-
}
|
| 193 |
-
if len(alphaMap) == 0 {
|
| 194 |
-
return fmt.Errorf("alpha map cache for size %d is empty", config.LogoSize)
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
// Create writable canvas
|
| 198 |
-
canvas := image.NewRGBA(bounds)
|
| 199 |
-
draw.Draw(canvas, bounds, img, bounds.Min, draw.Src)
|
| 200 |
-
|
| 201 |
-
// Apply math formula on watermark region
|
| 202 |
-
for dy := 0; dy < config.LogoSize; dy++ {
|
| 203 |
-
py := config.Y + dy
|
| 204 |
-
if py < bounds.Min.Y || py >= bounds.Max.Y {
|
| 205 |
-
continue
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
for dx := 0; dx < config.LogoSize; dx++ {
|
| 209 |
-
px := config.X + dx
|
| 210 |
-
if px < bounds.Min.X || px >= bounds.Max.X {
|
| 211 |
-
continue
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
alpha := alphaMap[dy*config.LogoSize+dx]
|
| 215 |
-
if alpha < alphaThreshold {
|
| 216 |
-
continue
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
// Clamp alpha to avoid division by zero
|
| 220 |
-
if alpha > maxAlpha {
|
| 221 |
-
alpha = maxAlpha
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
r, g, b, a := canvas.At(px, py).RGBA()
|
| 225 |
-
|
| 226 |
-
// Scale down values to standard float32 [0.0, 255.0]
|
| 227 |
-
fR := float64(r) / 257.0
|
| 228 |
-
fG := float64(g) / 257.0
|
| 229 |
-
fB := float64(b) / 257.0
|
| 230 |
-
|
| 231 |
-
// Solve original = (watermarked - alpha * 255.0) / (1 - alpha)
|
| 232 |
-
oneMinusAlpha := 1.0 - float64(alpha)
|
| 233 |
-
newR := (fR - float64(alpha)*logoValue) / oneMinusAlpha
|
| 234 |
-
newG := (fG - float64(alpha)*logoValue) / oneMinusAlpha
|
| 235 |
-
newB := (fB - float64(alpha)*logoValue) / oneMinusAlpha
|
| 236 |
-
|
| 237 |
-
// Clamp to [0, 255]
|
| 238 |
-
cR := uint8(math.Min(math.Max(newR, 0), 255))
|
| 239 |
-
cG := uint8(math.Min(math.Max(newG, 0), 255))
|
| 240 |
-
cB := uint8(math.Min(math.Max(newB, 0), 255))
|
| 241 |
-
cA := uint8(a / 257)
|
| 242 |
-
|
| 243 |
-
canvas.SetRGBA(px, py, color.RGBA{R: cR, G: cG, B: cB, A: cA})
|
| 244 |
-
}
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
// Save back in-place
|
| 248 |
-
outFile, err := os.Create(imagePath)
|
| 249 |
-
if err != nil {
|
| 250 |
-
return err
|
| 251 |
-
}
|
| 252 |
-
defer outFile.Close()
|
| 253 |
-
|
| 254 |
-
if format == "png" {
|
| 255 |
-
return png.Encode(outFile, canvas)
|
| 256 |
-
}
|
| 257 |
-
return jpeg.Encode(outFile, canvas, &jpeg.Options{Quality: 95})
|
| 258 |
-
}
|
| 259 |
-
|
| 260 |
-
// VideoStreamInfo is mapped from ffprobe JSON output
|
| 261 |
-
type VideoStreamInfo struct {
|
| 262 |
-
Streams []struct {
|
| 263 |
-
Width int `json:"width"`
|
| 264 |
-
Height int `json:"height"`
|
| 265 |
-
CodecType string `json:"codec_type"`
|
| 266 |
-
RFrameRate string `json:"r_frame_rate"`
|
| 267 |
-
} `json:"streams"`
|
| 268 |
-
}
|
| 269 |
-
|
| 270 |
-
// removeWatermarkFromVideo runs native FFMPEG byte pipes for zero-Python video watermark removal
|
| 271 |
-
func removeWatermarkFromVideo(videoPath string) error {
|
| 272 |
-
absPath, err := filepath.Abs(videoPath)
|
| 273 |
-
if err != nil {
|
| 274 |
-
return err
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
// 1. Get dimensions using ffprobe
|
| 278 |
-
probeCmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", absPath)
|
| 279 |
-
probeOut, err := probeCmd.Output()
|
| 280 |
-
if err != nil {
|
| 281 |
-
return fmt.Errorf("ffprobe failed (is ffmpeg installed?): %w", err)
|
| 282 |
-
}
|
| 283 |
-
|
| 284 |
-
var info VideoStreamInfo
|
| 285 |
-
if err := json.Unmarshal(probeOut, &info); err != nil {
|
| 286 |
-
return fmt.Errorf("failed to parse ffprobe json: %w", err)
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
var width, height int
|
| 290 |
-
var fps string
|
| 291 |
-
for _, stream := range info.Streams {
|
| 292 |
-
if stream.CodecType == "video" {
|
| 293 |
-
width = stream.Width
|
| 294 |
-
height = stream.Height
|
| 295 |
-
fps = stream.RFrameRate
|
| 296 |
-
break
|
| 297 |
-
}
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
if width == 0 || height == 0 {
|
| 301 |
-
return fmt.Errorf("could not extract video dimensions for: %s", videoPath)
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
if fps == "" {
|
| 305 |
-
fps = "24"
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
config := DetectWatermarkConfig(width, height, true)
|
| 309 |
-
alphaMap := alphaCache48
|
| 310 |
-
if config.LogoSize == 96 {
|
| 311 |
-
alphaMap = alphaCache96
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
// Setup temp path for safe in-place rewrite
|
| 315 |
-
tempOut := absPath + ".tmp.mp4"
|
| 316 |
-
defer os.Remove(tempOut)
|
| 317 |
-
|
| 318 |
-
// FFMPEG Read command: Decode frames to raw BGR24 on stdout
|
| 319 |
-
readCmd := exec.Command("ffmpeg", "-i", absPath, "-f", "rawvideo", "-pix_fmt", "bgr24", "-v", "quiet", "-")
|
| 320 |
-
stdout, err := readCmd.StdoutPipe()
|
| 321 |
-
if err != nil {
|
| 322 |
-
return err
|
| 323 |
-
}
|
| 324 |
-
|
| 325 |
-
// FFMPEG Write command: Encode BGR24 frames back into H.264 mp4, copying audio from original
|
| 326 |
-
writeCmd := exec.Command("ffmpeg", "-y",
|
| 327 |
-
"-f", "rawvideo", "-pix_fmt", "bgr24",
|
| 328 |
-
"-s", fmt.Sprintf("%dx%d", width, height), "-r", fps,
|
| 329 |
-
"-i", "-",
|
| 330 |
-
"-i", absPath, // Re-read for audio mapping
|
| 331 |
-
"-map", "0:v", "-map", "1:a?",
|
| 332 |
-
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
| 333 |
-
"-c:a", "copy",
|
| 334 |
-
"-movflags", "+faststart",
|
| 335 |
-
"-v", "quiet",
|
| 336 |
-
tempOut,
|
| 337 |
-
)
|
| 338 |
-
|
| 339 |
-
stdin, err := writeCmd.StdinPipe()
|
| 340 |
-
if err != nil {
|
| 341 |
-
return err
|
| 342 |
-
}
|
| 343 |
-
|
| 344 |
-
if err := readCmd.Start(); err != nil {
|
| 345 |
-
return fmt.Errorf("ffmpeg reader start failed: %w", err)
|
| 346 |
-
}
|
| 347 |
-
if err := writeCmd.Start(); err != nil {
|
| 348 |
-
return fmt.Errorf("ffmpeg writer start failed: %w", err)
|
| 349 |
-
}
|
| 350 |
-
|
| 351 |
-
frameSize := width * height * 3 // BGR24
|
| 352 |
-
frameBuf := make([]byte, frameSize)
|
| 353 |
-
|
| 354 |
-
for {
|
| 355 |
-
_, err := io.ReadFull(stdout, frameBuf)
|
| 356 |
-
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
| 357 |
-
break
|
| 358 |
-
}
|
| 359 |
-
if err != nil {
|
| 360 |
-
break
|
| 361 |
-
}
|
| 362 |
-
|
| 363 |
-
// Apply watermark reverse blending directly in BGR24 byte slice
|
| 364 |
-
for dy := 0; dy < config.LogoSize; dy++ {
|
| 365 |
-
py := config.Y + dy
|
| 366 |
-
if py < 0 || py >= height {
|
| 367 |
-
continue
|
| 368 |
-
}
|
| 369 |
-
|
| 370 |
-
for dx := 0; dx < config.LogoSize; dx++ {
|
| 371 |
-
px := config.X + dx
|
| 372 |
-
if px < 0 || px >= width {
|
| 373 |
-
continue
|
| 374 |
-
}
|
| 375 |
-
|
| 376 |
-
alpha := alphaMap[dy*config.LogoSize+dx]
|
| 377 |
-
if alpha < alphaThreshold {
|
| 378 |
-
continue
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
// Scale down watermark intensity for videos
|
| 382 |
-
scaledAlpha := alpha * videoAlphaScale
|
| 383 |
-
if scaledAlpha > maxAlpha {
|
| 384 |
-
scaledAlpha = maxAlpha
|
| 385 |
-
}
|
| 386 |
-
|
| 387 |
-
// Offset inside raw BGR24 frame byte slice
|
| 388 |
-
pixelOffset := (py*width + px) * 3
|
| 389 |
-
|
| 390 |
-
b := frameBuf[pixelOffset]
|
| 391 |
-
g := frameBuf[pixelOffset+1]
|
| 392 |
-
r := frameBuf[pixelOffset+2]
|
| 393 |
-
|
| 394 |
-
// Solve original = (watermarked - alpha * 255.0) / (1 - alpha)
|
| 395 |
-
oneMinusAlpha := 1.0 - float64(scaledAlpha)
|
| 396 |
-
|
| 397 |
-
newB := (float64(b) - float64(scaledAlpha)*logoValue) / oneMinusAlpha
|
| 398 |
-
newG := (float64(g) - float64(scaledAlpha)*logoValue) / oneMinusAlpha
|
| 399 |
-
newR := (float64(r) - float64(scaledAlpha)*logoValue) / oneMinusAlpha
|
| 400 |
-
|
| 401 |
-
// Set BGR byte values, clamped to [0, 255]
|
| 402 |
-
frameBuf[pixelOffset] = byte(math.Min(math.Max(newB, 0), 255))
|
| 403 |
-
frameBuf[pixelOffset+1] = byte(math.Min(math.Max(newG, 0), 255))
|
| 404 |
-
frameBuf[pixelOffset+2] = byte(math.Min(math.Max(newR, 0), 255))
|
| 405 |
-
}
|
| 406 |
-
}
|
| 407 |
-
|
| 408 |
-
// Write modified frame to encoder stdin pipe
|
| 409 |
-
if _, err := stdin.Write(frameBuf); err != nil {
|
| 410 |
-
break
|
| 411 |
-
}
|
| 412 |
-
}
|
| 413 |
-
|
| 414 |
-
stdin.Close()
|
| 415 |
-
writeCmd.Wait()
|
| 416 |
-
readCmd.Wait()
|
| 417 |
-
|
| 418 |
-
// Replace original video with clean copy
|
| 419 |
-
if _, err := os.Stat(tempOut); err == nil {
|
| 420 |
-
return os.Rename(tempOut, absPath)
|
| 421 |
-
}
|
| 422 |
-
|
| 423 |
-
return fmt.Errorf("failed to compile clean watermark-free video")
|
| 424 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module monitor
|
| 2 |
+
|
| 3 |
+
go 1.22
|
| 4 |
+
|
| 5 |
+
require github.com/gorilla/websocket v1.5.3
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
| 2 |
+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Chrome Navigator — GPT · Gemini · Flow</title>
|
| 8 |
+
<style>
|
| 9 |
+
:root { --accent: #007acc; }
|
| 10 |
+
* { box-sizing: border-box; }
|
| 11 |
+
body {
|
| 12 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 13 |
+
background: #1e1e1e; color: #d4d4d4; margin: 0; padding: 16px;
|
| 14 |
+
display: flex; flex-direction: column; align-items: center; min-height: 100vh;
|
| 15 |
+
}
|
| 16 |
+
.dashboard { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; justify-content: center; }
|
| 17 |
+
.card { background: #252526; border-radius: 8px; padding: 8px 15px; display: flex; align-items: center; gap: 12px; box-shadow: 0 4px 6px rgba(0,0,0,.3); }
|
| 18 |
+
.card-header { font-size: .72rem; color: #888; text-transform: uppercase; }
|
| 19 |
+
.card-value { font-size: 1.1rem; font-weight: 600; color: #fff; }
|
| 20 |
+
.progress-bg { background: #333; height: 6px; border-radius: 3px; overflow: hidden; width: 60px; }
|
| 21 |
+
.progress-fill { background: var(--accent); height: 100%; width: 0%; transition: width .5s ease; }
|
| 22 |
+
|
| 23 |
+
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; justify-content: center; max-width: 95vw; }
|
| 24 |
+
.tab-btn {
|
| 25 |
+
background: #252526; color: #d4d4d4; border: 1px solid #333; border-radius: 8px;
|
| 26 |
+
padding: 7px 10px 7px 14px; font-size: .9rem; font-weight: 600; cursor: pointer;
|
| 27 |
+
transition: all .15s; display: inline-flex; align-items: center; gap: 8px; max-width: 220px;
|
| 28 |
+
}
|
| 29 |
+
.tab-btn:hover { border-color: var(--accent); }
|
| 30 |
+
.tab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); box-shadow: 0 0 14px rgba(0,122,204,.55); }
|
| 31 |
+
.tab-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 32 |
+
.tab-close {
|
| 33 |
+
border: none; background: transparent; color: inherit; opacity: .6;
|
| 34 |
+
font-size: 1rem; line-height: 1; cursor: pointer; padding: 0 2px; border-radius: 4px;
|
| 35 |
+
}
|
| 36 |
+
.tab-close:hover { opacity: 1; background: rgba(255,255,255,.15); }
|
| 37 |
+
.tab-new { font-weight: 700; padding: 7px 13px; }
|
| 38 |
+
|
| 39 |
+
.navbar { display: flex; gap: 6px; margin-bottom: 12px; width: min(95vw, 900px); }
|
| 40 |
+
.nav-btn {
|
| 41 |
+
background: #252526; color: #d4d4d4; border: 1px solid #333; border-radius: 8px;
|
| 42 |
+
width: 38px; min-width: 38px; font-size: 1rem; cursor: pointer; transition: all .15s;
|
| 43 |
+
}
|
| 44 |
+
.nav-btn:hover { border-color: var(--accent); color: #fff; }
|
| 45 |
+
#address {
|
| 46 |
+
flex: 1; background: #1b1b1b; color: #e8e8e8; border: 1px solid #333; border-radius: 8px;
|
| 47 |
+
padding: 8px 12px; font-size: .9rem; font-family: ui-monospace, Menlo, Consolas, monospace;
|
| 48 |
+
}
|
| 49 |
+
#address:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 10px rgba(0,122,204,.4); }
|
| 50 |
+
.nav-go { width: auto; padding: 0 16px; font-weight: 600; }
|
| 51 |
+
|
| 52 |
+
#screen-container {
|
| 53 |
+
border: 2px solid var(--accent); box-shadow: 0 0 20px rgba(0,122,204,.5);
|
| 54 |
+
background: #000; border-radius: 8px; overflow: hidden; line-height: 0; position: relative;
|
| 55 |
+
}
|
| 56 |
+
img { max-width: 95vw; max-height: 72vh; width: auto; height: auto; display: block; }
|
| 57 |
+
#hint { color: #777; font-size: .75rem; margin-top: 8px; }
|
| 58 |
+
</style>
|
| 59 |
+
</head>
|
| 60 |
+
|
| 61 |
+
<body>
|
| 62 |
+
<div class="dashboard">
|
| 63 |
+
<div class="card"><div class="card-header">Memory</div>
|
| 64 |
+
<div style="display:flex;align-items:center;gap:10px">
|
| 65 |
+
<div class="card-value" id="memVal">...</div>
|
| 66 |
+
<div class="progress-bg"><div class="progress-fill" id="memBar"></div></div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="card"><div class="card-header">CPU Load</div><div class="card-value" id="cpuVal">...</div></div>
|
| 70 |
+
<div class="card"><div class="card-header">Uptime</div><div class="card-value" id="uptimeVal">...</div></div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div class="tabs" id="tabs"></div>
|
| 74 |
+
|
| 75 |
+
<div class="navbar">
|
| 76 |
+
<button class="nav-btn" id="backBtn" title="Back">←</button>
|
| 77 |
+
<button class="nav-btn" id="fwdBtn" title="Forward">→</button>
|
| 78 |
+
<button class="nav-btn" id="reloadBtn" title="Reload">⟳</button>
|
| 79 |
+
<input id="address" type="text" placeholder="Enter URL and press Enter…" spellcheck="false" autocomplete="off" />
|
| 80 |
+
<button class="nav-btn nav-go" id="goBtn">Go</button>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div id="screen-container">
|
| 84 |
+
<img id="monitor" alt="Live Stream" />
|
| 85 |
+
</div>
|
| 86 |
+
<div id="hint">Click and type directly on the page · use the bar above to navigate the active tab anywhere.</div>
|
| 87 |
+
|
| 88 |
+
<script>
|
| 89 |
+
let activeTab = null; // active CDP target id
|
| 90 |
+
let tabList = []; // last /api/tabs payload
|
| 91 |
+
const img = document.getElementById('monitor');
|
| 92 |
+
const tabsEl = document.getElementById('tabs');
|
| 93 |
+
const address = document.getElementById('address');
|
| 94 |
+
|
| 95 |
+
const urlOf = id => (tabList.find(t => t.id === id) || {}).url || '';
|
| 96 |
+
|
| 97 |
+
// Build the tab strip from the server's live target list.
|
| 98 |
+
async function buildTabs() {
|
| 99 |
+
try {
|
| 100 |
+
const res = await fetch('/api/tabs');
|
| 101 |
+
tabList = await res.json();
|
| 102 |
+
if (!tabList.length) { tabsEl.innerHTML = ''; return; }
|
| 103 |
+
if (!tabList.some(t => t.id === activeTab)) activeTab = tabList[0].id;
|
| 104 |
+
|
| 105 |
+
tabsEl.innerHTML = '';
|
| 106 |
+
tabList.forEach(t => {
|
| 107 |
+
const b = document.createElement('button');
|
| 108 |
+
b.className = 'tab-btn' + (t.id === activeTab ? ' active' : '');
|
| 109 |
+
b.dataset.key = t.id;
|
| 110 |
+
b.title = t.url;
|
| 111 |
+
|
| 112 |
+
const label = document.createElement('span');
|
| 113 |
+
label.className = 'tab-label';
|
| 114 |
+
label.textContent = t.title;
|
| 115 |
+
b.appendChild(label);
|
| 116 |
+
|
| 117 |
+
const x = document.createElement('button');
|
| 118 |
+
x.className = 'tab-close';
|
| 119 |
+
x.textContent = '×';
|
| 120 |
+
x.title = 'Close tab';
|
| 121 |
+
x.onclick = e => { e.stopPropagation(); closeTab(t.id); };
|
| 122 |
+
b.appendChild(x);
|
| 123 |
+
|
| 124 |
+
b.onclick = () => switchTab(t.id);
|
| 125 |
+
tabsEl.appendChild(b);
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
// "+ new" button
|
| 129 |
+
const plus = document.createElement('button');
|
| 130 |
+
plus.className = 'tab-btn tab-new';
|
| 131 |
+
plus.textContent = '+';
|
| 132 |
+
plus.title = 'New tab';
|
| 133 |
+
plus.onclick = newTab;
|
| 134 |
+
tabsEl.appendChild(plus);
|
| 135 |
+
|
| 136 |
+
syncAddress();
|
| 137 |
+
} catch (e) { console.error(e); }
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function syncAddress() {
|
| 141 |
+
if (document.activeElement !== address) address.value = urlOf(activeTab);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
function switchTab(id) {
|
| 145 |
+
activeTab = id;
|
| 146 |
+
document.querySelectorAll('.tab-btn').forEach(b =>
|
| 147 |
+
b.classList.toggle('active', b.dataset.key === id));
|
| 148 |
+
syncAddress();
|
| 149 |
+
refreshImage();
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
async function post(path) {
|
| 153 |
+
try { await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
|
| 154 |
+
catch (e) { console.error(e); }
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function navigate() {
|
| 158 |
+
const url = address.value.trim();
|
| 159 |
+
if (!url || !activeTab) return;
|
| 160 |
+
try {
|
| 161 |
+
await fetch('/api/navigate?tab=' + activeTab, {
|
| 162 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 163 |
+
body: JSON.stringify({ url })
|
| 164 |
+
});
|
| 165 |
+
address.blur();
|
| 166 |
+
setTimeout(() => { buildTabs(); refreshImage(); }, 600);
|
| 167 |
+
} catch (e) { console.error(e); }
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
async function reload() { await post('/api/reload?tab=' + activeTab); setTimeout(refreshImage, 500); }
|
| 171 |
+
async function goBack() { await post('/api/back?tab=' + activeTab); setTimeout(() => { buildTabs(); refreshImage(); }, 500); }
|
| 172 |
+
async function goForward() { await post('/api/forward?tab=' + activeTab); setTimeout(() => { buildTabs(); refreshImage(); }, 500); }
|
| 173 |
+
|
| 174 |
+
async function newTab() {
|
| 175 |
+
const url = prompt('Open URL in a new tab:', 'https://');
|
| 176 |
+
if (url === null) return;
|
| 177 |
+
try {
|
| 178 |
+
const res = await fetch('/api/newtab', {
|
| 179 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 180 |
+
body: JSON.stringify({ url: url.trim() })
|
| 181 |
+
});
|
| 182 |
+
const { id } = await res.json();
|
| 183 |
+
if (id) activeTab = id;
|
| 184 |
+
await buildTabs();
|
| 185 |
+
refreshImage();
|
| 186 |
+
} catch (e) { console.error(e); }
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
async function closeTab(id) {
|
| 190 |
+
try {
|
| 191 |
+
await fetch('/api/closetab?tab=' + id, { method: 'POST' });
|
| 192 |
+
if (id === activeTab) activeTab = null;
|
| 193 |
+
await buildTabs();
|
| 194 |
+
refreshImage();
|
| 195 |
+
} catch (e) { console.error(e); }
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
document.getElementById('goBtn').onclick = navigate;
|
| 199 |
+
document.getElementById('reloadBtn').onclick = reload;
|
| 200 |
+
document.getElementById('backBtn').onclick = goBack;
|
| 201 |
+
document.getElementById('fwdBtn').onclick = goForward;
|
| 202 |
+
address.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); navigate(); } });
|
| 203 |
+
|
| 204 |
+
async function sendInput(data) {
|
| 205 |
+
if (!activeTab) return;
|
| 206 |
+
try {
|
| 207 |
+
await fetch('/api/input?tab=' + activeTab, {
|
| 208 |
+
method: 'POST',
|
| 209 |
+
headers: { 'Content-Type': 'application/json' },
|
| 210 |
+
body: JSON.stringify(data)
|
| 211 |
+
});
|
| 212 |
+
setTimeout(refreshImage, 120);
|
| 213 |
+
} catch (e) { console.error(e); }
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function coords(e) {
|
| 217 |
+
const rect = img.getBoundingClientRect();
|
| 218 |
+
return {
|
| 219 |
+
x: Math.round((e.clientX - rect.left) * (img.naturalWidth / rect.width)),
|
| 220 |
+
y: Math.round((e.clientY - rect.top) * (img.naturalHeight / rect.height))
|
| 221 |
+
};
|
| 222 |
+
}
|
| 223 |
+
img.addEventListener('mousedown', e => sendInput({ type: 'mousedown', ...coords(e), button: 'left' }));
|
| 224 |
+
img.addEventListener('mouseup', e => sendInput({ type: 'mouseup', ...coords(e), button: 'left' }));
|
| 225 |
+
|
| 226 |
+
document.addEventListener('keydown', e => {
|
| 227 |
+
if (document.activeElement === address) return; // typing in the URL bar
|
| 228 |
+
if (e.key === 'r' && (e.metaKey || e.ctrlKey)) return;
|
| 229 |
+
if (e.key.length === 1) sendInput({ type: 'keydown', text: e.key });
|
| 230 |
+
else if (e.key === 'Enter') sendInput({ type: 'keydown', text: '\r' });
|
| 231 |
+
else if (e.key === 'Backspace') sendInput({ type: 'keydown', text: '\b' });
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
function refreshImage() {
|
| 235 |
+
if (!activeTab) return;
|
| 236 |
+
const src = `/api/screen?tab=${activeTab}&t=${Date.now()}`;
|
| 237 |
+
const tmp = new Image();
|
| 238 |
+
tmp.onload = () => { img.src = src; };
|
| 239 |
+
tmp.src = src;
|
| 240 |
+
}
|
| 241 |
+
setInterval(refreshImage, 1000);
|
| 242 |
+
setInterval(buildTabs, 5000); // pick up new/closed tabs and url changes
|
| 243 |
+
|
| 244 |
+
async function updateStats() {
|
| 245 |
+
try {
|
| 246 |
+
const d = await (await fetch('/api/stats')).json();
|
| 247 |
+
document.getElementById('memVal').innerText = `${d.memUsedGB}/${d.memTotalGB}GB`;
|
| 248 |
+
document.getElementById('memBar').style.width = `${d.memPct}%`;
|
| 249 |
+
document.getElementById('cpuVal').innerText = `${Math.round(d.cpu)}%`;
|
| 250 |
+
const h = Math.floor(d.uptime / 3600), m = Math.floor((d.uptime % 3600) / 60);
|
| 251 |
+
document.getElementById('uptimeVal').innerText = `${h}h ${m}m`;
|
| 252 |
+
} catch (e) { console.error(e); }
|
| 253 |
+
}
|
| 254 |
+
setInterval(updateStats, 2000);
|
| 255 |
+
|
| 256 |
+
buildTabs().then(refreshImage);
|
| 257 |
+
updateStats();
|
| 258 |
+
</script>
|
| 259 |
+
</body>
|
| 260 |
+
|
| 261 |
+
</html>
|
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Chrome live monitor with a built-in navigator.
|
| 2 |
+
//
|
| 3 |
+
// Talks to Chrome's DevTools Protocol on 127.0.0.1:9222 and serves a browser-like
|
| 4 |
+
// UI on :3001:
|
| 5 |
+
// - a live tab strip listing every open Chrome page (known AI services first),
|
| 6 |
+
// - an address bar + back / forward / reload to drive the active tab anywhere,
|
| 7 |
+
// - new-tab / close-tab buttons,
|
| 8 |
+
// - click + type forwarded straight into the active page.
|
| 9 |
+
// The active tab is brought to front before every screenshot so background tabs
|
| 10 |
+
// still render. Tabs are keyed by their live CDP target id (not hardcoded).
|
| 11 |
+
package main
|
| 12 |
+
|
| 13 |
+
import (
|
| 14 |
+
_ "embed"
|
| 15 |
+
"encoding/base64"
|
| 16 |
+
"encoding/json"
|
| 17 |
+
"fmt"
|
| 18 |
+
"io"
|
| 19 |
+
"log"
|
| 20 |
+
"net/http"
|
| 21 |
+
"os"
|
| 22 |
+
"sort"
|
| 23 |
+
"strings"
|
| 24 |
+
"sync"
|
| 25 |
+
"time"
|
| 26 |
+
|
| 27 |
+
"github.com/gorilla/websocket"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
const cdpBase = "http://127.0.0.1:9222"
|
| 31 |
+
|
| 32 |
+
//go:embed index.html
|
| 33 |
+
var indexHTML []byte
|
| 34 |
+
|
| 35 |
+
// knownTab is one of the AI services we pre-open and label nicely.
|
| 36 |
+
type knownTab struct {
|
| 37 |
+
Title string
|
| 38 |
+
URL string // page to open on startup
|
| 39 |
+
Match string // substring used to find an already-open page target
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
var knownTabs = []knownTab{
|
| 43 |
+
{"ChatGPT", "https://chatgpt.com/", "chatgpt.com"},
|
| 44 |
+
{"Gemini", "https://gemini.google.com/", "gemini.google.com"},
|
| 45 |
+
{"Flow", "https://labs.google/fx/tools/flow", "labs.google"},
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// cdpTarget mirrors the relevant fields of an entry from GET /json.
|
| 49 |
+
type cdpTarget struct {
|
| 50 |
+
ID string `json:"id"`
|
| 51 |
+
Type string `json:"type"`
|
| 52 |
+
URL string `json:"url"`
|
| 53 |
+
Title string `json:"title"`
|
| 54 |
+
WS string `json:"webSocketDebuggerUrl"`
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// tabInfo is what the UI consumes from /api/tabs.
|
| 58 |
+
type tabInfo struct {
|
| 59 |
+
ID string `json:"id"`
|
| 60 |
+
Title string `json:"title"`
|
| 61 |
+
URL string `json:"url"`
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// cdpConn is a serialized request/response wrapper around one DevTools websocket.
|
| 65 |
+
type cdpConn struct {
|
| 66 |
+
c *websocket.Conn
|
| 67 |
+
mu sync.Mutex
|
| 68 |
+
id int
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
func (cc *cdpConn) call(method string, params map[string]any) (json.RawMessage, error) {
|
| 72 |
+
cc.mu.Lock()
|
| 73 |
+
defer cc.mu.Unlock()
|
| 74 |
+
cc.id++
|
| 75 |
+
myID := cc.id
|
| 76 |
+
if err := cc.c.WriteJSON(map[string]any{"id": myID, "method": method, "params": params}); err != nil {
|
| 77 |
+
return nil, err
|
| 78 |
+
}
|
| 79 |
+
cc.c.SetReadDeadline(time.Now().Add(15 * time.Second))
|
| 80 |
+
for {
|
| 81 |
+
_, data, err := cc.c.ReadMessage()
|
| 82 |
+
if err != nil {
|
| 83 |
+
return nil, err
|
| 84 |
+
}
|
| 85 |
+
var m struct {
|
| 86 |
+
ID int `json:"id"`
|
| 87 |
+
Result json.RawMessage `json:"result"`
|
| 88 |
+
Error *struct {
|
| 89 |
+
Message string `json:"message"`
|
| 90 |
+
} `json:"error"`
|
| 91 |
+
}
|
| 92 |
+
if json.Unmarshal(data, &m) != nil {
|
| 93 |
+
continue
|
| 94 |
+
}
|
| 95 |
+
if m.ID == myID { // ignore CDP events (no matching id)
|
| 96 |
+
if m.Error != nil {
|
| 97 |
+
return nil, fmt.Errorf("cdp %s: %s", method, m.Error.Message)
|
| 98 |
+
}
|
| 99 |
+
return m.Result, nil
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// connections, one persistent websocket per target id.
|
| 105 |
+
var (
|
| 106 |
+
conns = map[string]*cdpConn{}
|
| 107 |
+
connMu sync.Mutex
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
func httpGetJSON(url string, out any) error {
|
| 111 |
+
resp, err := http.Get(url)
|
| 112 |
+
if err != nil {
|
| 113 |
+
return err
|
| 114 |
+
}
|
| 115 |
+
defer resp.Body.Close()
|
| 116 |
+
return json.NewDecoder(resp.Body).Decode(out)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
func listTargets() ([]cdpTarget, error) {
|
| 120 |
+
var t []cdpTarget
|
| 121 |
+
err := httpGetJSON(cdpBase+"/json", &t)
|
| 122 |
+
return t, err
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// pageTargets returns the open page targets, known services first then the rest.
|
| 126 |
+
func pageTargets() ([]cdpTarget, error) {
|
| 127 |
+
targets, err := listTargets()
|
| 128 |
+
if err != nil {
|
| 129 |
+
return nil, err
|
| 130 |
+
}
|
| 131 |
+
var pages []cdpTarget
|
| 132 |
+
for _, t := range targets {
|
| 133 |
+
if t.Type != "page" || strings.HasPrefix(t.URL, "devtools://") {
|
| 134 |
+
continue
|
| 135 |
+
}
|
| 136 |
+
pages = append(pages, t)
|
| 137 |
+
}
|
| 138 |
+
rank := func(t cdpTarget) int {
|
| 139 |
+
for i, k := range knownTabs {
|
| 140 |
+
if strings.Contains(t.URL, k.Match) {
|
| 141 |
+
return i
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
return len(knownTabs) + 1
|
| 145 |
+
}
|
| 146 |
+
sort.SliceStable(pages, func(i, j int) bool { return rank(pages[i]) < rank(pages[j]) })
|
| 147 |
+
return pages, nil
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// friendlyTitle prefers a known service label, then the page title, then the URL.
|
| 151 |
+
func friendlyTitle(t cdpTarget) string {
|
| 152 |
+
for _, k := range knownTabs {
|
| 153 |
+
if strings.Contains(t.URL, k.Match) {
|
| 154 |
+
return k.Title
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
if t.URL == "" || t.URL == "about:blank" {
|
| 158 |
+
return "New Tab"
|
| 159 |
+
}
|
| 160 |
+
if strings.TrimSpace(t.Title) != "" {
|
| 161 |
+
return t.Title
|
| 162 |
+
}
|
| 163 |
+
return t.URL
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// browserWS returns the browser-level websocket URL (for Target.* calls).
|
| 167 |
+
func browserWS() (string, error) {
|
| 168 |
+
var v struct {
|
| 169 |
+
WS string `json:"webSocketDebuggerUrl"`
|
| 170 |
+
}
|
| 171 |
+
if err := httpGetJSON(cdpBase+"/json/version", &v); err != nil {
|
| 172 |
+
return "", err
|
| 173 |
+
}
|
| 174 |
+
return v.WS, nil
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
func dial(ws string) (*cdpConn, error) {
|
| 178 |
+
c, _, err := websocket.DefaultDialer.Dial(ws, nil)
|
| 179 |
+
if err != nil {
|
| 180 |
+
return nil, err
|
| 181 |
+
}
|
| 182 |
+
return &cdpConn{c: c}, nil
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// browserCall runs a single browser-level command (createTarget / closeTarget).
|
| 186 |
+
func browserCall(method string, params map[string]any) (json.RawMessage, error) {
|
| 187 |
+
bws, err := browserWS()
|
| 188 |
+
if err != nil {
|
| 189 |
+
return nil, err
|
| 190 |
+
}
|
| 191 |
+
bc, err := dial(bws)
|
| 192 |
+
if err != nil {
|
| 193 |
+
return nil, err
|
| 194 |
+
}
|
| 195 |
+
defer bc.c.Close()
|
| 196 |
+
return bc.call(method, params)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// targetWSByID looks up a page target's websocket URL by its id.
|
| 200 |
+
func targetWSByID(id string) (string, error) {
|
| 201 |
+
targets, err := listTargets()
|
| 202 |
+
if err != nil {
|
| 203 |
+
return "", err
|
| 204 |
+
}
|
| 205 |
+
for _, t := range targets {
|
| 206 |
+
if t.ID == id {
|
| 207 |
+
return t.WS, nil
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
return "", fmt.Errorf("no target %q", id)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// ensureKnown finds an already-open page for a known service, creating it if absent.
|
| 214 |
+
func ensureKnown(k knownTab) error {
|
| 215 |
+
targets, err := listTargets()
|
| 216 |
+
if err != nil {
|
| 217 |
+
return err
|
| 218 |
+
}
|
| 219 |
+
for _, tg := range targets {
|
| 220 |
+
if tg.Type == "page" && strings.Contains(tg.URL, k.Match) {
|
| 221 |
+
return nil
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
_, err = browserCall("Target.createTarget", map[string]any{"url": k.URL})
|
| 225 |
+
return err
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// conn returns a live cdpConn for the given target id, (re)dialing as needed.
|
| 229 |
+
func conn(id string) (*cdpConn, error) {
|
| 230 |
+
connMu.Lock()
|
| 231 |
+
defer connMu.Unlock()
|
| 232 |
+
if cc, ok := conns[id]; ok {
|
| 233 |
+
if _, err := cc.call("Target.getTargetInfo", nil); err == nil { // cheap liveness probe
|
| 234 |
+
return cc, nil
|
| 235 |
+
}
|
| 236 |
+
cc.c.Close()
|
| 237 |
+
delete(conns, id)
|
| 238 |
+
}
|
| 239 |
+
ws, err := targetWSByID(id)
|
| 240 |
+
if err != nil {
|
| 241 |
+
return nil, err
|
| 242 |
+
}
|
| 243 |
+
cc, err := dial(ws)
|
| 244 |
+
if err != nil {
|
| 245 |
+
return nil, err
|
| 246 |
+
}
|
| 247 |
+
conns[id] = cc
|
| 248 |
+
return cc, nil
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// connForReq resolves the ?tab= target, writing an error response on failure.
|
| 252 |
+
func connForReq(w http.ResponseWriter, r *http.Request) (*cdpConn, bool) {
|
| 253 |
+
id := r.URL.Query().Get("tab")
|
| 254 |
+
if id == "" {
|
| 255 |
+
http.Error(w, "missing tab", http.StatusBadRequest)
|
| 256 |
+
return nil, false
|
| 257 |
+
}
|
| 258 |
+
cc, err := conn(id)
|
| 259 |
+
if err != nil {
|
| 260 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 261 |
+
return nil, false
|
| 262 |
+
}
|
| 263 |
+
return cc, true
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
func screenHandler(w http.ResponseWriter, r *http.Request) {
|
| 267 |
+
cc, ok := connForReq(w, r)
|
| 268 |
+
if !ok {
|
| 269 |
+
return
|
| 270 |
+
}
|
| 271 |
+
cc.call("Page.bringToFront", nil) // ensure background tab renders
|
| 272 |
+
res, err := cc.call("Page.captureScreenshot", map[string]any{"format": "png"})
|
| 273 |
+
if err != nil {
|
| 274 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 275 |
+
return
|
| 276 |
+
}
|
| 277 |
+
var out struct {
|
| 278 |
+
Data string `json:"data"`
|
| 279 |
+
}
|
| 280 |
+
if json.Unmarshal(res, &out) != nil || out.Data == "" {
|
| 281 |
+
http.Error(w, "no screenshot", http.StatusBadGateway)
|
| 282 |
+
return
|
| 283 |
+
}
|
| 284 |
+
png, err := base64.StdEncoding.DecodeString(out.Data)
|
| 285 |
+
if err != nil {
|
| 286 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 287 |
+
return
|
| 288 |
+
}
|
| 289 |
+
w.Header().Set("Content-Type", "image/png")
|
| 290 |
+
w.Header().Set("Cache-Control", "no-store")
|
| 291 |
+
w.Write(png)
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
func inputHandler(w http.ResponseWriter, r *http.Request) {
|
| 295 |
+
var in struct {
|
| 296 |
+
Type string `json:"type"`
|
| 297 |
+
X float64 `json:"x"`
|
| 298 |
+
Y float64 `json:"y"`
|
| 299 |
+
Button string `json:"button"`
|
| 300 |
+
Text string `json:"text"`
|
| 301 |
+
}
|
| 302 |
+
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
| 303 |
+
http.Error(w, "bad json", http.StatusBadRequest)
|
| 304 |
+
return
|
| 305 |
+
}
|
| 306 |
+
cc, ok := connForReq(w, r)
|
| 307 |
+
if !ok {
|
| 308 |
+
return
|
| 309 |
+
}
|
| 310 |
+
switch in.Type {
|
| 311 |
+
case "mousedown", "mouseup":
|
| 312 |
+
mt := "mousePressed"
|
| 313 |
+
if in.Type == "mouseup" {
|
| 314 |
+
mt = "mouseReleased"
|
| 315 |
+
}
|
| 316 |
+
btn := in.Button
|
| 317 |
+
if btn == "" {
|
| 318 |
+
btn = "left"
|
| 319 |
+
}
|
| 320 |
+
cc.call("Input.dispatchMouseEvent", map[string]any{
|
| 321 |
+
"type": mt, "x": in.X, "y": in.Y, "button": btn, "clickCount": 1,
|
| 322 |
+
})
|
| 323 |
+
case "keydown":
|
| 324 |
+
if in.Text == "\r" || in.Text == "\n" {
|
| 325 |
+
cc.call("Input.dispatchKeyEvent", map[string]any{
|
| 326 |
+
"type": "keyDown", "key": "Enter", "windowsVirtualKeyCode": 13, "text": "\r",
|
| 327 |
+
})
|
| 328 |
+
cc.call("Input.dispatchKeyEvent", map[string]any{"type": "keyUp", "key": "Enter", "windowsVirtualKeyCode": 13})
|
| 329 |
+
} else if in.Text == "\b" {
|
| 330 |
+
cc.call("Input.dispatchKeyEvent", map[string]any{"type": "keyDown", "key": "Backspace", "windowsVirtualKeyCode": 8})
|
| 331 |
+
cc.call("Input.dispatchKeyEvent", map[string]any{"type": "keyUp", "key": "Backspace", "windowsVirtualKeyCode": 8})
|
| 332 |
+
} else if in.Text != "" {
|
| 333 |
+
cc.call("Input.insertText", map[string]any{"text": in.Text})
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
w.WriteHeader(http.StatusNoContent)
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// normalizeURL prepends https:// when the user typed a bare host.
|
| 340 |
+
func normalizeURL(u string) string {
|
| 341 |
+
u = strings.TrimSpace(u)
|
| 342 |
+
if u == "" {
|
| 343 |
+
return ""
|
| 344 |
+
}
|
| 345 |
+
if !strings.Contains(u, "://") {
|
| 346 |
+
u = "https://" + u
|
| 347 |
+
}
|
| 348 |
+
return u
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
func navigateHandler(w http.ResponseWriter, r *http.Request) {
|
| 352 |
+
var in struct {
|
| 353 |
+
URL string `json:"url"`
|
| 354 |
+
}
|
| 355 |
+
if json.NewDecoder(r.Body).Decode(&in) != nil {
|
| 356 |
+
http.Error(w, "bad json", http.StatusBadRequest)
|
| 357 |
+
return
|
| 358 |
+
}
|
| 359 |
+
url := normalizeURL(in.URL)
|
| 360 |
+
if url == "" {
|
| 361 |
+
http.Error(w, "empty url", http.StatusBadRequest)
|
| 362 |
+
return
|
| 363 |
+
}
|
| 364 |
+
cc, ok := connForReq(w, r)
|
| 365 |
+
if !ok {
|
| 366 |
+
return
|
| 367 |
+
}
|
| 368 |
+
if _, err := cc.call("Page.navigate", map[string]any{"url": url}); err != nil {
|
| 369 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 370 |
+
return
|
| 371 |
+
}
|
| 372 |
+
w.WriteHeader(http.StatusNoContent)
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
func reloadHandler(w http.ResponseWriter, r *http.Request) {
|
| 376 |
+
cc, ok := connForReq(w, r)
|
| 377 |
+
if !ok {
|
| 378 |
+
return
|
| 379 |
+
}
|
| 380 |
+
cc.call("Page.reload", nil)
|
| 381 |
+
w.WriteHeader(http.StatusNoContent)
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// historyStep moves the active tab back (delta -1) or forward (delta +1).
|
| 385 |
+
func historyStep(cc *cdpConn, delta int) error {
|
| 386 |
+
res, err := cc.call("Page.getNavigationHistory", nil)
|
| 387 |
+
if err != nil {
|
| 388 |
+
return err
|
| 389 |
+
}
|
| 390 |
+
var h struct {
|
| 391 |
+
CurrentIndex int `json:"currentIndex"`
|
| 392 |
+
Entries []struct {
|
| 393 |
+
ID int `json:"id"`
|
| 394 |
+
} `json:"entries"`
|
| 395 |
+
}
|
| 396 |
+
if err := json.Unmarshal(res, &h); err != nil {
|
| 397 |
+
return err
|
| 398 |
+
}
|
| 399 |
+
target := h.CurrentIndex + delta
|
| 400 |
+
if target < 0 || target >= len(h.Entries) {
|
| 401 |
+
return nil // nothing to do at the ends
|
| 402 |
+
}
|
| 403 |
+
_, err = cc.call("Page.navigateToHistoryEntry", map[string]any{"entryId": h.Entries[target].ID})
|
| 404 |
+
return err
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
func historyHandler(delta int) http.HandlerFunc {
|
| 408 |
+
return func(w http.ResponseWriter, r *http.Request) {
|
| 409 |
+
cc, ok := connForReq(w, r)
|
| 410 |
+
if !ok {
|
| 411 |
+
return
|
| 412 |
+
}
|
| 413 |
+
if err := historyStep(cc, delta); err != nil {
|
| 414 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 415 |
+
return
|
| 416 |
+
}
|
| 417 |
+
w.WriteHeader(http.StatusNoContent)
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
func newtabHandler(w http.ResponseWriter, r *http.Request) {
|
| 422 |
+
var in struct {
|
| 423 |
+
URL string `json:"url"`
|
| 424 |
+
}
|
| 425 |
+
json.NewDecoder(r.Body).Decode(&in)
|
| 426 |
+
url := normalizeURL(in.URL)
|
| 427 |
+
if url == "" {
|
| 428 |
+
url = "about:blank"
|
| 429 |
+
}
|
| 430 |
+
res, err := browserCall("Target.createTarget", map[string]any{"url": url})
|
| 431 |
+
if err != nil {
|
| 432 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 433 |
+
return
|
| 434 |
+
}
|
| 435 |
+
var out struct {
|
| 436 |
+
TargetID string `json:"targetId"`
|
| 437 |
+
}
|
| 438 |
+
json.Unmarshal(res, &out)
|
| 439 |
+
w.Header().Set("Content-Type", "application/json")
|
| 440 |
+
json.NewEncoder(w).Encode(map[string]string{"id": out.TargetID})
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
func closetabHandler(w http.ResponseWriter, r *http.Request) {
|
| 444 |
+
id := r.URL.Query().Get("tab")
|
| 445 |
+
if id == "" {
|
| 446 |
+
http.Error(w, "missing tab", http.StatusBadRequest)
|
| 447 |
+
return
|
| 448 |
+
}
|
| 449 |
+
if _, err := browserCall("Target.closeTarget", map[string]any{"targetId": id}); err != nil {
|
| 450 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 451 |
+
return
|
| 452 |
+
}
|
| 453 |
+
connMu.Lock()
|
| 454 |
+
if cc, ok := conns[id]; ok {
|
| 455 |
+
cc.c.Close()
|
| 456 |
+
delete(conns, id)
|
| 457 |
+
}
|
| 458 |
+
connMu.Unlock()
|
| 459 |
+
w.WriteHeader(http.StatusNoContent)
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
func tabsHandler(w http.ResponseWriter, r *http.Request) {
|
| 463 |
+
pages, err := pageTargets()
|
| 464 |
+
if err != nil {
|
| 465 |
+
http.Error(w, err.Error(), http.StatusBadGateway)
|
| 466 |
+
return
|
| 467 |
+
}
|
| 468 |
+
out := make([]tabInfo, 0, len(pages))
|
| 469 |
+
for _, t := range pages {
|
| 470 |
+
out = append(out, tabInfo{ID: t.ID, Title: friendlyTitle(t), URL: t.URL})
|
| 471 |
+
}
|
| 472 |
+
w.Header().Set("Content-Type", "application/json")
|
| 473 |
+
json.NewEncoder(w).Encode(out)
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
func chromeLogHandler(w http.ResponseWriter, r *http.Request) {
|
| 477 |
+
f, err := os.Open("/home/chrome/chrome.log")
|
| 478 |
+
if err != nil {
|
| 479 |
+
http.Error(w, "no log", http.StatusNotFound)
|
| 480 |
+
return
|
| 481 |
+
}
|
| 482 |
+
defer f.Close()
|
| 483 |
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
| 484 |
+
io.Copy(w, f)
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
func statsHandler(w http.ResponseWriter, r *http.Request) {
|
| 488 |
+
mem := readMeminfo()
|
| 489 |
+
w.Header().Set("Content-Type", "application/json")
|
| 490 |
+
json.NewEncoder(w).Encode(map[string]any{
|
| 491 |
+
"memUsedGB": mem.usedGB,
|
| 492 |
+
"memTotalGB": mem.totalGB,
|
| 493 |
+
"memPct": mem.pct,
|
| 494 |
+
"cpu": readCPUPercent(),
|
| 495 |
+
"uptime": readUptime(),
|
| 496 |
+
})
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
func main() {
|
| 500 |
+
// Pre-open the known AI services in the background so they're ready by first view.
|
| 501 |
+
go func() {
|
| 502 |
+
for i := 0; i < 60; i++ {
|
| 503 |
+
ok := true
|
| 504 |
+
for _, k := range knownTabs {
|
| 505 |
+
if err := ensureKnown(k); err != nil {
|
| 506 |
+
ok = false
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
if ok {
|
| 510 |
+
log.Println("all known tabs ready")
|
| 511 |
+
return
|
| 512 |
+
}
|
| 513 |
+
time.Sleep(2 * time.Second)
|
| 514 |
+
}
|
| 515 |
+
}()
|
| 516 |
+
|
| 517 |
+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
| 518 |
+
if r.URL.Path != "/" {
|
| 519 |
+
http.NotFound(w, r)
|
| 520 |
+
return
|
| 521 |
+
}
|
| 522 |
+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
| 523 |
+
w.Write(indexHTML)
|
| 524 |
+
})
|
| 525 |
+
http.HandleFunc("/api/tabs", tabsHandler)
|
| 526 |
+
http.HandleFunc("/api/screen", screenHandler)
|
| 527 |
+
http.HandleFunc("/api/input", inputHandler)
|
| 528 |
+
http.HandleFunc("/api/navigate", navigateHandler)
|
| 529 |
+
http.HandleFunc("/api/reload", reloadHandler)
|
| 530 |
+
http.HandleFunc("/api/back", historyHandler(-1))
|
| 531 |
+
http.HandleFunc("/api/forward", historyHandler(+1))
|
| 532 |
+
http.HandleFunc("/api/newtab", newtabHandler)
|
| 533 |
+
http.HandleFunc("/api/closetab", closetabHandler)
|
| 534 |
+
http.HandleFunc("/api/stats", statsHandler)
|
| 535 |
+
http.HandleFunc("/chrome.log", chromeLogHandler)
|
| 536 |
+
|
| 537 |
+
addr := ":3001"
|
| 538 |
+
if p := os.Getenv("MONITOR_PORT"); p != "" {
|
| 539 |
+
addr = ":" + p
|
| 540 |
+
}
|
| 541 |
+
log.Printf("navigator monitor listening on %s", addr)
|
| 542 |
+
log.Fatal(http.ListenAndServe(addr, nil))
|
| 543 |
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bufio"
|
| 5 |
+
"os"
|
| 6 |
+
"strconv"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
type memInfo struct {
|
| 12 |
+
usedGB float64
|
| 13 |
+
totalGB float64
|
| 14 |
+
pct float64
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
func readMeminfo() memInfo {
|
| 18 |
+
f, err := os.Open("/proc/meminfo")
|
| 19 |
+
if err != nil {
|
| 20 |
+
return memInfo{}
|
| 21 |
+
}
|
| 22 |
+
defer f.Close()
|
| 23 |
+
var totalKB, availKB float64
|
| 24 |
+
sc := bufio.NewScanner(f)
|
| 25 |
+
for sc.Scan() {
|
| 26 |
+
fields := strings.Fields(sc.Text())
|
| 27 |
+
if len(fields) < 2 {
|
| 28 |
+
continue
|
| 29 |
+
}
|
| 30 |
+
v, _ := strconv.ParseFloat(fields[1], 64)
|
| 31 |
+
switch fields[0] {
|
| 32 |
+
case "MemTotal:":
|
| 33 |
+
totalKB = v
|
| 34 |
+
case "MemAvailable:":
|
| 35 |
+
availKB = v
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
if totalKB == 0 {
|
| 39 |
+
return memInfo{}
|
| 40 |
+
}
|
| 41 |
+
usedKB := totalKB - availKB
|
| 42 |
+
return memInfo{
|
| 43 |
+
usedGB: round1(usedKB / 1024 / 1024),
|
| 44 |
+
totalGB: round1(totalKB / 1024 / 1024),
|
| 45 |
+
pct: round1(usedKB / totalKB * 100),
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func round1(f float64) float64 {
|
| 50 |
+
return float64(int(f*10+0.5)) / 10
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// readCPUPercent samples /proc/stat over a short window.
|
| 54 |
+
func readCPUPercent() float64 {
|
| 55 |
+
idle1, total1 := cpuSample()
|
| 56 |
+
time.Sleep(200 * time.Millisecond)
|
| 57 |
+
idle2, total2 := cpuSample()
|
| 58 |
+
dt := total2 - total1
|
| 59 |
+
if dt == 0 {
|
| 60 |
+
return 0
|
| 61 |
+
}
|
| 62 |
+
return round1((1 - (idle2-idle1)/dt) * 100)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func cpuSample() (idle, total float64) {
|
| 66 |
+
f, err := os.Open("/proc/stat")
|
| 67 |
+
if err != nil {
|
| 68 |
+
return 0, 0
|
| 69 |
+
}
|
| 70 |
+
defer f.Close()
|
| 71 |
+
sc := bufio.NewScanner(f)
|
| 72 |
+
for sc.Scan() {
|
| 73 |
+
fields := strings.Fields(sc.Text())
|
| 74 |
+
if len(fields) > 0 && fields[0] == "cpu" {
|
| 75 |
+
for i, v := range fields[1:] {
|
| 76 |
+
n, _ := strconv.ParseFloat(v, 64)
|
| 77 |
+
total += n
|
| 78 |
+
if i == 3 { // idle
|
| 79 |
+
idle = n
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
return idle, total
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
return 0, 0
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
func readUptime() int {
|
| 89 |
+
b, err := os.ReadFile("/proc/uptime")
|
| 90 |
+
if err != nil {
|
| 91 |
+
return 0
|
| 92 |
+
}
|
| 93 |
+
fields := strings.Fields(string(b))
|
| 94 |
+
if len(fields) == 0 {
|
| 95 |
+
return 0
|
| 96 |
+
}
|
| 97 |
+
v, _ := strconv.ParseFloat(fields[0], 64)
|
| 98 |
+
return int(v)
|
| 99 |
+
}
|
|
@@ -85,94 +85,15 @@ with open(pref_path, 'w') as f:
|
|
| 85 |
|
| 86 |
|
| 87 |
# 5. Start Go Monitor Server
|
| 88 |
-
echo "🚀 Starting
|
| 89 |
touch /home/chrome/chrome.log
|
| 90 |
-
|
| 91 |
-
cd /opt/monitor-server
|
| 92 |
-
./monitor-server > /home/chrome/monitor.log 2>&1 &
|
| 93 |
|
| 94 |
# 6. Start CDP Proxy
|
| 95 |
echo "🔌 Starting CDP Proxy..."
|
| 96 |
socat TCP-LISTEN:9223,fork,bind=0.0.0.0 TCP:127.0.0.1:9222 &
|
| 97 |
|
| 98 |
-
# 7.
|
| 99 |
-
cat << 'EOF' > /tmp/navigate.py
|
| 100 |
-
import urllib.request
|
| 101 |
-
import json
|
| 102 |
-
import socket
|
| 103 |
-
import urllib.parse
|
| 104 |
-
import time
|
| 105 |
-
|
| 106 |
-
# Wait for Chrome to start and expose a page target
|
| 107 |
-
page_target = None
|
| 108 |
-
for _ in range(30):
|
| 109 |
-
try:
|
| 110 |
-
res = urllib.request.urlopen("http://127.0.0.1:9222/json")
|
| 111 |
-
targets = json.loads(res.read().decode())
|
| 112 |
-
pages = [t for t in targets if t["type"] == "page"]
|
| 113 |
-
if pages:
|
| 114 |
-
page_target = pages[0]
|
| 115 |
-
break
|
| 116 |
-
except:
|
| 117 |
-
pass
|
| 118 |
-
time.sleep(1)
|
| 119 |
-
|
| 120 |
-
if not page_target:
|
| 121 |
-
print("Failed to get targets: No active page found")
|
| 122 |
-
exit(1)
|
| 123 |
-
|
| 124 |
-
ws_url = page_target['webSocketDebuggerUrl']
|
| 125 |
-
|
| 126 |
-
parsed = urllib.parse.urlparse(ws_url)
|
| 127 |
-
host = '127.0.0.1'
|
| 128 |
-
port = parsed.port or 9222
|
| 129 |
-
path = parsed.path
|
| 130 |
-
|
| 131 |
-
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
| 132 |
-
s.connect((host, port))
|
| 133 |
-
|
| 134 |
-
handshake = (
|
| 135 |
-
f"GET {path} HTTP/1.1\r\n"
|
| 136 |
-
f"Host: {host}:{port}\r\n"
|
| 137 |
-
"Upgrade: websocket\r\n"
|
| 138 |
-
"Connection: Upgrade\r\n"
|
| 139 |
-
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
| 140 |
-
"Sec-WebSocket-Version: 13\r\n\r\n"
|
| 141 |
-
)
|
| 142 |
-
s.sendall(handshake.encode())
|
| 143 |
-
resp = s.recv(1024)
|
| 144 |
-
|
| 145 |
-
payload = json.dumps({
|
| 146 |
-
"id": 1,
|
| 147 |
-
"method": "Page.navigate",
|
| 148 |
-
"params": {"url": "chrome://extensions/"}
|
| 149 |
-
})
|
| 150 |
-
|
| 151 |
-
payload_bytes = payload.encode()
|
| 152 |
-
length = len(payload_bytes)
|
| 153 |
-
|
| 154 |
-
frame = bytearray([0x81])
|
| 155 |
-
if length <= 125:
|
| 156 |
-
frame.append(length | 0x80)
|
| 157 |
-
elif length <= 65535:
|
| 158 |
-
frame.append(126 | 0x80)
|
| 159 |
-
frame.extend(length.to_bytes(2, byteorder='big'))
|
| 160 |
-
else:
|
| 161 |
-
frame.append(127 | 0x80)
|
| 162 |
-
frame.extend(length.to_bytes(8, byteorder='big'))
|
| 163 |
-
|
| 164 |
-
mask_key = b'\x11\x22\x33\x44'
|
| 165 |
-
frame.extend(mask_key)
|
| 166 |
-
masked_payload = bytearray(b ^ mask_key[i % 4] for i, b in enumerate(payload_bytes))
|
| 167 |
-
frame.extend(masked_payload)
|
| 168 |
-
|
| 169 |
-
s.sendall(frame)
|
| 170 |
-
s.close()
|
| 171 |
-
print("Successfully navigated to chrome://extensions/")
|
| 172 |
-
EOF
|
| 173 |
-
python3 -u /tmp/navigate.py >> /home/chrome/chrome.log 2>&1 &
|
| 174 |
-
|
| 175 |
-
# 8. Launch Chrome Headless
|
| 176 |
echo "✨ Launching Chrome Headless..."
|
| 177 |
/opt/chrome-linux64/chrome \
|
| 178 |
--headless=new \
|
|
@@ -232,4 +153,4 @@ echo "✨ Launching Chrome Headless..."
|
|
| 232 |
--disable-site-isolation-trials \
|
| 233 |
--js-flags="--max-old-space-size=256" \
|
| 234 |
--metrics-recording-only \
|
| 235 |
-
'
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
# 5. Start Go Monitor Server
|
| 88 |
+
echo "🚀 Starting 3-tab Monitor Server..."
|
| 89 |
touch /home/chrome/chrome.log
|
| 90 |
+
/opt/monitor3/monitor > /home/chrome/monitor.log 2>&1 &
|
|
|
|
|
|
|
| 91 |
|
| 92 |
# 6. Start CDP Proxy
|
| 93 |
echo "🔌 Starting CDP Proxy..."
|
| 94 |
socat TCP-LISTEN:9223,fork,bind=0.0.0.0 TCP:127.0.0.1:9222 &
|
| 95 |
|
| 96 |
+
# 7. Launch Chrome Headless
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
echo "✨ Launching Chrome Headless..."
|
| 98 |
/opt/chrome-linux64/chrome \
|
| 99 |
--headless=new \
|
|
|
|
| 153 |
--disable-site-isolation-trials \
|
| 154 |
--js-flags="--max-old-space-size=256" \
|
| 155 |
--metrics-recording-only \
|
| 156 |
+
'about:blank' >> /home/chrome/chrome.log 2>&1
|