Initial commit

This commit is contained in:
2026-07-23 09:12:00 +09:00
commit 521d11ffa6
41 changed files with 8799 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
const state = document.querySelector("[data-state]");
const video = document.querySelector("[data-video]");
const image = document.querySelector("[data-mjpeg]");
const logs = document.querySelector("[data-logs]");
const modeSelect = document.querySelector("[data-mode]");
const startButton = document.querySelector("[data-start]");
const stopButton = document.querySelector("[data-stop]");
let ws = null;
let pc = null;
let statsTimer = null;
function setState(message) {
if (state) state.textContent = message;
}
function renderLogs(items = []) {
if (!logs) return;
logs.textContent = items.slice(-30).join("\n");
logs.scrollTop = logs.scrollHeight;
}
async function getStatus() {
const response = await fetch("/api/camera/status");
const status = await response.json();
renderLogs(status.logs);
return status;
}
function normalizeOfferDescription(description) {
if (description.type !== "offer" || !description.sdp || description.sdp.includes("a=msid:")) return description;
const lines = description.sdp.replace(/\r\n/g, "\n").split("\n");
const normalized = [];
let hasMsidSemantic = false;
let insertedVideoMsid = false;
for (const line of lines) {
if (line.startsWith("a=msid-semantic:")) hasMsidSemantic = true;
if (line.startsWith("m=video") && !hasMsidSemantic) {
normalized.push("a=msid-semantic: WMS jetson-camera");
hasMsidSemantic = true;
}
normalized.push(line);
if (!insertedVideoMsid && line.startsWith("a=mid:")) {
normalized.push("a=msid:jetson-camera video0");
insertedVideoMsid = true;
}
}
return { ...description, sdp: normalized.join("\r\n") };
}
function stopViewerOnly() {
if (statsTimer) window.clearInterval(statsTimer);
statsTimer = null;
if (image) image.removeAttribute("src");
if (video) video.srcObject = null;
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
if (ws && ws.readyState === WebSocket.CONNECTING) ws.onopen = () => ws.close();
if (pc) pc.close();
ws = null;
pc = null;
}
function showMjpeg(status) {
if (!image || !video) return;
video.style.display = "none";
image.style.display = "block";
const params = new URLSearchParams({
device: status.videoDevice || "/dev/video0",
width: String(status.width || 1920),
height: String(status.height || 1080),
framerate: String(status.framerate || 30),
t: String(Date.now()),
});
image.src = `/api/camera/mjpeg?${params.toString()}`;
image.onload = () => setState("MJPEG streaming");
image.onerror = () => setState("MJPEG stream failed");
}
async function startWebRtc(status) {
if (!video || !image) return;
image.style.display = "none";
video.style.display = "block";
pc = new RTCPeerConnection();
ws = new WebSocket(status.signalingUrl);
pc.ontrack = (event) => {
const stream = event.streams[0];
console.debug("[camera-web] track received", event.track.kind, event.track.readyState, event.track.muted);
if (!stream) return;
video.srcObject = stream;
video.play().catch((error) => console.error("[camera-web] video play failed", error));
setState("WebRTC streaming");
};
pc.onicecandidate = (event) => {
if (!event.candidate || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({
type: "candidate",
roomId: status.roomId || "jetson-1",
role: "viewer",
candidate: event.candidate,
}));
};
pc.onconnectionstatechange = () => {
console.debug("[camera-web] connection state", pc.connectionState);
if (pc.connectionState === "connected") setState("WebRTC connected");
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setState("WebRTC failed");
};
ws.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data.type === "offer") {
console.debug("[camera-web] offer received");
await pc.setRemoteDescription(normalizeOfferDescription(data.offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: "answer",
roomId: status.roomId || "jetson-1",
role: "viewer",
answer,
}));
}
}
if (data.type === "candidate" && data.candidate) {
await pc.addIceCandidate(data.candidate);
}
};
await new Promise((resolve, reject) => {
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join", roomId: status.roomId || "jetson-1", role: "viewer" }));
resolve();
};
ws.onerror = () => reject(new Error("signaling connection failed"));
});
setState("starting WebRTC sender");
await fetch("/api/camera/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomId: status.roomId || "jetson-1" }),
});
setState("waiting for WebRTC offer");
}
async function startCamera() {
stopViewerOnly();
setState("starting");
await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined);
const status = await getStatus();
if (!status.enabled) {
setState("camera disabled");
return;
}
const mode = modeSelect?.value || "mjpeg";
if (mode === "webrtc") {
await startWebRtc(status);
} else {
setState("starting MJPEG stream");
showMjpeg(status);
}
}
async function stopCamera() {
stopViewerOnly();
await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined);
setState("stopped");
await getStatus();
}
startButton?.addEventListener("click", () => startCamera().catch((error) => setState(error.message)));
stopButton?.addEventListener("click", () => stopCamera().catch((error) => setState(error.message)));
modeSelect?.addEventListener("change", () => startCamera().catch((error) => setState(error.message)));
getStatus().then((status) => {
if (!status.enabled) setState("camera disabled");
else startCamera().catch((error) => setState(error.message));
});
setInterval(() => {
getStatus().catch(() => undefined);
}, 3000);