fix: PTZ actions should reflect camera rotation (#2258)
- Closes: #2210
This commit is contained in:
@@ -356,6 +356,9 @@ advanced_camera_card_action: ptz
|
||||
> [!NOTE]
|
||||
> If no `ptz_action` is specified, the camera returns to its "home" position. For a real PTZ camera, the "home" position is the first available preset. If there are no presets, there is no home position.
|
||||
|
||||
> [!TIP]
|
||||
> When a camera has a [`rotation`](../../cameras/README.md?id=dimensions) configured, directional PTZ actions (`left`, `right`, `up`, `down`) are automatically rotated to match the camera's orientation. Use the `ptz_action` value that matches how the camera is being displayed, and it will be automatically translated based on the rotation to the true value.
|
||||
|
||||
## `ptz_controls`
|
||||
|
||||
Show or hide the PTZ controls.
|
||||
|
||||
@@ -139,8 +139,10 @@ cameras:
|
||||
| `rotation` | `0` | Rotates the camera clockwise by `0`, `90`, `180` or `270` degrees. |
|
||||
|
||||
> [!NOTE]
|
||||
> Use of `rotation` causes the browser to rotate the video player, unavoidably _including_ rotating the builtin video controls on the pl
|
||||
> ayer, which may be distracting or confusing (e.g. upside down controls). Builtin controls can be disabled using the [`live.controls.builtin` parameter](../live.md?id=controls). Rotation is not available in iOS fullscreen, due to the limited fullscreen support offered by that OS.
|
||||
> Use of `rotation` causes the browser to rotate the video player, unavoidably _including_ rotating the builtin video controls on the player, which may be distracting or confusing (e.g. upside down controls). Builtin controls can be disabled using the [`live.controls.builtin` parameter](../live.md?id=controls). Rotation is not available in iOS fullscreen, due to the limited fullscreen support offered by that OS.
|
||||
|
||||
> [!TIP]
|
||||
> When rotation is configured, directional PTZ actions (`left`, `right`, `up`, `down`) are automatically rotated to match the camera's orientation.
|
||||
|
||||
> [!WARNING]
|
||||
> Rotating the camera incurs a rendering performance penalty. Always rotate "upstream" if possible (e.g. in your camera settings).
|
||||
|
||||
@@ -3,8 +3,13 @@ import { cloneDeep, sum } from 'lodash-es';
|
||||
import PQueue from 'p-queue';
|
||||
import { CardCameraAPI } from '../card-controller/types.js';
|
||||
import { sortItems } from '../card-controller/view/sort.js';
|
||||
import { PTZAction, PTZActionPhase } from '../config/schema/actions/custom/ptz.js';
|
||||
import { CameraConfig, CamerasConfig } from '../config/schema/cameras.js';
|
||||
import {
|
||||
PTZ_PAN_TILT_ACTIONS,
|
||||
PTZAction,
|
||||
PTZActionPhase,
|
||||
PTZPanTiltAction,
|
||||
} from '../config/schema/actions/custom/ptz.js';
|
||||
import { CameraConfig, CamerasConfig, Rotation } from '../config/schema/cameras.js';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { Endpoint } from '../types.js';
|
||||
@@ -788,6 +793,29 @@ export class CameraManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a PTZ action based on camera rotation setting.
|
||||
* When camera view is rotated, PTZ controls should logically rotate too.
|
||||
* For example: with 90° rotation, pressing "left" should send "down" to camera.
|
||||
*/
|
||||
private _rotatePTZAction(action: PTZAction, rotation?: Rotation): PTZAction {
|
||||
if (!rotation) {
|
||||
return action;
|
||||
}
|
||||
|
||||
// Pan/tilt directions in clockwise order for rotation calculation
|
||||
const index = PTZ_PAN_TILT_ACTIONS.indexOf(action as PTZPanTiltAction);
|
||||
|
||||
if (index === -1) {
|
||||
// Not a directional action (e.g., zoom_in, zoom_out, preset)
|
||||
return action;
|
||||
}
|
||||
|
||||
// Each 90° rotation shifts the direction index counter-clockwise.
|
||||
const shift = (4 - rotation / 90) % 4;
|
||||
return PTZ_PAN_TILT_ACTIONS[(index + shift) % 4];
|
||||
}
|
||||
|
||||
public async executePTZAction(
|
||||
cameraID: string,
|
||||
action: PTZAction,
|
||||
@@ -800,8 +828,12 @@ export class CameraManager {
|
||||
if (!camera) {
|
||||
return;
|
||||
}
|
||||
const rotatedAction = this._rotatePTZAction(
|
||||
action,
|
||||
camera.getConfig().dimensions?.rotation,
|
||||
);
|
||||
await this._requestLimit.add(() =>
|
||||
camera.executePTZAction(this._api.getActionsManager(), action, options),
|
||||
camera.executePTZAction(this._api.getActionsManager(), rotatedAction, options),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { advancedCameraCardCustomActionsBaseSchema } from './base';
|
||||
|
||||
const PTZ_PAN_TILT_ACTIONS = ['left', 'right', 'up', 'down'] as const;
|
||||
export const PTZ_PAN_TILT_ACTIONS = ['up', 'right', 'down', 'left'] as const;
|
||||
export type PTZPanTiltAction = (typeof PTZ_PAN_TILT_ACTIONS)[number];
|
||||
|
||||
const PTZ_ZOOM_ACTIONS = ['zoom_in', 'zoom_out'] as const;
|
||||
const PTZ_BASE_ACTIONS = [...PTZ_PAN_TILT_ACTIONS, ...PTZ_ZOOM_ACTIONS] as const;
|
||||
export type PTZBaseAction = (typeof PTZ_BASE_ACTIONS)[number];
|
||||
|
||||
@@ -1141,6 +1141,129 @@ describe('CameraManager', async () => {
|
||||
|
||||
expect(api.getActionsManager().executeActions).toBeCalledWith({ actions: action });
|
||||
});
|
||||
|
||||
describe('with rotation', () => {
|
||||
it.each([
|
||||
// No rotation
|
||||
[undefined, 'left', 'left', undefined],
|
||||
[undefined, 'right', 'right', undefined],
|
||||
[undefined, 'up', 'up', undefined],
|
||||
[undefined, 'down', 'down', undefined],
|
||||
[0, 'left', 'left', undefined],
|
||||
[0, 'right', 'right', undefined],
|
||||
[0, 'up', 'up', undefined],
|
||||
[0, 'down', 'down', undefined],
|
||||
|
||||
// 90° rotation (clockwise view rotation means controls rotate counter-clockwise)
|
||||
[90, 'left', 'down', undefined],
|
||||
[90, 'right', 'up', undefined],
|
||||
[90, 'up', 'left', undefined],
|
||||
[90, 'down', 'right', undefined],
|
||||
|
||||
// 180° rotation
|
||||
[180, 'left', 'right', undefined],
|
||||
[180, 'right', 'left', undefined],
|
||||
[180, 'up', 'down', undefined],
|
||||
[180, 'down', 'up', undefined],
|
||||
|
||||
// 270° rotation
|
||||
[270, 'left', 'up', undefined],
|
||||
[270, 'right', 'down', undefined],
|
||||
[270, 'up', 'right', undefined],
|
||||
[270, 'down', 'left', undefined],
|
||||
|
||||
// Non-directional actions should pass through unchanged
|
||||
[90, 'zoom_in', 'zoom_in', undefined],
|
||||
[90, 'zoom_out', 'zoom_out', undefined],
|
||||
[90, 'preset', 'preset', 'test-preset'],
|
||||
[180, 'zoom_in', 'zoom_in', undefined],
|
||||
[180, 'zoom_out', 'zoom_out', undefined],
|
||||
[180, 'preset', 'preset', 'test-preset'],
|
||||
[270, 'zoom_in', 'zoom_in', undefined],
|
||||
[270, 'zoom_out', 'zoom_out', undefined],
|
||||
[270, 'preset', 'preset', 'test-preset'],
|
||||
] as const)(
|
||||
'rotates %s° %s to %s',
|
||||
async (rotation, inputAction, expectedAction, preset) => {
|
||||
const api = createCardAPI();
|
||||
const engine = mock<CameraManagerEngine>();
|
||||
const hass = createHASS();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
|
||||
|
||||
const leftAction = {
|
||||
action: 'perform-action' as const,
|
||||
perform_action: 'left-action',
|
||||
};
|
||||
const rightAction = {
|
||||
action: 'perform-action' as const,
|
||||
perform_action: 'right-action',
|
||||
};
|
||||
const upAction = {
|
||||
action: 'perform-action' as const,
|
||||
perform_action: 'up-action',
|
||||
};
|
||||
const downAction = {
|
||||
action: 'perform-action' as const,
|
||||
perform_action: 'down-action',
|
||||
};
|
||||
const zoomInAction = {
|
||||
action: 'perform-action' as const,
|
||||
perform_action: 'zoom-in-action',
|
||||
};
|
||||
const zoomOutAction = {
|
||||
action: 'perform-action' as const,
|
||||
perform_action: 'zoom-out-action',
|
||||
};
|
||||
const presetAction = {
|
||||
action: 'perform-action' as const,
|
||||
perform_action: 'preset-action',
|
||||
};
|
||||
|
||||
const manager = createCameraManager(api, engine, [
|
||||
{
|
||||
config: createCameraConfig({
|
||||
baseCameraConfig,
|
||||
id: 'rotated-camera',
|
||||
dimensions: rotation !== undefined ? { rotation } : undefined,
|
||||
ptz: {
|
||||
actions_left: leftAction,
|
||||
actions_right: rightAction,
|
||||
actions_up: upAction,
|
||||
actions_down: downAction,
|
||||
actions_zoom_in: zoomInAction,
|
||||
actions_zoom_out: zoomOutAction,
|
||||
presets: {
|
||||
'test-preset': presetAction,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(await manager.initializeCamerasFromConfig()).toBeTruthy();
|
||||
|
||||
manager.executePTZAction(
|
||||
'rotated-camera',
|
||||
inputAction,
|
||||
preset ? { preset } : undefined,
|
||||
);
|
||||
|
||||
// Map expected action to the corresponding action object
|
||||
const expectedActionMap = {
|
||||
left: leftAction,
|
||||
right: rightAction,
|
||||
up: upAction,
|
||||
down: downAction,
|
||||
zoom_in: zoomInAction,
|
||||
zoom_out: zoomOutAction,
|
||||
preset: presetAction,
|
||||
};
|
||||
|
||||
expect(api.getActionsManager().executeActions).toBeCalledWith({
|
||||
actions: expectedActionMap[expectedAction],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should determine if queries are fresh', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { Capabilities } from '../../src/camera-manager/capabilities';
|
||||
import { AdvancedCameraCardView } from '../../src/config/schema/common/const';
|
||||
import { IMAGE_VIEW_ZOOM_TARGET_SENTINEL } from '../../src/const';
|
||||
import { PTZMovementType } from '../../src/types';
|
||||
import {
|
||||
getPTZTarget,
|
||||
hasCameraTruePTZ,
|
||||
@@ -64,7 +65,7 @@ describe('getPTZTarget', () => {
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
|
||||
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -105,7 +106,7 @@ describe('getPTZTarget', () => {
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
|
||||
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -139,7 +140,7 @@ describe('hasCameraTruePTZ', () => {
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
capabilities: new Capabilities({ ptz: { left: ['relative'] } }),
|
||||
capabilities: new Capabilities({ ptz: { left: [PTZMovementType.Relative] } }),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user