add:截图查看

This commit is contained in:
wess09 2026-04-29 01:30:40 +08:00
parent 4882c78545
commit 10c4034897
9 changed files with 547 additions and 14 deletions

View File

@ -171,6 +171,260 @@
obs.observe(document.body, { childList: true, subtree: true });
})();
// ============================================================
// 实时截图预览H264/H265 over fragmented MP4 WebSocket
// ============================================================
(function () {
var state = {
socket: null,
mediaSource: null,
sourceBuffer: null,
queue: [],
objectUrl: '',
instance: 'alas',
codec: localStorage.getItem('alas_live_preview_codec') || 'h264',
open: false,
transportId: 0
};
function sanitizeText(text) {
return String(text || '').replace(/[<>&]/g, function (ch) {
return ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[ch];
});
}
function ensurePanel() {
var panel = document.getElementById('alas-live-preview');
if (panel) return panel;
panel = document.createElement('div');
panel.id = 'alas-live-preview';
panel.innerHTML = [
'<div class="alas-live-preview-head">',
'<span class="alas-live-preview-title">实时截图</span>',
'<select class="alas-live-preview-codec" title="编码">',
'<option value="h264">H264</option>',
'<option value="h265">H265</option>',
'</select>',
'<button class="alas-live-preview-close" type="button" title="关闭">×</button>',
'</div>',
'<video class="alas-live-preview-video" muted autoplay playsinline></video>',
'<div class="alas-live-preview-status">连接中</div>'
].join('');
var style = document.createElement('style');
style.textContent = [
'#alas-live-preview{position:fixed;right:18px;bottom:18px;width:min(560px,calc(100vw - 36px));background:#101418;border:1px solid rgba(255,255,255,.14);border-radius:8px;box-shadow:0 12px 36px rgba(0,0,0,.35);z-index:99990;overflow:hidden;display:none;}',
'.alas-live-preview-head{height:38px;display:flex;align-items:center;gap:8px;padding:0 8px 0 12px;background:#1b222b;color:#f2f5f8;font-size:14px;}',
'.alas-live-preview-title{font-weight:600;margin-right:auto;}',
'.alas-live-preview-codec{height:26px;border-radius:4px;border:1px solid rgba(255,255,255,.2);background:#111820;color:#f2f5f8;padding:0 6px;}',
'.alas-live-preview-close{width:28px;height:28px;border:0;background:transparent;color:#f2f5f8;font-size:24px;line-height:24px;cursor:pointer;}',
'.alas-live-preview-video{display:block;width:100%;aspect-ratio:16/9;background:#000;object-fit:contain;}',
'.alas-live-preview-status{position:absolute;left:12px;bottom:10px;max-width:calc(100% - 24px);padding:4px 8px;border-radius:4px;background:rgba(0,0,0,.58);color:#fff;font-size:12px;line-height:1.35;pointer-events:none;}'
].join('');
document.head.appendChild(style);
document.body.appendChild(panel);
panel.querySelector('.alas-live-preview-close').onclick = function () {
window.alasStopLivePreview();
};
panel.querySelector('.alas-live-preview-codec').onchange = function (e) {
state.codec = e.target.value;
localStorage.setItem('alas_live_preview_codec', state.codec);
if (state.open) start(state.instance, state.codec);
};
return panel;
}
function setStatus(text) {
var panel = ensurePanel();
var status = panel.querySelector('.alas-live-preview-status');
status.innerHTML = sanitizeText(text);
status.style.display = text ? 'block' : 'none';
}
function cleanupTransport() {
state.transportId += 1;
if (state.socket) {
state.socket.onclose = null;
state.socket.onerror = null;
state.socket.onmessage = null;
try { state.socket.close(); } catch (e) { }
state.socket = null;
}
if (state.sourceBuffer) {
state.sourceBuffer.onupdateend = null;
state.sourceBuffer = null;
}
if (state.mediaSource) {
try {
if (state.mediaSource.readyState === 'open') state.mediaSource.endOfStream();
} catch (e) { }
state.mediaSource = null;
}
if (state.objectUrl) {
URL.revokeObjectURL(state.objectUrl);
state.objectUrl = '';
}
state.queue = [];
}
function appendNext(transportId) {
if (transportId !== state.transportId) return;
var sb = state.sourceBuffer;
if (!sb || sb.updating || !state.queue.length) return;
try {
sb.appendBuffer(state.queue.shift());
} catch (e) {
if (transportId !== state.transportId) return;
setStatus(e.message || e);
}
}
function attachMedia(socket, codec, mime, transportId) {
var panel = ensurePanel();
var video = panel.querySelector('.alas-live-preview-video');
if (!state.open || transportId !== state.transportId) {
try { socket.close(); } catch (e) { }
return;
}
state.socket = socket;
state.mediaSource = new MediaSource();
state.objectUrl = URL.createObjectURL(state.mediaSource);
video.src = state.objectUrl;
state.mediaSource.addEventListener('sourceopen', function () {
if (!state.open || transportId !== state.transportId || state.socket !== socket) {
return;
}
if (!MediaSource.isTypeSupported(mime)) {
setStatus(codec.toUpperCase() + ' 当前浏览器不支持');
cleanupTransport();
return;
}
state.sourceBuffer = state.mediaSource.addSourceBuffer(mime);
state.sourceBuffer.mode = 'segments';
state.sourceBuffer.onupdateend = function () {
appendNext(transportId);
};
state.socket.onmessage = function (event) {
if (!state.open || transportId !== state.transportId || state.socket !== socket) return;
if (typeof event.data === 'string') {
try {
var msg = JSON.parse(event.data);
if (msg.type === 'error') setStatus(msg.message);
} catch (e) { }
return;
}
state.queue.push(event.data);
setStatus('');
appendNext(transportId);
};
state.socket.onerror = function () {
if (transportId === state.transportId) setStatus('实时截图连接错误');
};
state.socket.onclose = function () {
if (state.open && transportId === state.transportId) setStatus('实时截图已断开');
};
}, { once: true });
}
function getSocketCandidates() {
var scheme = location.protocol === 'https:' ? 'wss://' : 'ws://';
var query = '?instance=' + encodeURIComponent(state.instance) +
'&codec=' + encodeURIComponent(state.codec) + '&fps=5&width=640';
var candidates = [scheme + location.host + '/ws/live_screenshot' + query];
var pathParts = location.pathname.split('/').filter(Boolean);
var firstPart = pathParts.length ? pathParts[0] : '';
// Alas 远程访问入口通常是 /{sock_name}/...,其中 sock_name 为 8+ 位小写字母数字。
if (/^[a-z0-9]{8,}$/.test(firstPart)) {
candidates.unshift(scheme + location.host + '/' + firstPart + '/ws/live_screenshot' + query);
}
return candidates;
}
function start(instance, codec) {
var panel = ensurePanel();
cleanupTransport();
state.open = true;
state.instance = instance || 'alas';
state.codec = codec || state.codec || 'h264';
panel.style.display = 'block';
panel.querySelector('.alas-live-preview-codec').value = state.codec;
setStatus('连接中');
var transportId = state.transportId;
var candidates = getSocketCandidates();
var attempt = 0;
function connectNext() {
if (!state.open || transportId !== state.transportId) return;
if (attempt >= candidates.length) {
setStatus('实时截图连接失败');
return;
}
var socket = new WebSocket(candidates[attempt++]);
var ready = false;
var advanced = false;
function advance() {
if (advanced) return;
advanced = true;
connectNext();
}
socket.binaryType = 'arraybuffer';
socket.onmessage = function (event) {
if (transportId !== state.transportId) return;
if (typeof event.data !== 'string') return;
var msg;
try { msg = JSON.parse(event.data); } catch (e) { return; }
if (msg.type === 'ready') {
ready = true;
attachMedia(socket, state.codec, msg.mime, transportId);
} else if (msg.type === 'error') {
setStatus(msg.message);
socket.close();
}
};
socket.onerror = function () {
if (!ready) advance();
};
socket.onclose = function () {
if (!state.open || transportId !== state.transportId) return;
if (!ready && !state.socket) {
advance();
} else if (ready && state.socket === socket) {
setStatus('实时截图已断开');
}
};
}
connectNext();
}
window.alasStartLivePreview = function (instance, codec) {
start(instance, codec);
};
window.alasStopLivePreview = function () {
state.open = false;
cleanupTransport();
var panel = ensurePanel();
panel.style.display = 'none';
};
window.alasToggleLivePreview = function (instance) {
if (state.open) {
window.alasStopLivePreview();
} else {
window.alasStartLivePreview(instance, state.codec);
}
};
})();
// ============================================================
// 公告系统
// ============================================================

