One-KVM/web/share/js/kvm/stream.js
mofeng-git 1a13760df0 #44 添加视频录制支持
使用浏览器前端 API
支持 mjpeg 和 h.264 模式下的视频录制
录制格式为wbem(vp8)
2024-11-04 13:25:18 +00:00

398 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*****************************************************************************
# #
# KVMD - The main PiKVM daemon. #
# #
# Copyright (C) 2018-2024 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
*****************************************************************************/
"use strict";
import {tools, $} from "../tools.js";
import {wm} from "../wm.js";
import {JanusStreamer} from "./stream_janus.js";
import {MjpegStreamer} from "./stream_mjpeg.js";
export function Streamer() {
var self = this;
/************************************************************************/
var __janus_enabled = null;
var __streamer = null;
var __state = null;
var __resolution = {"width": 640, "height": 480};
var __init__ = function() {
__streamer = new MjpegStreamer(__setActive, __setInactive, __setInfo);
$("stream-led").title = "Stream inactive";
tools.slider.setParams($("stream-quality-slider"), 5, 100, 5, 80, function(value) {
$("stream-quality-value").innerHTML = `${value}%`;
});
tools.slider.setOnUpDelayed($("stream-quality-slider"), 1000, (value) => __sendParam("quality", value));
tools.slider.setParams($("stream-h264-bitrate-slider"), 25, 20000, 25, 5000, function(value) {
$("stream-h264-bitrate-value").innerHTML = value;
});
tools.slider.setOnUpDelayed($("stream-h264-bitrate-slider"), 1000, (value) => __sendParam("h264_bitrate", value));
tools.slider.setParams($("stream-h264-gop-slider"), 0, 60, 1, 30, function(value) {
$("stream-h264-gop-value").innerHTML = value;
});
tools.slider.setOnUpDelayed($("stream-h264-gop-slider"), 1000, (value) => __sendParam("h264_gop", value));
tools.slider.setParams($("stream-desired-fps-slider"), 0, 120, 1, 0, function(value) {
$("stream-desired-fps-value").innerHTML = (value === 0 ? "Unlimited" : value);
});
tools.slider.setOnUpDelayed($("stream-desired-fps-slider"), 1000, (value) => __sendParam("desired_fps", value));
$("stream-resolution-selector").onchange = (() => __sendParam("resolution", $("stream-resolution-selector").value));
tools.radio.setOnClick("stream-mode-radio", __clickModeRadio, false);
// Not getInt() because of radio is a string container.
// Also don't reset Janus at class init.
tools.radio.clickValue("stream-orient-radio", tools.storage.get("stream.orient", 0));
tools.radio.setOnClick("stream-orient-radio", function() {
if (__streamer.getMode() === "janus") { // Right now it's working only for H.264
let orient = parseInt(tools.radio.getValue("stream-orient-radio"));
tools.storage.setInt("stream.orient", orient);
if (__streamer.getOrientation() != orient) {
__resetStream();
}
}
}, false);
tools.slider.setParams($("stream-audio-volume-slider"), 0, 100, 1, 0, function(value) {
$("stream-video").muted = !value;
$("stream-video").volume = value / 100;
$("stream-audio-volume-value").innerHTML = value + "%";
if (__streamer.getMode() === "janus") {
let allow_audio = !$("stream-video").muted;
if (__streamer.isAudioAllowed() !== allow_audio) {
__resetStream();
}
}
});
tools.el.setOnClick($("stream-screenshot-button"), __clickScreenshotButton);
tools.el.setOnClick($("stream-reset-button"), __clickResetButton);
tools.el.setOnClick($("stream-record-start-button"), __clickRecordStartButton);
tools.el.setOnClick($("stream-record-stop-button"), __clickRecordStopButton);
$("stream-window").show_hook = () => __applyState(__state);
$("stream-window").close_hook = () => __applyState(null);
//hidden stream-record-stop-button
document.getElementById('stream-record-stop-button').disabled = true;
};
/************************************************************************/
self.getGeometry = function() {
// Первоначально обновление геометрии считалось через ResizeObserver.
// Но оно не ловило некоторые события, например в последовательности:
// - Находять в HD переходим в фулскрин
// - Меняем разрешение на маленькое
// - Убираем фулскрин
// - Переходим в HD
// - Видим нарушение пропорций
// Так что теперь используются быстре рассчеты через offset*
// вместо getBoundingClientRect().
let res = __streamer.getResolution();
let ratio = Math.min(res.view_width / res.real_width, res.view_height / res.real_height);
return {
"x": Math.round((res.view_width - ratio * res.real_width) / 2),
"y": Math.round((res.view_height - ratio * res.real_height) / 2),
"width": Math.round(ratio * res.real_width),
"height": Math.round(ratio * res.real_height),
"real_width": res.real_width,
"real_height": res.real_height,
};
};
self.setJanusEnabled = function(enabled) {
let has_webrtc = JanusStreamer.is_webrtc_available();
let has_h264 = JanusStreamer.is_h264_available();
let set_enabled = function(imported) {
tools.hidden.setVisible($("stream-message-no-webrtc"), enabled && !has_webrtc);
tools.hidden.setVisible($("stream-message-no-h264"), enabled && !has_h264);
__janus_enabled = (enabled && has_webrtc && imported); // Don't check has_h264 for sure
tools.feature.setEnabled($("stream-mode"), __janus_enabled);
tools.info(
`Stream: Janus WebRTC state: enabled=${enabled},`
+ ` webrtc=${has_webrtc}, h264=${has_h264}, imported=${imported}`
);
let mode = (__janus_enabled ? tools.storage.get("stream.mode", "janus") : "mjpeg");
tools.radio.clickValue("stream-mode-radio", mode);
if (!__janus_enabled) {
tools.feature.setEnabled($("stream-audio"), false); // Enabling in stream_janus.js
}
self.setState(__state);
};
if (enabled && has_webrtc) {
JanusStreamer.ensure_janus(set_enabled);
} else {
set_enabled(false);
}
};
self.setState = function(state) {
__state = state;
if (__janus_enabled !== null) {
__applyState(wm.isWindowVisible($("stream-window")) ? __state : null);
}
};
var __applyState = function(state) {
if (state) {
tools.feature.setEnabled($("stream-quality"), state.features.quality && (state.streamer === null || state.streamer.encoder.quality > 0));
tools.feature.setEnabled($("stream-h264-bitrate"), state.features.h264 && __janus_enabled);
tools.feature.setEnabled($("stream-h264-gop"), state.features.h264 && __janus_enabled);
tools.feature.setEnabled($("stream-resolution"), state.features.resolution);
if (state.streamer) {
tools.el.setEnabled($("stream-quality-slider"), true);
tools.slider.setValue($("stream-quality-slider"), state.streamer.encoder.quality);
if (state.features.h264 && __janus_enabled) {
__setLimitsAndValue($("stream-h264-bitrate-slider"), state.limits.h264_bitrate, state.streamer.h264.bitrate);
tools.el.setEnabled($("stream-h264-bitrate-slider"), true);
__setLimitsAndValue($("stream-h264-gop-slider"), state.limits.h264_gop, state.streamer.h264.gop);
tools.el.setEnabled($("stream-h264-gop-slider"), true);
}
__setLimitsAndValue($("stream-desired-fps-slider"), state.limits.desired_fps, state.streamer.source.desired_fps);
tools.el.setEnabled($("stream-desired-fps-slider"), true);
let resolution_str = __makeStringResolution(state.streamer.source.resolution);
if (__makeStringResolution(__resolution) !== resolution_str) {
__resolution = state.streamer.source.resolution;
}
if (state.features.resolution) {
let el = $("stream-resolution-selector");
if (!state.limits.available_resolutions.includes(resolution_str)) {
state.limits.available_resolutions.push(resolution_str);
}
tools.selector.setValues(el, state.limits.available_resolutions);
tools.selector.setSelectedValue(el, resolution_str);
tools.el.setEnabled(el, true);
}
} else {
tools.el.setEnabled($("stream-quality-slider"), false);
tools.el.setEnabled($("stream-h264-bitrate-slider"), false);
tools.el.setEnabled($("stream-h264-gop-slider"), false);
tools.el.setEnabled($("stream-desired-fps-slider"), false);
tools.el.setEnabled($("stream-resolution-selector"), false);
}
__streamer.ensureStream(state.streamer);
} else {
__streamer.stopStream();
}
};
var __setActive = function() {
$("stream-led").className = "led-green";
$("stream-led").title = "Stream is active";
};
var __setInactive = function() {
$("stream-led").className = "led-gray";
$("stream-led").title = "Stream inactive";
};
var __setInfo = function(is_active, online, text) {
$("stream-box").classList.toggle("stream-box-offline", !online);
let el_grab = document.querySelector("#stream-window-header .window-grab");
let el_info = $("stream-info");
let title = `${__streamer.getName()} &ndash; `;
if (is_active) {
if (!online) {
title += "No signal / ";
}
title += __makeStringResolution(__resolution);
if (text.length > 0) {
title += " / " + text;
}
} else {
if (text.length > 0) {
title += text;
} else {
title += "Inactive";
}
}
el_grab.innerHTML = el_info.innerHTML = title;
};
var __setLimitsAndValue = function(el, limits, value) {
tools.slider.setRange(el, limits.min, limits.max);
tools.slider.setValue(el, value);
};
var __resetStream = function(mode=null) {
if (mode === null) {
mode = __streamer.getMode();
}
__streamer.stopStream();
if (mode === "janus") {
__streamer = new JanusStreamer(__setActive, __setInactive, __setInfo,
tools.storage.getInt("stream.orient", 0), !$("stream-video").muted);
// Firefox doesn't support RTP orientation:
// - https://bugzilla.mozilla.org/show_bug.cgi?id=1316448
tools.feature.setEnabled($("stream-orient"), !tools.browser.is_firefox);
} else { // mjpeg
__streamer = new MjpegStreamer(__setActive, __setInactive, __setInfo);
tools.feature.setEnabled($("stream-orient"), false);
tools.feature.setEnabled($("stream-audio"), false); // Enabling in stream_janus.js
}
if (wm.isWindowVisible($("stream-window"))) {
__streamer.ensureStream(__state ? __state.streamer : null);
}
};
var __clickModeRadio = function() {
let mode = tools.radio.getValue("stream-mode-radio");
tools.storage.set("stream.mode", mode);
if (mode !== __streamer.getMode()) {
tools.hidden.setVisible($("stream-image"), (mode !== "janus"));
tools.hidden.setVisible($("stream-video"), (mode === "janus"));
__resetStream(mode);
}
};
var __clickScreenshotButton = function() {
let el = document.createElement("a");
el.href = "/api/streamer/snapshot";
el.target = "_blank";
document.body.appendChild(el);
el.click();
setTimeout(() => document.body.removeChild(el), 0);
};
var __clickResetButton = function() {
wm.confirm("Are you sure you want to reset stream?").then(function (ok) {
if (ok) {
__resetStream();
tools.httpPost("/api/streamer/reset", function(http) {
if (http.status !== 200) {
wm.error("Can't reset stream:<br>", http.responseText);
}
});
}
});
};
var stream_mjpeg_refresh_img;
var stream_now_fps
let mediaRecorder;
var __clickRecordStartButton = function() {
wm.confirm("Are you sure you want to record stream?").then(function (ok) {
if (ok) {
stream_now_fps = tools.slider.getValue($("stream-desired-fps-slider"));
let recordedBlobs = [];
//"mjpeg" or "janus"
let stream_type = document.querySelector('input[name="stream-mode-radio"]:checked').value;
if ( stream_type == "mjpeg"){
var stream_mjpeg_img = document.getElementById('stream-image');
var stream_mjpeg_canvas = document.getElementById('stream-mjpeg-canvas');
var ctx = stream_mjpeg_canvas.getContext('2d');
stream_mjpeg_canvas.width = stream_mjpeg_img.width;
stream_mjpeg_canvas.height = stream_mjpeg_img.height;
const stream = stream_mjpeg_canvas.captureStream(stream_now_fps); // Capture FPS
mediaRecorder = new MediaRecorder(stream);
}else{
const stream = document.getElementById("stream-video")
stream.captureStream = stream.captureStream || stream.mozCaptureStream;
mediaRecorder = new MediaRecorder(stream.captureStream());
}
mediaRecorder.ondataavailable = function(event) {
if (event.data && event.data.size > 0) {
recordedBlobs.push(event.data);
}
};
mediaRecorder.onstop = function() {
const blob = new Blob(recordedBlobs, {type: 'video/webm'});
var url = URL.createObjectURL(blob);
const a = document.createElement('a');
document.body.appendChild(a);
const now = new Date();
const year = now.getFullYear();
const month = ('0' + (now.getMonth() + 1)).slice(-2);
const day = ('0' + now.getDate()).slice(-2);
const hours = ('0' + now.getHours()).slice(-2);
const minutes = ('0' + now.getMinutes()).slice(-2);
const seconds = ('0' + now.getSeconds()).slice(-2);//Get now time
a.style = "display: none";
a.href = url;
a.download = stream_type +"_"+ year + month + day + hours + minutes + seconds + ".webm";
a.click();
window.URL.revokeObjectURL(url);
};
mediaRecorder.start();
document.getElementById('stream-record-start-button').disabled = true;
document.getElementById('stream-record-stop-button').disabled = false;
if (stream_type == "mjpeg"){
stream_mjpeg_refresh_img = setInterval(function() {
ctx.drawImage(stream_mjpeg_img, 0, 0, stream_mjpeg_img.width, stream_mjpeg_img.height);
}, 1000 / stream_now_fps);
}
}
});
};
var __clickRecordStopButton = function() {
mediaRecorder.stop();
clearInterval(stream_mjpeg_refresh_img);
document.getElementById('stream-record-start-button').disabled = false;
document.getElementById('stream-record-stop-button').disabled = true;
};
var __sendParam = function(name, value) {
tools.httpPost(`/api/streamer/set_params?${name}=${value}`, function(http) {
if (http.status !== 200) {
wm.error("Can't configure stream:<br>", http.responseText);
}
});
};
var __makeStringResolution = function(resolution) {
return `${resolution.width}x${resolution.height}`;
};
__init__();
}