Compare commits

...

5 Commits

Author SHA1 Message Date
Nicolas Mowen
b47a47c44a Fix gemini tool calling 2026-04-29 17:03:11 -06:00
Nicolas Mowen
33abaaa9f8 Move openai specific workaround so it doesn't apply to other providers 2026-04-29 16:51:59 -06:00
Josh Hawkins
95b5b89ed9
Miscellaneous fixes (#23032)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* ensure embeddings process restarts after maintainer thread crash

* add docs link to media sync settings

* fix color

Co-authored-by: Copilot <copilot@github.com>

* match link color with other sections

* ensure recording staleness threshold scales with segment_time

* docs tweak

* Fix llama.cpp media marker

* Fix gemini tools call

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-04-29 16:20:19 -06:00
Nicolas Mowen
a182385618
Fix ROCm build (#23040)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-04-29 09:30:16 -05:00
Nicolas Mowen
088e1ad7ef
Add ability to download case as zip (#23034)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-04-28 19:11:41 -05:00
12 changed files with 353 additions and 84 deletions

View File

@ -32,11 +32,14 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
FROM deps AS deps-prelim FROM deps AS deps-prelim
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
RUN apt-get update && \ # install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching
# -dev package must also come from trixie or apt refuses to satisfy it.
RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \
apt-get update && \
apt-get install -y libnuma1 && \ apt-get install -y libnuma1 && \
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \ apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
# Install C++ standard library headers for HIPRTC kernel compilation fallback apt-get install -qq -y -t trixie libstdc++-14-dev && \
apt-get install -qq -y libstdc++-12-dev && \ rm -f /etc/apt/sources.list.d/trixie.list && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /opt/frigate WORKDIR /opt/frigate

View File

@ -171,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al
- If it is difficult to make out details in a persons face it will not be helpful in training. - If it is difficult to make out details in a persons face it will not be helpful in training.
- Avoid images with extreme under/over-exposure. - Avoid images with extreme under/over-exposure.
- Avoid blurry / pixelated images. - Avoid blurry / pixelated images.
- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images. - Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images.
- Using images of people wearing hats / sunglasses may confuse the model. - Using images of people wearing hats / sunglasses may confuse the model.
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting. - Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.

View File

@ -5,13 +5,15 @@ import logging
import random import random
import string import string
import time import time
import zipfile
from collections import deque
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import Iterator, List, Optional
import psutil import psutil
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, StreamingResponse
from pathvalidate import sanitize_filepath from pathvalidate import sanitize_filename, sanitize_filepath
from peewee import DoesNotExist from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
@ -361,6 +363,136 @@ def get_export_case(case_id: str):
) )
_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB
class _StreamingZipBuffer:
"""File-like sink for ZipFile that exposes written bytes via drain().
ZipFile writes synchronously into this buffer; the generator drains the
queue between writes so StreamingResponse can yield bytes without
materializing the whole archive in memory.
"""
def __init__(self) -> None:
self._queue: deque[bytes] = deque()
self._offset = 0
def write(self, data: bytes) -> int:
if data:
self._queue.append(bytes(data))
self._offset += len(data)
return len(data)
def tell(self) -> int:
return self._offset
def flush(self) -> None:
pass
def drain(self) -> Iterator[bytes]:
while self._queue:
yield self._queue.popleft()
def _unique_archive_name(export: Export, used: set[str]) -> str:
base = sanitize_filename(export.name) if export.name else None
if not base:
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
candidate = f"{base}.mp4"
counter = 1
while candidate in used:
candidate = f"{base}_{counter}.mp4"
counter += 1
used.add(candidate)
return candidate
def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
"""Yield bytes of a zip archive built from the given exports' mp4 files."""
buffer = _StreamingZipBuffer()
used_names: set[str] = set()
# ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win.
with zipfile.ZipFile(
buffer,
mode="w",
compression=zipfile.ZIP_STORED,
allowZip64=True,
) as archive:
for export in exports:
source = Path(export.video_path)
if not source.exists():
continue
arcname = _unique_archive_name(export, used_names)
with (
archive.open(arcname, mode="w", force_zip64=True) as entry,
source.open("rb") as src,
):
while True:
chunk = src.read(_ZIP_STREAM_CHUNK_SIZE)
if not chunk:
break
entry.write(chunk)
yield from buffer.drain()
yield from buffer.drain()
yield from buffer.drain()
@router.get(
"/cases/{case_id}/download",
dependencies=[Depends(allow_any_authenticated())],
summary="Download export case as zip",
description="Streams a zip archive containing every completed export's mp4 for the given case.",
)
def download_export_case(
case_id: str,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
exports = list(
Export.select()
.where(
Export.export_case == case_id,
~Export.in_progress,
Export.camera << allowed_cameras,
)
.order_by(Export.date.asc())
)
if not exports:
return JSONResponse(
content={"success": False, "message": "No exports available to download."},
status_code=404,
)
archive_base = sanitize_filename(case.name) if case.name else ""
if not archive_base:
archive_base = case_id
return StreamingResponse(
_stream_case_archive(exports),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
},
)
@router.patch( @router.patch(
"/cases/{case_id}", "/cases/{case_id}",
response_model=GenericResponse, response_model=GenericResponse,

View File

@ -4,6 +4,7 @@ import base64
import json import json
import logging import logging
import os import os
import sys
import threading import threading
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
@ -52,6 +53,14 @@ class EmbeddingProcess(FrigateProcess):
self.stop_event, self.stop_event,
) )
maintainer.start() maintainer.start()
maintainer.join()
# If the maintainer thread exited but no shutdown was requested, it
# crashed. Surface as a non-zero exit so the watchdog restarts us
# instead of treating the silent thread death as a clean shutdown.
if not self.stop_event.is_set():
logger.error("Embeddings maintainer thread exited unexpectedly")
sys.exit(1)
class EmbeddingsContext: class EmbeddingsContext:

View File

@ -153,9 +153,6 @@ Each line represents a detection state, not necessarily unique individuals. The
if "other_concerns" in schema.get("required", []): if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns") schema["required"].remove("other_concerns")
# OpenAI strict mode requires additionalProperties: false on all objects
schema["additionalProperties"] = False
response_format = { response_format = {
"type": "json_schema", "type": "json_schema",
"json_schema": { "json_schema": {

View File

@ -136,22 +136,44 @@ class GeminiClient(GenAIClient):
) )
) )
elif role == "assistant": elif role == "assistant":
gemini_messages.append( parts: list[types.Part] = []
types.Content( if content:
role="model", parts=[types.Part.from_text(text=content)] parts.append(types.Part.from_text(text=content))
for tc in msg.get("tool_calls") or []:
func = tc.get("function") or {}
tc_name = func.get("name") or ""
tc_args: Any = func.get("arguments")
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except (json.JSONDecodeError, TypeError):
tc_args = {}
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
) )
) )
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
elif role == "tool": elif role == "tool":
# Handle tool response # Handle tool response
function_response = { response_payload = (
"name": msg.get("name", ""), content if isinstance(content, dict) else {"result": content}
"response": content, )
}
gemini_messages.append( gemini_messages.append(
types.Content( types.Content(
role="function", role="function",
parts=[ parts=[
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type] types.Part.from_function_response(
name=msg.get("name")
or msg.get("tool_call_id")
or "",
response=response_payload,
)
], ],
) )
) )
@ -343,22 +365,44 @@ class GeminiClient(GenAIClient):
) )
) )
elif role == "assistant": elif role == "assistant":
gemini_messages.append( parts: list[types.Part] = []
types.Content( if content:
role="model", parts=[types.Part.from_text(text=content)] parts.append(types.Part.from_text(text=content))
for tc in msg.get("tool_calls") or []:
func = tc.get("function") or {}
tc_name = func.get("name") or ""
tc_args: Any = func.get("arguments")
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except (json.JSONDecodeError, TypeError):
tc_args = {}
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
) )
) )
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
elif role == "tool": elif role == "tool":
# Handle tool response # Handle tool response
function_response = { response_payload = (
"name": msg.get("name", ""), content if isinstance(content, dict) else {"result": content}
"response": content, )
}
gemini_messages.append( gemini_messages.append(
types.Content( types.Content(
role="function", role="function",
parts=[ parts=[
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type] types.Part.from_function_response(
name=msg.get("name")
or msg.get("tool_call_id")
or "",
response=response_payload,
)
], ],
) )
) )