View File

@ -308,9 +308,7 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
config = AzurLaneConfig(inst)
device = Device(config)
image = device.screenshot()
# ALAS uses BGR (OpenCV format), convert to RGB for PIL
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
image_pil = Image.fromarray(image_rgb)
image_pil = Image.fromarray(image)
buffered = BytesIO()
image_pil.save(buffered, format="JPEG")

View File

@ -602,7 +602,7 @@ class NemuIpc(Platform):
def screenshot_nemu_ipc(self):
image = self.nemu_ipc.screenshot()
image = cv2.cvtColor(image, cv2.COLOR_BGRA2RGB)
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
cv2.flip(image, 0, dst=image)
return image

View File

@ -1,6 +1,16 @@
import asyncio
import json
import os
import queue
import shutil
import subprocess
import threading
import time
import cv2
from starlette.responses import JSONResponse, HTMLResponse
from starlette.routing import Route
from starlette.routing import Route, WebSocketRoute
from starlette.websockets import WebSocketDisconnect
from module.logger import logger
def api_cl1_stats(request):
@ -36,8 +46,255 @@ def serve_obs_overlay(request):
except Exception as e:
return HTMLResponse(f"Error loading obs overlay: {e}", status_code=500)
def _get_ffmpeg_path():
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
return ffmpeg
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
return None
def _video_stream_command(ffmpeg, codec, width, height, fps):
bitrate = "800k"
bufsize = "1600k"
base = [
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-s",
f"{width}x{height}",
"-r",
str(fps),
"-i",
"pipe:0",
"-an",
]
if codec == "h265":
return base + [
"-c:v",
"libx265",
"-preset",
"ultrafast",
"-b:v",
bitrate,
"-maxrate",
bitrate,
"-bufsize",
bufsize,
"-x265-params",
f"log-level=error:keyint={fps}:min-keyint={fps}:scenecut=0",
"-tag:v",
"hvc1",
"-pix_fmt",
"yuv420p",
"-f",
"mp4",
"-movflags",
"empty_moov+default_base_moof+frag_keyframe",
"pipe:1",
]
return base + [
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-tune",
"zerolatency",
"-b:v",
bitrate,
"-maxrate",
bitrate,
"-bufsize",
bufsize,
"-profile:v",
"baseline",
"-level",
"3.1",
"-pix_fmt",
"yuv420p",
"-g",
str(fps),
"-keyint_min",
str(fps),
"-sc_threshold",
"0",
"-f",
"mp4",
"-movflags",
"empty_moov+default_base_moof+frag_keyframe",
"pipe:1",
]
async def ws_live_screenshot(websocket):
await websocket.accept()
instance = websocket.query_params.get("instance", "alas")
codec = websocket.query_params.get("codec", "h264").lower()
if codec not in ("h264", "h265"):
codec = "h264"
try:
fps = int(websocket.query_params.get("fps", "5"))
except ValueError:
fps = 5
fps = max(1, min(fps, 15))
try:
target_width = int(websocket.query_params.get("width", "640"))
except ValueError:
target_width = 640
target_width = max(320, min(target_width, 1280))
ffmpeg = _get_ffmpeg_path()
if not ffmpeg:
await websocket.send_text(json.dumps({
"type": "error",
"message": "ffmpeg not found. Install ffmpeg or imageio-ffmpeg to use H264/H265 live preview.",
}))
await websocket.close()
return
stop_event = threading.Event()
out_queue = queue.Queue(maxsize=16)
proc = None
try:
from module.webui.fake_pil_module import remove_fake_pil_module
remove_fake_pil_module()
except Exception:
pass
try:
from module.config.config import AzurLaneConfig
from module.device.device import Device
if "ALAS_CONFIG_NAME" not in os.environ:
os.environ["ALAS_CONFIG_NAME"] = instance
config = AzurLaneConfig(instance)
device = Device(config)
first = device.screenshot()
src_height, src_width = first.shape[:2]
target_height = int(round(target_width * src_height / src_width))
if target_height % 2:
target_height += 1
size = (target_width, target_height)
mime = (
'video/mp4; codecs="hvc1.1.6.L93.B0"'
if codec == "h265"
else 'video/mp4; codecs="avc1.42E01E"'
)
await websocket.send_text(json.dumps({
"type": "ready",
"codec": codec,
"mime": mime,
"width": target_width,
"height": target_height,
"fps": fps,
}))
proc = subprocess.Popen(
_video_stream_command(ffmpeg, codec, target_width, target_height, fps),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
def normalize_frame(image):
if image.shape[1] != target_width or image.shape[0] != target_height:
image = cv2.resize(image, size, interpolation=cv2.INTER_AREA)
if not image.flags["C_CONTIGUOUS"]:
image = image.copy()
return image
def writer():
frame_interval = 1 / fps
next_frame = time.perf_counter()
image = first
while not stop_event.is_set():
try:
proc.stdin.write(normalize_frame(image).tobytes())
proc.stdin.flush()
except Exception:
break
next_frame += frame_interval
sleep_for = next_frame - time.perf_counter()
if sleep_for > 0:
stop_event.wait(sleep_for)
if stop_event.is_set():
break
try:
image = device.screenshot()
except Exception as e:
out_queue.put(("error", str(e)))
break
try:
proc.stdin.close()
except Exception:
pass
def reader():
while not stop_event.is_set():
try:
chunk = proc.stdout.read(32768)
except Exception:
break
if not chunk:
break
out_queue.put(("data", chunk))
out_queue.put(("eof", None))
def stderr_reader():
try:
err = proc.stderr.read().decode("utf-8", errors="replace").strip()
except Exception:
err = ""
if err and not stop_event.is_set():
out_queue.put(("error", err[-1000:]))
threading.Thread(target=writer, daemon=True).start()
threading.Thread(target=reader, daemon=True).start()
threading.Thread(target=stderr_reader, daemon=True).start()
while not stop_event.is_set():
kind, payload = await asyncio.to_thread(out_queue.get)
if kind == "data":
await websocket.send_bytes(payload)
elif kind == "error":
await websocket.send_text(json.dumps({"type": "error", "message": payload}))
break
else:
break
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"ws_live_screenshot error: {e}")
try:
await websocket.send_text(json.dumps({"type": "error", "message": str(e)}))
except Exception:
pass
finally:
stop_event.set()
if proc is not None:
try:
proc.kill()
except Exception:
pass
api_routes = [
Route("/api/cl1_stats", api_cl1_stats),
Route("/api/ap_timeline", api_ap_timeline),
Route("/obs", serve_obs_overlay),
WebSocketRoute("/ws/live_screenshot", ws_live_screenshot),
]

