diff --git a/jsonrpc.go b/jsonrpc.go index 34cca39..f3819c9 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -236,6 +236,124 @@ func rpcSetStreamEncodecType(encodecType string) error { return nil } +type RcQpParams struct { + S32FirstFrameStartQp int `json:"s32FirstFrameStartQp"` + U32StepQp int `json:"u32StepQp"` + U32MinQp int `json:"u32MinQp"` + U32MaxQp int `json:"u32MaxQp"` + U32MinIQp int `json:"u32MinIQp"` + U32MaxIQp int `json:"u32MaxIQp"` + S32DeltIpQp int `json:"s32DeltIpQp"` + S32MaxReEncodeTimes int `json:"s32MaxReEncodeTimes"` + U32FrmMaxQp int `json:"u32FrmMaxQp"` + U32FrmMinQp int `json:"u32FrmMinQp"` + U32FrmMinIQp int `json:"u32FrmMinIQp"` + U32FrmMaxIQp int `json:"u32FrmMaxIQp"` + U32MotionStaticSwitchFrmQp int `json:"u32MotionStaticSwitchFrmQp"` +} + +type VideoRcConfigParams struct { + H264 RcQpParams `json:"h264"` + H265 RcQpParams `json:"h265"` +} + +func rpcSetVideoRc(params VideoRcConfigParams) error { + logger.Info().Interface("params", params).Msg("Setting video RC params") + rcParams := map[string]interface{}{ + "h264": map[string]interface{}{ + "s32FirstFrameStartQp": params.H264.S32FirstFrameStartQp, + "u32StepQp": params.H264.U32StepQp, + "u32MinQp": params.H264.U32MinQp, + "u32MaxQp": params.H264.U32MaxQp, + "u32MinIQp": params.H264.U32MinIQp, + "u32MaxIQp": params.H264.U32MaxIQp, + "s32DeltIpQp": params.H264.S32DeltIpQp, + "s32MaxReEncodeTimes": params.H264.S32MaxReEncodeTimes, + "u32FrmMaxQp": params.H264.U32FrmMaxQp, + "u32FrmMinQp": params.H264.U32FrmMinQp, + "u32FrmMinIQp": params.H264.U32FrmMinIQp, + "u32FrmMaxIQp": params.H264.U32FrmMaxIQp, + "u32MotionStaticSwitchFrmQp": params.H264.U32MotionStaticSwitchFrmQp, + }, + "h265": map[string]interface{}{ + "s32FirstFrameStartQp": params.H265.S32FirstFrameStartQp, + "u32StepQp": params.H265.U32StepQp, + "u32MinQp": params.H265.U32MinQp, + "u32MaxQp": params.H265.U32MaxQp, + "u32MinIQp": params.H265.U32MinIQp, + "u32MaxIQp": params.H265.U32MaxIQp, + "s32DeltIpQp": params.H265.S32DeltIpQp, + "s32MaxReEncodeTimes": params.H265.S32MaxReEncodeTimes, + "u32FrmMaxQp": params.H265.U32FrmMaxQp, + "u32FrmMinQp": params.H265.U32FrmMinQp, + "u32FrmMinIQp": params.H265.U32FrmMinIQp, + "u32FrmMaxIQp": params.H265.U32FrmMaxIQp, + "u32MotionStaticSwitchFrmQp": params.H265.U32MotionStaticSwitchFrmQp, + }, + } + var _, err = CallCtrlAction("set_video_rc", rcParams) + return err +} + +func rpcGetVideoRc() (VideoRcConfigParams, error) { + resp, err := CallCtrlAction("get_video_rc", nil) + if err != nil { + return VideoRcConfigParams{}, err + } + + result := resp.Result + if result == nil { + return VideoRcConfigParams{}, errors.New("invalid response format") + } + + h264Map, _ := result["h264"].(map[string]interface{}) + h265Map, _ := result["h265"].(map[string]interface{}) + + getInt := func(m map[string]interface{}, k string) int { + if v, ok := m[k].(float64); ok { + return int(v) + } + return 0 + } + getUint := func(m map[string]interface{}, k string) int { + return getInt(m, k) + } + + rc := VideoRcConfigParams{ + H264: RcQpParams{ + S32FirstFrameStartQp: getInt(h264Map, "s32FirstFrameStartQp"), + U32StepQp: getUint(h264Map, "u32StepQp"), + U32MinQp: getUint(h264Map, "u32MinQp"), + U32MaxQp: getUint(h264Map, "u32MaxQp"), + U32MinIQp: getUint(h264Map, "u32MinIQp"), + U32MaxIQp: getUint(h264Map, "u32MaxIQp"), + S32DeltIpQp: getInt(h264Map, "s32DeltIpQp"), + S32MaxReEncodeTimes: getInt(h264Map, "s32MaxReEncodeTimes"), + U32FrmMaxQp: getUint(h264Map, "u32FrmMaxQp"), + U32FrmMinQp: getUint(h264Map, "u32FrmMinQp"), + U32FrmMinIQp: getUint(h264Map, "u32FrmMinIQp"), + U32FrmMaxIQp: getUint(h264Map, "u32FrmMaxIQp"), + U32MotionStaticSwitchFrmQp: getUint(h264Map, "u32MotionStaticSwitchFrmQp"), + }, + H265: RcQpParams{ + S32FirstFrameStartQp: getInt(h265Map, "s32FirstFrameStartQp"), + U32StepQp: getUint(h265Map, "u32StepQp"), + U32MinQp: getUint(h265Map, "u32MinQp"), + U32MaxQp: getUint(h265Map, "u32MaxQp"), + U32MinIQp: getUint(h265Map, "u32MinIQp"), + U32MaxIQp: getUint(h265Map, "u32MaxIQp"), + S32DeltIpQp: getInt(h265Map, "s32DeltIpQp"), + S32MaxReEncodeTimes: getInt(h265Map, "s32MaxReEncodeTimes"), + U32FrmMaxQp: getUint(h265Map, "u32FrmMaxQp"), + U32FrmMinQp: getUint(h265Map, "u32FrmMinQp"), + U32FrmMinIQp: getUint(h265Map, "u32FrmMinIQp"), + U32FrmMaxIQp: getUint(h265Map, "u32FrmMaxIQp"), + U32MotionStaticSwitchFrmQp: getUint(h265Map, "u32MotionStaticSwitchFrmQp"), + }, + } + return rc, nil +} + func rpcSetNpuAppStatus(enable bool) error { logger.Info().Bool("enable", enable).Msg("Setting NPU app status") var _, err = CallCtrlAction("set_yolo_enable", map[string]interface{}{"enable": enable}) @@ -1441,7 +1559,7 @@ var rpcHandlers = map[string]RPCHandler{ "resetSDStorage": {Func: rpcResetSDStorage}, "mountSDStorage": {Func: rpcMountSDStorage}, "unmountSDStorage": {Func: rpcUnmountSDStorage}, - "formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm"}}, + "formatSDStorage": {Func: rpcFormatSDStorage, Params: []string{"confirm", "fsType"}}, "mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, "mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}}, "mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, @@ -1527,8 +1645,16 @@ var rpcHandlers = map[string]RPCHandler{ "stopCloudflared": {Func: rpcStopCloudflared}, "getCloudflaredStatus": {Func: rpcGetCloudflaredStatus}, "getCloudflaredLog": {Func: rpcGetCloudflaredLog}, + "getVpnToolSystemInfo": {Func: rpcGetVpnToolSystemInfo}, + "getVpnToolStatus": {Func: rpcGetVpnToolStatus, Params: []string{"tool"}}, + "listVpnToolReleases": {Func: rpcListVpnToolReleases, Params: []string{"tool"}}, + "installVpnTool": {Func: rpcInstallVpnTool, Params: []string{"tool", "version", "assetName", "downloadURL"}}, + "useVpnToolVersion": {Func: rpcUseVpnToolVersion, Params: []string{"tool", "version"}}, + "uninstallVpnToolVersion": {Func: rpcUninstallVpnToolVersion, Params: []string{"tool", "version"}}, "getStreamEncodecType": {Func: rpcGetStreamEncodecType}, "setStreamEncodecType": {Func: rpcSetStreamEncodecType, Params: []string{"encodecType"}}, + "setVideoRc": {Func: rpcSetVideoRc, Params: []string{"params"}}, + "getVideoRc": {Func: rpcGetVideoRc}, "setNpuAppStatus": {Func: rpcSetNpuAppStatus, Params: []string{"enable"}}, "getNpuAppStatus": {Func: rpcGetNpuAppStatus}, "startWireguard": {Func: rpcStartWireguard, Params: []string{"configFile"}}, diff --git a/ui/src/layout/components_side/Video/SettingsVideoSide.tsx b/ui/src/layout/components_side/Video/SettingsVideoSide.tsx index 9b8797c..8c8dee3 100644 --- a/ui/src/layout/components_side/Video/SettingsVideoSide.tsx +++ b/ui/src/layout/components_side/Video/SettingsVideoSide.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Button as AntdButton , Slider , Checkbox, Select } from "antd"; +import { Button as AntdButton , Slider , Checkbox, Select, Modal, InputNumber, Tabs, Typography } from "antd"; import { useReactAt } from "i18n-auto-extractor/react"; import { isMobile } from "react-device-detect"; @@ -10,6 +10,7 @@ import { SettingsItem, SettingsItemNew } from "@components/Settings/SettingsView import notifications from "../../../notifications"; +const { Text } = Typography; @@ -45,6 +46,133 @@ const streamQualityOptions = [ { value: "0.1", label: "Low" }, ]; +type RcQpParams = { + s32FirstFrameStartQp: number; + u32StepQp: number; + u32MinQp: number; + u32MaxQp: number; + u32MinIQp: number; + u32MaxIQp: number; + s32DeltIpQp: number; + s32MaxReEncodeTimes: number; + u32FrmMaxQp: number; + u32FrmMinQp: number; + u32FrmMinIQp: number; + u32FrmMaxIQp: number; + u32MotionStaticSwitchFrmQp: number; +}; + +type VideoRcConfig = { + h264: RcQpParams; + h265: RcQpParams; +}; + +type RcSliderValues = { + stepQp: number; + minQp: number; + minIQp: number; + deltIpQp: number; +}; + +type RcSliderState = { + h264: RcSliderValues; + h265: RcSliderValues; +}; + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +const sliderValueToNumber = (value: number | number[]) => + Array.isArray(value) ? value[0] : value; + +const DEFAULT_RC_CODEC: RcQpParams = { + s32FirstFrameStartQp: 0, + u32StepQp: 48, + u32MinQp: 48, + u32MaxQp: 51, + u32MinIQp: 48, + u32MaxIQp: 51, + s32DeltIpQp: 7, + s32MaxReEncodeTimes: 2, + u32FrmMaxQp: 51, + u32FrmMinQp: 48, + u32FrmMinIQp: 51, + u32FrmMaxIQp: 48, + u32MotionStaticSwitchFrmQp: 50, +}; + +const DEFAULT_VIDEO_RC_CONFIG: VideoRcConfig = { + h264: { ...DEFAULT_RC_CODEC }, + h265: { ...DEFAULT_RC_CODEC }, +}; + +const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const toVideoRcConfigOrDefault = (value: unknown): VideoRcConfig => { + if (!isObjectRecord(value)) return DEFAULT_VIDEO_RC_CONFIG; + if (!("h264" in value) || !("h265" in value)) return DEFAULT_VIDEO_RC_CONFIG; + return value as VideoRcConfig; +}; + +const RC_LIMITS = { + stepQp: { min: 1, max: 51 }, + maxQp: { min: 1, max: 51 }, + maxIQp: { min: 1, max: 51 }, + deltIpQp: { min: -7, max: 7 }, + maxReEncodeTimes: { min: 0, max: 3 }, + frmQp: { min: 1, max: 51 }, +}; + +const normalizeRcCodec = (codec: RcQpParams): RcQpParams => { + const maxQp = clamp(Number(codec.u32MaxQp), RC_LIMITS.maxQp.min, RC_LIMITS.maxQp.max); + const maxIQp = clamp(Number(codec.u32MaxIQp), RC_LIMITS.maxIQp.min, RC_LIMITS.maxIQp.max); + const minQp = clamp(Number(codec.u32MinQp), RC_LIMITS.maxQp.min, maxQp); + const minIQp = clamp(Number(codec.u32MinIQp), RC_LIMITS.maxIQp.min, maxIQp); + + return { + ...codec, + s32FirstFrameStartQp: clamp(Number(codec.s32FirstFrameStartQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max), + u32StepQp: clamp(Number(codec.u32StepQp), RC_LIMITS.stepQp.min, RC_LIMITS.stepQp.max), + u32MaxQp: maxQp, + u32MinQp: minQp, + u32MaxIQp: maxIQp, + u32MinIQp: minIQp, + s32DeltIpQp: clamp(Number(codec.s32DeltIpQp), RC_LIMITS.deltIpQp.min, RC_LIMITS.deltIpQp.max), + s32MaxReEncodeTimes: clamp( + Number(codec.s32MaxReEncodeTimes), + RC_LIMITS.maxReEncodeTimes.min, + RC_LIMITS.maxReEncodeTimes.max, + ), + u32FrmMaxQp: clamp(Number(codec.u32FrmMaxQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max), + u32FrmMinQp: clamp(Number(codec.u32FrmMinQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max), + u32FrmMinIQp: clamp(Number(codec.u32FrmMinIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max), + u32FrmMaxIQp: clamp(Number(codec.u32FrmMaxIQp), RC_LIMITS.frmQp.min, RC_LIMITS.frmQp.max), + u32MotionStaticSwitchFrmQp: clamp( + Number(codec.u32MotionStaticSwitchFrmQp), + RC_LIMITS.frmQp.min, + RC_LIMITS.frmQp.max, + ), + }; +}; + +const normalizeVideoRcConfig = (config: VideoRcConfig): VideoRcConfig => ({ + h264: normalizeRcCodec(config.h264), + h265: normalizeRcCodec(config.h265), +}); + +const sliderValuesFromCodec = (codec: RcQpParams): RcSliderValues => ({ + stepQp: clamp(Number(codec.u32StepQp), 1, 50), + minQp: clamp(Number(codec.u32MinQp), 1, 50), + minIQp: clamp(Number(codec.u32MinIQp), 1, 50), + deltIpQp: clamp(Number(codec.s32DeltIpQp), -7, 7), +}); + +const sliderStateFromConfig = (config: VideoRcConfig): RcSliderState => ({ + h264: sliderValuesFromCodec(config.h264), + h265: sliderValuesFromCodec(config.h265), +}); + export default function SettingsVideoSide() { const { $at } = useReactAt(); const [send] = useJsonRpc(); @@ -54,6 +182,12 @@ export default function SettingsVideoSide() { const [customEdidValue, setCustomEdidValue] = useState(null); const [edid, setEdid] = useState(null); const [forceHpd, setForceHpd] = useState(false); + const [videoRcConfig, setVideoRcConfig] = useState(DEFAULT_VIDEO_RC_CONFIG); + const [rcSliderValues, setRcSliderValues] = useState( + sliderStateFromConfig(DEFAULT_VIDEO_RC_CONFIG), + ); + const [showRcAdvanced, setShowRcAdvanced] = useState(false); + const [rcDraftConfig, setRcDraftConfig] = useState(null); // Video enhancement settings from store const videoSaturation = useSettingsStore(state => state.videoSaturation); @@ -63,6 +197,129 @@ export default function SettingsVideoSide() { const videoContrast = useSettingsStore(state => state.videoContrast); const setVideoContrast = useSettingsStore(state => state.setVideoContrast); + const currentCodec: "h264" | "h265" = streamEncodecType === "hevc" ? "h265" : "h264"; + const currentSliders = rcSliderValues[currentCodec]; + + const applySliderToCodec = (codec: RcQpParams, sliders: RcSliderValues): RcQpParams => ({ + ...codec, + u32StepQp: sliders.stepQp, + u32MinQp: sliders.minQp, + u32MinIQp: sliders.minIQp, + s32DeltIpQp: sliders.deltIpQp, + }); + + const applyRcBasicConfig = () => { + const nextRcConfig = normalizeVideoRcConfig({ + ...videoRcConfig, + [currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders), + }); + send("setVideoRc", { params: nextRcConfig }, resp => { + if ("error" in resp) { + notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`); + return; + } + setVideoRcConfig(nextRcConfig); + setRcDraftConfig(nextRcConfig); + setRcSliderValues(sliderStateFromConfig(nextRcConfig)); + notifications.success("Video RC updated"); + }); + }; + + const updateRcDraftField = ( + codec: "h264" | "h265", + field: keyof RcQpParams, + value: number, + ) => { + setRcDraftConfig(prev => { + if (!prev) return prev; + const nextCodec = { ...prev[codec], [field]: value }; + const normalizedCodec = normalizeRcCodec(nextCodec); + return { + ...prev, + [codec]: normalizedCodec, + }; + }); + }; + + const openRcAdvancedModal = () => { + const nextDraft = normalizeVideoRcConfig({ + ...videoRcConfig, + [currentCodec]: applySliderToCodec(videoRcConfig[currentCodec], currentSliders), + }); + setRcDraftConfig(nextDraft); + setShowRcAdvanced(true); + }; + + const applyRcAdvancedConfig = () => { + if (!rcDraftConfig) { + return; + } + const normalized = normalizeVideoRcConfig(rcDraftConfig); + send("setVideoRc", { params: normalized }, resp => { + if ("error" in resp) { + notifications.error(`Failed to set video RC: ${resp.error.data || "Unknown error"}`); + return; + } + setVideoRcConfig(normalized); + setRcDraftConfig(normalized); + setRcSliderValues(sliderStateFromConfig(normalized)); + notifications.success("Video RC updated"); + setShowRcAdvanced(false); + }); + }; + + const renderRcAdvancedForm = (codec: "h264" | "h265") => { + const current = rcDraftConfig?.[codec]; + if (!current) return null; + + const fields: Array<{ + key: keyof RcQpParams; + label: string; + min?: number; + max?: number; + }> = [ + { key: "s32FirstFrameStartQp", label: "FirstFrameStartQp", min: 1, max: 51 }, + { key: "u32StepQp", label: "StepQp", min: 1, max: 51 }, + { key: "u32MaxQp", label: "MaxQp", min: 1, max: 51 }, + { key: "u32MinQp", label: "MinQp", min: 1, max: Number(current.u32MaxQp) || 51 }, + { key: "u32MaxIQp", label: "MaxIQp", min: 1, max: 51 }, + { key: "u32MinIQp", label: "MinIQp", min: 1, max: Number(current.u32MaxIQp) || 51 }, + { key: "s32DeltIpQp", label: "DeltIpQp", min: -7, max: 7 }, + { key: "s32MaxReEncodeTimes", label: "MaxReEncodeTimes", min: 0, max: 3 }, + { key: "u32FrmMaxQp", label: "FrmMaxQp", min: 1, max: 51 }, + { key: "u32FrmMinQp", label: "FrmMinQp", min: 1, max: 51 }, + { key: "u32FrmMaxIQp", label: "FrmMaxIQp", min: 1, max: 51 }, + { key: "u32FrmMinIQp", label: "FrmMinIQp", min: 1, max: 51 }, + { key: "u32MotionStaticSwitchFrmQp", label: "MotionStaticSwitchFrmQp", min: 1, max: 51 }, + ]; + + return ( +
+ {fields.map(field => ( +
+ {field.label} + { + if (typeof val !== "number") return; + const safeValue = + field.min !== undefined && field.max !== undefined + ? clamp(Number(val), field.min, field.max) + : Number(val); + updateRcDraftField(codec, field.key, safeValue); + }} + /> +
+ ))} +
+ ); + }; + useEffect(() => { send("getNpuAppStatus", {}, resp => { if ("error" in resp) return; @@ -79,6 +336,20 @@ export default function SettingsVideoSide() { setStreamQuality(String(resp.result)); }); + send("getVideoRc", {}, resp => { + if ("error" in resp) { + notifications.error(`Failed to get video RC: ${resp.error.data || "Unknown error"}`); + const fallbackRc = normalizeVideoRcConfig(DEFAULT_VIDEO_RC_CONFIG); + setVideoRcConfig(fallbackRc); + setRcSliderValues(sliderStateFromConfig(fallbackRc)); + return; + } + + const rc = normalizeVideoRcConfig(toVideoRcConfigOrDefault(resp.result)); + setVideoRcConfig(rc); + setRcSliderValues(sliderStateFromConfig(rc)); + }); + send("getEDID", {}, resp => { if ("error" in resp) { notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`); @@ -218,6 +489,116 @@ export default function SettingsVideoSide() { /> + +
+ + { + const nextStepQp = clamp(sliderValueToNumber(value), 1, 50); + setRcSliderValues(prev => ({ + ...prev, + [currentCodec]: { + ...prev[currentCodec], + stepQp: nextStepQp, + }, + })); + }} + className={"w-full"} + /> + + + + { + const nextMinQp = clamp(sliderValueToNumber(value), 1, 50); + setRcSliderValues(prev => ({ + ...prev, + [currentCodec]: { + ...prev[currentCodec], + minQp: nextMinQp, + }, + })); + }} + className={"w-full"} + /> + + + + { + const nextMinIQp = clamp(sliderValueToNumber(value), 1, 50); + setRcSliderValues(prev => ({ + ...prev, + [currentCodec]: { + ...prev[currentCodec], + minIQp: nextMinIQp, + }, + })); + }} + className={"w-full"} + /> + + + + { + const nextDeltIpQp = clamp(sliderValueToNumber(value), -7, 7); + setRcSliderValues(prev => ({ + ...prev, + [currentCodec]: { + ...prev[currentCodec], + deltIpQp: nextDeltIpQp, + }, + })); + }} + className={"w-full"} + /> + + +
+ + {$at("Advanced")} + + + {$at("Apply")} + +
+
- {/* options={[...edids, { value: "custom", label: "Custom" }]}*/} - {/* {*/} - {/* console.log(e.target.value)*/} - {/* if (e.target.value === "custom") {*/} - {/* setEdid("custom");*/} - {/* setCustomEdidValue("");*/} - {/* } else {*/} - {/* setCustomEdidValue(null);*/} - {/* handleEDIDChange(e.target.value as string);*/} - {/* }*/} - {/* }}*/} - {/* options={[...edids, { value: "custom", label: "Custom" }]}*/} - {/*/>*/} {customEdidValue !== null && ( <> @@ -418,6 +781,39 @@ export default function SettingsVideoSide() { )}
+ + + {$at("RC Advanced Config")} + + } + open={showRcAdvanced} + onCancel={() => setShowRcAdvanced(false)} + onOk={applyRcAdvancedConfig} + okText={$at("Apply")} + cancelText={$at("Cancel")} + maskClosable={true} + keyboard={true} + width={520} + styles={{ + body: { + padding: "20px 24px", + }, + header: { + borderBottom: "1px solid #f0f0f0", + padding: "16px 24px", + marginBottom: 0, + } + }} + > + + ); -} \ No newline at end of file +}