mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-13 07:21:14 +03:00
Compare commits
3 Commits
43d97acd21
...
b1de5e2290
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1de5e2290 | ||
|
|
4fdc107987 | ||
|
|
a83809de54 |
@ -629,10 +629,11 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
# set default min_score for object attributes
|
# set default min_score for object attributes
|
||||||
for attribute in self.model.all_attributes:
|
for attribute in self.model.all_attributes:
|
||||||
if not self.objects.filters.get(attribute):
|
existing = self.objects.filters.get(attribute)
|
||||||
|
if existing is None:
|
||||||
self.objects.filters[attribute] = FilterConfig(min_score=0.7)
|
self.objects.filters[attribute] = FilterConfig(min_score=0.7)
|
||||||
elif self.objects.filters[attribute].min_score == 0.5:
|
elif "min_score" not in existing.model_fields_set:
|
||||||
self.objects.filters[attribute].min_score = 0.7
|
existing.min_score = 0.7
|
||||||
|
|
||||||
# auto detect hwaccel args
|
# auto detect hwaccel args
|
||||||
if self.ffmpeg.hwaccel_args == "auto":
|
if self.ffmpeg.hwaccel_args == "auto":
|
||||||
|
|||||||
@ -203,6 +203,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
"model": self.genai_config.model,
|
"model": self.genai_config.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"timeout": self.timeout,
|
"timeout": self.timeout,
|
||||||
|
**self.genai_config.runtime_options,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
@ -219,7 +220,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
}
|
}
|
||||||
request_params.update(provider_opts)
|
request_params.update(provider_opts)
|
||||||
|
|
||||||
result = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
result = self.provider.chat.completions.create(**request_params)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
result is None
|
result is None
|
||||||
@ -315,6 +316,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
"timeout": self.timeout,
|
"timeout": self.timeout,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"stream_options": {"include_usage": True},
|
"stream_options": {"include_usage": True},
|
||||||
|
**self.genai_config.runtime_options,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
@ -337,7 +339,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
finish_reason = "stop"
|
finish_reason = "stop"
|
||||||
usage_stats: Optional[dict[str, Any]] = None
|
usage_stats: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
stream = self.provider.chat.completions.create(**request_params) # type: ignore[call-overload]
|
stream = self.provider.chat.completions.create(**request_params)
|
||||||
|
|
||||||
for chunk in stream:
|
for chunk in stream:
|
||||||
chunk_usage = getattr(chunk, "usage", None)
|
chunk_usage = getattr(chunk, "usage", None)
|
||||||
|
|||||||
@ -1673,5 +1673,60 @@ class TestConfig(unittest.TestCase):
|
|||||||
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
|
self.assertRaises(ValueError, lambda: FrigateConfig(**config))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttributeFilterDefaults(unittest.TestCase):
|
||||||
|
"""Verify attribute filter min_score handling at config load."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.minimal = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_config(self, object_filters: dict | None = None) -> FrigateConfig:
|
||||||
|
config = deep_merge({}, self.minimal)
|
||||||
|
if object_filters is not None:
|
||||||
|
config.setdefault("objects", {})["filters"] = object_filters
|
||||||
|
return FrigateConfig(**config)
|
||||||
|
|
||||||
|
def test_attribute_with_no_filter_gets_default_min_score(self):
|
||||||
|
"""Attribute with no user-provided filter gets created with min_score=0.7."""
|
||||||
|
config = self._build_config()
|
||||||
|
face_filter = config.objects.filters.get("face")
|
||||||
|
self.assertIsNotNone(face_filter)
|
||||||
|
self.assertEqual(face_filter.min_score, 0.7)
|
||||||
|
|
||||||
|
def test_attribute_filter_without_min_score_gets_bumped(self):
|
||||||
|
"""If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7."""
|
||||||
|
config = self._build_config({"face": {"min_area": 500}})
|
||||||
|
face_filter = config.objects.filters["face"]
|
||||||
|
self.assertEqual(face_filter.min_area, 500)
|
||||||
|
self.assertEqual(face_filter.min_score, 0.7)
|
||||||
|
|
||||||
|
def test_attribute_filter_explicit_min_score_half_is_preserved(self):
|
||||||
|
"""User-provided min_score=0.5 must NOT be silently rewritten to 0.7."""
|
||||||
|
config = self._build_config({"face": {"min_score": 0.5}})
|
||||||
|
face_filter = config.objects.filters["face"]
|
||||||
|
self.assertEqual(face_filter.min_score, 0.5)
|
||||||
|
|
||||||
|
def test_attribute_filter_explicit_min_score_other_value_is_preserved(self):
|
||||||
|
"""Sanity: explicit non-0.5 values pass through unchanged."""
|
||||||
|
config = self._build_config({"face": {"min_score": 0.3}})
|
||||||
|
face_filter = config.objects.filters["face"]
|
||||||
|
self.assertEqual(face_filter.min_score, 0.3)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@ -364,6 +364,64 @@ def main():
|
|||||||
continue
|
continue
|
||||||
section_data.pop(key, None)
|
section_data.pop(key, None)
|
||||||
|
|
||||||
|
if field_name == "objects":
|
||||||
|
# Produce a parallel `filters_attribute` block alongside `filters`,
|
||||||
|
# with object-wording rewritten for attribute filters (face,
|
||||||
|
# license_plate, courier logos). The frontend's
|
||||||
|
# buildTranslationPath routes `filters.<attr>.<field>` lookups to
|
||||||
|
# `filters_attribute.<field>` when `<attr>` is in
|
||||||
|
# `model.all_attributes`. Keep this rewrite list explicit rather
|
||||||
|
# than running a blanket s/object/attribute/ so unrelated
|
||||||
|
# descriptions (e.g. "JSON object") never accidentally flip.
|
||||||
|
filters_block = section_data.get("filters")
|
||||||
|
if isinstance(filters_block, dict):
|
||||||
|
attribute_rewrites = [
|
||||||
|
("Object filters", "Attribute filters"),
|
||||||
|
("detected objects", "detected attributes"),
|
||||||
|
("object area", "attribute area"),
|
||||||
|
("object type", "attribute"),
|
||||||
|
("the object", "the attribute"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Per-field overrides for cases where the generic rewrite
|
||||||
|
# doesn't capture the attribute-specific semantics. Keys
|
||||||
|
# match the FilterConfig field name; values are partial
|
||||||
|
# overrides applied AFTER the generic rewrites.
|
||||||
|
attribute_field_overrides: Dict[str, Dict[str, str]] = {
|
||||||
|
"min_score": {
|
||||||
|
"description": (
|
||||||
|
"Minimum single-frame detection confidence required "
|
||||||
|
"to associate this attribute with its parent object."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def rewrite(text: str) -> str:
|
||||||
|
for source, replacement in attribute_rewrites:
|
||||||
|
text = text.replace(source, replacement)
|
||||||
|
return text
|
||||||
|
|
||||||
|
attribute_variant: Dict[str, Any] = {}
|
||||||
|
for key, value in filters_block.items():
|
||||||
|
if key in ("label", "description"):
|
||||||
|
if isinstance(value, str):
|
||||||
|
attribute_variant[key] = rewrite(value)
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
field_trans: Dict[str, str] = {}
|
||||||
|
if isinstance(value.get("label"), str):
|
||||||
|
field_trans["label"] = rewrite(value["label"])
|
||||||
|
if isinstance(value.get("description"), str):
|
||||||
|
field_trans["description"] = rewrite(value["description"])
|
||||||
|
overrides = attribute_field_overrides.get(key)
|
||||||
|
if overrides:
|
||||||
|
field_trans.update(overrides)
|
||||||
|
if field_trans:
|
||||||
|
attribute_variant[key] = field_trans
|
||||||
|
if attribute_variant:
|
||||||
|
section_data["filters_attribute"] = attribute_variant
|
||||||
|
|
||||||
if not section_data:
|
if not section_data:
|
||||||
logger.warning(f"No translations found for section: {field_name}")
|
logger.warning(f"No translations found for section: {field_name}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
235
web/e2e/specs/settings/go2rtc-streams.spec.ts
Normal file
235
web/e2e/specs/settings/go2rtc-streams.spec.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* go2rtc streams settings page tests -- MEDIUM tier.
|
||||||
|
*
|
||||||
|
* Regression coverage for the compat-mode (ffmpeg:) URL editor: unknown
|
||||||
|
* fragments like #timeout=10 must remain visible and editable when the
|
||||||
|
* stream is using compatibility mode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "../../fixtures/frigate-test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const STREAM_NAME = "dome_sub";
|
||||||
|
const FFMPEG_URL_WITH_TIMEOUT =
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10";
|
||||||
|
|
||||||
|
async function installRawPathsRoute(page: Page, streamUrl: string) {
|
||||||
|
let lastSavedConfig: unknown = null;
|
||||||
|
await page.route("**/api/config/raw_paths", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
cameras: {},
|
||||||
|
go2rtc: { streams: { [STREAM_NAME]: [streamUrl] } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/api/config/set", async (route) => {
|
||||||
|
lastSavedConfig = route.request().postDataJSON();
|
||||||
|
await route.fulfill({ json: { success: true, require_restart: false } });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
capturedConfig: () => lastSavedConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expandStream(page: Page, streamName: string) {
|
||||||
|
// Each StreamCard renders the stream name as an h4 next to a rename
|
||||||
|
// button, with the chevron toggle as the last button in the header row.
|
||||||
|
// Scope to the header row (h4's grandparent) and click that last button.
|
||||||
|
const headerRow = page
|
||||||
|
.locator(`h4:text-is("${streamName}")`)
|
||||||
|
.locator("xpath=../..");
|
||||||
|
await headerRow.getByRole("button").last().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("go2rtc streams settings — ffmpeg compat mode @medium", () => {
|
||||||
|
test("preserves unknown fragments like #timeout= in the URL input", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await installRawPathsRoute(frigateApp.page, FFMPEG_URL_WITH_TIMEOUT);
|
||||||
|
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("heading", { name: STREAM_NAME }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await expandStream(frigateApp.page, STREAM_NAME);
|
||||||
|
|
||||||
|
const urlInput = frigateApp.page.getByPlaceholder(
|
||||||
|
"e.g., rtsp://user:pass@192.168.1.100/stream",
|
||||||
|
);
|
||||||
|
await expect(urlInput).toBeVisible();
|
||||||
|
|
||||||
|
// Focus the input so credential masking is bypassed and the raw value
|
||||||
|
// is rendered — this matches how a user would inspect the URL before
|
||||||
|
// editing it.
|
||||||
|
await urlInput.focus();
|
||||||
|
await expect(urlInput).toHaveValue(
|
||||||
|
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lets the user add an extra fragment in compat mode", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const capture = await installRawPathsRoute(
|
||||||
|
frigateApp.page,
|
||||||
|
FFMPEG_URL_WITH_TIMEOUT,
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||||
|
await expandStream(frigateApp.page, STREAM_NAME);
|
||||||
|
|
||||||
|
const urlInput = frigateApp.page.getByPlaceholder(
|
||||||
|
"e.g., rtsp://user:pass@192.168.1.100/stream",
|
||||||
|
);
|
||||||
|
await urlInput.focus();
|
||||||
|
await urlInput.fill(
|
||||||
|
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
|
||||||
|
);
|
||||||
|
await urlInput.blur();
|
||||||
|
|
||||||
|
// Reopen and re-focus to assert the new value round-tripped through
|
||||||
|
// parseFfmpegBaseAndExtras + buildFfmpegUrl back into the displayed text.
|
||||||
|
await urlInput.focus();
|
||||||
|
await expect(urlInput).toHaveValue(
|
||||||
|
"rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save and verify the persisted URL includes both extras after the
|
||||||
|
// recognized video/audio directives.
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||||
|
.toMatchObject({
|
||||||
|
config_data: {
|
||||||
|
go2rtc: {
|
||||||
|
streams: {
|
||||||
|
[STREAM_NAME]: [
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10#backchannel=0",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves repeatable #audio= fallback chain and lets the user add another codec", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const capture = await installRawPathsRoute(
|
||||||
|
frigateApp.page,
|
||||||
|
// Idiomatic go2rtc fallback: copy if source has the codec, else transcode
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||||
|
await expandStream(frigateApp.page, STREAM_NAME);
|
||||||
|
|
||||||
|
// Two pre-populated audio rows — one per #audio= fragment.
|
||||||
|
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
|
||||||
|
const audioRowsContainer = audioLabel.locator("xpath=../..");
|
||||||
|
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(2);
|
||||||
|
await expect(audioRowsContainer.getByRole("combobox").first()).toHaveText(
|
||||||
|
"Copy",
|
||||||
|
);
|
||||||
|
await expect(audioRowsContainer.getByRole("combobox").nth(1)).toHaveText(
|
||||||
|
"Transcode to Opus",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a third audio codec via the LuPlus next to the "Audio" label.
|
||||||
|
await audioRowsContainer
|
||||||
|
.getByRole("button", { name: "Add audio codec" })
|
||||||
|
.click();
|
||||||
|
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(3);
|
||||||
|
|
||||||
|
// Change the newly-added entry to AAC.
|
||||||
|
await audioRowsContainer.getByRole("combobox").nth(2).click();
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("option", { name: "Transcode to AAC" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||||
|
.toMatchObject({
|
||||||
|
config_data: {
|
||||||
|
go2rtc: {
|
||||||
|
streams: {
|
||||||
|
[STREAM_NAME]: [
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus#audio=aac",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LuX is only shown on fallback rows and removes only that codec", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const capture = await installRawPathsRoute(
|
||||||
|
frigateApp.page,
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus",
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||||
|
await expandStream(frigateApp.page, STREAM_NAME);
|
||||||
|
|
||||||
|
const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`);
|
||||||
|
const audioRowsContainer = audioLabel.locator("xpath=../..");
|
||||||
|
const removeButtons = audioRowsContainer.getByRole("button", {
|
||||||
|
name: "Remove codec",
|
||||||
|
});
|
||||||
|
// Primary (audio=copy) row is permanent and has no X; only the audio=opus
|
||||||
|
// fallback exposes a remove button.
|
||||||
|
await expect(removeButtons).toHaveCount(1);
|
||||||
|
|
||||||
|
await removeButtons.first().click();
|
||||||
|
await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(1);
|
||||||
|
await expect(audioRowsContainer.getByRole("combobox")).toHaveText("Copy");
|
||||||
|
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||||
|
.toMatchObject({
|
||||||
|
config_data: {
|
||||||
|
go2rtc: {
|
||||||
|
streams: {
|
||||||
|
[STREAM_NAME]: [
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("picking Exclude on the primary row drops the #video= fragment entirely", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
const capture = await installRawPathsRoute(
|
||||||
|
frigateApp.page,
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy",
|
||||||
|
);
|
||||||
|
await frigateApp.goto("/settings?page=systemGo2rtcStreams");
|
||||||
|
await expandStream(frigateApp.page, STREAM_NAME);
|
||||||
|
|
||||||
|
const videoLabel = frigateApp.page.locator(`label:text-is("Video")`);
|
||||||
|
const videoRowsContainer = videoLabel.locator("xpath=../..");
|
||||||
|
await videoRowsContainer.getByRole("combobox").first().click();
|
||||||
|
await frigateApp.page.getByRole("option", { name: "Exclude" }).click();
|
||||||
|
|
||||||
|
await frigateApp.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
|
||||||
|
.toMatchObject({
|
||||||
|
config_data: {
|
||||||
|
go2rtc: {
|
||||||
|
streams: {
|
||||||
|
[STREAM_NAME]: [
|
||||||
|
"ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#audio=copy",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -921,6 +921,41 @@
|
|||||||
"label": "Original GenAI state",
|
"label": "Original GenAI state",
|
||||||
"description": "Indicates whether GenAI was enabled in the original static config."
|
"description": "Indicates whether GenAI was enabled in the original static config."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"filters_attribute": {
|
||||||
|
"label": "Attribute filters",
|
||||||
|
"description": "Filters applied to detected attributes to reduce false positives (area, ratio, confidence).",
|
||||||
|
"min_area": {
|
||||||
|
"label": "Minimum attribute area",
|
||||||
|
"description": "Minimum bounding box area (pixels or percentage) required for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
|
||||||
|
},
|
||||||
|
"max_area": {
|
||||||
|
"label": "Maximum attribute area",
|
||||||
|
"description": "Maximum bounding box area (pixels or percentage) allowed for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
|
||||||
|
},
|
||||||
|
"min_ratio": {
|
||||||
|
"label": "Minimum aspect ratio",
|
||||||
|
"description": "Minimum width/height ratio required for the bounding box to qualify."
|
||||||
|
},
|
||||||
|
"max_ratio": {
|
||||||
|
"label": "Maximum aspect ratio",
|
||||||
|
"description": "Maximum width/height ratio allowed for the bounding box to qualify."
|
||||||
|
},
|
||||||
|
"threshold": {
|
||||||
|
"label": "Confidence threshold",
|
||||||
|
"description": "Average detection confidence threshold required for the attribute to be considered a true positive."
|
||||||
|
},
|
||||||
|
"min_score": {
|
||||||
|
"label": "Minimum confidence",
|
||||||
|
"description": "Minimum single-frame detection confidence required to associate this attribute with its parent object."
|
||||||
|
},
|
||||||
|
"mask": {
|
||||||
|
"label": "Filter mask",
|
||||||
|
"description": "Polygon coordinates defining where this filter applies within the frame."
|
||||||
|
},
|
||||||
|
"raw_mask": {
|
||||||
|
"label": "Raw Mask"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"record": {
|
"record": {
|
||||||
|
|||||||
@ -1649,6 +1649,7 @@
|
|||||||
"addStream": "Add stream",
|
"addStream": "Add stream",
|
||||||
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
|
"addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.",
|
||||||
"addUrl": "Add URL",
|
"addUrl": "Add URL",
|
||||||
|
"streamNumber": "Stream {{index}}",
|
||||||
"streamName": "Stream name",
|
"streamName": "Stream name",
|
||||||
"streamNamePlaceholder": "e.g., front_door",
|
"streamNamePlaceholder": "e.g., front_door",
|
||||||
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
|
"streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream",
|
||||||
@ -1682,7 +1683,15 @@
|
|||||||
"audioMp3": "Transcode to MP3",
|
"audioMp3": "Transcode to MP3",
|
||||||
"audioExclude": "Exclude",
|
"audioExclude": "Exclude",
|
||||||
"hardwareNone": "No hardware acceleration",
|
"hardwareNone": "No hardware acceleration",
|
||||||
"hardwareAuto": "Automatic hardware acceleration"
|
"hardwareAuto": "Automatic (recommended)",
|
||||||
|
"hardwareVaapi": "VAAPI",
|
||||||
|
"hardwareCuda": "CUDA",
|
||||||
|
"hardwareV4l2m2m": "V4L2 M2M",
|
||||||
|
"hardwareDxva2": "DXVA2",
|
||||||
|
"hardwareVideotoolbox": "VideoToolbox",
|
||||||
|
"addVideoCodec": "Add video codec",
|
||||||
|
"addAudioCodec": "Add audio codec",
|
||||||
|
"removeCodec": "Remove codec"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
|
|||||||
@ -1,12 +1,60 @@
|
|||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import type { HiddenFieldContext } from "@/types/configForm";
|
||||||
|
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||||
import type { SectionConfigOverrides } from "./types";
|
import type { SectionConfigOverrides } from "./types";
|
||||||
|
|
||||||
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
|
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
|
||||||
// etc.) are populated into objects.filters by the backend even when the
|
// etc.) are populated into objects.filters by the backend for every
|
||||||
// model can't actually detect them. They aren't user-settable, so hide any
|
// attribute the model knows about.
|
||||||
// `filters.<attr>` patterns from forms and override comparisons.
|
//
|
||||||
const hideAttributeFilters = (config: FrigateConfig): string[] =>
|
// - Untracked attributes: hide the whole `filters.<attr>` collapsible.
|
||||||
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
|
// - Tracked attributes: strip the FilterConfig fields we don't expose
|
||||||
|
// (`threshold`, `min_ratio`, `max_ratio`) from the form data so RJSF
|
||||||
|
// doesn't surface them as ad-hoc additionalProperties entries under the
|
||||||
|
// restricted AttributeFilter schema (see modifySchemaForSection objects
|
||||||
|
// branch). The data is sanitized out symmetrically from the baseline
|
||||||
|
// too, so power-user YAML values for those fields are preserved on save
|
||||||
|
// (buildOverrides only emits diffs of fields the form has seen).
|
||||||
|
const ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS = [
|
||||||
|
"threshold",
|
||||||
|
"min_ratio",
|
||||||
|
"max_ratio",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hideAttributeFilters = ({
|
||||||
|
fullConfig,
|
||||||
|
fullCameraConfig,
|
||||||
|
level,
|
||||||
|
formData,
|
||||||
|
}: HiddenFieldContext): string[] => {
|
||||||
|
const trackFromForm = Array.isArray(
|
||||||
|
(formData as { track?: unknown } | undefined)?.track,
|
||||||
|
)
|
||||||
|
? (formData as { track: string[] }).track
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const track =
|
||||||
|
trackFromForm ??
|
||||||
|
(level !== "global" ? fullCameraConfig?.objects?.track : undefined) ??
|
||||||
|
fullConfig.objects?.track ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
const attrs = getEffectiveAttributeLabels(
|
||||||
|
fullConfig,
|
||||||
|
fullCameraConfig,
|
||||||
|
level,
|
||||||
|
);
|
||||||
|
const hidden: string[] = [];
|
||||||
|
for (const attr of attrs) {
|
||||||
|
if (!track.includes(attr)) {
|
||||||
|
hidden.push(`filters.${attr}`);
|
||||||
|
} else {
|
||||||
|
for (const field of ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS) {
|
||||||
|
hidden.push(`filters.${attr}.${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
|
};
|
||||||
|
|
||||||
const objects: SectionConfigOverrides = {
|
const objects: SectionConfigOverrides = {
|
||||||
base: {
|
base: {
|
||||||
|
|||||||
@ -308,11 +308,30 @@ export function ConfigSection({
|
|||||||
// Get section schema using cached hook
|
// Get section schema using cached hook
|
||||||
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
|
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
|
||||||
|
|
||||||
// Apply special case handling for sections with problematic schema defaults
|
// Apply special case handling for sections with problematic schema defaults.
|
||||||
|
// The HiddenFieldContext is built from `config` (saved state) only — not the
|
||||||
|
// in-flight raw section value — because the schema is computed before
|
||||||
|
// rawFormData is derived. The objects-branch fallback in
|
||||||
|
// modifySchemaForSection reads `track` from fullCameraConfig / fullConfig.
|
||||||
const modifiedSchema = useMemo(
|
const modifiedSchema = useMemo(
|
||||||
() =>
|
() =>
|
||||||
modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined),
|
modifySchemaForSection(
|
||||||
[sectionPath, level, sectionSchema],
|
sectionPath,
|
||||||
|
level,
|
||||||
|
sectionSchema ?? undefined,
|
||||||
|
config
|
||||||
|
? {
|
||||||
|
fullConfig: config,
|
||||||
|
fullCameraConfig:
|
||||||
|
effectiveLevel === "camera" && cameraName
|
||||||
|
? config.cameras?.[cameraName]
|
||||||
|
: undefined,
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
[sectionPath, level, sectionSchema, config, effectiveLevel, cameraName],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get override status (camera vs global)
|
// Get override status (camera vs global)
|
||||||
@ -384,7 +403,19 @@ export function ConfigSection({
|
|||||||
// When editing a profile, hide fields that require a restart since they
|
// When editing a profile, hide fields that require a restart since they
|
||||||
// cannot take effect via profile switching alone.
|
// cannot take effect via profile switching alone.
|
||||||
const effectiveHiddenFields = useMemo(() => {
|
const effectiveHiddenFields = useMemo(() => {
|
||||||
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
|
const ctx = config
|
||||||
|
? {
|
||||||
|
fullConfig: config,
|
||||||
|
fullCameraConfig:
|
||||||
|
effectiveLevel === "camera" && cameraName
|
||||||
|
? config.cameras?.[cameraName]
|
||||||
|
: undefined,
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
formData: rawFormData,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, ctx);
|
||||||
if (!profileName || !sectionConfig.restartRequired?.length) {
|
if (!profileName || !sectionConfig.restartRequired?.length) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@ -394,6 +425,10 @@ export function ConfigSection({
|
|||||||
sectionConfig.hiddenFields,
|
sectionConfig.hiddenFields,
|
||||||
sectionConfig.restartRequired,
|
sectionConfig.restartRequired,
|
||||||
config,
|
config,
|
||||||
|
effectiveLevel,
|
||||||
|
cameraName,
|
||||||
|
level,
|
||||||
|
rawFormData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sanitizeSectionData = useCallback(
|
const sanitizeSectionData = useCallback(
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile";
|
|||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { formatList } from "@/utils/stringUtil";
|
import { formatList } from "@/utils/stringUtil";
|
||||||
import {
|
import {
|
||||||
|
buildHiddenFieldContext,
|
||||||
getEffectiveHiddenFields,
|
getEffectiveHiddenFields,
|
||||||
pathMatchesHiddenPattern,
|
pathMatchesHiddenPattern,
|
||||||
} from "@/utils/configUtil";
|
} from "@/utils/configUtil";
|
||||||
@ -187,7 +188,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
|||||||
const hiddenFields = getEffectiveHiddenFields(
|
const hiddenFields = getEffectiveHiddenFields(
|
||||||
sectionPath,
|
sectionPath,
|
||||||
"global",
|
"global",
|
||||||
config,
|
buildHiddenFieldContext(config, "global"),
|
||||||
);
|
);
|
||||||
if (hiddenFields.length === 0) return rawEntries;
|
if (hiddenFields.length === 0) return rawEntries;
|
||||||
return rawEntries
|
return rawEntries
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
import { RJSFSchema } from "@rjsf/utils";
|
import { RJSFSchema } from "@rjsf/utils";
|
||||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||||
import { isJsonObject } from "@/lib/utils";
|
import { isJsonObject } from "@/lib/utils";
|
||||||
import { JsonObject, JsonValue } from "@/types/configForm";
|
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
|
||||||
|
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sections that require special handling at the global level.
|
* Sections that require special handling at the global level.
|
||||||
@ -37,13 +38,28 @@ export function isSpecialCaseSection(
|
|||||||
*
|
*
|
||||||
* - detectors: Strip the "default" field to prevent RJSF from merging the
|
* - detectors: Strip the "default" field to prevent RJSF from merging the
|
||||||
* default {"cpu": {"type": "cpu"}} with stored detector keys.
|
* default {"cpu": {"type": "cpu"}} with stored detector keys.
|
||||||
|
* - genai: Inject a default provider value on the additionalProperties shape.
|
||||||
|
* - objects: Promote tracked attribute labels (face, license_plate, courier
|
||||||
|
* logos) from `filters.additionalProperties` to explicit
|
||||||
|
* `filters.properties.<attr>` entries with a restricted FilterConfig
|
||||||
|
* shape, so RJSF renders just that one field for
|
||||||
|
* attribute filters. Non-attribute tracked labels (person, car, …)
|
||||||
|
* keep flowing through the unmodified `additionalProperties` and render
|
||||||
|
* the full FilterConfig form.
|
||||||
*/
|
*/
|
||||||
export function modifySchemaForSection(
|
export function modifySchemaForSection(
|
||||||
sectionPath: string,
|
sectionPath: string,
|
||||||
level: string,
|
level: string,
|
||||||
schema: RJSFSchema | undefined,
|
schema: RJSFSchema | undefined,
|
||||||
|
ctx?: HiddenFieldContext,
|
||||||
): RJSFSchema | undefined {
|
): RJSFSchema | undefined {
|
||||||
if (!schema || !isSpecialCaseSection(sectionPath, level)) {
|
if (!schema) return schema;
|
||||||
|
|
||||||
|
if (sectionPath === "objects") {
|
||||||
|
return modifyObjectsSchema(schema, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSpecialCaseSection(sectionPath, level)) {
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +95,151 @@ export function modifySchemaForSection(
|
|||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stripped FilterConfig schema for tracked attribute filters
|
||||||
|
* (face, license_plate, etc.). Keeps only the fields meaningful for
|
||||||
|
* attribute detections — `min_score`, `min_area`, `max_area`. `threshold`
|
||||||
|
* and the ratio fields aren't exposed: attributes don't flow through
|
||||||
|
* `_is_false_positive` (no median-of-history check), and aspect-ratio
|
||||||
|
* filtering isn't a typical attribute-tuning knob.
|
||||||
|
*
|
||||||
|
* `min_area` and `max_area` are `Union[int, float]` in Pydantic which
|
||||||
|
* emits as `anyOf` in JSON schema; we flatten to a plain `number` so RJSF
|
||||||
|
* doesn't render the int/float type-selector dropdown for each attribute
|
||||||
|
* filter. The backend still accepts either int (pixels) or float
|
||||||
|
* (percentage) since the underlying FilterConfig union is unchanged.
|
||||||
|
*/
|
||||||
|
function buildAttributeFilterSchema(
|
||||||
|
filterConfigSchema: RJSFSchema,
|
||||||
|
attributeLabel: string,
|
||||||
|
): RJSFSchema {
|
||||||
|
const props = isJsonObject(
|
||||||
|
(filterConfigSchema as { properties?: unknown }).properties,
|
||||||
|
)
|
||||||
|
? (filterConfigSchema as { properties: Record<string, RJSFSchema> })
|
||||||
|
.properties
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const minScoreSchema =
|
||||||
|
props && props.min_score ? props.min_score : { type: "number" };
|
||||||
|
|
||||||
|
const flattenToNumber = (src: RJSFSchema | undefined): RJSFSchema => {
|
||||||
|
if (!src) return { type: "number" };
|
||||||
|
const { anyOf: _anyOf, ...rest } = src as {
|
||||||
|
anyOf?: unknown;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
return { ...rest, type: "number" } as RJSFSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "object",
|
||||||
|
title: attributeLabel,
|
||||||
|
properties: {
|
||||||
|
min_score: minScoreSchema,
|
||||||
|
min_area: flattenToNumber(props && props.min_area),
|
||||||
|
max_area: flattenToNumber(props && props.max_area),
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
} as RJSFSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifyObjectsSchema(
|
||||||
|
schema: RJSFSchema,
|
||||||
|
ctx: HiddenFieldContext | undefined,
|
||||||
|
): RJSFSchema {
|
||||||
|
if (!ctx) return schema;
|
||||||
|
|
||||||
|
const allAttributes = getEffectiveAttributeLabels(
|
||||||
|
ctx.fullConfig,
|
||||||
|
ctx.fullCameraConfig,
|
||||||
|
ctx.level,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve effective track at this scope, falling back through camera
|
||||||
|
// config then global config (matches hideAttributeFilters in objects.ts).
|
||||||
|
const trackFromForm = Array.isArray(
|
||||||
|
(ctx.formData as { track?: unknown } | undefined)?.track,
|
||||||
|
)
|
||||||
|
? (ctx.formData as { track: string[] }).track
|
||||||
|
: undefined;
|
||||||
|
const track =
|
||||||
|
trackFromForm ??
|
||||||
|
(ctx.level !== "global"
|
||||||
|
? ctx.fullCameraConfig?.objects?.track
|
||||||
|
: undefined) ??
|
||||||
|
ctx.fullConfig.objects?.track ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
if (track.length === 0) return schema;
|
||||||
|
|
||||||
|
const schemaProperties = isJsonObject(
|
||||||
|
(schema as { properties?: unknown }).properties,
|
||||||
|
)
|
||||||
|
? (schema as { properties: Record<string, RJSFSchema> }).properties
|
||||||
|
: undefined;
|
||||||
|
const filtersSchema =
|
||||||
|
schemaProperties && schemaProperties.filters
|
||||||
|
? schemaProperties.filters
|
||||||
|
: undefined;
|
||||||
|
if (!filtersSchema) return schema;
|
||||||
|
|
||||||
|
const filterEntrySchema = isJsonObject(
|
||||||
|
(filtersSchema as { additionalProperties?: unknown }).additionalProperties,
|
||||||
|
)
|
||||||
|
? (filtersSchema as { additionalProperties: RJSFSchema })
|
||||||
|
.additionalProperties
|
||||||
|
: undefined;
|
||||||
|
if (!filterEntrySchema) return schema;
|
||||||
|
|
||||||
|
const attributeSet = new Set(allAttributes);
|
||||||
|
const existingProperties = isJsonObject(
|
||||||
|
(filtersSchema as { properties?: unknown }).properties,
|
||||||
|
)
|
||||||
|
? (filtersSchema as { properties: Record<string, RJSFSchema> }).properties
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// Promote every tracked label to an explicit property entry so RJSF
|
||||||
|
// renders it as a normal collapsible (no additionalProperties key/value
|
||||||
|
// editor UI). Attribute labels get a restricted shape with only
|
||||||
|
// `min_score`; non-attribute labels get the full FilterConfig. Sorted
|
||||||
|
// alphabetically so the filter collapsibles match the order of the
|
||||||
|
// sibling `track` switches.
|
||||||
|
const sortedTrackedLabels = track
|
||||||
|
.filter((label): label is string => typeof label === "string")
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
const updatedFilterProperties: Record<string, RJSFSchema> = {
|
||||||
|
...existingProperties,
|
||||||
|
};
|
||||||
|
for (const label of sortedTrackedLabels) {
|
||||||
|
if (attributeSet.has(label)) {
|
||||||
|
updatedFilterProperties[label] = buildAttributeFilterSchema(
|
||||||
|
filterEntrySchema,
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updatedFilterProperties[label] = {
|
||||||
|
...filterEntrySchema,
|
||||||
|
title: label,
|
||||||
|
} as RJSFSchema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedFiltersSchema: RJSFSchema = {
|
||||||
|
...filtersSchema,
|
||||||
|
properties: updatedFilterProperties,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
properties: {
|
||||||
|
...schemaProperties,
|
||||||
|
filters: updatedFiltersSchema,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get effective defaults for sections with special schema patterns.
|
* Get effective defaults for sections with special schema patterns.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConfigFormContext } from "@/types/configForm";
|
import type { ConfigFormContext } from "@/types/configForm";
|
||||||
|
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null;
|
typeof value === "object" && value !== null;
|
||||||
@ -70,12 +71,27 @@ export function buildTranslationPath(
|
|||||||
(segment): segment is string => typeof segment === "string",
|
(segment): segment is string => typeof segment === "string",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle filters section - skip the dynamic filter object name
|
// Handle filters section - skip the dynamic filter object name. Route
|
||||||
// Example: filters.person.threshold -> filters.threshold
|
// to `filters_attribute.<field>` when the dynamic key is an attribute
|
||||||
|
// label (face, license_plate, courier logos) so attribute filter fields
|
||||||
|
// pick up the attribute-worded translations emitted by
|
||||||
|
// generate_config_translations.py.
|
||||||
|
// Example: filters.person.threshold -> filters.threshold
|
||||||
|
// Example: filters.face.min_area -> filters_attribute.min_area
|
||||||
const filtersIndex = stringSegments.indexOf("filters");
|
const filtersIndex = stringSegments.indexOf("filters");
|
||||||
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
|
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
|
||||||
|
const filterKey = stringSegments[filtersIndex + 1];
|
||||||
|
const allAttributes = getEffectiveAttributeLabels(
|
||||||
|
formContext?.fullConfig,
|
||||||
|
formContext?.fullCameraConfig,
|
||||||
|
formContext?.level,
|
||||||
|
);
|
||||||
|
const sectionWord = allAttributes.includes(filterKey)
|
||||||
|
? "filters_attribute"
|
||||||
|
: "filters";
|
||||||
const normalized = [
|
const normalized = [
|
||||||
...stringSegments.slice(0, filtersIndex + 1),
|
...stringSegments.slice(0, filtersIndex),
|
||||||
|
sectionWord,
|
||||||
...stringSegments.slice(filtersIndex + 2),
|
...stringSegments.slice(filtersIndex + 2),
|
||||||
];
|
];
|
||||||
return normalized.join(".");
|
return normalized.join(".");
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { JsonObject, JsonValue } from "@/types/configForm";
|
import { JsonObject, JsonValue } from "@/types/configForm";
|
||||||
import { isJsonObject } from "@/lib/utils";
|
import { isJsonObject } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
buildHiddenFieldContext,
|
||||||
getBaseCameraSectionValue,
|
getBaseCameraSectionValue,
|
||||||
getEffectiveHiddenFields,
|
getEffectiveHiddenFields,
|
||||||
pathMatchesHiddenPattern,
|
pathMatchesHiddenPattern,
|
||||||
@ -286,7 +287,7 @@ export function useConfigOverride({
|
|||||||
const hiddenFields = getEffectiveHiddenFields(
|
const hiddenFields = getEffectiveHiddenFields(
|
||||||
sectionPath,
|
sectionPath,
|
||||||
"camera",
|
"camera",
|
||||||
config,
|
buildHiddenFieldContext(config, "camera", cameraName),
|
||||||
);
|
);
|
||||||
const collapsedGlobal = stripHiddenPaths(
|
const collapsedGlobal = stripHiddenPaths(
|
||||||
collapseEmpty(normalizedGlobalValue),
|
collapseEmpty(normalizedGlobalValue),
|
||||||
@ -439,7 +440,11 @@ export function useAllCameraOverrides(
|
|||||||
getBaseCameraSectionValue(config, cameraName, key),
|
getBaseCameraSectionValue(config, cameraName, key),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hiddenFields = getEffectiveHiddenFields(key, "camera", config);
|
const hiddenFields = getEffectiveHiddenFields(
|
||||||
|
key,
|
||||||
|
"camera",
|
||||||
|
buildHiddenFieldContext(config, "camera", cameraName),
|
||||||
|
);
|
||||||
const collapsedGlobal = stripHiddenPaths(
|
const collapsedGlobal = stripHiddenPaths(
|
||||||
collapseEmpty(globalValue),
|
collapseEmpty(globalValue),
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
@ -795,7 +800,7 @@ export function useCameraSectionDeltas(
|
|||||||
const hiddenFields = getEffectiveHiddenFields(
|
const hiddenFields = getEffectiveHiddenFields(
|
||||||
sectionPath,
|
sectionPath,
|
||||||
"camera",
|
"camera",
|
||||||
config,
|
buildHiddenFieldContext(config, "camera", cameraName),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deltas: FieldDelta[] = [];
|
const deltas: FieldDelta[] = [];
|
||||||
@ -864,7 +869,7 @@ export function useProfileSectionDeltas(
|
|||||||
const hiddenFields = getEffectiveHiddenFields(
|
const hiddenFields = getEffectiveHiddenFields(
|
||||||
sectionPath,
|
sectionPath,
|
||||||
"camera",
|
"camera",
|
||||||
config,
|
buildHiddenFieldContext(config, "camera", cameraName),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deltas: FieldDelta[] = [];
|
const deltas: FieldDelta[] = [];
|
||||||
|
|||||||
@ -89,6 +89,7 @@ import { mutate } from "swr";
|
|||||||
import { RJSFSchema } from "@rjsf/utils";
|
import { RJSFSchema } from "@rjsf/utils";
|
||||||
import {
|
import {
|
||||||
buildConfigDataForPath,
|
buildConfigDataForPath,
|
||||||
|
buildHiddenFieldContext,
|
||||||
flattenOverrides,
|
flattenOverrides,
|
||||||
getSectionConfig,
|
getSectionConfig,
|
||||||
parseProfileFromSectionPath,
|
parseProfileFromSectionPath,
|
||||||
@ -851,11 +852,11 @@ export default function Settings() {
|
|||||||
// they stay in sync with what the embedded forms strip on render
|
// they stay in sync with what the embedded forms strip on render
|
||||||
const detectorHiddenFields = resolveHiddenFieldEntries(
|
const detectorHiddenFields = resolveHiddenFieldEntries(
|
||||||
getSectionConfig("detectors", "global").hiddenFields,
|
getSectionConfig("detectors", "global").hiddenFields,
|
||||||
config,
|
buildHiddenFieldContext(config, "global"),
|
||||||
);
|
);
|
||||||
const modelHiddenFields = resolveHiddenFieldEntries(
|
const modelHiddenFields = resolveHiddenFieldEntries(
|
||||||
getSectionConfig("model", "global").hiddenFields,
|
getSectionConfig("model", "global").hiddenFields,
|
||||||
config,
|
buildHiddenFieldContext(config, "global"),
|
||||||
);
|
);
|
||||||
const sanitizedDetectors =
|
const sanitizedDetectors =
|
||||||
pendingDetectors !== undefined
|
pendingDetectors !== undefined
|
||||||
|
|||||||
@ -13,7 +13,19 @@ export type JsonArray = JsonValue[];
|
|||||||
|
|
||||||
export type ConfigSectionData = JsonObject;
|
export type ConfigSectionData = JsonObject;
|
||||||
|
|
||||||
export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]);
|
export type HiddenFieldContext = {
|
||||||
|
fullConfig: FrigateConfig;
|
||||||
|
fullCameraConfig?: CameraConfig;
|
||||||
|
level: "global" | "camera" | "replay";
|
||||||
|
cameraName?: string;
|
||||||
|
// Saved form data for the current section/scope (i.e. rawFormData in
|
||||||
|
// BaseSection.tsx). Not the user's in-flight RJSF edits. Optional because
|
||||||
|
// most hidden-field callsites compute patterns without a specific section
|
||||||
|
// value on hand; resolvers fall back to fullCameraConfig / fullConfig.
|
||||||
|
formData?: ConfigSectionData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]);
|
||||||
|
|
||||||
export type ConfigFormContext = {
|
export type ConfigFormContext = {
|
||||||
level?: "global" | "camera";
|
level?: "global" | "camera";
|
||||||
|
|||||||
@ -522,8 +522,8 @@ export interface FrigateConfig {
|
|||||||
path: string | null;
|
path: string | null;
|
||||||
width: number;
|
width: number;
|
||||||
colormap: { [key: string]: [number, number, number] };
|
colormap: { [key: string]: [number, number, number] };
|
||||||
attributes_map: { [key: string]: [string] };
|
attributes_map: { [key: string]: string[] };
|
||||||
all_attributes: [string];
|
all_attributes: string[];
|
||||||
plus?: {
|
plus?: {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -19,9 +19,10 @@ import {
|
|||||||
sanitizeOverridesForSection,
|
sanitizeOverridesForSection,
|
||||||
} from "@/components/config-form/sections/section-special-cases";
|
} from "@/components/config-form/sections/section-special-cases";
|
||||||
import type { RJSFSchema } from "@rjsf/utils";
|
import type { RJSFSchema } from "@rjsf/utils";
|
||||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import type {
|
import type {
|
||||||
ConfigSectionData,
|
ConfigSectionData,
|
||||||
|
HiddenFieldContext,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
} from "@/types/configForm";
|
} from "@/types/configForm";
|
||||||
@ -568,6 +569,17 @@ export function prepareSectionSavePayload(opts: {
|
|||||||
schemaSection,
|
schemaSection,
|
||||||
level,
|
level,
|
||||||
sectionSchema,
|
sectionSchema,
|
||||||
|
config
|
||||||
|
? {
|
||||||
|
fullConfig: config,
|
||||||
|
fullCameraConfig:
|
||||||
|
level === "camera" && cameraName
|
||||||
|
? config.cameras?.[cameraName]
|
||||||
|
: undefined,
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute rawFormData (the current stored value for this section)
|
// Compute rawFormData (the current stored value for this section)
|
||||||
@ -615,10 +627,16 @@ export function prepareSectionSavePayload(opts: {
|
|||||||
// For profile sections, also hide restart-required fields to match
|
// For profile sections, also hide restart-required fields to match
|
||||||
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
|
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
|
||||||
// for fields that are hidden from the form during profile editing).
|
// for fields that are hidden from the form during profile editing).
|
||||||
const resolvedHidden = resolveHiddenFieldEntries(
|
const resolvedHidden = resolveHiddenFieldEntries(sectionConfig.hiddenFields, {
|
||||||
sectionConfig.hiddenFields,
|
fullConfig: config,
|
||||||
config,
|
fullCameraConfig:
|
||||||
);
|
level === "camera" && cameraName
|
||||||
|
? config.cameras?.[cameraName]
|
||||||
|
: undefined,
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
formData: rawFormData as ConfigSectionData,
|
||||||
|
});
|
||||||
const hiddenFieldsForSanitize =
|
const hiddenFieldsForSanitize =
|
||||||
profileInfo.isProfile && sectionConfig.restartRequired?.length
|
profileInfo.isProfile && sectionConfig.restartRequired?.length
|
||||||
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
|
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
|
||||||
@ -731,32 +749,77 @@ export function getSectionConfig(
|
|||||||
return mergeSectionConfig(entry.base, overrides);
|
return mergeSectionConfig(entry.base, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective attribute label set at a given scope. At camera
|
||||||
|
* (and replay) scope on a dedicated LPR camera (`camera.type === "lpr"`),
|
||||||
|
* `license_plate` is treated as a regular tracked object — not an
|
||||||
|
* attribute — to match the backend's per-camera carve-out in
|
||||||
|
* `frigate/video/detect.py`. Returns the full attribute list at global
|
||||||
|
* scope and for non-LPR cameras.
|
||||||
|
*/
|
||||||
|
export function getEffectiveAttributeLabels(
|
||||||
|
fullConfig: FrigateConfig | undefined,
|
||||||
|
fullCameraConfig: CameraConfig | undefined,
|
||||||
|
level: "global" | "camera" | "replay" | undefined,
|
||||||
|
): string[] {
|
||||||
|
const all = fullConfig?.model?.all_attributes ?? [];
|
||||||
|
if (level !== "global" && fullCameraConfig?.type === "lpr") {
|
||||||
|
return all.filter((attr) => attr !== "license_plate");
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `HiddenFieldContext` for the common case where a callsite has
|
||||||
|
* `config`, an optional `cameraName`, and a level, but no per-section
|
||||||
|
* saved form data to thread through. Resolvers that don't read `formData`
|
||||||
|
* (which is most of them) just fall through to `fullCameraConfig` /
|
||||||
|
* `fullConfig`.
|
||||||
|
*/
|
||||||
|
export function buildHiddenFieldContext(
|
||||||
|
config: FrigateConfig | undefined,
|
||||||
|
level: "global" | "camera" | "replay",
|
||||||
|
cameraName?: string,
|
||||||
|
): HiddenFieldContext | undefined {
|
||||||
|
if (!config) return undefined;
|
||||||
|
return {
|
||||||
|
fullConfig: config,
|
||||||
|
fullCameraConfig:
|
||||||
|
level !== "global" && cameraName
|
||||||
|
? config.cameras?.[cameraName]
|
||||||
|
: undefined,
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the effective hidden-field patterns for a section. Each entry in
|
* Resolve the effective hidden-field patterns for a section. Each entry in
|
||||||
* `hiddenFields` is either a literal pattern or a function that produces
|
* `hiddenFields` is either a literal pattern or a function that produces
|
||||||
* patterns from the loaded config (e.g. `filters.<attr>` for each
|
* patterns from the loaded config and scope (e.g. `filters.<attr>` for each
|
||||||
* `model.all_attributes` entry on the objects section).
|
* `model.all_attributes` entry on the objects section, gated by the
|
||||||
|
* effective `objects.track` list at the current scope).
|
||||||
*/
|
*/
|
||||||
export function getEffectiveHiddenFields(
|
export function getEffectiveHiddenFields(
|
||||||
sectionKey: string,
|
sectionKey: string,
|
||||||
level: "global" | "camera" | "replay",
|
level: "global" | "camera" | "replay",
|
||||||
config: FrigateConfig | undefined,
|
ctx: HiddenFieldContext | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
return resolveHiddenFieldEntries(
|
return resolveHiddenFieldEntries(
|
||||||
getSectionConfig(sectionKey, level).hiddenFields,
|
getSectionConfig(sectionKey, level).hiddenFields,
|
||||||
config,
|
ctx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveHiddenFieldEntries(
|
export function resolveHiddenFieldEntries(
|
||||||
entries: SectionConfig["hiddenFields"] | undefined,
|
entries: SectionConfig["hiddenFields"] | undefined,
|
||||||
config: FrigateConfig | undefined,
|
ctx: HiddenFieldContext | undefined,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (!entries || entries.length === 0) return [];
|
if (!entries || entries.length === 0) return [];
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (typeof entry === "function") {
|
if (typeof entry === "function") {
|
||||||
if (config) result.push(...entry(config));
|
if (ctx) result.push(...entry(ctx));
|
||||||
} else {
|
} else {
|
||||||
result.push(entry);
|
result.push(entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,23 @@ export type FfmpegAudioOption =
|
|||||||
| "pcm"
|
| "pcm"
|
||||||
| "mp3"
|
| "mp3"
|
||||||
| "exclude";
|
| "exclude";
|
||||||
export type FfmpegHardwareOption = "none" | "auto";
|
export type FfmpegHardwareOption =
|
||||||
|
| "none"
|
||||||
|
| "auto"
|
||||||
|
| "vaapi"
|
||||||
|
| "cuda"
|
||||||
|
| "v4l2m2m"
|
||||||
|
| "dxva2"
|
||||||
|
| "videotoolbox";
|
||||||
|
|
||||||
export type ParsedFfmpegUrl = {
|
export type ParsedFfmpegUrl = {
|
||||||
isFfmpeg: boolean;
|
isFfmpeg: boolean;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
video: FfmpegVideoOption;
|
// go2rtc accepts repeatable #video=/#audio= fragments to express a fallback
|
||||||
audio: FfmpegAudioOption;
|
// chain (copy if source codec matches, otherwise transcode). An empty array
|
||||||
|
// means no fragment is emitted for that track — equivalent to "exclude".
|
||||||
|
videos: FfmpegVideoOption[];
|
||||||
|
audios: FfmpegAudioOption[];
|
||||||
hardware: FfmpegHardwareOption;
|
hardware: FfmpegHardwareOption;
|
||||||
extraFragments: string[];
|
extraFragments: string[];
|
||||||
};
|
};
|
||||||
@ -37,13 +47,21 @@ const HARDWARE_SPECIFIC = new Set([
|
|||||||
"videotoolbox",
|
"videotoolbox",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function isRecognizedFragment(frag: string): boolean {
|
||||||
|
if (frag === "hardware") return true;
|
||||||
|
if (frag.startsWith("video=")) return VIDEO_VALUES.has(frag.slice(6));
|
||||||
|
if (frag.startsWith("audio=")) return AUDIO_VALUES.has(frag.slice(6));
|
||||||
|
if (frag.startsWith("hardware=")) return HARDWARE_SPECIFIC.has(frag.slice(9));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseFfmpegUrl(url: string): ParsedFfmpegUrl {
|
export function parseFfmpegUrl(url: string): ParsedFfmpegUrl {
|
||||||
if (!url.startsWith("ffmpeg:")) {
|
if (!url.startsWith("ffmpeg:")) {
|
||||||
return {
|
return {
|
||||||
isFfmpeg: false,
|
isFfmpeg: false,
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
video: "copy",
|
videos: [],
|
||||||
audio: "copy",
|
audios: [],
|
||||||
hardware: "none",
|
hardware: "none",
|
||||||
extraFragments: [],
|
extraFragments: [],
|
||||||
};
|
};
|
||||||
@ -54,63 +72,76 @@ export function parseFfmpegUrl(url: string): ParsedFfmpegUrl {
|
|||||||
const baseUrl = parts[0];
|
const baseUrl = parts[0];
|
||||||
const fragments = parts.slice(1);
|
const fragments = parts.slice(1);
|
||||||
|
|
||||||
let video: FfmpegVideoOption | null = null;
|
const videos: FfmpegVideoOption[] = [];
|
||||||
let audio: FfmpegAudioOption | null = null;
|
const audios: FfmpegAudioOption[] = [];
|
||||||
let hardware: FfmpegHardwareOption = "none";
|
let hardware: FfmpegHardwareOption = "none";
|
||||||
const extraFragments: string[] = [];
|
const extraFragments: string[] = [];
|
||||||
|
|
||||||
for (const frag of fragments) {
|
for (const frag of fragments) {
|
||||||
if (frag.startsWith("video=")) {
|
if (frag.startsWith("video=") && VIDEO_VALUES.has(frag.slice(6))) {
|
||||||
const val = frag.slice(6);
|
videos.push(frag.slice(6) as FfmpegVideoOption);
|
||||||
if (VIDEO_VALUES.has(val)) {
|
} else if (frag.startsWith("audio=") && AUDIO_VALUES.has(frag.slice(6))) {
|
||||||
video = val as FfmpegVideoOption;
|
audios.push(frag.slice(6) as FfmpegAudioOption);
|
||||||
} else {
|
|
||||||
extraFragments.push(frag);
|
|
||||||
}
|
|
||||||
} else if (frag.startsWith("audio=")) {
|
|
||||||
const val = frag.slice(6);
|
|
||||||
if (AUDIO_VALUES.has(val)) {
|
|
||||||
audio = val as FfmpegAudioOption;
|
|
||||||
} else {
|
|
||||||
extraFragments.push(frag);
|
|
||||||
}
|
|
||||||
} else if (frag === "hardware") {
|
} else if (frag === "hardware") {
|
||||||
hardware = "auto";
|
hardware = "auto";
|
||||||
} else if (frag.startsWith("hardware=")) {
|
} else if (
|
||||||
const val = frag.slice(9);
|
frag.startsWith("hardware=") &&
|
||||||
if (HARDWARE_SPECIFIC.has(val)) {
|
HARDWARE_SPECIFIC.has(frag.slice(9))
|
||||||
hardware = "auto";
|
) {
|
||||||
} else {
|
hardware = frag.slice(9) as FfmpegHardwareOption;
|
||||||
extraFragments.push(frag);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
extraFragments.push(frag);
|
extraFragments.push(frag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyKnownFragment = video !== null || audio !== null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isFfmpeg: true,
|
isFfmpeg: true,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
video: video ?? (hasAnyKnownFragment ? "exclude" : "copy"),
|
// Guarantee at least one row per track so the UI always has a primary
|
||||||
audio: audio ?? (hasAnyKnownFragment ? "exclude" : "copy"),
|
// dropdown to render; "exclude" is the sentinel meaning "no fragment".
|
||||||
|
videos: videos.length > 0 ? videos : ["exclude"],
|
||||||
|
audios: audios.length > 0 ? audios : ["exclude"],
|
||||||
hardware,
|
hardware,
|
||||||
extraFragments,
|
extraFragments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits the editable "base URL + extra fragments" portion of a compat-mode
|
||||||
|
// URL into its parts. Recognized fragments (video=, audio=, hardware) are
|
||||||
|
// dropped — they are managed by the dedicated controls in the UI.
|
||||||
|
export function parseFfmpegBaseAndExtras(input: string): {
|
||||||
|
baseUrl: string;
|
||||||
|
extraFragments: string[];
|
||||||
|
} {
|
||||||
|
const cleaned = input.startsWith("ffmpeg:") ? input.slice(7) : input;
|
||||||
|
const parts = cleaned.split("#");
|
||||||
|
const baseUrl = parts[0];
|
||||||
|
const extraFragments = parts.slice(1).filter((f) => !isRecognizedFragment(f));
|
||||||
|
return { baseUrl, extraFragments };
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFfmpegUrl(parsed: ParsedFfmpegUrl): string {
|
export function buildFfmpegUrl(parsed: ParsedFfmpegUrl): string {
|
||||||
let url = `ffmpeg:${parsed.baseUrl}`;
|
let url = `ffmpeg:${parsed.baseUrl}`;
|
||||||
|
|
||||||
if (parsed.video !== "exclude") {
|
// Exclude is a primary-row sentinel meaning "no fragment for this track" —
|
||||||
url += `#video=${parsed.video}`;
|
// it's mutually exclusive with fallbacks. If the primary is exclude, emit
|
||||||
|
// nothing for that track regardless of trailing entries.
|
||||||
|
if (parsed.videos[0] !== "exclude") {
|
||||||
|
for (const v of parsed.videos) {
|
||||||
|
if (v === "exclude") continue;
|
||||||
|
url += `#video=${v}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (parsed.audio !== "exclude") {
|
if (parsed.audios[0] !== "exclude") {
|
||||||
url += `#audio=${parsed.audio}`;
|
for (const a of parsed.audios) {
|
||||||
|
if (a === "exclude") continue;
|
||||||
|
url += `#audio=${a}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (parsed.hardware === "auto") {
|
if (parsed.hardware === "auto") {
|
||||||
url += "#hardware";
|
url += "#hardware";
|
||||||
|
} else if (parsed.hardware !== "none") {
|
||||||
|
url += `#hardware=${parsed.hardware}`;
|
||||||
}
|
}
|
||||||
for (const frag of parsed.extraFragments) {
|
for (const frag of parsed.extraFragments) {
|
||||||
url += `#${frag}`;
|
url += `#${frag}`;
|
||||||
@ -131,7 +162,9 @@ export function toggleFfmpegMode(url: string, enable: boolean): string {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const withoutPrefix = url.slice(7);
|
// Preserve unknown fragments (e.g. #timeout=10) when leaving compat mode;
|
||||||
const baseUrl = withoutPrefix.split("#")[0];
|
// only video/audio/hardware are go2rtc-ffmpeg directives that should be
|
||||||
return baseUrl;
|
// dropped along with the prefix.
|
||||||
|
const parsed = parseFfmpegUrl(url);
|
||||||
|
return [parsed.baseUrl, ...parsed.extraFragments].join("#");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,7 @@ import { ConfigSectionTemplate } from "@/components/config-form/sections";
|
|||||||
import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner";
|
import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
|
buildHiddenFieldContext,
|
||||||
getSectionConfig,
|
getSectionConfig,
|
||||||
resolveHiddenFieldEntries,
|
resolveHiddenFieldEntries,
|
||||||
sanitizeSectionData,
|
sanitizeSectionData,
|
||||||
@ -226,7 +227,7 @@ export default function DetectorsAndModelSettingsView({
|
|||||||
() =>
|
() =>
|
||||||
resolveHiddenFieldEntries(
|
resolveHiddenFieldEntries(
|
||||||
getSectionConfig("detectors", "global").hiddenFields,
|
getSectionConfig("detectors", "global").hiddenFields,
|
||||||
config,
|
buildHiddenFieldContext(config, "global"),
|
||||||
),
|
),
|
||||||
[config],
|
[config],
|
||||||
);
|
);
|
||||||
@ -234,7 +235,7 @@ export default function DetectorsAndModelSettingsView({
|
|||||||
() =>
|
() =>
|
||||||
resolveHiddenFieldEntries(
|
resolveHiddenFieldEntries(
|
||||||
getSectionConfig("model", "global").hiddenFields,
|
getSectionConfig("model", "global").hiddenFields,
|
||||||
config,
|
buildHiddenFieldContext(config, "global"),
|
||||||
),
|
),
|
||||||
[config],
|
[config],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,15 +10,21 @@ import {
|
|||||||
LuEye,
|
LuEye,
|
||||||
LuEyeOff,
|
LuEyeOff,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
LuPlus,
|
LuCirclePlus,
|
||||||
|
LuSlidersHorizontal,
|
||||||
LuTrash2,
|
LuTrash2,
|
||||||
|
LuX,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@ -62,11 +68,13 @@ import {
|
|||||||
} from "@/utils/credentialMask";
|
} from "@/utils/credentialMask";
|
||||||
import {
|
import {
|
||||||
parseFfmpegUrl,
|
parseFfmpegUrl,
|
||||||
|
parseFfmpegBaseAndExtras,
|
||||||
buildFfmpegUrl,
|
buildFfmpegUrl,
|
||||||
toggleFfmpegMode,
|
toggleFfmpegMode,
|
||||||
type FfmpegVideoOption,
|
type FfmpegVideoOption,
|
||||||
type FfmpegAudioOption,
|
type FfmpegAudioOption,
|
||||||
type FfmpegHardwareOption,
|
type FfmpegHardwareOption,
|
||||||
|
type ParsedFfmpegUrl,
|
||||||
} from "@/utils/go2rtcFfmpeg";
|
} from "@/utils/go2rtcFfmpeg";
|
||||||
|
|
||||||
type RawPathsResponse = {
|
type RawPathsResponse = {
|
||||||
@ -365,7 +373,7 @@ export default function Go2RtcStreamsSettingsView({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="my-4"
|
className="my-4"
|
||||||
>
|
>
|
||||||
<LuPlus className="mr-2 size-4" />
|
<LuCirclePlus className="mr-2 size-4" />
|
||||||
{t("go2rtcStreams.addStream")}
|
{t("go2rtcStreams.addStream")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -703,7 +711,7 @@ function StreamCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="space-y-3 px-4 pb-4">
|
<div className="space-y-2 px-4 pb-4">
|
||||||
{urls.map((url, urlIndex) => (
|
{urls.map((url, urlIndex) => (
|
||||||
<StreamUrlEntry
|
<StreamUrlEntry
|
||||||
key={urlIndex}
|
key={urlIndex}
|
||||||
@ -728,7 +736,7 @@ function StreamCard({
|
|||||||
onClick={onAddUrl}
|
onClick={onAddUrl}
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
>
|
>
|
||||||
<LuPlus className="mr-2 size-4" />
|
<LuCirclePlus className="mr-2 size-4" />
|
||||||
{t("go2rtcStreams.addUrl")}
|
{t("go2rtcStreams.addUrl")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -764,7 +772,9 @@ function StreamUrlEntry({
|
|||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const parsed = useMemo(() => parseFfmpegUrl(url), [url]);
|
const parsed = useMemo(() => parseFfmpegUrl(url), [url]);
|
||||||
|
|
||||||
const rawBaseUrl = parsed.isFfmpeg ? parsed.baseUrl : url;
|
const rawBaseUrl = parsed.isFfmpeg
|
||||||
|
? [parsed.baseUrl, ...parsed.extraFragments].join("#")
|
||||||
|
: url;
|
||||||
const canToggleCredentials =
|
const canToggleCredentials =
|
||||||
hasCredentials(rawBaseUrl) && !isMaskedPath(rawBaseUrl);
|
hasCredentials(rawBaseUrl) && !isMaskedPath(rawBaseUrl);
|
||||||
|
|
||||||
@ -778,15 +788,16 @@ function StreamUrlEntry({
|
|||||||
}, [rawBaseUrl, showCredentials, isFocused]);
|
}, [rawBaseUrl, showCredentials, isFocused]);
|
||||||
|
|
||||||
const isTranscodingVideo =
|
const isTranscodingVideo =
|
||||||
parsed.isFfmpeg && parsed.video !== "copy" && parsed.video !== "exclude";
|
parsed.isFfmpeg && parsed.videos.some((v) => v === "h264" || v === "h265");
|
||||||
|
|
||||||
const handleBaseUrlChange = useCallback(
|
const handleBaseUrlChange = useCallback(
|
||||||
(newBaseUrl: string) => {
|
(newInput: string) => {
|
||||||
if (parsed.isFfmpeg) {
|
if (parsed.isFfmpeg) {
|
||||||
const newUrl = buildFfmpegUrl({ ...parsed, baseUrl: newBaseUrl });
|
const { baseUrl, extraFragments } = parseFfmpegBaseAndExtras(newInput);
|
||||||
|
const newUrl = buildFfmpegUrl({ ...parsed, baseUrl, extraFragments });
|
||||||
onUpdateUrl(streamName, urlIndex, newUrl);
|
onUpdateUrl(streamName, urlIndex, newUrl);
|
||||||
} else {
|
} else {
|
||||||
onUpdateUrl(streamName, urlIndex, newBaseUrl);
|
onUpdateUrl(streamName, urlIndex, newInput);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[parsed, streamName, urlIndex, onUpdateUrl],
|
[parsed, streamName, urlIndex, onUpdateUrl],
|
||||||
@ -800,212 +811,328 @@ function StreamUrlEntry({
|
|||||||
[url, streamName, urlIndex, onUpdateUrl],
|
[url, streamName, urlIndex, onUpdateUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFfmpegOptionChange = useCallback(
|
const persistFfmpeg = useCallback(
|
||||||
(
|
(next: Partial<ParsedFfmpegUrl>) => {
|
||||||
field: "video" | "audio" | "hardware",
|
const merged = { ...parsed, ...next };
|
||||||
value: FfmpegVideoOption | FfmpegAudioOption | FfmpegHardwareOption,
|
// Hardware acceleration is meaningless without a transcoding video codec
|
||||||
) => {
|
if (!merged.videos.some((v) => v === "h264" || v === "h265")) {
|
||||||
const updated = { ...parsed, [field]: value };
|
merged.hardware = "none";
|
||||||
// Clear hardware when switching away from transcoding video
|
|
||||||
if (field === "video" && (value === "copy" || value === "exclude")) {
|
|
||||||
updated.hardware = "none";
|
|
||||||
}
|
}
|
||||||
const newUrl = buildFfmpegUrl(updated);
|
onUpdateUrl(streamName, urlIndex, buildFfmpegUrl(merged));
|
||||||
onUpdateUrl(streamName, urlIndex, newUrl);
|
|
||||||
},
|
},
|
||||||
[parsed, streamName, urlIndex, onUpdateUrl],
|
[parsed, streamName, urlIndex, onUpdateUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const audioDisplayLabel = useMemo(() => {
|
const updateVideoAt = useCallback(
|
||||||
const labels: Record<string, string> = {
|
(idx: number, value: FfmpegVideoOption) => {
|
||||||
copy: t("go2rtcStreams.ffmpeg.audioCopy"),
|
// Picking exclude on the primary row drops any existing fallbacks —
|
||||||
aac: t("go2rtcStreams.ffmpeg.audioAac"),
|
// they have no meaning when the track is excluded entirely.
|
||||||
opus: t("go2rtcStreams.ffmpeg.audioOpus"),
|
const videos =
|
||||||
pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"),
|
idx === 0 && value === "exclude"
|
||||||
pcma: t("go2rtcStreams.ffmpeg.audioPcma"),
|
? ["exclude" as FfmpegVideoOption]
|
||||||
pcm: t("go2rtcStreams.ffmpeg.audioPcm"),
|
: parsed.videos.map((v, i) => (i === idx ? value : v));
|
||||||
mp3: t("go2rtcStreams.ffmpeg.audioMp3"),
|
persistFfmpeg({ videos });
|
||||||
exclude: t("go2rtcStreams.ffmpeg.audioExclude"),
|
},
|
||||||
};
|
[parsed.videos, persistFfmpeg],
|
||||||
return labels[parsed.audio] || parsed.audio;
|
);
|
||||||
}, [parsed.audio, t]);
|
|
||||||
|
const addVideo = useCallback(() => {
|
||||||
|
persistFfmpeg({ videos: [...parsed.videos, "copy"] });
|
||||||
|
}, [parsed.videos, persistFfmpeg]);
|
||||||
|
|
||||||
|
const removeVideoAt = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
persistFfmpeg({ videos: parsed.videos.filter((_, i) => i !== idx) });
|
||||||
|
},
|
||||||
|
[parsed.videos, persistFfmpeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAudioAt = useCallback(
|
||||||
|
(idx: number, value: FfmpegAudioOption) => {
|
||||||
|
// Picking exclude on the primary row drops any existing fallbacks —
|
||||||
|
// they have no meaning when the track is excluded entirely.
|
||||||
|
const audios =
|
||||||
|
idx === 0 && value === "exclude"
|
||||||
|
? ["exclude" as FfmpegAudioOption]
|
||||||
|
: parsed.audios.map((a, i) => (i === idx ? value : a));
|
||||||
|
persistFfmpeg({ audios });
|
||||||
|
},
|
||||||
|
[parsed.audios, persistFfmpeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addAudio = useCallback(() => {
|
||||||
|
persistFfmpeg({ audios: [...parsed.audios, "copy"] });
|
||||||
|
}, [parsed.audios, persistFfmpeg]);
|
||||||
|
|
||||||
|
const removeAudioAt = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
persistFfmpeg({ audios: parsed.audios.filter((_, i) => i !== idx) });
|
||||||
|
},
|
||||||
|
[parsed.audios, persistFfmpeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateHardware = useCallback(
|
||||||
|
(value: FfmpegHardwareOption) => {
|
||||||
|
persistFfmpeg({ hardware: value });
|
||||||
|
},
|
||||||
|
[persistFfmpeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoLabels: Record<FfmpegVideoOption, string> = {
|
||||||
|
copy: t("go2rtcStreams.ffmpeg.videoCopy"),
|
||||||
|
h264: t("go2rtcStreams.ffmpeg.videoH264"),
|
||||||
|
h265: t("go2rtcStreams.ffmpeg.videoH265"),
|
||||||
|
exclude: t("go2rtcStreams.ffmpeg.videoExclude"),
|
||||||
|
};
|
||||||
|
const audioLabels: Record<FfmpegAudioOption, string> = {
|
||||||
|
copy: t("go2rtcStreams.ffmpeg.audioCopy"),
|
||||||
|
aac: t("go2rtcStreams.ffmpeg.audioAac"),
|
||||||
|
opus: t("go2rtcStreams.ffmpeg.audioOpus"),
|
||||||
|
pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"),
|
||||||
|
pcma: t("go2rtcStreams.ffmpeg.audioPcma"),
|
||||||
|
pcm: t("go2rtcStreams.ffmpeg.audioPcm"),
|
||||||
|
mp3: t("go2rtcStreams.ffmpeg.audioMp3"),
|
||||||
|
exclude: t("go2rtcStreams.ffmpeg.audioExclude"),
|
||||||
|
};
|
||||||
|
const hardwareLabels: Record<FfmpegHardwareOption, string> = {
|
||||||
|
none: t("go2rtcStreams.ffmpeg.hardwareNone"),
|
||||||
|
auto: t("go2rtcStreams.ffmpeg.hardwareAuto"),
|
||||||
|
vaapi: t("go2rtcStreams.ffmpeg.hardwareVaapi"),
|
||||||
|
cuda: t("go2rtcStreams.ffmpeg.hardwareCuda"),
|
||||||
|
v4l2m2m: t("go2rtcStreams.ffmpeg.hardwareV4l2m2m"),
|
||||||
|
dxva2: t("go2rtcStreams.ffmpeg.hardwareDxva2"),
|
||||||
|
videotoolbox: t("go2rtcStreams.ffmpeg.hardwareVideotoolbox"),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded-lg bg-background p-3">
|
<div className="pb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex h-7 flex-row items-center justify-start gap-2 text-sm text-primary-variant">
|
||||||
<div className="relative flex-1">
|
{t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })}
|
||||||
<Input
|
|
||||||
className="text-md h-8 pr-10"
|
|
||||||
value={baseUrlForDisplay}
|
|
||||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={() => setIsFocused(false)}
|
|
||||||
placeholder={t("go2rtcStreams.streamUrlPlaceholder")}
|
|
||||||
/>
|
|
||||||
{canToggleCredentials && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
|
||||||
onClick={onToggleCredentialVisibility}
|
|
||||||
>
|
|
||||||
{showCredentials ? (
|
|
||||||
<LuEyeOff className="size-4" />
|
|
||||||
) : (
|
|
||||||
<LuEye className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRemoveUrl}
|
onClick={onRemoveUrl}
|
||||||
className="text-secondary-foreground hover:text-secondary-foreground"
|
className="size-7 p-0 text-secondary-foreground hover:text-secondary-foreground"
|
||||||
|
aria-label={t("button.delete", { ns: "common" })}
|
||||||
>
|
>
|
||||||
<LuTrash2 className="size-4" />
|
<LuTrash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-4 rounded-lg bg-background p-4">
|
||||||
{/* ffmpeg module toggle */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="relative flex-1">
|
||||||
<Switch
|
<Input
|
||||||
checked={parsed.isFfmpeg}
|
className="text-md h-8 pr-10"
|
||||||
onCheckedChange={handleFfmpegToggle}
|
value={baseUrlForDisplay}
|
||||||
/>
|
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||||
<Label className="text-sm">
|
onFocus={() => setIsFocused(true)}
|
||||||
{t("go2rtcStreams.ffmpeg.useFfmpegModule")}
|
onBlur={() => setIsFocused(false)}
|
||||||
</Label>
|
placeholder={t("go2rtcStreams.streamUrlPlaceholder")}
|
||||||
</div>
|
/>
|
||||||
|
{canToggleCredentials && (
|
||||||
{/* ffmpeg options */}
|
<Button
|
||||||
{parsed.isFfmpeg && (
|
type="button"
|
||||||
<div
|
variant="ghost"
|
||||||
className={cn(
|
size="sm"
|
||||||
"grid grid-cols-1 gap-3 pl-4",
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
isTranscodingVideo ? "sm:grid-cols-3" : "sm:grid-cols-2",
|
onClick={onToggleCredentialVisibility}
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Video */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">
|
|
||||||
{t("go2rtcStreams.ffmpeg.video")}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={parsed.video}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
handleFfmpegOptionChange("video", v as FfmpegVideoOption)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8">
|
|
||||||
{parsed.video === "copy"
|
|
||||||
? t("go2rtcStreams.ffmpeg.videoCopy")
|
|
||||||
: parsed.video === "h264"
|
|
||||||
? t("go2rtcStreams.ffmpeg.videoH264")
|
|
||||||
: parsed.video === "h265"
|
|
||||||
? t("go2rtcStreams.ffmpeg.videoH265")
|
|
||||||
: t("go2rtcStreams.ffmpeg.videoExclude")}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="copy">
|
|
||||||
{t("go2rtcStreams.ffmpeg.videoCopy")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="h264">
|
|
||||||
{t("go2rtcStreams.ffmpeg.videoH264")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="h265">
|
|
||||||
{t("go2rtcStreams.ffmpeg.videoH265")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="exclude">
|
|
||||||
{t("go2rtcStreams.ffmpeg.videoExclude")}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audio */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audio")}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={parsed.audio}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
handleFfmpegOptionChange("audio", v as FfmpegAudioOption)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8">{audioDisplayLabel}</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="copy">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioCopy")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="aac">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioAac")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="opus">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioOpus")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="pcmu">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioPcmu")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="pcma">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioPcma")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="pcm">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioPcm")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="mp3">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioMp3")}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="exclude">
|
|
||||||
{t("go2rtcStreams.ffmpeg.audioExclude")}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hardware acceleration - only when transcoding video */}
|
|
||||||
{isTranscodingVideo && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs font-medium">
|
|
||||||
{t("go2rtcStreams.ffmpeg.hardware")}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={parsed.hardware}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
handleFfmpegOptionChange(
|
|
||||||
"hardware",
|
|
||||||
v as FfmpegHardwareOption,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8">
|
{showCredentials || isFocused ? (
|
||||||
{parsed.hardware === "auto"
|
<LuEyeOff className="size-4" />
|
||||||
? t("go2rtcStreams.ffmpeg.hardwareAuto")
|
) : (
|
||||||
: t("go2rtcStreams.ffmpeg.hardwareNone")}
|
<LuEye className="size-4" />
|
||||||
</SelectTrigger>
|
)}
|
||||||
<SelectContent>
|
</Button>
|
||||||
<SelectGroup>
|
)}
|
||||||
<SelectItem value="none">
|
</div>
|
||||||
{t("go2rtcStreams.ffmpeg.hardwareNone")}
|
<Tooltip>
|
||||||
</SelectItem>
|
<TooltipTrigger asChild>
|
||||||
<SelectItem value="auto">
|
<Button
|
||||||
{t("go2rtcStreams.ffmpeg.hardwareAuto")}
|
type="button"
|
||||||
</SelectItem>
|
variant={parsed.isFfmpeg ? "select" : "ghost"}
|
||||||
</SelectGroup>
|
size="sm"
|
||||||
</SelectContent>
|
aria-pressed={parsed.isFfmpeg}
|
||||||
</Select>
|
aria-label={t("go2rtcStreams.ffmpeg.useFfmpegModule")}
|
||||||
</div>
|
onClick={() => handleFfmpegToggle(!parsed.isFfmpeg)}
|
||||||
)}
|
className="size-8 p-0"
|
||||||
|
>
|
||||||
|
<LuSlidersHorizontal className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t("go2rtcStreams.ffmpeg.useFfmpegModule")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* ffmpeg options */}
|
||||||
|
{parsed.isFfmpeg && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 gap-3 pl-4",
|
||||||
|
isTranscodingVideo ? "sm:grid-cols-3" : "sm:grid-cols-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Video — one row per #video= fragment */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex h-7 items-center justify-start gap-2">
|
||||||
|
<Label className="text-xs font-medium">
|
||||||
|
{t("go2rtcStreams.ffmpeg.video")}
|
||||||
|
</Label>
|
||||||
|
{parsed.videos[0] !== "exclude" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addVideo}
|
||||||
|
className="size-6 p-0 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label={t("go2rtcStreams.ffmpeg.addVideoCodec")}
|
||||||
|
>
|
||||||
|
<LuCirclePlus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{parsed.videos.map((v, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1">
|
||||||
|
<Select
|
||||||
|
value={v}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
updateVideoAt(idx, next as FfmpegVideoOption)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1">
|
||||||
|
{videoLabels[v] ?? v}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{(Object.keys(videoLabels) as FfmpegVideoOption[])
|
||||||
|
// Exclude is only meaningful on the primary row.
|
||||||
|
.filter((opt) => idx === 0 || opt !== "exclude")
|
||||||
|
.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{videoLabels[opt]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{idx > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeVideoAt(idx)}
|
||||||
|
className="size-8 p-0 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label={t("go2rtcStreams.ffmpeg.removeCodec")}
|
||||||
|
>
|
||||||
|
<LuX className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
// Reserve the same horizontal slot so the primary Select
|
||||||
|
// doesn't stretch wider than fallback rows.
|
||||||
|
<div className="size-8 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audio — one row per #audio= fragment */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex h-7 items-center justify-start gap-2">
|
||||||
|
<Label className="text-xs font-medium">
|
||||||
|
{t("go2rtcStreams.ffmpeg.audio")}
|
||||||
|
</Label>
|
||||||
|
{parsed.audios[0] !== "exclude" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addAudio}
|
||||||
|
className="size-6 p-0 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label={t("go2rtcStreams.ffmpeg.addAudioCodec")}
|
||||||
|
>
|
||||||
|
<LuCirclePlus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{parsed.audios.map((a, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1">
|
||||||
|
<Select
|
||||||
|
value={a}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
updateAudioAt(idx, next as FfmpegAudioOption)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1">
|
||||||
|
{audioLabels[a] ?? a}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{(Object.keys(audioLabels) as FfmpegAudioOption[])
|
||||||
|
// Exclude is only meaningful on the primary row.
|
||||||
|
.filter((opt) => idx === 0 || opt !== "exclude")
|
||||||
|
.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{audioLabels[opt]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{idx > 0 ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeAudioAt(idx)}
|
||||||
|
className="size-8 p-0 text-muted-foreground hover:text-primary"
|
||||||
|
aria-label={t("go2rtcStreams.ffmpeg.removeCodec")}
|
||||||
|
>
|
||||||
|
<LuX className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="size-8 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware acceleration — only when transcoding video */}
|
||||||
|
{isTranscodingVideo && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex h-7 items-center">
|
||||||
|
<Label className="text-xs font-medium">
|
||||||
|
{t("go2rtcStreams.ffmpeg.hardware")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={parsed.hardware}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateHardware(v as FfmpegHardwareOption)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8">
|
||||||
|
{hardwareLabels[parsed.hardware] ?? parsed.hardware}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{(
|
||||||
|
Object.keys(hardwareLabels) as FfmpegHardwareOption[]
|
||||||
|
).map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{hardwareLabels[opt]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user