View File

@ -1419,6 +1419,13 @@ class AlasGUI(Frame):
"log-bar-btns",
[
put_scope("log_scroll_btn"),
put_button(
label="截图预览",
onclick=lambda: run_js(
f"window.alasToggleLivePreview({json.dumps(self.alas_name)});"
),
color="off",
),
],
),
],
@ -1757,6 +1764,13 @@ class AlasGUI(Frame):
"log-bar-btns",
[
put_scope("log_scroll_btn"),
put_button(
label="截图预览",
onclick=lambda: run_js(
f"window.alasToggleLivePreview({json.dumps(self.alas_name)});"
),
color="off",
),
put_scope("dashboard_btn"),
],
),
@ -2168,6 +2182,13 @@ class AlasGUI(Frame):
"log-bar-btns",
[
put_scope("log_scroll_btn"),
put_button(
label="截图预览",
onclick=lambda: run_js(
f"window.alasToggleLivePreview({json.dumps(self.alas_name)});"
),
color="off",
),
],
)

View File

@ -3,6 +3,7 @@ scipy
pillow
opencv-python>=4.8.0
imageio
imageio-ffmpeg
adbutils
uiautomator2
uiautomator2cache
@ -30,7 +31,8 @@ typing_extensions>=4.8.0
pydantic>=2.5.0
rapidocr
onnxruntime-directml>=1.24.4; sys_platform == "win32"
onnxruntime>=1.24.4; sys_platform != "win32"
onnxruntime>=1.24.4; sys_platform == "linux"
onnxruntime @ https://files.pythonhosted.org/packages/fb/aa/04530bd38e31e26970fa1212346d76cf81705dc16a8ee5e6f4fb24634c11/onnxruntime-1.25.1-cp314-cp314-macosx_14_0_arm64.whl; sys_platform == "darwin" and platform_machine == "arm64"
watchdog>=2.0.0
numba
pip
@ -41,4 +43,4 @@ importlib_metadata>=8.0.0
openai
mcp==1.23.0
sse-starlette==3.0.3
pygments>=2.20.0
pygments>=2.20.0