View File

@ -44,6 +44,7 @@ class LlamaCppClient(GenAIClient):
_supports_tools: bool _supports_tools: bool
_image_token_cache: dict[tuple[int, int], int] _image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None _text_baseline_tokens: int | None
_media_marker: str
def _init_provider(self) -> str | None: def _init_provider(self) -> str | None:
"""Initialize the client and query model metadata from the server.""" """Initialize the client and query model metadata from the server."""
@ -56,6 +57,7 @@ class LlamaCppClient(GenAIClient):
self._supports_tools = False self._supports_tools = False
self._image_token_cache = {} self._image_token_cache = {}
self._text_baseline_tokens = None self._text_baseline_tokens = None
self._media_marker = "<__media__>"
base_url = ( base_url = (
self.genai_config.base_url.rstrip("/") self.genai_config.base_url.rstrip("/")
@ -141,6 +143,13 @@ class LlamaCppClient(GenAIClient):
chat_caps = props.get("chat_template_caps", {}) chat_caps = props.get("chat_template_caps", {})
self._supports_tools = chat_caps.get("supports_tools", False) self._supports_tools = chat_caps.get("supports_tools", False)
# Media marker for multimodal embeddings; the server randomizes this
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
# from /props rather than hardcoding "<__media__>".
media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker:
self._media_marker = media_marker
logger.info( logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s", "llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
configured_model, configured_model,
@ -465,10 +474,11 @@ class LlamaCppClient(GenAIClient):
jpeg_bytes = _to_jpeg(img) jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded = base64.b64encode(to_encode).decode("utf-8") encoded = base64.b64encode(to_encode).decode("utf-8")
# prompt_string must contain <__media__> placeholder for image tokenization # prompt_string must contain the server's media marker placeholder.
# The marker is randomized per server startup (read from /props).
content.append( content.append(
{ {
"prompt_string": "<__media__>\n", "prompt_string": f"{self._media_marker}\n",
"multimodal_data": [encoded], # type: ignore[dict-item] "multimodal_data": [encoded], # type: ignore[dict-item]
} }
) )

View File

@ -73,8 +73,17 @@ class OpenAIClient(GenAIClient):
**self.genai_config.runtime_options, **self.genai_config.runtime_options,
} }
if response_format: if response_format:
# OpenAI strict mode requires additionalProperties: false on the schema
if response_format.get("type") == "json_schema" and response_format.get(
"json_schema", {}
).get("strict"):
schema = response_format.get("json_schema", {}).get("schema")
if isinstance(schema, dict):
schema["additionalProperties"] = False
request_params["response_format"] = response_format request_params["response_format"] = response_format
result = self.provider.chat.completions.create(**request_params) result = self.provider.chat.completions.create(**request_params)
if ( if (
result is not None result is not None
and hasattr(result, "choices") and hasattr(result, "choices")

View File

@ -24,7 +24,7 @@ from frigate.config.camera.updater import (
) )
from frigate.const import PROCESS_PRIORITY_HIGH from frigate.const import PROCESS_PRIORITY_HIGH
from frigate.log import LogPipe from frigate.log import LogPipe
from frigate.util.builtin import EventsPerSecond from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
from frigate.util.image import ( from frigate.util.image import (
FrameManager, FrameManager,
@ -34,6 +34,23 @@ from frigate.util.process import FrigateProcess
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# all built-in record presets use this segment_time
DEFAULT_RECORD_SEGMENT_TIME = 10
def _get_record_segment_time(config: CameraConfig) -> int:
"""Extract -segment_time from the camera's record output args."""
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
if record_args and record_args[0].startswith("preset"):
return DEFAULT_RECORD_SEGMENT_TIME
try:
idx = record_args.index("-segment_time")
return int(record_args[idx + 1])
except (ValueError, IndexError):
return DEFAULT_RECORD_SEGMENT_TIME
def capture_frames( def capture_frames(
ffmpeg_process: sp.Popen[Any], ffmpeg_process: sp.Popen[Any],
@ -164,6 +181,12 @@ class CameraWatchdog(threading.Thread):
self.latest_cache_segment_time: float = 0 self.latest_cache_segment_time: float = 0
self.record_enable_time: datetime | None = None self.record_enable_time: datetime | None = None
# `valid` segments are published with the segment's start time, so the
# gap between consecutive publishes can reach 2 * segment_time. Pad the
# staleness threshold so it's never tighter than that worst case.
segment_time = _get_record_segment_time(self.config)
self.record_stale_threshold = max(120, 2 * segment_time + 30)
# Stall tracking (based on last processed frame) # Stall tracking (based on last processed frame)
self._stall_timestamps: deque[float] = deque() self._stall_timestamps: deque[float] = deque()
self._stall_active: bool = False self._stall_active: bool = False
@ -413,16 +436,17 @@ class CameraWatchdog(threading.Thread):
# ensure segments are still being created and that they have valid video data # ensure segments are still being created and that they have valid video data
# Skip checks during grace period to allow segments to start being created # Skip checks during grace period to allow segments to start being created
stale_window = timedelta(seconds=self.record_stale_threshold)
cache_stale = not in_grace_period and now_utc > ( cache_stale = not in_grace_period and now_utc > (
latest_cache_dt + timedelta(seconds=120) latest_cache_dt + stale_window
) )
valid_stale = not in_grace_period and now_utc > ( valid_stale = not in_grace_period and now_utc > (
latest_valid_dt + timedelta(seconds=120) latest_valid_dt + stale_window
) )
invalid_stale_condition = ( invalid_stale_condition = (
self.latest_invalid_segment_time > 0 self.latest_invalid_segment_time > 0
and not in_grace_period and not in_grace_period
and now_utc > (latest_invalid_dt + timedelta(seconds=120)) and now_utc > (latest_invalid_dt + stale_window)
and self.latest_valid_segment_time and self.latest_valid_segment_time
<= self.latest_invalid_segment_time <= self.latest_invalid_segment_time
) )
@ -439,7 +463,7 @@ class CameraWatchdog(threading.Thread):
) )
self.logger.error( self.logger.error(
f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..." f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..."
) )
p["process"] = start_or_restart_ffmpeg( p["process"] = start_or_restart_ffmpeg(
p["cmd"], p["cmd"],

View File

@ -28,6 +28,7 @@ class MonitoredProcess:
restart_timestamps: deque[float] = field( restart_timestamps: deque[float] = field(
default_factory=lambda: deque(maxlen=MAX_RESTARTS) default_factory=lambda: deque(maxlen=MAX_RESTARTS)
) )
clean_exit_logged: bool = False
def is_restarting_too_fast(self, now: float) -> bool: def is_restarting_too_fast(self, now: float) -> bool:
while ( while (
@ -72,7 +73,9 @@ class FrigateWatchdog(threading.Thread):
exitcode = entry.process.exitcode exitcode = entry.process.exitcode
if exitcode == 0: if exitcode == 0:
if not entry.clean_exit_logged:
logger.info("Process %s exited cleanly, not restarting", entry.name) logger.info("Process %s exited cleanly, not restarting", entry.name)
entry.clean_exit_logged = True
return return
logger.warning( logger.warning(

View File

@ -57,6 +57,7 @@ import { useTranslation } from "react-i18next";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { import {
LuDownload,
LuFolderPlus, LuFolderPlus,
LuFolderX, LuFolderX,
LuPencil, LuPencil,
@ -777,8 +778,29 @@ function Exports() {
filters={["cameras"]} filters={["cameras"]}
onUpdateFilter={setExportFilter} onUpdateFilter={setExportFilter}
/> />
{isAdmin && (
<div className="flex items-center gap-1 md:gap-2"> <div className="flex items-center gap-1 md:gap-2">
{(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
<Button
asChild
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("button.download", { ns: "common" })}
>
<a
download
href={`${baseUrl}api/cases/${selectedCase.id}/download`}
>
<LuDownload className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("button.download", { ns: "common" })}
</div>
)}
</a>
</Button>
)}
{isAdmin && (
<>
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
size="sm" size="sm"
@ -823,9 +845,10 @@ function Exports() {
</div> </div>
)} )}
</Button> </Button>
</div> </>
)} )}
</div> </div>
</div>
)} )}
</> </>
)} )}

