Add support for precise layout cropping options.

This commit is contained in:
Dermot Duffy
2024-04-15 16:10:09 -07:00
parent ad1f30554e
commit c7c85d188d
21 changed files with 251 additions and 27 deletions
+26 -5
View File
@@ -114,7 +114,8 @@ cameras:
## `dimensions`
The `dimensions` block configures the dimensions and layout of media of a given camera.
The `dimensions` block configures the dimensions and layout of media of a given camera (see [Card Dimensions](../dimensions.md) to set the dimensions of the whole card and not just a single camera).
```yaml
cameras:
@@ -130,8 +131,7 @@ cameras:
### Layout Configuration
The `layout` block configures the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions configuration](../dimensions.md) ). As the default behavior is to always expand to fit the media precisely, these options only make sense if the camera `dimensions.aspect_ratio` is set to a static value that forces a particular aspect ratio that does not match the camera media.
The `layout` block configures the fit and position of the media _within_ the camera dimensions (in order to control the dimensions for the whole card see [the card dimensions configuration](../dimensions.md) ).
```yaml
cameras:
@@ -143,11 +143,32 @@ cameras:
| Option | Default | Description |
| - | - | - |
| `fit` | `contain` | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the card is fully covered. If `fill`, the media is stretched to fill the card (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. |
| `position` | | A dictionary that contains an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover`. This can be effectively used to "pan" the media around. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the card width (in which case `x` controls the position) or the media height is larger than the card height (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technical details and a visualization. |
| `fit` | `contain` | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. |
| `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. |
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. See visualizations below. |
!> The `view_box` parameter only has effect on browsers that support [`object-view-box`](https://caniuse.com/mdn-css_properties_object-view-box). As of writing this is exclusively Chromium based browsers (e.g. Google Chrome or Microsoft Edge).
See [media layout examples](../../examples.md?id=media-layout).
#### Layout **Visualizations**
##### `fit`
![](../../images/media_layout/fit.png "Media Layout Fit :size=400")
##### `position`: When media is shorter than dimensions height
![](../../images/media_layout/position-shorter-than-height.png "Media Layout Position: Wider than taller :size=400")
##### `position`: When media is thinner than dimensions width
![](../../images/media_layout/position-thinner-than-width.png "Media Layout Position: Taller than wider :size=400")
#### `view_box`: Precise media cropping
![](../../images/media_layout/view-box.png "Media Layout Position: Taller than wider :size=400")
## `triggers`
The `triggers` block configures what triggers a camera. Triggering can be used
+7 -5
View File
@@ -1,10 +1,12 @@
# `dimensions`
These options control the dimensions and aspect-ratio of the card. These options
configuration applies once to the entire card (including the menu, thumbnails,
etc), not just to displayed media. This only applies to the card in normal
render mode -- when in fullscreen, or when in expanded (popup/dialog mode) the
aspect ratio is chosen dynamically to maximize the amount of content shown.
These options control the dimensions and aspect-ratio of the card (See [Camera
Dimensions](./cameras/README.md?id=dimensions) to set the dimensions of a camera
and not the whole card). These options configuration applies once to the entire
card (including the menu, thumbnails, etc), not just to displayed media. This
only applies to the card in normal render mode -- when in fullscreen, or when in
expanded (popup/dialog mode) the aspect ratio is chosen dynamically to maximize
the amount of content shown.
```yaml
dimensions:
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+2
View File
@@ -35,6 +35,7 @@
"lodash-es": "^4.17.21",
"masonry-layout": "^4.2.2",
"moment": "^2.29.4",
"p-queue": "^8.0.1",
"propagating-hammerjs": "^2.0.1",
"quick-lru": "^6.1.0",
"screenfull": "^6.0.2",
@@ -89,6 +90,7 @@
"build": "yarn run lint && yarn run rollup",
"docs": "docsify serve ./docs",
"docs-check-links": "find ./docs -name '*.md' -print0 | xargs -0 -n1 markdown-link-check -c ./markdown-link-check.json",
"docs-update-images": "./scripts/update-images.sh",
"lint": "eslint 'src/**/*.ts'",
"rollup": "rollup -c",
"prune": "ts-prune",
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
WIDTH=800
URL_FIT="https://docs.google.com/drawings/d/e/2PACX-1vTq0SVS8HWs3jGC0jjNpJoYfMbZS6P27CYyPlDhSa9OhdB_3jEb0HTLLYwu8Nv3J1TdjAJppcjTiVNy/pub?w=$WIDTH"
URL_THINNER_THAN_WIDTH="https://docs.google.com/drawings/d/e/2PACX-1vRkMd89N0tkZt5IghPKhR6gs8zMhB-5_hx5QfP6BCxbsSIga_h44IczP06Sj_YnKkxhe0lRdeR-uh04/pub?w=$WIDTH"
URL_SHORTER_THAN_HEIGHT="https://docs.google.com/drawings/d/e/2PACX-1vTKVsXEWIbj9lYKrCeugdLKcK_rOwAZZDK8IzhPdHH4wMwV2v7kEI0nsn2Qgugb00qDVHsE7kE8CBIC/pub?w=$WIDTH"
URL_VIEW_BOX="https://docs.google.com/drawings/d/e/2PACX-1vRpPNsaStxW2ENmv1kfUQg41cua9XQ2sQziq2PC8LdRCtkvjHSKYH3CyPO1Pz7kOdiQ2yQKrBX88-TF/pub?w=$WIDTH"
DIR_MEDIA_LAYOUT_IMAGES="./docs/images/media_layout"
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/fit.png" "$URL_FIT" && \
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-thinner-than-width.png" "$URL_THINNER_THAN_WIDTH" && \
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-shorter-than-height.png" "$URL_SHORTER_THAN_HEIGHT" && \
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/view-box.png" "$URL_VIEW_BOX"
+11 -3
View File
@@ -1,6 +1,7 @@
import add from 'date-fns/add';
import cloneDeep from 'lodash-es/cloneDeep';
import sum from 'lodash-es/sum';
import PQueue from 'p-queue';
import { CardCameraAPI } from '../card-controller/types.js';
import { CameraConfig, CamerasConfig, PTZAction, PTZPhase } from '../config/types.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
@@ -108,6 +109,7 @@ export class CameraManager {
protected _api: CardCameraAPI;
protected _engineFactory: CameraManagerEngineFactory;
protected _store: CameraManagerStore;
protected _initializationLimit = new PQueue({ concurrency: 1 });
constructor(
api: CardCameraAPI,
@@ -138,8 +140,6 @@ export class CameraManager {
return false;
}
await this.reset();
// For each camera merge the config (which has no defaults) into the camera
// global config (which does have defaults). The merging must happen in this
// order, to ensure that the defaults in the cameras global config do not
@@ -148,8 +148,16 @@ export class CameraManager {
recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
);
try {
const resetAndInitialize = async () => {
await this.reset();
await this._initializeCameras(cameras);
};
try {
// This concurrency limit prevents multiple rapidly arriving configs from
// generating reset-n-initialize race conditions (e.g. changing values
// rapidly in the config editor).
await this._initializationLimit.add(resetAndInitialize);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
return false;
+8
View File
@@ -618,6 +618,14 @@ const mediaLayoutConfigSchema = z.object({
y: z.number().min(0).max(100).optional(),
})
.optional(),
view_box: z
.object({
bottom: z.number().min(0).max(100).optional().default(0),
left: z.number().min(0).max(100).optional().default(0),
right: z.number().min(0).max(100).optional().default(0),
top: z.number().min(0).max(100).optional().default(0),
})
.optional(),
});
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
+8
View File
@@ -61,6 +61,14 @@ export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X =
`${CONF_CAMERAS}.#.dimensions.layout.position.x` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y =
`${CONF_CAMERAS}.#.dimensions.layout.position.y` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.top` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.bottom` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.left` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
`${CONF_CAMERAS}.#.triggers.motion` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
+68 -1
View File
@@ -46,6 +46,10 @@ import {
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP,
CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME,
CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID,
CONF_CAMERAS_ARRAY_FRIGATE_LABELS,
@@ -1226,6 +1230,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
configPathFit: string,
configPathPositionX: string,
configPathPositionY: string,
configPathPositionTop: string,
configPathPositionBottom: string,
configPathPositionLeft: string,
configPathPositionRight: string,
): TemplateResult | void {
return this._putInSubmenu(
domain,
@@ -1236,7 +1244,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderOptionSelector(configPathFit, this._layoutFits, {
label: localize('config.cameras.dimensions.layout.fit'),
})}
${this._renderNumberInput(configPathPositionX, {
${this._putInSubmenu(
`${domain}.position`,
true,
'config.cameras.dimensions.layout.position.editor_label',
{ name: 'mdi:aspect-ratio' },
html` ${this._renderNumberInput(configPathPositionX, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.position.x'),
@@ -1245,7 +1258,36 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.position.y'),
})}`,
)}
${this._putInSubmenu(
`${domain}.view_box`,
true,
'config.cameras.dimensions.layout.view_box.editor_label',
{ name: 'mdi:crop' },
html`
${this._renderNumberInput(configPathPositionTop, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.view_box.top'),
})}
${this._renderNumberInput(configPathPositionBottom, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.view_box.bottom'),
})}
${this._renderNumberInput(configPathPositionLeft, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.view_box.left'),
})}
${this._renderNumberInput(configPathPositionRight, {
min: 0,
max: 100,
label: localize('config.cameras.dimensions.layout.view_box.right'),
})}
`,
)}
`,
);
}
@@ -1971,9 +2013,34 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMediaLayout(
MENU_CAMERAS_DIMENSIONS_LAYOUT,
'config.cameras.dimensions.layout.editor_label',
getArrayConfigPath(
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT,
cameraIndex,
),
getArrayConfigPath(
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X,
cameraIndex,
),
getArrayConfigPath(
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y,
cameraIndex,
),
getArrayConfigPath(
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP,
cameraIndex,
),
getArrayConfigPath(
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM,
cameraIndex,
),
getArrayConfigPath(
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT,
cameraIndex,
),
getArrayConfigPath(
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
cameraIndex,
),
)}
`,
)}
+8
View File
@@ -56,8 +56,16 @@
"fill": "El suport s'estira per omplir la targeta"
},
"position": {
"editor_label": "",
"x": "Percentatge d'emplaçament horitzontal",
"y": "Percentatge d'emplaçament vertical"
},
"view_box": {
"bottom": "",
"editor_label": "",
"left": "",
"right": "",
"top": ""
}
}
},
+8
View File
@@ -56,8 +56,16 @@
"fill": "Media is stretched to fill the card"
},
"position": {
"editor_label": "Layout Cover Position",
"x": "Horizontal placement percentage",
"y": "Vertical placement percentage"
},
"view_box": {
"bottom": "Bottom inset percentage",
"editor_label": "Layout View Box",
"left": "Left inset percentage",
"right": "Right inset percentage",
"top": "Top inset percentage"
}
}
},
+8
View File
@@ -56,8 +56,16 @@
"fill": "Il supporto viene allungato per riempire la scheda"
},
"position": {
"editor_label": "",
"x": "Percentuale di posizionamento orizzontale",
"y": "Percentuale di posizionamento verticale"
},
"view_box": {
"bottom": "",
"editor_label": "",
"left": "",
"right": "",
"top": ""
}
}
},
+8
View File
@@ -56,8 +56,16 @@
"fill": "Il supporto viene allungato per riempire la scheda"
},
"position": {
"editor_label": "",
"x": "Percentuale di posizionamento orizzontale",
"y": "Percentuale di posizionamento verticale"
},
"view_box": {
"bottom": "",
"editor_label": "",
"left": "",
"right": "",
"top": ""
}
}
},
+8
View File
@@ -56,8 +56,16 @@
"fill": "A mídia é esticada para preencher o cartão"
},
"position": {
"editor_label": "",
"x": "Porcentagem do posicionamento horizontal",
"y": "Porcentagem do posicionamento vertical"
},
"view_box": {
"bottom": "",
"editor_label": "",
"left": "",
"right": "",
"top": ""
}
}
},
+8
View File
@@ -56,8 +56,16 @@
"fill": "Preencher"
},
"position": {
"editor_label": "",
"x": "Percentagem da localização horizontal",
"y": "Percentagem da localização vertical"
},
"view_box": {
"bottom": "",
"editor_label": "",
"left": "",
"right": "",
"top": ""
}
}
},
+9
View File
@@ -2,4 +2,13 @@
object-fit: var(--frigate-card-media-layout-fit, contain);
object-position: var(--frigate-card-media-layout-position-x, 50%)
var(--frigate-card-media-layout-position-y, 50%);
// This is supported on limited browsers:
// See: https://caniuse.com/?search=object-view-box
object-view-box: inset(
var(--frigate-card-media-layout-view-box-top, 0%)
var(--frigate-card-media-layout-view-box-right, 0%)
var(--frigate-card-media-layout-view-box-bottom, 0%)
var(--frigate-card-media-layout-view-box-left, 0%)
);
}
+10
View File
@@ -24,4 +24,14 @@ export const updateElementStyleFromMediaLayoutConfig = (
element.style.removeProperty(`--frigate-card-media-layout-position-${dimension}`);
}
}
for (const dimension of ['top', 'bottom', 'left', 'right']) {
if (mediaLayoutConfig?.view_box?.[dimension] !== undefined) {
element.style.setProperty(
`--frigate-card-media-layout-view-box-${dimension}`,
`${mediaLayoutConfig.view_box[dimension]}%`,
);
} else {
element.style.removeProperty(`--frigate-card-media-layout-view-box-${dimension}`);
}
}
};
+25
View File
@@ -3622,6 +3622,13 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^5.0.1":
version: 5.0.1
resolution: "eventemitter3@npm:5.0.1"
checksum: 10c0/4ba5c00c506e6c786b4d6262cfbce90ddc14c10d4667e5c83ae993c9de88aa856033994dd2b35b83e8dc1170e224e66a319fa80adc4c32adcd2379bbc75da814
languageName: node
linkType: hard
"execa@npm:^8.0.1":
version: 8.0.1
resolution: "execa@npm:8.0.1"
@@ -3886,6 +3893,7 @@ __metadata:
markdown-link-check: "npm:^3.12.1"
masonry-layout: "npm:^4.2.2"
moment: "npm:^2.29.4"
p-queue: "npm:^8.0.1"
prettier: "npm:^2.6.0"
propagating-hammerjs: "npm:^2.0.1"
quick-lru: "npm:^6.1.0"
@@ -6234,6 +6242,16 @@ __metadata:
languageName: node
linkType: hard
"p-queue@npm:^8.0.1":
version: 8.0.1
resolution: "p-queue@npm:8.0.1"
dependencies:
eventemitter3: "npm:^5.0.1"
p-timeout: "npm:^6.1.2"
checksum: 10c0/fe185bc8bbd32d17a5f6dba090077b1bb326b008b4ec9b0646c57a32a6984035aa8ece909a6d0de7f6c4640296dc288197f430e7394cdc76a26d862339494616
languageName: node
linkType: hard
"p-timeout@npm:^3.1.0, p-timeout@npm:^3.2.0":
version: 3.2.0
resolution: "p-timeout@npm:3.2.0"
@@ -6243,6 +6261,13 @@ __metadata:
languageName: node
linkType: hard
"p-timeout@npm:^6.1.2":
version: 6.1.2
resolution: "p-timeout@npm:6.1.2"
checksum: 10c0/d46b90a9a5fb7c650a5c56dd5cf7102ea9ab6ce998defa2b3d4672789aaec4e2f45b3b0b5a4a3e17a0fb94301ad5dd26da7d8728402e48db2022ad1847594d19
languageName: node
linkType: hard
"p-try@npm:^2.0.0":
version: 2.2.0
resolution: "p-try@npm:2.2.0"