View File

@ -95,6 +95,8 @@ idna==3.7
# requests
imageio==2.26.0
# via -r requirements-in.txt
imageio-ffmpeg==0.6.0
# via -r requirements-in.txt
importlib-metadata==8.0.0
# via -r requirements-in.txt
importlib-resources==6.0.0

View File

@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-in.txt --python-platform macos --python-version 3.14 --output-file requirements-macos.txt
# uv pip compile requirements-in.txt --python-platform aarch64-apple-darwin --python-version 3.14 --output-file requirements-macos.txt
adbutils==2.12.0
# via
# -r requirements-in.txt
@ -81,6 +81,8 @@ idna==3.7
# requests
imageio==2.37.3
# via -r requirements-in.txt
imageio-ffmpeg==0.6.0
# via -r requirements-in.txt
importlib-metadata==8.0.0
# via -r requirements-in.txt
importlib-resources==6.0.0
@ -111,8 +113,6 @@ mcp==1.23.0
# via -r requirements-in.txt
mdurl==0.1.2
# via markdown-it-py
mpmath==1.3.0
# via sympy
msgpack==1.1.2
# via zerorpc
numba==0.65.0
@ -133,7 +133,7 @@ omegaconf==2.3.0
# via rapidocr
onepush==1.8.0
# via -r requirements-in.txt
onnxruntime==1.24.4
onnxruntime @ https://files.pythonhosted.org/packages/fb/aa/04530bd38e31e26970fa1212346d76cf81705dc16a8ee5e6f4fb24634c11/onnxruntime-1.25.1-cp314-cp314-macosx_14_0_arm64.whl
# via -r requirements-in.txt
openai==2.31.0
# via -r requirements-in.txt
@ -254,8 +254,6 @@ starlette==0.49.1
# via
# -r requirements-in.txt
# mcp
sympy==1.14.0
# via onnxruntime
tornado==6.5.5
# via pywebio
tqdm==4.67.3

View File

@ -24,7 +24,7 @@ decorator==5.1.1 # via retry
deprecated==1.2.13 # via uiautomator2
deprecation==2.1.0 # via adbutils
distro==1.9.0 # via openai
filelock==3.20.3 # via uiautomator2
filelock==3.20.3 # via uiautomator2
flatbuffers==25.12.19 # via onnxruntime-directml
fonttools==4.62.1 # via matplotlib
future==0.18.3 # via zerorpc
@ -37,6 +37,7 @@ httpx==0.28.1 # via mcp, openai
httpx-sse==0.4.3 # via mcp
idna==3.7 # via anyio, httpx, requests
imageio==2.26.0 # via -r requirements-in.txt
imageio-ffmpeg==0.6.0 # via -r requirements-in.txt
importlib-metadata==8.0.0 # via -r requirements-in.txt
importlib-resources==6.0.0 # via -r requirements-in.txt
inflection==0.5.1 # via -r requirements-in.txt