add quality slider to webcam and implemented H264
This commit is contained in:
@@ -3,8 +3,12 @@
|
|||||||
package capture
|
package capture
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/draw"
|
||||||
|
"image/jpeg"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -117,12 +121,44 @@ func NowWebcam(ctx context.Context, env *rt.Env) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
quality := env.WebcamQuality
|
||||||
|
codec := env.WebcamCodec
|
||||||
|
outFormat := format
|
||||||
|
|
||||||
|
if codec == "h264" && format == "jpeg" && h264Available() {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(frameBytes))
|
||||||
|
if err == nil {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
w, h := bounds.Dx(), bounds.Dy()
|
||||||
|
if w%2 != 0 || h%2 != 0 {
|
||||||
|
log.Printf("webcam: h264 skipped for odd dimensions (%dx%d), falling back to jpeg", w, h)
|
||||||
|
} else {
|
||||||
|
rgba := toRGBA(img)
|
||||||
|
if h264Bytes, err := encodeH264Frame(rgba); err == nil && len(h264Bytes) > 0 {
|
||||||
|
frameBytes = h264Bytes
|
||||||
|
outFormat = "h264"
|
||||||
|
} else {
|
||||||
|
log.Printf("webcam: h264 encode failed, falling back to jpeg: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if outFormat == "jpeg" && quality > 0 && quality < 100 {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(frameBytes))
|
||||||
|
if err == nil {
|
||||||
|
if reencoded, err := encodeJPEG(img, quality); err == nil {
|
||||||
|
frameBytes = reencoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
frame := wire.Frame{
|
frame := wire.Frame{
|
||||||
Type: "frame",
|
Type: "frame",
|
||||||
Header: wire.FrameHeader{
|
Header: wire.FrameHeader{
|
||||||
Monitor: deviceIndex,
|
Monitor: deviceIndex,
|
||||||
FPS: 0,
|
FPS: 0,
|
||||||
Format: format,
|
Format: outFormat,
|
||||||
Webcam: true,
|
Webcam: true,
|
||||||
},
|
},
|
||||||
Data: frameBytes,
|
Data: frameBytes,
|
||||||
@@ -296,3 +332,13 @@ func closeWebcamLocked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toRGBA(img image.Image) *image.RGBA {
|
||||||
|
if rgba, ok := img.(*image.RGBA); ok {
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
rgba := image.NewRGBA(b)
|
||||||
|
draw.Draw(rgba, b, img, b.Min, draw.Src)
|
||||||
|
return rgba
|
||||||
|
}
|
||||||
|
|||||||
@@ -1579,6 +1579,35 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
env.WebcamUseMaxFPS = useMax
|
env.WebcamUseMaxFPS = useMax
|
||||||
sendCommandResultSafe(env, cmdID, true, "")
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
return nil
|
return nil
|
||||||
|
case "webcam_set_quality":
|
||||||
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
|
quality := 0
|
||||||
|
codec := ""
|
||||||
|
if payload != nil {
|
||||||
|
if q, ok := payloadInt(payload, "quality"); ok {
|
||||||
|
quality = q
|
||||||
|
}
|
||||||
|
if v, ok := payload["codec"].(string); ok {
|
||||||
|
codec = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quality < 0 {
|
||||||
|
quality = 0
|
||||||
|
}
|
||||||
|
if quality > 100 {
|
||||||
|
quality = 100
|
||||||
|
}
|
||||||
|
switch codec {
|
||||||
|
case "jpeg", "h264":
|
||||||
|
// valid
|
||||||
|
default:
|
||||||
|
codec = "jpeg"
|
||||||
|
}
|
||||||
|
env.WebcamQuality = quality
|
||||||
|
env.WebcamCodec = codec
|
||||||
|
log.Printf("webcam: set quality=%d codec=%s", quality, codec)
|
||||||
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
|
return nil
|
||||||
case "webcam_stop":
|
case "webcam_stop":
|
||||||
env.WebcamMu.Lock()
|
env.WebcamMu.Lock()
|
||||||
log.Printf("webcam: stop requested")
|
log.Printf("webcam: stop requested")
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ type Env struct {
|
|||||||
WebcamDeviceIndex int
|
WebcamDeviceIndex int
|
||||||
WebcamFPS int
|
WebcamFPS int
|
||||||
WebcamUseMaxFPS bool
|
WebcamUseMaxFPS bool
|
||||||
|
WebcamQuality int
|
||||||
|
WebcamCodec string
|
||||||
WebcamCancel context.CancelFunc
|
WebcamCancel context.CancelFunc
|
||||||
WebcamDone chan struct{}
|
WebcamDone chan struct{}
|
||||||
WebcamMu sync.Mutex
|
WebcamMu sync.Mutex
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
const refreshCameras = document.getElementById("refreshCameras");
|
const refreshCameras = document.getElementById("refreshCameras");
|
||||||
const fpsInput = document.getElementById("fpsInput");
|
const fpsInput = document.getElementById("fpsInput");
|
||||||
const applyFps = document.getElementById("applyFps");
|
const applyFps = document.getElementById("applyFps");
|
||||||
|
const qualitySlider = document.getElementById("qualitySlider");
|
||||||
|
const qualityValue = document.getElementById("qualityValue");
|
||||||
|
const codecH264 = document.getElementById("codecH264");
|
||||||
|
const codecMode = document.getElementById("codecMode");
|
||||||
const viewerFps = document.getElementById("viewerFps");
|
const viewerFps = document.getElementById("viewerFps");
|
||||||
const statusEl = document.getElementById("streamStatus");
|
const statusEl = document.getElementById("streamStatus");
|
||||||
const canvas = document.getElementById("frameCanvas");
|
const canvas = document.getElementById("frameCanvas");
|
||||||
@@ -34,6 +38,43 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
let hasRenderedFrame = false;
|
let hasRenderedFrame = false;
|
||||||
let drawPending = false;
|
let drawPending = false;
|
||||||
|
|
||||||
|
const codecPrefKey = "webcamCodecPreferH264";
|
||||||
|
let prefersH264 = typeof VideoDecoder === "function";
|
||||||
|
|
||||||
|
const storedCodecPref = localStorage.getItem(codecPrefKey);
|
||||||
|
if (storedCodecPref === "0") {
|
||||||
|
prefersH264 = false;
|
||||||
|
} else if (storedCodecPref === "1") {
|
||||||
|
prefersH264 = typeof VideoDecoder === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codecH264) {
|
||||||
|
codecH264.checked = prefersH264;
|
||||||
|
codecH264.disabled = typeof VideoDecoder !== "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCodecModeLabel(mode, detail) {
|
||||||
|
if (!codecMode) return;
|
||||||
|
const suffix = detail ? ` (${detail})` : "";
|
||||||
|
codecMode.textContent = `Codec: ${String(mode || "auto").toUpperCase()}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCodecModeLabel(prefersH264 ? "h264" : "jpeg", "preferred");
|
||||||
|
|
||||||
|
function updateQualityLabel(val) {
|
||||||
|
if (qualityValue) {
|
||||||
|
qualityValue.textContent = `${val}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushQuality(val) {
|
||||||
|
const q = Number(val) || 90;
|
||||||
|
const codec = prefersH264 ? "h264" : "jpeg";
|
||||||
|
console.debug("webcam: pushQuality val=", val, "q=", q, "codec=", codec);
|
||||||
|
setCodecModeLabel(codec, "requested");
|
||||||
|
send("webcam_set_quality", { quality: q, codec });
|
||||||
|
}
|
||||||
|
|
||||||
function buildScreenshotFilename() {
|
function buildScreenshotFilename() {
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
return `webcam-${clientId}-${ts}.jpg`;
|
return `webcam-${clientId}-${ts}.jpg`;
|
||||||
@@ -336,6 +377,7 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
requestCameraList();
|
requestCameraList();
|
||||||
applyFpsSettings();
|
applyFpsSettings();
|
||||||
|
pushQuality(qualitySlider ? qualitySlider.value : 90);
|
||||||
if (desiredStreaming) {
|
if (desiredStreaming) {
|
||||||
send("webcam_start");
|
send("webcam_start");
|
||||||
setStreamState("starting", "Starting");
|
setStreamState("starting", "Starting");
|
||||||
@@ -411,6 +453,27 @@ import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (codecH264) {
|
||||||
|
codecH264.addEventListener("change", function () {
|
||||||
|
prefersH264 = !!codecH264.checked && typeof VideoDecoder === "function";
|
||||||
|
localStorage.setItem(codecPrefKey, prefersH264 ? "1" : "0");
|
||||||
|
if (!prefersH264) {
|
||||||
|
destroyVideoDecoder();
|
||||||
|
}
|
||||||
|
if (qualitySlider) {
|
||||||
|
pushQuality(qualitySlider.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualitySlider) {
|
||||||
|
updateQualityLabel(qualitySlider.value);
|
||||||
|
qualitySlider.addEventListener("input", function () {
|
||||||
|
updateQualityLabel(qualitySlider.value);
|
||||||
|
pushQuality(qualitySlider.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
applyFps.addEventListener("click", () => {
|
applyFps.addEventListener("click", () => {
|
||||||
applyFpsSettings();
|
applyFpsSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,29 @@
|
|||||||
<i class="fa-solid fa-sliders"></i><span>Apply</span>
|
<i class="fa-solid fa-sliders"></i><span>Apply</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 for="qualitySlider" class="text-slate-200">Quality</label>
|
||||||
|
<input
|
||||||
|
id="qualitySlider"
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value="90"
|
||||||
|
class="accent-sky-400"
|
||||||
|
/>
|
||||||
|
<span id="qualityValue" class="pill pill-ghost text-slate-100">90%</span>
|
||||||
|
</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" title="Prefer H.264 when available; disable to force JPEG">
|
||||||
|
<input type="checkbox" id="codecH264" checked /> H.264
|
||||||
|
</label>
|
||||||
|
<span id="codecMode" class="pill pill-ghost text-slate-100">Codec: auto</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button id="startBtn" class="button primary">
|
<button id="startBtn" class="button primary">
|
||||||
<i class="fa-solid fa-play"></i><span>Start</span>
|
<i class="fa-solid fa-play"></i><span>Start</span>
|
||||||
|
|||||||
@@ -482,11 +482,12 @@ function handleRemoteDesktopFrame(payload: any) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
export const hvncStreamingState = new Map<string, { isStreaming: boolean; display: number; quality: number; codec: string }>();
|
||||||
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean }>();
|
export const webcamStreamingState = new Map<string, { isStreaming: boolean; deviceIndex: number; fps: number; useMax: boolean; quality: number; codec: string }>();
|
||||||
|
|
||||||
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
|
export function handleWebcamViewerOpen(ws: ServerWebSocket<SocketData>) {
|
||||||
const { clientId } = ws.data;
|
const { clientId } = ws.data;
|
||||||
const sessionId = uuidv4();
|
const sessionId = uuidv4();
|
||||||
|
ws.data.sessionId = sessionId;
|
||||||
const target = clientManager.getClient(clientId);
|
const target = clientManager.getClient(clientId);
|
||||||
const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
const session: RemoteDesktopViewer = { id: sessionId, clientId, viewer: ws, createdAt: Date.now() };
|
||||||
sessionManager.addWebcamSession(session);
|
sessionManager.addWebcamSession(session);
|
||||||
@@ -554,7 +555,7 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = webcamStreamingState.get(clientId) || { isStreaming: false, deviceIndex: 0, fps: 30, useMax: false };
|
const state = webcamStreamingState.get(clientId) || { isStreaming: false, deviceIndex: 0, fps: 30, useMax: false, quality: 90, codec: "" };
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
case "webcam_list":
|
case "webcam_list":
|
||||||
sendDesktopCommand(target, "webcam_list", {});
|
sendDesktopCommand(target, "webcam_list", {});
|
||||||
@@ -586,11 +587,21 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw:
|
|||||||
case "webcam_start":
|
case "webcam_start":
|
||||||
if (!state.isStreaming) {
|
if (!state.isStreaming) {
|
||||||
sendDesktopCommand(target, "webcam_set_fps", { fps: state.fps, useMax: state.useMax });
|
sendDesktopCommand(target, "webcam_set_fps", { fps: state.fps, useMax: state.useMax });
|
||||||
|
sendDesktopCommand(target, "webcam_set_quality", { quality: state.quality, codec: state.codec });
|
||||||
sendDesktopCommand(target, "webcam_start", {});
|
sendDesktopCommand(target, "webcam_start", {});
|
||||||
state.isStreaming = true;
|
state.isStreaming = true;
|
||||||
webcamStreamingState.set(clientId, state);
|
webcamStreamingState.set(clientId, state);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "webcam_set_quality": {
|
||||||
|
const quality = Math.max(0, Math.min(100, Number(payload.quality) || 0));
|
||||||
|
const codec = String(payload.codec || "").toLowerCase();
|
||||||
|
state.quality = quality;
|
||||||
|
state.codec = codec;
|
||||||
|
webcamStreamingState.set(clientId, state);
|
||||||
|
sendDesktopCommand(target, "webcam_set_quality", { quality, codec });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "webcam_stop": {
|
case "webcam_stop": {
|
||||||
const otherWebcamViewers = sessionManager.getWebcamSessionsForClient(clientId)
|
const otherWebcamViewers = sessionManager.getWebcamSessionsForClient(clientId)
|
||||||
.filter(s => s.id !== ws.data.sessionId);
|
.filter(s => s.id !== ws.data.sessionId);
|
||||||
|
|||||||
Reference in New Issue
Block a user