View File

@ -10,13 +10,16 @@ import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { useJobStatus } from "@/api/ws"; import { useJobStatus } from "@/api/ws";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { LuCheck, LuX } from "react-icons/lu"; import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { MediaSyncResults, MediaSyncStats } from "@/types/ws"; import { MediaSyncResults, MediaSyncStats } from "@/types/ws";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
export default function MediaSyncSettingsView() { export default function MediaSyncSettingsView() {
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
const { getLocaleDocUrl } = useDocDomain();
const [selectedMediaTypes, setSelectedMediaTypes] = useState<string[]>([ const [selectedMediaTypes, setSelectedMediaTypes] = useState<string[]>([
"all", "all",
]); ]);
@ -109,13 +112,25 @@ export default function MediaSyncSettingsView() {
<Heading as="h4" className="mb-2 hidden md:block"> <Heading as="h4" className="mb-2 hidden md:block">
{t("maintenance.sync.title")} {t("maintenance.sync.title")}
</Heading> </Heading>
<div className="max-w-6xl"> <div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-muted-foreground"> <div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-muted-foreground">
<p>{t("maintenance.sync.desc")}</p> <p>{t("maintenance.sync.desc")}</p>
</div>
</div>
<div className="flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl(
"configuration/record#syncing-media-files-with-disk",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="space-y-6"> <div className="space-y-6">
{/* Media Types Selection */} {/* Media Types Selection */}
<div> <div>