Compare commits

..
Author SHA1 Message Date
Cedric Verstraeten 5457842f6e do not read if not needed 2025-01-20 15:03:38 +01:00
97 changed files with 1725 additions and 14540 deletions
+2 -26
View File
@@ -1,26 +1,2 @@
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm
# Install node environment
RUN apt-get update && \
apt-get install -y --no-install-recommends \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install ffmpeg
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
libavcodec-extra \
libavutil-dev \
libavformat-dev \
libavfilter-dev \
libavdevice-dev \
libswscale-dev \
libswresample-dev \
&& rm -rf /var/lib/apt/lists/*
USER vscode
# Install go swagger
RUN go install github.com/swaggo/swag/cmd/swag@latest
FROM kerberos/devcontainer:5da0fe7
LABEL AUTHOR=Kerberos.io
+12 -15
View File
@@ -1,24 +1,21 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "go:1.24-bookworm",
"runArgs": [
"--name=agent",
"--network=host"
"name": "A Dockerfile containing FFmpeg, OpenCV, Go and Yarn",
"context": "..",
"dockerFile": "./Dockerfile",
"forwardPorts": [
3000,
80
],
"dockerFile": "Dockerfile",
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download",
"customizations": {
"vscode": {
"extensions": [
"GitHub.copilot",
"ms-kubernetes-tools.vscode-kubernetes-tools",
"ms-azuretools.vscode-docker",
"mongodb.mongodb-vscode"
"GitHub.copilot",
"golang.go",
"ms-vscode.vscode-typescript-next"
]
}
},
"forwardPorts": [
3000,
8080
],
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download"
}
}
+58
View File
@@ -0,0 +1,58 @@
name: Docker development build
on:
push:
branches: [develop]
jobs:
build-amd64:
runs-on: ubuntu-latest
strategy:
matrix:
architecture: [amd64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t kerberos/agent-dev:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
- name: Create new and append to latest manifest
run: docker buildx imagetools create -t kerberos/agent-dev:latest kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
build-other:
runs-on: ubuntu-latest
strategy:
matrix:
#architecture: [arm64, arm/v7, arm/v6]
architecture: [arm64, arm/v7]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t kerberos/agent-dev:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
- name: Create new and append to manifest latest
run: docker buildx imagetools create --append -t kerberos/agent-dev:latest kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
@@ -1,14 +1,12 @@
name: Nightly build
name: Docker nightly build
on:
# Triggers the workflow every day at 9PM (CET).
schedule:
- cron: "0 22 * * *"
# Allows manual triggering from the Actions tab.
workflow_dispatch:
jobs:
nightly-build-amd64:
build-amd64:
runs-on: ubuntu-latest
strategy:
matrix:
@@ -20,9 +18,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
run: git clone https://github.com/kerberos-io/agent && cd agent
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
@@ -30,10 +26,10 @@ jobs:
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
run: cd agent && docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
nightly-build-other:
run: cd agent && docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
build-other:
runs-on: ubuntu-latest
strategy:
matrix:
@@ -45,9 +41,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
run: git clone https://github.com/kerberos-io/agent && cd agent
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
@@ -55,6 +49,6 @@ jobs:
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
run: cd agent && docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
run: cd agent && docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
+113
View File
@@ -0,0 +1,113 @@
name: Docker release build
on:
release:
types: [created]
env:
REPO: kerberos/agent
jobs:
build-amd64:
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
matrix:
architecture: [amd64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}} --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t $REPO:${{ github.ref_name }} $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Create new and append to manifest latest
run: docker buildx imagetools create -t $REPO:latest $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Run Buildx with output
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-$(echo ${{matrix.architecture}} | tr / -)-${{github.ref_name}} --output type=tar,dest=output-${{matrix.architecture}}.tar .
- name: Strip binary
run: mkdir -p output/ && tar -xf output-${{matrix.architecture}}.tar -C output && rm output-${{matrix.architecture}}.tar && cd output/ && tar -cf ../agent-${{matrix.architecture}}.tar -C home/agent . && rm -rf output
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
allowUpdates: true
name: ${{ github.ref_name }}
tag: ${{ github.ref_name }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-${{matrix.architecture}}.tar"
# Taken from GoReleaser's own release workflow.
# The available Snapcraft Action has some bugs described in the issue below.
# The mkdirs are a hack for https://github.com/goreleaser/goreleaser/issues/1715.
#- name: Setup Snapcraft
# run: |
# sudo apt-get update
# sudo apt-get -yq --no-install-suggests --no-install-recommends install snapcraft
# mkdir -p $HOME/.cache/snapcraft/download
# mkdir -p $HOME/.cache/snapcraft/stage-packages
#- name: Use Snapcraft
# run: tar -xf agent-${{matrix.architecture}}.tar && snapcraft
build-other:
runs-on: ubuntu-latest
permissions:
contents: write
needs: build-amd64
strategy:
matrix:
architecture: [arm64, arm-v7, arm-v6]
#architecture: [arm64, arm-v7]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}} --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t $REPO:${{ github.ref_name }} $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Create new and append to manifest latest
run: docker buildx imagetools create --append -t $REPO:latest $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Run Buildx with output
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-$(echo ${{matrix.architecture}} | tr / -)-${{github.ref_name}} --output type=tar,dest=output-${{matrix.architecture}}.tar .
- name: Strip binary
run: mkdir -p output/ && tar -xf output-${{matrix.architecture}}.tar -C output && rm output-${{matrix.architecture}}.tar && cd output/ && tar -cf ../agent-${{matrix.architecture}}.tar -C home/agent . && rm -rf output
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
allowUpdates: true
name: ${{ github.ref_name }}
tag: ${{ github.ref_name }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-${{matrix.architecture}}.tar"
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
matrix:
#No longer supported Go versions.
#go-version: ['1.17', '1.18', '1.19', '1.20', '1.21']
go-version: ["1.24"]
go-version: ["1.22"]
steps:
- name: Set up Go ${{ matrix.go-version }}
@@ -1,51 +0,0 @@
name: Create User Story Issue
on:
workflow_dispatch:
inputs:
issue_title:
description: 'Title for the issue'
required: true
issue_description:
description: 'Brief description of the feature'
required: true
complexity:
description: 'Complexity of the feature'
required: true
type: choice
options:
- 'Low'
- 'Medium'
- 'High'
default: 'Medium'
duration:
description: 'Estimated duration'
required: true
type: choice
options:
- '1 day'
- '3 days'
- '1 week'
- '2 weeks'
- '1 month'
default: '1 week'
jobs:
create-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Create Issue with User Story
uses: cedricve/llm-create-issue-user-story@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
openai_model: ${{ secrets.OPENAI_MODEL }}
issue_title: ${{ github.event.inputs.issue_title }}
issue_description: ${{ github.event.inputs.issue_description }}
complexity: ${{ github.event.inputs.complexity }}
duration: ${{ github.event.inputs.duration }}
labels: 'user-story,feature'
assignees: ${{ github.actor }}
-48
View File
@@ -1,48 +0,0 @@
name: Build pull request
on:
pull_request:
types: [opened, synchronize]
env:
REPO: kerberos/agent
jobs:
build:
runs-on: ${{ matrix.runner }}
permissions:
contents: write
strategy:
matrix:
include:
- architecture: amd64
runner: ubuntu-24.04
dockerfile: Dockerfile
- architecture: arm64
runner: ubuntu-24.04-arm
dockerfile: Dockerfile.arm64
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build -t ${{ matrix.architecture }} -f ${{ matrix.dockerfile }} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
+13 -11
View File
@@ -4,14 +4,16 @@ on: pull_request
jobs:
openai-pr-description:
uses: uug-ai/workflows/.github/workflows/pr-description.yml@main
with:
pr_number: ${{ github.event.number }}
pull_request_url: ""
overwrite_description: true
secrets:
TOKEN: ${{ secrets.TOKEN }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_VERSION: ${{ secrets.AZURE_OPENAI_VERSION }}
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Autofill PR description if empty using OpenAI
uses: cedricve/azureopenai-pr-description@master
with:
github_token: ${{ secrets.TOKEN }}
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
overwrite_description: true
-168
View File
@@ -1,168 +0,0 @@
name: Bump release
on:
workflow_dispatch:
inputs:
bump:
description: "Which part of the version to bump"
required: true
default: patch
type: choice
options:
- major
- minor
- patch
permissions:
contents: write
env:
REPO: kerberos/agent
jobs:
# Determine the next version, create the GitHub release and expose the tag.
bump-release:
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
with:
bump: ${{ github.event.inputs.bump }}
secrets: inherit
# Publish the platform image to the uug-ai GitHub Container Registry
# (ghcr.io/uug-ai/agent-platform).
release:
needs: bump-release
uses: uug-ai/workflows/.github/workflows/release-create.yml@main
with:
organization: uug-ai
project: ${{ github.event.repository.name }}
tag: ${{ needs.bump-release.outputs.tag }}
docker_context: "."
create_gitops_pr: false
runner_matrix: >-
[
{"architecture":"amd64","runner":"ubuntu-24.04"},
{"architecture":"arm64","runner":"ubuntu-24.04-arm"}
]
secrets: inherit
# Everything below mirrors the agent's own release-create.yml pipeline and
# publishes the multi-arch image to the kerberos/agent Docker Hub repo, driven
# by the freshly bumped tag instead of a `release: created` event.
build-amd64:
needs: bump-release
runs-on: ubuntu-24.04
permissions:
contents: write
strategy:
matrix:
architecture: [amd64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
build-arm64:
needs: bump-release
runs-on: ubuntu-24.04-arm
permissions:
contents: write
strategy:
matrix:
architecture: [arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
create-manifest:
runs-on: ubuntu-24.04
needs: [bump-release, build-amd64, build-arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push multi-arch manifest
run: |
docker manifest create $REPO:${{ needs.bump-release.outputs.tag }} \
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
docker manifest push $REPO:${{ needs.bump-release.outputs.tag }}
- name: Create and push latest manifest
run: |
docker manifest create $REPO:latest \
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
docker manifest push $REPO:latest
create-release:
runs-on: ubuntu-24.04
needs: [bump-release, build-amd64, build-arm64]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
allowUpdates: true
name: ${{ needs.bump-release.outputs.tag }}
tag: ${{ needs.bump-release.outputs.tag }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-*.tar/agent-*.tar"
-130
View File
@@ -1,130 +0,0 @@
name: Create a new release
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: "Tag for the Docker image"
required: true
default: "test"
env:
REPO: kerberos/agent
jobs:
build-amd64:
runs-on: ubuntu-24.04
permissions:
contents: write
strategy:
matrix:
architecture: [amd64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
build-arm64:
runs-on: ubuntu-24.04-arm
permissions:
contents: write
strategy:
matrix:
architecture: [arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
create-manifest:
runs-on: ubuntu-24.04
needs: [build-amd64, build-arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push multi-arch manifest
run: |
docker manifest create $REPO:${{ github.event.inputs.tag || github.ref_name }} \
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
docker manifest push $REPO:${{ github.event.inputs.tag || github.ref_name }}
- name: Create and push latest manifest
run: |
docker manifest create $REPO:latest \
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
docker manifest push $REPO:latest
if: github.event.inputs.tag == 'test'
create-release:
runs-on: ubuntu-24.04
needs: [build-amd64, build-arm64]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
allowUpdates: true
name: ${{ github.event.inputs.tag || github.ref_name }}
tag: ${{ github.event.inputs.tag || github.ref_name }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-*.tar/agent-*.tar"
+2 -7
View File
@@ -1,8 +1,6 @@
ui/node_modules
ui/build
ui/public/assets/env.js
.DS_Store
__debug*
.idea
machinery/www
yarn.lock
@@ -12,9 +10,6 @@ machinery/data/recordings
machinery/data/snapshots
machinery/test*
machinery/init-dev.sh
machinery/.env.local
machinery/.env
machinery/vendor
machinery/go.work
machinery/go.work.sum
deployments/docker/private-docker-compose.yaml
video.mp4
deployments/docker/private-docker-compose.yaml
+19
View File
@@ -0,0 +1,19 @@
language: go
go:
- 1.12.x
- 1.13.x
- 1.14.x
- 1.15.x
- tip
before_install:
- cd machinery
- go mod download
script:
- go vet
- go test -race -coverprofile=coverage.txt -covermode=atomic
after_success:
- bash <(curl -s https://codecov.io/bash)
+2 -7
View File
@@ -12,15 +12,10 @@
"program": "${workspaceFolder}/machinery/main.go",
"args": [
"-action",
"run",
"-port",
"8080"
"run"
],
"envFile": "${workspaceFolder}/machinery/.env.local",
"envFile": "${workspaceFolder}/machinery/.env",
"buildFlags": "--tags dynamic",
"env": {
"GOWORK": "off"
},
},
{
"name": "Launch React",
+21 -17
View File
@@ -1,13 +1,6 @@
ARG BASE_IMAGE_VERSION=amd64-ddbe40e
ARG VERSION=0.0.0
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
LABEL AUTHOR=uug.ai
# Re-declare VERSION inside this stage so the value passed via
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
# ARGs declared before the first FROM are not visible inside build stages.
ARG VERSION
FROM kerberos/base:5d5e86b AS build-machinery
LABEL AUTHOR=Kerberos.io
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
@@ -40,10 +33,7 @@ RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
go mod download && \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
fi && \
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
go build -tags timetzdata,netgo,osusergo --ldflags '-s -w -extldflags "-static -latomic"' main.go && \
mkdir -p /agent && \
mv main /agent && \
mv version /agent && \
@@ -53,7 +43,8 @@ RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
mkdir -p /agent/data/log && \
mkdir -p /agent/data/recordings && \
mkdir -p /agent/data/capture-test && \
mkdir -p /agent/data/config
mkdir -p /agent/data/config && \
rm -rf /go/src/gitlab.com/
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
@@ -67,7 +58,19 @@ RUN cp -r /agent ./
RUN /dist/agent/main version
FROM node:22-alpine AS build-ui
###############################################
# Build Bento4 -> we want fragmented mp4 files
ENV BENTO4_VERSION 1.6.0-639
RUN cd /tmp && git clone https://github.com/axiomatic-systems/Bento4 && cd Bento4 && \
git checkout tags/v${BENTO4_VERSION} && \
cd Build && \
cmake -DCMAKE_BUILD_TYPE=Release .. && \
make && \
mv /tmp/Bento4/Build/mp4fragment /dist/agent/ && \
rm -rf /tmp/Bento4
FROM node:18.14.0-alpine3.16 AS build-ui
RUN apk update && apk upgrade --available && sync
@@ -102,12 +105,13 @@ RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent v
COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist /
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
RUN apk update && apk add ca-certificates curl libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
##################
# Try running agent
RUN mv /agent/* /home/agent/
RUN cp /home/agent/mp4fragment /usr/local/bin/
RUN /home/agent/main version
#######################
@@ -144,4 +148,4 @@ HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1
# Leeeeettttt'ssss goooooo!!!
# Run the shizzle from the right working directory.
WORKDIR /home/agent
CMD ["./main", "-action", "run", "-port", "80"]
CMD ["./main", "-action", "run", "-port", "80"]
-147
View File
@@ -1,147 +0,0 @@
ARG BASE_IMAGE_VERSION=arm64-ddbe40e
ARG VERSION=0.0.0
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
LABEL AUTHOR=uug.ai
# Re-declare VERSION inside this stage so the value passed via
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
# ARGs declared before the first FROM are not visible inside build stages.
ARG VERSION
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
ENV GOSUMDB=off
##########################################
# Installing some additional dependencies.
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
git build-essential cmake pkg-config unzip libgtk2.0-dev \
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
rm -rf /var/lib/apt/lists/*
##############################################################################
# Copy all the relevant source code in the Docker image, so we can build this.
RUN mkdir -p /go/src/github.com/kerberos-io/agent
COPY machinery /go/src/github.com/kerberos-io/agent/machinery
RUN rm -rf /go/src/github.com/kerberos-io/agent/machinery/.env
##################################################################
# Get the latest commit hash, so we know which version we're running
COPY .git /go/src/github.com/kerberos-io/agent/.git
RUN cd /go/src/github.com/kerberos-io/agent/.git && git log --format="%H" -n 1 | head -c7 > /go/src/github.com/kerberos-io/agent/machinery/version
RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
##################
# Build Machinery
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
go mod download && \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
fi && \
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
mkdir -p /agent && \
mv main /agent && \
mv version /agent && \
mv data /agent && \
mkdir -p /agent/data/cloud && \
mkdir -p /agent/data/snapshots && \
mkdir -p /agent/data/log && \
mkdir -p /agent/data/recordings && \
mkdir -p /agent/data/capture-test && \
mkdir -p /agent/data/config
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
# Later, it will be copied as the / (root) of the output image.
WORKDIR /dist
RUN cp -r /agent ./
####################################################################################
# This will collect dependent libraries so they're later copied to the final image.
RUN /dist/agent/main version
FROM node:22-alpine AS build-ui
RUN apk update && apk upgrade --available && sync
########################
# Build Web (React app)
RUN mkdir -p /go/src/github.com/kerberos-io/agent/machinery/www
COPY ui /go/src/github.com/kerberos-io/agent/ui
RUN cd /go/src/github.com/kerberos-io/agent/ui && rm -rf yarn.lock && yarn config set network-timeout 300000 && \
yarn && yarn build
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
# Later, it will be copied as the / (root) of the output image.
WORKDIR /dist
RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www ./agent/
############################################
# Publish main binary to GitHub release
FROM alpine:latest
############################
# Protect by non-root user.
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
#################################
# Copy files from previous images
COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist /
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
##################
# Try running agent
RUN mv /agent/* /home/agent/
RUN /home/agent/main version
#######################
# Make template config
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
###########################
# Set permissions correctly
RUN chown -R agent:kerberosio /home/agent/data
RUN chown -R agent:kerberosio /home/agent/www
###########################
# Grant the necessary root capabilities to the process trying to bind to the privileged port
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
###################
# Run non-root user
USER agent
######################################
# By default the app runs on port 80
EXPOSE 80
######################################
# Check if agent is still running
HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1
###################################################
# Leeeeettttt'ssss goooooo!!!
# Run the shizzle from the right working directory.
WORKDIR /home/agent
CMD ["./main", "-action", "run", "-port", "80"]
+63 -78
View File
@@ -65,7 +65,6 @@ There are a myriad of cameras out there (USB, IP and other cameras), and it migh
### Contributing
1. [Security vulnerability reporting](#security-vulnerability-reporting)
1. [Contribute with Codespaces](#contribute-with-codespaces)
2. [Develop and build](#develop-and-build)
3. [Building from source](#building-from-source)
@@ -191,78 +190,68 @@ Next to attaching the configuration file, it is also possible to override the co
| Name | Description | Default Value |
| --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
| `LOG_OUTPUT` | Logging output format "json" or "text". | "text" |
| `AGENT_MODE` | You can choose to run this in 'release' for production, and or 'demo' for showcasing. | "release" |
| `AGENT_TLS_INSECURE` | Specify if you want to use `InsecureSkipVerify` for the internal HTTP client. | "false" |
| `AGENT_USERNAME` | The username used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_PASSWORD` | The password used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_KEY` | A unique identifier for your Kerberos Agent, this is auto-generated but can be overriden. | "" |
| `AGENT_NAME` | The agent friendly-name. | "agent" |
| `AGENT_TIMEZONE` | Timezone which is used for converting time. | "Africa/Ceuta" |
| `AGENT_REMOVE_AFTER_UPLOAD` | When enabled, recordings uploaded successfully to a storage will be removed from disk. | "true" |
| `AGENT_OFFLINE` | Makes sure no external connection is made. | "false" |
| `AGENT_AUTO_CLEAN` | Cleans up the recordings directory. | "true" |
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, set the max size of the recordings directory (in MB). | "100" |
| `AGENT_TIME` | Enable the timetable for Kerberos Agent | "false" |
| `AGENT_TIMETABLE` | A (weekly) time table to specify when to make recordings "start1,end1,start2,end2;start1.. | "" |
| `AGENT_REGION_POLYGON` | A single polygon set for motion detection: "x1,y1;x2,y2;x3,y3;... | "" |
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP endpoint to the camera you're targetting. | "" |
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | Sub-stream RTSP endpoint used for livestreaming (WebRTC). | "" |
| `AGENT_CAPTURE_IPCAMERA_BASE_WIDTH` | Force a specific width resolution for live view processing. | "" |
| `AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT` | Force a specific height resolution for live view processing. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF` | Mark as a compliant ONVIF device. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
| `AGENT_CAPTURE_RECORDING` | Toggle for enabling making recordings. | "true" |
| `AGENT_CAPTURE_CONTINUOUS` | Toggle for enabling continuous "true" or motion "false". | "false" |
| `AGENT_CAPTURE_PRERECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) before/after motion event. | "10" |
| `AGENT_CAPTURE_POSTRECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) after motion event. | "20" |
| `AGENT_CAPTURE_MAXLENGTH` | The maximum length of a single recording (seconds). | "30" |
| `AGENT_CAPTURE_PIXEL_CHANGE` | If `CONTINUOUS` set to `false`, the number of pixel require to change before motion triggers. | "150" |
| `AGENT_CAPTURE_FRAGMENTED` | Set the format of the recorded MP4 to fragmented (suitable for HLS). | "false" |
| `AGENT_CAPTURE_FRAGMENTED_DURATION` | If `AGENT_CAPTURE_FRAGMENTED` set to `true`, define the duration (seconds) of a fragment. | "8" |
| `AGENT_MQTT_URI` | An MQTT broker endpoint that is used for bi-directional communication (live view, onvif, etc) | "tcp://mqtt.kerberos.io:1883" |
| `AGENT_MQTT_USERNAME` | Username of the MQTT broker. | "" |
| `AGENT_MQTT_PASSWORD` | Password of the MQTT broker. | "" |
| `AGENT_REALTIME_PROCESSING` | If `AGENT_REALTIME_PROCESSING` set to `true`, the agent will send key frames to the topic | "" |
| `AGENT_REALTIME_PROCESSING_TOPIC` | The topic to which keyframes will be sent in base64 encoded format. | "" |
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn-fra1.kerberos.io:3478"|
| `AGENT_FORCE_TURN` | Force using a TURN server, by generating relay candidates only. | "false" |
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn-fra1.kerberos.io:3478"|
| `AGENT_TURN_USERNAME` | TURN username used for WebRTC. | "username1" |
| `AGENT_TURN_PASSWORD` | TURN password used for WebRTC. | "password1" |
| `AGENT_CLOUD` | Store recordings in Kerberos Hub (s3), Kerberos Vault (kstorage), or Dropbox (dropbox). | "s3" |
| `AGENT_HUB_ENCRYPTION` | Turning on/off encryption of traffic from your Kerberos Agent to Kerberos Hub. | "true" |
| `AGENT_HUB_URI` | The Kerberos Hub API, defaults to our Kerberos Hub SAAS. | "https://api.hub.domain.com" |
| `AGENT_HUB_KEY` | The access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_PRIVATE_KEY` | The secret access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_REGION` | The Kerberos Hub region, to which you want to upload. | "" |
| `AGENT_HUB_SITE` | The site ID of a site you've created in your Kerberos Hub account. | "" |
| `AGENT_KERBEROSVAULT_URI` | The Kerberos Vault API url. | "https://vault.domain.com/api" |
| `AGENT_KERBEROSVAULT_ACCESS_KEY` | The access key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECRET_KEY` | The secret key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_PROVIDER` | A Kerberos Vault provider you have created (optional). | "" |
| `AGENT_KERBEROSVAULT_DIRECTORY` | The directory, in the Kerberos vault, where the recordings will be stored. | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_URI` | The Kerberos Vault API url. | "https://vault.domain.com/api" |
| `AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY` | The access key of a secondary Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY` | The secret key of a secondary Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_PROVIDER` | A secondary Kerberos Vault provider you have created (optional). | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY` | The directory, in the secondary Kerberos vault, where the recordings will be stored. | "" |
| `AGENT_DROPBOX_ACCESS_TOKEN` | The Access Token from your Dropbox app, that is used to leverage the Dropbox SDK. | "" |
| `AGENT_DROPBOX_DIRECTORY` | The directory, in Dropbox, where the recordings will be stored. | "" |
| `AGENT_ENCRYPTION` | Enable 'true' or disable 'false' end-to-end encryption for MQTT messages. | "false" |
| `AGENT_ENCRYPTION_RECORDINGS` | Enable 'true' or disable 'false' end-to-end encryption for recordings. | "false" |
| `AGENT_ENCRYPTION_FINGERPRINT` | The fingerprint of the keypair (public/private keys), so you know which one to use. | "" |
| `AGENT_ENCRYPTION_PRIVATE_KEY` | The private key (assymetric/RSA) to decrypt and sign requests send over MQTT. | "" |
| `AGENT_ENCRYPTION_SYMMETRIC_KEY` | The symmetric key (AES) to encrypt and decrypt requests sent over MQTT. | "" |
| `AGENT_SIGNING` | Enable 'true' or disable 'false' for signing recordings. | "true" |
| `AGENT_SIGNING_PRIVATE_KEY` | The private key (RSA) to sign the recordings fingerprint to validate origin. | "" - uses default one if empty |
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
| `LOG_OUTPUT` | Logging output format "json" or "text". | "text" |
| `AGENT_MODE` | You can choose to run this in 'release' for production, and or 'demo' for showcasing. | "release" |
| `AGENT_TLS_INSECURE` | Specify if you want to use `InsecureSkipVerify` for the internal HTTP client. | "false" |
| `AGENT_USERNAME` | The username used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_PASSWORD` | The password used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_KEY` | A unique identifier for your Kerberos Agent, this is auto-generated but can be overriden. | "" |
| `AGENT_NAME` | The agent friendly-name. | "agent" |
| `AGENT_TIMEZONE` | Timezone which is used for converting time. | "Africa/Ceuta" |
| `AGENT_REMOVE_AFTER_UPLOAD` | When enabled, recordings uploaded successfully to a storage will be removed from disk. | "true" |
| `AGENT_OFFLINE` | Makes sure no external connection is made. | "false" |
| `AGENT_AUTO_CLEAN` | Cleans up the recordings directory. | "true" |
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, set the max size of the recordings directory (in MB). | "100" |
| `AGENT_TIME` | Enable the timetable for Kerberos Agent | "false" |
| `AGENT_TIMETABLE` | A (weekly) time table to specify when to make recordings "start1,end1,start2,end2;start1.. | "" |
| `AGENT_REGION_POLYGON` | A single polygon set for motion detection: "x1,y1;x2,y2;x3,y3;... | "" |
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP endpoint to the camera you're targetting. | "" |
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | Sub-stream RTSP endpoint used for livestreaming (WebRTC). | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF` | Mark as a compliant ONVIF device. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
| `AGENT_CAPTURE_RECORDING` | Toggle for enabling making recordings. | "true" |
| `AGENT_CAPTURE_CONTINUOUS` | Toggle for enabling continuous "true" or motion "false". | "false" |
| `AGENT_CAPTURE_PRERECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) before/after motion event. | "10" |
| `AGENT_CAPTURE_POSTRECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) after motion event. | "20" |
| `AGENT_CAPTURE_MAXLENGTH` | The maximum length of a single recording (seconds). | "30" |
| `AGENT_CAPTURE_PIXEL_CHANGE` | If `CONTINUOUS` set to `false`, the number of pixel require to change before motion triggers. | "150" |
| `AGENT_CAPTURE_FRAGMENTED` | Set the format of the recorded MP4 to fragmented (suitable for HLS). | "false" |
| `AGENT_CAPTURE_FRAGMENTED_DURATION` | If `AGENT_CAPTURE_FRAGMENTED` set to `true`, define the duration (seconds) of a fragment. | "8" |
| `AGENT_MQTT_URI` | An MQTT broker endpoint that is used for bi-directional communication (live view, onvif, etc) | "tcp://mqtt.kerberos.io:1883" |
| `AGENT_MQTT_USERNAME` | Username of the MQTT broker. | "" |
| `AGENT_MQTT_PASSWORD` | Password of the MQTT broker. | "" |
| `AGENT_REALTIME_PROCESSING` | If `AGENT_REALTIME_PROCESSING` set to `true`, the agent will send key frames to the topic | "" |
| `AGENT_REALTIME_PROCESSING_TOPIC` | The topic to which keyframes will be sent in base64 encoded format. | "" |
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn.kerberos.io:8443" |
| `AGENT_FORCE_TURN` | Force using a TURN server, by generating relay candidates only. | "false" |
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn.kerberos.io:8443" |
| `AGENT_TURN_USERNAME` | TURN username used for WebRTC. | "username1" |
| `AGENT_TURN_PASSWORD` | TURN password used for WebRTC. | "password1" |
| `AGENT_CLOUD` | Store recordings in Kerberos Hub (s3), Kerberos Vault (kstorage), or Dropbox (dropbox). | "s3" |
| `AGENT_HUB_ENCRYPTION` | Turning on/off encryption of traffic from your Kerberos Agent to Kerberos Hub. | "true" |
| `AGENT_HUB_URI` | The Kerberos Hub API, defaults to our Kerberos Hub SAAS. | "https://api.hub.domain.com" |
| `AGENT_HUB_KEY` | The access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_PRIVATE_KEY` | The secret access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_REGION` | The Kerberos Hub region, to which you want to upload. | "" |
| `AGENT_HUB_SITE` | The site ID of a site you've created in your Kerberos Hub account. | "" |
| `AGENT_KERBEROSVAULT_URI` | The Kerberos Vault API url. | "https://vault.domain.com/api" |
| `AGENT_KERBEROSVAULT_ACCESS_KEY` | The access key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECRET_KEY` | The secret key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_PROVIDER` | A Kerberos Vault provider you have created (optional). | "" |
| `AGENT_KERBEROSVAULT_DIRECTORY` | The directory, in the Kerberos vault, where the recordings will be stored. | "" |
| `AGENT_DROPBOX_ACCESS_TOKEN` | The Access Token from your Dropbox app, that is used to leverage the Dropbox SDK. | "" |
| `AGENT_DROPBOX_DIRECTORY` | The directory, in Dropbox, where the recordings will be stored. | "" |
| `AGENT_ENCRYPTION` | Enable 'true' or disable 'false' end-to-end encryption for MQTT messages. | "false" |
| `AGENT_ENCRYPTION_RECORDINGS` | Enable 'true' or disable 'false' end-to-end encryption for recordings. | "false" |
| `AGENT_ENCRYPTION_FINGERPRINT` | The fingerprint of the keypair (public/private keys), so you know which one to use. | "" |
| `AGENT_ENCRYPTION_PRIVATE_KEY` | The private key (assymetric/RSA) to decrypt and sign requests send over MQTT. | "" |
| `AGENT_ENCRYPTION_SYMMETRIC_KEY` | The symmetric key (AES) to encrypt and decrypt requests sent over MQTT. | "" |
## Encryption
@@ -302,10 +291,6 @@ If we talk about video encoders and decoders (codecs) there are 2 major video co
Conclusion: depending on the use case you might choose one over the other, and you can use both at the same time. For example you can use H264 (main stream) for livestreaming, and H265 (sub stream) for recording. If you wish to play recordings in a cross-platform and cross-browser environment, you might opt for H264 for better support.
## Security vulnerability reporting
If you found a potential security vulnerability, please use the private channels described in [SECURITY.md](SECURITY.md). Avoid opening public GitHub issues for sensitive findings.
## Contribute with Codespaces
One of the major blockers for letting you contribute to an Open Source project is to set up your local development machine. Why? Because you might already have some tools and libraries installed that are used for other projects, and the libraries you would need for Kerberos Agent, for example FFmpeg, might require a different version. Welcome to dependency hell...
@@ -428,7 +413,7 @@ Read more about this [at the FAQ](#faq) below.
## Contributors
This project exists thanks to all the people who contribute. Bravo!
This project exists thanks to all the people who contribute.
<a href="https://github.com/kerberos-io/agent/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kerberos-io/agent" />
-40
View File
@@ -1,40 +0,0 @@
# Security Policy
## Supported Versions
We only provide security fixes for the latest release series on the `master` branch.
## Reporting a Vulnerability
Please do **not** open a public GitHub issue for potential security vulnerabilities.
Use one of the private channels below:
1. Preferred: GitHub private vulnerability reporting
- https://github.com/kerberos-io/agent/security/advisories/new
2. Fallback: Email
- support@kerberos.io
- Optional CC: support@uug.ai
Please include:
- A short summary and impact.
- Reproduction steps or proof of concept.
- Affected version(s), commit hash, or deployment details.
- Any proposed mitigation/workaround.
- Your preferred attribution name.
For faster triage, use this subject format in email:
`[Security][Kerberos Agent] <short title>`
## Response Expectations
- Acknowledgement target: within 3 business days.
- Triage/update target: within 7 business days after acknowledgement.
If you do not receive a response in time, please resend your report and include your original timestamp.
## Disclosure and Credits
We follow coordinated disclosure. After a fix is available, we will credit reporters unless they prefer to stay anonymous.
+10
View File
@@ -0,0 +1,10 @@
export version=0.0.1
export name=agent
docker build -t $name .
docker tag $name kerberos/$name:$version
docker push kerberos/$name:$version
docker tag $name kerberos/$name:latest
docker push kerberos/$name:latest
+1 -1
View File
@@ -41,7 +41,7 @@ You attach a volume to your container by leveraging the `-v` option. To mount yo
### Override with environment variables
Next to attaching the configuration file, it is also possible to override the configuration with environment variables. This makes deployments when leveraging `docker compose` or `kubernetes` much easier and more scalable. Using this approach we simplify automation through `ansible` and `terraform`. You'll find [the full list of environment variables on the main README.md file](https://github.com/kerberos-io/agent#override-with-environment-variables).
Next to attaching the configuration file, it is also possible to override the configuration with environment variables. This makes deployments easier when leveraging `docker compose` or `kubernetes` deployments much easier and scalable. Using this approach we simplify automation through `ansible` and `terraform`. You'll find [the full list of environment variables on the main README.md file](https://github.com/kerberos-io/agent#override-with-environment-variables).
### 2. Running multiple containers with Docker compose
BIN
View File
Binary file not shown.
-38
View File
@@ -1,38 +0,0 @@
AGENT_NAME=camera-name
AGENT_KEY=uniq-camera-id
AGENT_TIMEZONE=Europe/Brussels
#AGENT_CAPTURE_CONTINUOUS=true
#AGENT_CAPTURE_IPCAMERA_RTSP=rtsp://fake.kerberos.io/stream
#AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsp://fake.kerberos.io/stream
AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR=x.x.x.x
AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME=xxx
AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD=xxx
AGENT_HUB_URI=https://api.cloud.kerberos.io
AGENT_HUB_KEY=AKIXxxx4JBEI
AGENT_HUB_PRIVATE_KEY=DIOXxxxAlYpaxxxxXioL0txxx
AGENT_HUB_SITE=681xxxxxxx9bcda5
# By default will send to Hub (=S3), if you wish to send to Kerberos Vault, set to "kstorage"
AGENT_CLOUD=s3
AGENT_KERBEROSVAULT_URI=
AGENT_KERBEROSVAULT_PROVIDER=
AGENT_KERBEROSVAULT_DIRECTORY=
AGENT_KERBEROSVAULT_ACCESS_KEY=
AGENT_KERBEROSVAULT_SECRET_KEY=
AGENT_KERBEROSVAULT_MAX_RETRIES=10
AGENT_KERBEROSVAULT_TIMEOUT=120
AGENT_KERBEROSVAULT_SECONDARY_URI=
AGENT_KERBEROSVAULT_SECONDARY_PROVIDER=
AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY=
AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY=
AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY=
# Resumable (tus) uploads to Kerberos Vault are enabled by default.
# Set to true to fall back to the legacy single-shot POST /storage upload.
#AGENT_DISABLE_RESUMABLE_UPLOAD=true
# Bytes sent per PATCH request (default 1 MiB = 1048576). 0 disables chunking
# and sends the whole file in a single PATCH.
AGENT_TUS_CHUNK_SIZE_BYTES=1048576
# Open telemetry tracing endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=
+1 -5
View File
@@ -22,8 +22,4 @@ https://brianmacdonald.github.io/Ethonate/address#0xf4a759C9436E2280Ea9cdd23d314
[**Docker Hub**](https://hub.docker.com/r/kerberos/agent) | [**Documentation**](https://doc.kerberos.io) | [**Website**](https://kerberos.io)
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
## Security reporting
For sensitive vulnerabilities, use private disclosure channels documented in [../SECURITY.md](../SECURITY.md).
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
-636
View File
@@ -1,636 +0,0 @@
package main
import (
"flag"
"fmt"
"os"
"sort"
"github.com/Eyevinn/mp4ff/avc"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
)
func main() {
fromFlag := flag.Int64("from", -1, "start of the detailed inspection window (track timescale units); default auto-detects the largest keyframe gap")
toFlag := flag.Int64("to", -1, "end of the detailed inspection window (track timescale units); default auto-detected")
flag.Parse()
if flag.NArg() < 1 {
fmt.Println("usage: mp4analyze [-from N] [-to N] <file.mp4>")
os.Exit(1)
}
f, err := os.Open(flag.Arg(0))
if err != nil {
panic(err)
}
defer f.Close()
parsed, err := mp4ff.DecodeFile(f)
if err != nil {
panic(err)
}
// Movie-level info
if parsed.Init != nil && parsed.Init.Moov != nil {
moov := parsed.Init.Moov
fmt.Printf("ftyp/moov present. timescale(mvhd)=%d duration(mvhd)=%d\n",
moov.Mvhd.Timescale, moov.Mvhd.Duration)
for _, trak := range moov.Traks {
ts := trak.Mdia.Mdhd.Timescale
fmt.Printf(" trak id=%d handler=%s mdhd.timescale=%d mdhd.duration=%d\n",
trak.Tkhd.TrackID, trak.Mdia.Hdlr.HandlerType, ts, trak.Mdia.Mdhd.Duration)
}
} else {
fmt.Println("no Init/Moov (pure fragmented stream?)")
}
// sidx vs actual segment layout. MSE players use sidx to map presentation
// time -> byte ranges; if sidx references disagree with the real segment
// sizes/durations (e.g. after an early/short flush) the player fetches the
// wrong bytes and fails to decode — a failure that "heals" on seek.
fmt.Println("=== sidx references vs actual segments ===")
var sidxRefs []mp4ff.SidxRef
for _, c := range parsed.Children {
if s, ok := c.(*mp4ff.SidxBox); ok {
fmt.Printf(" sidx: timescale=%d earliestPresTime=%d firstOffset=%d refCount=%d anchor(after sidx)=%d\n",
s.Timescale, s.EarliestPresentationTime, s.FirstOffset, len(s.SidxRefs), s.AnchorPoint)
sidxRefs = s.SidxRefs
}
}
// Actual segment sizes (styp+moof+mdat) and fragment durations.
type segInfo struct {
size uint64
dur uint64
}
var actual []segInfo
for _, seg := range parsed.Segments {
var sz uint64
if seg.Styp != nil {
sz += seg.Styp.Size()
}
if seg.Sidx != nil {
sz += seg.Sidx.Size()
}
var dur uint64
for _, fr := range seg.Fragments {
sz += fr.Moof.Size()
if fr.Mdat != nil {
sz += fr.Mdat.Size()
}
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
dur += uint64(s.Dur)
}
}
}
}
actual = append(actual, segInfo{size: sz, dur: dur})
}
for i := range actual {
refStr := "(no sidx ref)"
if i < len(sidxRefs) {
r := sidxRefs[i]
mark := ""
if uint64(r.ReferencedSize) != actual[i].size {
mark += fmt.Sprintf(" SIZE MISMATCH actual=%d", actual[i].size)
}
if uint64(r.SubSegmentDuration) != actual[i].dur {
mark += fmt.Sprintf(" DUR MISMATCH actual=%d", actual[i].dur)
}
refStr = fmt.Sprintf("sidx.size=%d sidx.dur=%d type=%d sap=%d/%d%s",
r.ReferencedSize, r.SubSegmentDuration, r.ReferenceType, r.StartsWithSAP, r.SAPType, mark)
}
fmt.Printf(" seg%02d actual.size=%d actual.dur=%d | %s\n", i, actual[i].size, actual[i].dur, refStr)
}
fmt.Println("=== fragments ===")
fragIdx := 0
var allKeyGlobal []uint64 // global keyframe decode times (track timescale units)
var prevTfdtEnd = map[uint32]uint64{}
for si, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
tid := traf.Tfhd.TrackID
tfdt := traf.Tfdt.BaseMediaDecodeTime()
offset := uint64(0)
var keys []uint64 // keyframe offset-from-tfdt
var durs []uint64
zeroDur := 0
nSamples := 0
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
nSamples++
if (s.Flags>>24)&0x03 == 0x02 { // sample_depends_on==2 => IDR/sync
keys = append(keys, offset)
if tid == 1 {
allKeyGlobal = append(allKeyGlobal, tfdt+offset)
}
}
if s.Dur == 0 {
zeroDur++
}
durs = append(durs, uint64(s.Dur))
offset += uint64(s.Dur)
}
}
cont := ""
if pe, ok := prevTfdtEnd[tid]; ok {
if tfdt != pe {
cont = fmt.Sprintf(" <-- tfdt GAP/JUMP prev_end=%d delta=%d", pe, int64(tfdt)-int64(pe))
}
}
prevTfdtEnd[tid] = tfdt + offset
if tid == 1 {
// in-fragment keyframe gaps
var gaps []int64
for i := 1; i < len(keys); i++ {
gaps = append(gaps, int64(keys[i])-int64(keys[i-1]))
}
fmt.Printf("seg%d frag%d trk%d tfdt=%d dur=%d nSamp=%d zeroDur=%d keys=%v inFragKeyGaps=%v%s\n",
si, fragIdx, tid, tfdt, offset, nSamples, zeroDur, keys, gaps, cont)
}
}
fragIdx++
}
}
fmt.Println("=== global video keyframe decode times & gaps ===")
for i, k := range allKeyGlobal {
gap := int64(0)
if i > 0 {
gap = int64(k) - int64(allKeyGlobal[i-1])
}
seam := ""
if i > 1 {
prevGap := int64(allKeyGlobal[i-1]) - int64(allKeyGlobal[i-2])
if gap > 0 && prevGap > 0 && gap*2 < prevGap {
seam = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
}
}
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, seam)
}
// Choose the detailed-inspection window. By default centre it on the largest
// keyframe gap (the most likely artifact location); -from/-to override.
winLo, winHi := inspectWindow(allKeyGlobal, *fromFlag, *toFlag)
fmt.Printf("=== detailed inspection window: dts %d..%d ===\n", winLo, winHi)
// Full sample timeline: DTS, CTS (=DTS+cto), composition offset, NAL types,
// to detect PTS non-monotonicity / gaps / param-set changes at the seam.
fmt.Println("=== per-sample timeline (full) — checking PTS monotonicity & nal types ===")
var trex *mp4ff.TrexBox
if parsed.Init != nil && parsed.Init.Moov != nil && parsed.Init.Moov.Mvex != nil {
for _, t := range parsed.Init.Moov.Mvex.Trexs {
if t.TrackID == 1 {
trex = t
}
}
}
var lastCTS int64 = -1
var lastDTS int64 = -1
sampIdx := 0
fragIdx = 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
fs, err := fr.GetFullSamples(trex)
if err != nil {
fmt.Printf(" frag%d GetFullSamples err: %v\n", fragIdx, err)
fragIdx++
continue
}
for _, s := range fs {
dts := int64(s.DecodeTime)
cts := dts + int64(s.CompositionTimeOffset)
nals := nalTypes(s.Data)
anomaly := ""
if lastCTS >= 0 && cts < lastCTS {
anomaly += fmt.Sprintf(" <== CTS BACKWARDS (prev=%d)", lastCTS)
}
if lastDTS >= 0 && dts < lastDTS {
anomaly += fmt.Sprintf(" <== DTS BACKWARDS (prev=%d)", lastDTS)
}
// sample_is_non_sync_sample is bit 16 (0x00010000); a sync sample
// has it clear and sample_depends_on==2 (i.e. an I-frame).
isSync := s.Flags&0x00010000 == 0 && (s.Flags>>24)&0x03 == 0x02
// Only print inside the inspection window and any anomalies, to keep output small.
near := dts >= winLo && dts <= winHi
if near || anomaly != "" {
fmt.Printf(" s%04d frag%d dts=%d cts=%d cto=%d dur=%d size=%d sync=%v nal=%v%s\n",
sampIdx, fragIdx, dts, cts, s.CompositionTimeOffset, s.Dur, len(s.Data), isSync, nals, anomaly)
}
lastCTS = cts
lastDTS = dts
sampIdx++
}
fragIdx++
}
}
// Compare parameter sets: avcC (in moov) vs inline SPS/PPS at every IDR.
// A looping source that restarts may re-emit SPS/PPS that differ from the
// ones the player configured its decoder with from avcC — a classic cause
// of a freeze that "heals" when you seek past the seam.
fmt.Println("=== parameter set comparison (avcC vs inline IDR) ===")
var avccSPS, avccPPS [][]byte
if parsed.Init != nil && parsed.Init.Moov != nil {
for _, trak := range parsed.Init.Moov.Traks {
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
continue
}
stsd := trak.Mdia.Minf.Stbl.Stsd
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
continue
}
avccSPS = stsd.AvcX.AvcC.SPSnalus
avccPPS = stsd.AvcX.AvcC.PPSnalus
}
}
for i, s := range avccSPS {
fmt.Printf(" avcC SPS[%d] = %x\n", i, s)
}
for i, p := range avccPPS {
fmt.Printf(" avcC PPS[%d] = %x\n", i, p)
}
fragIdx = 0
sampIdx = 0
var baseSPS, basePPS []byte
if len(avccSPS) > 0 {
baseSPS = avccSPS[0]
}
if len(avccPPS) > 0 {
basePPS = avccPPS[0]
}
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
fs, err := fr.GetFullSamples(trex)
if err != nil {
fragIdx++
continue
}
for _, s := range fs {
spsList := nalsByType(s.Data, 7)
ppsList := nalsByType(s.Data, 8)
if len(spsList) > 0 || len(ppsList) > 0 {
dts := int64(s.DecodeTime)
note := ""
if len(spsList) > 0 {
if baseSPS == nil {
baseSPS = spsList[0]
} else if !bytesEqual(baseSPS, spsList[0]) {
note += " <== SPS CHANGED vs base/avcC"
}
}
if len(ppsList) > 0 {
if basePPS == nil {
basePPS = ppsList[0]
} else if !bytesEqual(basePPS, ppsList[0]) {
note += " <== PPS CHANGED vs base/avcC"
}
}
var spsHex, ppsHex string
if len(spsList) > 0 {
spsHex = fmt.Sprintf("%x", spsList[0])
}
if len(ppsList) > 0 {
ppsHex = fmt.Sprintf("%x", ppsList[0])
}
fmt.Printf(" IDR s%04d frag%d dts=%d SPS=%s PPS=%s%s\n",
sampIdx, fragIdx, dts, spsHex, ppsHex, note)
}
sampIdx++
}
fragIdx++
}
}
sliceHeaders(parsed, trex, winLo, winHi)
summary(parsed, trex)
}
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox, winLo, winHi int64) {
// Build SPS/PPS maps from avcC.
spsMap := map[uint32]*avc.SPS{}
ppsMap := map[uint32]*avc.PPS{}
if parsed.Init != nil && parsed.Init.Moov != nil {
for _, trak := range parsed.Init.Moov.Traks {
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
continue
}
stsd := trak.Mdia.Minf.Stbl.Stsd
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
continue
}
for _, s := range stsd.AvcX.AvcC.SPSnalus {
if sps, err := avc.ParseSPSNALUnit(s, true); err == nil {
spsMap[uint32(sps.ParameterID)] = sps
}
}
for _, p := range stsd.AvcX.AvcC.PPSnalus {
if pps, err := avc.ParsePPSNALUnit(p, spsMap); err == nil {
ppsMap[pps.PicParameterSetID] = pps
}
}
}
}
fmt.Println("=== slice headers in inspection window (frame_num / poc / idr_pic_id) ===")
fragIdx := 0
sampIdx := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
fs, err := fr.GetFullSamples(trex)
if err != nil {
fragIdx++
continue
}
for _, s := range fs {
dts := int64(s.DecodeTime)
if dts < winLo || dts > winHi {
sampIdx++
continue
}
for _, nal := range splitAVCC(s.Data) {
t := nal[0] & 0x1f
if t == 1 || t == 5 { // non-IDR or IDR slice
sh, err := avc.ParseSliceHeader(nal, spsMap, ppsMap)
if err != nil {
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceHeader ERR: %v\n", sampIdx, fragIdx, dts, t, err)
break
}
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceType=%v frameNum=%d idrPicId=%d pocLsb=%d\n",
sampIdx, fragIdx, dts, t, sh.SliceType, sh.FrameNum, sh.IDRPicID, sh.PicOrderCntLsb)
break
}
}
sampIdx++
}
fragIdx++
}
}
}
// splitAVCC splits a length-prefixed (4-byte) AVCC buffer into NAL units.
func splitAVCC(b []byte) [][]byte {
var out [][]byte
i := 0
for i+4 <= len(b) {
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
i += 4
if n <= 0 || i+n > len(b) {
break
}
out = append(out, b[i:i+n])
i += n
}
return out
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// nalTypes returns the list of H.264 NAL unit types present in an AVCC
// (length-prefixed) sample buffer.
func nalTypes(b []byte) []int {
var out []int
i := 0
for i+4 <= len(b) {
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
i += 4
if n <= 0 || i+n > len(b) {
break
}
out = append(out, int(b[i]&0x1f))
i += n
}
return out
}
// nalsByType returns the raw NAL payloads (without length prefix) of the given
// type from an AVCC (length-prefixed) sample buffer.
func nalsByType(b []byte, want int) [][]byte {
var out [][]byte
i := 0
for i+4 <= len(b) {
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
i += 4
if n <= 0 || i+n > len(b) {
break
}
if int(b[i]&0x1f) == want {
nal := make([]byte, n)
copy(nal, b[i:i+n])
out = append(out, nal)
}
i += n
}
return out
}
// inspectWindow returns the [lo,hi] decode-time range (track timescale units)
// for which sample-level detail is printed. Explicit -from/-to win; otherwise
// the window auto-centres on the largest gap between consecutive video
// keyframes — the most likely location of a visible artifact — with a margin on
// each side so the frames leading into and out of the gap are shown too.
func inspectWindow(keyDecodeTimes []uint64, from, to int64) (int64, int64) {
if from >= 0 || to >= 0 {
if from < 0 {
from = 0
}
if to < 0 {
to = from + 2000
}
return from, to
}
if len(keyDecodeTimes) < 2 {
return 0, 1 << 62
}
worstIdx, worstGap := 1, uint64(0)
for i := 1; i < len(keyDecodeTimes); i++ {
if g := keyDecodeTimes[i] - keyDecodeTimes[i-1]; g > worstGap {
worstGap = g
worstIdx = i
}
}
const margin = 500
lo := int64(keyDecodeTimes[worstIdx-1]) - margin
if lo < 0 {
lo = 0
}
return lo, int64(keyDecodeTimes[worstIdx]) + margin
}
// summary prints a compact, generic health report so a recording can be
// validated at a glance without reading the full per-sample dump above.
func summary(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
fmt.Println("=== SUMMARY (health checks) ===")
videoTracks, audioTracks := 0, 0
var videoTimescale uint64 = 1
if parsed.Init != nil && parsed.Init.Moov != nil {
for _, trak := range parsed.Init.Moov.Traks {
switch trak.Mdia.Hdlr.HandlerType {
case "vide":
videoTracks++
if trak.Mdia.Mdhd.Timescale != 0 {
videoTimescale = uint64(trak.Mdia.Mdhd.Timescale)
}
case "soun":
audioTracks++
}
}
}
fmt.Printf(" tracks: %d video, %d audio\n", videoTracks, audioTracks)
if audioTracks == 0 {
fmt.Println(" note: no audio track is embedded in this file")
}
type fragStat struct {
idx int
tfdt uint64
dur uint64
nSamp int
nKeys int
zeroDur int
fps float64
}
var stats []fragStat
var keyTimes []uint64
var fpsArr []float64
tfdtGaps := 0
var prevEnd uint64
havePrev := false
fi := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
st := fragStat{idx: fi, tfdt: traf.Tfdt.BaseMediaDecodeTime()}
off := uint64(0)
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
st.nSamp++
if (s.Flags>>24)&0x03 == 0x02 {
st.nKeys++
keyTimes = append(keyTimes, st.tfdt+off)
}
if s.Dur == 0 {
st.zeroDur++
}
off += uint64(s.Dur)
}
}
st.dur = off
d := st.dur
if d == 0 {
d = 1
}
st.fps = float64(st.nSamp) * float64(videoTimescale) / float64(d)
fpsArr = append(fpsArr, st.fps)
if havePrev && st.tfdt != prevEnd {
tfdtGaps++
}
prevEnd = st.tfdt + st.dur
havePrev = true
stats = append(stats, st)
}
fi++
}
}
medFps := medianFloat(fpsArr)
fmt.Printf(" fragments: %d (video timescale=%d, median %.1f fps)\n", len(stats), videoTimescale, medFps)
lowFps := 0
totalZero := 0
for _, st := range stats {
totalZero += st.zeroDur
flagStr := ""
if medFps > 0 && st.fps < medFps*0.9 {
lowFps++
flagStr = " <== LOW FRAME RATE — likely dropped frames"
}
fmt.Printf(" frag%02d tfdt=%-6d dur=%-5d samples=%-3d keyframes=%d zeroDur=%d fps=%.1f%s\n",
st.idx, st.tfdt, st.dur, st.nSamp, st.nKeys, st.zeroDur, st.fps, flagStr)
}
var gaps []uint64
for i := 1; i < len(keyTimes); i++ {
gaps = append(gaps, keyTimes[i]-keyTimes[i-1])
}
irregular := 0
if len(gaps) > 0 {
med := medianUint(gaps)
mn, mx := gaps[0], gaps[0]
for _, g := range gaps {
if g < mn {
mn = g
}
if g > mx {
mx = g
}
// Flag intervals that deviate by more than ~50% from the median GOP.
if med > 0 && (g*2 > med*3 || g*2 < med) {
irregular++
}
}
fmt.Printf(" keyframe gaps: min=%d median=%d max=%d irregular=%d/%d\n", mn, med, mx, irregular, len(gaps))
}
fmt.Printf(" tfdt discontinuities: %d\n", tfdtGaps)
fmt.Printf(" zero-duration samples: %d\n", totalZero)
fmt.Println(" verdict:")
clean := true
if audioTracks == 0 {
fmt.Println(" - no audio track (expected if this recording is video-only)")
}
if lowFps > 0 {
clean = false
fmt.Printf(" - %d fragment(s) have a reduced frame rate (dropped frames) — likely source of the artifacts\n", lowFps)
}
if irregular > 0 {
clean = false
fmt.Printf(" - %d irregular keyframe interval(s)\n", irregular)
}
if tfdtGaps > 0 {
clean = false
fmt.Printf(" - %d timeline (tfdt) discontinuity(ies)\n", tfdtGaps)
}
if totalZero > 0 {
clean = false
fmt.Printf(" - %d zero-duration sample(s)\n", totalZero)
}
if clean {
fmt.Println(" - container structure looks healthy")
}
}
func medianUint(v []uint64) uint64 {
if len(v) == 0 {
return 0
}
c := append([]uint64(nil), v...)
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
return c[len(c)/2]
}
func medianFloat(v []float64) float64 {
if len(v) == 0 {
return 0
}
c := append([]float64(nil), v...)
sort.Float64s(c)
return c[len(c)/2]
}
+3 -8
View File
@@ -14,9 +14,7 @@
"ipcamera": {
"rtsp": "",
"sub_rtsp": "",
"fps": "",
"base_width": 640,
"base_height": 0
"fps": ""
},
"usbcamera": {
"device": ""
@@ -28,7 +26,6 @@
"recording": "true",
"snapshots": "true",
"liveview": "true",
"liveview_chunking": "false",
"motion": "true",
"postrecording": 20,
"prerecording": 10,
@@ -101,14 +98,13 @@
"region": "eu-west-1"
},
"kstorage": {},
"kstorage_secondary": {},
"dropbox": {},
"mqtturi": "tcp://mqtt.kerberos.io:1883",
"mqtt_username": "",
"mqtt_password": "",
"stunuri": "stun:turn.kerberos.io:8443",
"turn_force": "false",
"stunuri": "stun:turn-fra1.kerberos.io:3478",
"turnuri": "turn:turn-fra1.kerberos.io:3478",
"turnuri": "turn:turn.kerberos.io:8443",
"turn_username": "username1",
"turn_password": "password1",
"heartbeaturi": "",
@@ -119,7 +115,6 @@
"hub_site": "",
"condition_uri": "",
"encryption": {},
"signing": {},
"realtimeprocessing": "false",
"realtimeprocessing_topic": ""
}
+106 -63
View File
@@ -1,115 +1,147 @@
module github.com/kerberos-io/agent/machinery
go 1.24.2
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
go 1.23.1
require (
github.com/Eyevinn/mp4ff v0.48.0
github.com/InVisionApp/conjungo v1.1.0
github.com/appleboy/gin-jwt/v2 v2.10.3
github.com/bluenviron/gortsplib/v4 v4.14.1
github.com/bluenviron/mediacommon v1.14.0
github.com/appleboy/gin-jwt/v2 v2.10.1
github.com/bluenviron/gortsplib/v4 v4.12.1
github.com/bluenviron/mediacommon v1.13.3
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6
github.com/dromara/carbon/v2 v2.6.8
github.com/dromara/carbon/v2 v2.5.2
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/elastic/go-sysinfo v1.15.3
github.com/gin-contrib/cors v1.7.5
github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/contrib v0.0.0-20250521004450-2b1292699c15
github.com/gin-gonic/gin v1.10.1
github.com/elastic/go-sysinfo v1.15.0
github.com/gin-contrib/cors v1.7.3
github.com/gin-contrib/pprof v1.5.2
github.com/gin-gonic/contrib v0.0.0-20241229022435-d12709533de6
github.com/gin-gonic/gin v1.10.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/gorilla/websocket v1.5.3
github.com/kellydunn/golang-geo v0.7.0
github.com/kerberos-io/joy4 v1.0.64
github.com/kerberos-io/onvif v1.0.0
github.com/minio/minio-go/v6 v6.0.57
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pion/interceptor v0.1.40
github.com/pion/rtp v1.8.19
github.com/pion/webrtc/v4 v4.1.2
github.com/pion/rtp v1.8.10
github.com/pion/webrtc/v4 v4.0.7
github.com/sirupsen/logrus v1.9.3
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.4
github.com/tevino/abool v1.2.0
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
github.com/zaf/g711 v1.4.0
go.mongodb.org/mongo-driver v1.17.3
go.opentelemetry.io/otel v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/trace v1.36.0
go.mongodb.org/mongo-driver v1.17.1
gopkg.in/DataDog/dd-trace-go.v1 v1.70.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/DataDog/appsec-internal-go v1.9.0 // indirect
github.com/DataDog/datadog-agent/pkg/obfuscate v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/proto v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/trace v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/util/log v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/util/scrubber v0.58.0 // indirect
github.com/DataDog/datadog-go/v5 v5.5.0 // indirect
github.com/DataDog/go-libddwaf/v3 v3.5.1 // indirect
github.com/DataDog/go-runtime-metrics-internal v0.0.0-20241106155157-194426bbbd59 // indirect
github.com/DataDog/go-sqllexer v0.0.14 // indirect
github.com/DataDog/go-tuf v1.1.0-0.5.2 // indirect
github.com/DataDog/gostackparse v0.7.0 // indirect
github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.20.0 // indirect
github.com/DataDog/sketches-go v1.4.5 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/beevik/etree v1.2.0 // indirect
github.com/bluenviron/mediacommon/v2 v2.2.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/bytedance/sonic v1.12.6 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/elastic/go-windows v1.0.2 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect
github.com/ebitengine/purego v0.6.0-alpha.5 // indirect
github.com/elastic/go-windows v1.0.0 // indirect
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae // indirect
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
github.com/icholy/digest v0.1.23 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/errors v1.0.0 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/compress v1.17.1 // indirect
github.com/klauspost/cpuid v1.2.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kylelemons/go-gypsy v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/lib/pq v1.10.2 // indirect
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.0 // indirect
github.com/minio/sha256-simd v0.1.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/outcaste-io/ristretto v0.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/dtls/v3 v3.0.4 // indirect
github.com/pion/ice/v4 v4.0.3 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/sctp v1.8.35 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect
github.com/shirou/gopsutil/v3 v3.24.4 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tinylib/msgp v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
@@ -117,23 +149,34 @@ require (
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/ziutek/mymysql v1.5.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.30.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.42.0 // indirect
go.opentelemetry.io/collector/component v0.104.0 // indirect
go.opentelemetry.io/collector/config/configtelemetry v0.104.0 // indirect
go.opentelemetry.io/collector/pdata v1.11.0 // indirect
go.opentelemetry.io/collector/semconv v0.104.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.18.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.24.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect
google.golang.org/grpc v1.64.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect
+304 -1538
View File
File diff suppressed because it is too large Load Diff
+56 -94
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"flag"
"fmt"
"os"
"time"
@@ -12,61 +11,47 @@ import (
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/onvif"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
configService "github.com/kerberos-io/agent/machinery/src/config"
"github.com/kerberos-io/agent/machinery/src/routers"
"github.com/kerberos-io/agent/machinery/src/utils"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"gopkg.in/DataDog/dd-trace-go.v1/profiler"
)
var VERSION = utils.VERSION
func startTracing(agentKey string, otelEndpoint string) (*trace.TracerProvider, error) {
serviceName := "agent-" + agentKey
headers := map[string]string{
"content-type": "application/json",
}
exporter, err := otlptrace.New(
context.Background(),
otlptracehttp.NewClient(
otlptracehttp.WithEndpoint(otelEndpoint),
otlptracehttp.WithHeaders(headers),
otlptracehttp.WithInsecure(),
),
)
if err != nil {
return nil, fmt.Errorf("creating new exporter: %w", err)
}
tracerprovider := trace.NewTracerProvider(
trace.WithBatcher(
exporter,
trace.WithMaxExportBatchSize(trace.DefaultMaxExportBatchSize),
trace.WithBatchTimeout(trace.DefaultScheduleDelay*time.Millisecond),
trace.WithMaxExportBatchSize(trace.DefaultMaxExportBatchSize),
),
trace.WithResource(
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
attribute.String("environment", "develop"),
),
),
)
otel.SetTracerProvider(tracerprovider)
return tracerprovider, nil
}
func main() {
// You might be interested in debugging the agent.
if os.Getenv("DATADOG_AGENT_ENABLED") == "true" {
if os.Getenv("DATADOG_AGENT_K8S_ENABLED") == "true" {
tracer.Start()
defer tracer.Stop()
} else {
service := os.Getenv("DATADOG_AGENT_SERVICE")
environment := os.Getenv("DATADOG_AGENT_ENVIRONMENT")
log.Log.Info("Starting Datadog Agent with service: " + service + " and environment: " + environment)
rules := []tracer.SamplingRule{tracer.RateRule(1)}
tracer.Start(
tracer.WithSamplingRules(rules),
tracer.WithService(service),
tracer.WithEnv(environment),
)
defer tracer.Stop()
err := profiler.Start(
profiler.WithService(service),
profiler.WithEnv(environment),
profiler.WithProfileTypes(
profiler.CPUProfile,
profiler.HeapProfile,
),
)
if err != nil {
log.Log.Fatal(err.Error())
}
defer profiler.Stop()
}
}
// Start the show ;)
// We'll parse the flags (named variables), and start the agent.
@@ -101,39 +86,35 @@ func main() {
switch action {
case "version":
{
log.Log.Info("main.Main(): You are currrently running Kerberos Agent " + VERSION)
}
log.Log.Info("main.Main(): You are currrently running Kerberos Agent " + VERSION)
case "discover":
{
// Convert duration to int
timeout, err := time.ParseDuration(timeout + "ms")
if err != nil {
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
return
}
onvif.Discover(timeout)
// Convert duration to int
timeout, err := time.ParseDuration(timeout + "ms")
if err != nil {
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
return
}
onvif.Discover(timeout)
case "decrypt":
{
log.Log.Info("main.Main(): Decrypting: " + flag.Arg(0) + " with key: " + flag.Arg(1))
symmetricKey := []byte(flag.Arg(1))
log.Log.Info("main.Main(): Decrypting: " + flag.Arg(0) + " with key: " + flag.Arg(1))
symmetricKey := []byte(flag.Arg(1))
if len(symmetricKey) == 0 {
log.Log.Fatal("main.Main(): symmetric key should not be empty")
return
}
if len(symmetricKey) != 32 {
log.Log.Fatal("main.Main(): symmetric key should be 32 bytes")
return
}
utils.Decrypt(flag.Arg(0), symmetricKey)
if symmetricKey == nil || len(symmetricKey) == 0 {
log.Log.Fatal("main.Main(): symmetric key should not be empty")
return
}
if len(symmetricKey) != 32 {
log.Log.Fatal("main.Main(): symmetric key should be 32 bytes")
return
}
utils.Decrypt(flag.Arg(0), symmetricKey)
case "run":
{
// Print Agent ASCII art
// Print Kerberos.io ASCII art
utils.PrintASCIIArt()
// Print the environment variables which include "AGENT_" as prefix.
@@ -146,29 +127,12 @@ func main() {
configuration.Name = name
configuration.Port = port
// Open this configuration either from Agent or Factory.
// Open this configuration either from Kerberos Agent or Kerberos Factory.
configService.OpenConfig(configDirectory, &configuration)
// We will override the configuration with the environment variables
configService.OverrideWithEnvironmentVariables(&configuration)
// Start OpenTelemetry tracing
if otelEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); otelEndpoint == "" {
log.Log.Info("main.Main(): No OpenTelemetry endpoint provided, skipping tracing")
} else {
log.Log.Info("main.Main(): Starting OpenTelemetry tracing with endpoint: " + otelEndpoint)
agentKey := configuration.Config.Key
traceProvider, err := startTracing(agentKey, otelEndpoint)
if err != nil {
log.Log.Error("traceprovider: " + err.Error())
}
defer func() {
if err := traceProvider.Shutdown(context.Background()); err != nil {
log.Log.Error("traceprovider: " + err.Error())
}
}()
}
// Printing final configuration
utils.PrintConfiguration(&configuration)
@@ -211,14 +175,12 @@ func main() {
HandleBootstrap: make(chan string, 1),
}
go components.Bootstrap(ctx, configDirectory, &configuration, &communication, &capture)
go components.Bootstrap(configDirectory, &configuration, &communication, &capture)
// Start the REST API.
routers.StartWebserver(configDirectory, &configuration, &communication, &capture)
}
default:
{
log.Log.Error("main.Main(): Sorry I don't understand :(")
}
log.Log.Error("main.Main(): Sorry I don't understand :(")
}
}
@@ -38,16 +38,16 @@ func (c *Capture) SetBackChannelClient(rtspUrl string) *Golibrtsp {
// RTSPClient is a interface that abstracts the RTSP client implementation.
type RTSPClient interface {
// Connect to the RTSP server.
Connect(ctx context.Context, otelContext context.Context) error
Connect(ctx context.Context) error
// Connect to a backchannel RTSP server.
ConnectBackChannel(ctx context.Context, otelContext context.Context) error
ConnectBackChannel(ctx context.Context) error
// Start the RTSP client, and start reading packets.
Start(ctx context.Context, streamType string, queue *packets.Queue, configuration *models.Configuration, communication *models.Communication) error
// Start the RTSP client, and start reading packets.
StartBackChannel(ctx context.Context, otelContext context.Context) error
StartBackChannel(ctx context.Context) (err error)
// Decode a packet into a image.
DecodePacket(pkt packets.Packet) (image.YCbCr, error)
@@ -59,7 +59,7 @@ type RTSPClient interface {
WritePacket(pkt packets.Packet) error
// Close the connection to the RTSP server.
Close(ctx context.Context) error
Close() error
// Get a list of streams from the RTSP server.
GetStreams() ([]packets.Stream, error)
+47 -611
View File
@@ -33,11 +33,8 @@ import (
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/pion/rtp"
"go.opentelemetry.io/otel"
)
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/capture")
// Implements the RTSPClient interface.
type Golibrtsp struct {
RTSPClient
@@ -84,212 +81,6 @@ type Golibrtsp struct {
AudioMPEG4Decoder *rtpmpeg4audio.Decoder
Streams []packets.Stream
// Per-stream FPS calculation (keyed by stream index)
fpsTrackers map[int8]*fpsTracker
// I-frame interval tracking fields
packetsSinceLastKeyframe int
lastKeyframePacketCount int
keyframeIntervals []int
keyframeBufferSize int
keyframeBufferIndex int
keyframeMutex sync.Mutex
// Stream health instrumentation. Used to pinpoint the root cause behind
// "RTP packets lost" + watchdog restarts by separating downstream
// back-pressure from upstream network/camera stalls.
health *streamHealth
streamLabel string
}
// streamHealth instruments the RTSP read path. gortsplib delivers every RTP
// packet on a single read goroutine; queue.WritePacket() is synchronous, so if
// a downstream consumer (recording, muxing, WebRTC) is slow or the process is
// CPU-starved, WritePacket() blocks, the TCP socket is not drained, and the
// camera advances RTP sequence numbers -> "RTP packets lost". This type makes
// the two failure modes distinguishable:
// - large writeMax / writeAvg => downstream back-pressure (our side).
// - large gapMax with fast writes => upstream network / camera stall.
type streamHealth struct {
mu sync.Mutex
windowStart time.Time
lastPacket time.Time
frames int64
writeSum time.Duration
writeMax time.Duration
gapMax time.Duration
lost uint64
decodeErrs int64
}
const (
streamHealthWindow = 10 * time.Second
streamHealthWriteWarn = 150 * time.Millisecond
streamHealthGapWarn = 1500 * time.Millisecond
)
func newStreamHealth() *streamHealth {
now := time.Now()
return &streamHealth{windowStart: now, lastPacket: now}
}
// observePacket records one processed video frame: the wall-clock gap since the
// previous frame (arrival cadence) and how long WritePacket() blocked
// (back-pressure). It emits an immediate warning when either side stalls and a
// periodic summary every streamHealthWindow.
func (h *streamHealth) observePacket(streamType string, writeDur time.Duration) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
var gap time.Duration
if h.frames == 0 {
// First frame: initialize timing to avoid counting RTSP setup time as a stall.
h.windowStart = now
h.lastPacket = now
gap = 0
} else {
gap = now.Sub(h.lastPacket)
h.lastPacket = now
}
h.frames++
h.writeSum += writeDur
if writeDur > h.writeMax {
h.writeMax = writeDur
}
if gap > h.gapMax {
h.gapMax = gap
}
if writeDur >= streamHealthWriteWarn {
log.Log.Warning(fmt.Sprintf(
"capture.golibrtsp.health(%s): WritePacket blocked %dms — downstream back-pressure / CPU starvation",
streamType, writeDur.Milliseconds()))
}
if gap >= streamHealthGapWarn {
log.Log.Warning(fmt.Sprintf(
"capture.golibrtsp.health(%s): %dms since previous frame — upstream network / camera stall",
streamType, gap.Milliseconds()))
}
if now.Sub(h.windowStart) >= streamHealthWindow {
elapsed := now.Sub(h.windowStart).Seconds()
var avgWriteMs float64
if h.frames > 0 {
avgWriteMs = float64(h.writeSum.Milliseconds()) / float64(h.frames)
}
log.Log.Info(fmt.Sprintf(
"capture.golibrtsp.health(%s): %.0fs window — frames=%d (%.1f/s) writeAvg=%.1fms writeMax=%dms gapMax=%dms lost=%d decodeErrs=%d",
streamType, elapsed, h.frames, float64(h.frames)/elapsed, avgWriteMs,
h.writeMax.Milliseconds(), h.gapMax.Milliseconds(), h.lost, h.decodeErrs))
h.windowStart = now
h.frames = 0
h.writeSum = 0
h.writeMax = 0
h.gapMax = 0
h.lost = 0
h.decodeErrs = 0
}
}
// observeLost is invoked by gortsplib when RTP sequence numbers skip. On a TCP
// transport this means the sender (camera) dropped packets because we were not
// reading fast enough, not loss on the wire.
func (h *streamHealth) observeLost(streamType string, lost uint64) {
if h == nil {
return
}
h.mu.Lock()
h.lost += lost
h.mu.Unlock()
log.Log.Warning(fmt.Sprintf(
"capture.golibrtsp.health(%s): %d RTP packet(s) lost — sender-side gap (receiver not draining TCP fast enough)",
streamType, lost))
}
// observeDecodeError is invoked by gortsplib on incomplete/invalid access units,
// which are a downstream symptom of the loss reported by observeLost.
func (h *streamHealth) observeDecodeError(streamType string, err error) {
if h == nil {
return
}
h.mu.Lock()
h.decodeErrs++
h.mu.Unlock()
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.health(%s): decode error: %s", streamType, err.Error()))
}
// fpsTracker holds per-stream state for PTS-based FPS calculation.
// Each video stream (H264 / H265) gets its own tracker so PTS
// samples from different codecs never interleave.
type fpsTracker struct {
mu sync.Mutex
lastPTS time.Duration
hasPTS bool
frameTimeBuffer []time.Duration
bufferSize int
bufferIndex int
cachedFPS float64 // latest computed FPS
}
func newFPSTracker(bufferSize int) *fpsTracker {
return &fpsTracker{
frameTimeBuffer: make([]time.Duration, bufferSize),
bufferSize: bufferSize,
}
}
// update records a new PTS sample and returns the latest FPS estimate.
// It must be called once per complete decoded frame (after Decode()
// succeeds), not on every RTP packet fragment.
func (ft *fpsTracker) update(pts time.Duration) float64 {
ft.mu.Lock()
defer ft.mu.Unlock()
if !ft.hasPTS {
ft.lastPTS = pts
ft.hasPTS = true
return 0
}
interval := pts - ft.lastPTS
ft.lastPTS = pts
// Skip invalid intervals (zero, negative, or very large which
// indicate a PTS discontinuity or wrap).
if interval <= 0 || interval > 5*time.Second {
return ft.cachedFPS
}
ft.frameTimeBuffer[ft.bufferIndex] = interval
ft.bufferIndex = (ft.bufferIndex + 1) % ft.bufferSize
var totalInterval time.Duration
validSamples := 0
for _, iv := range ft.frameTimeBuffer {
if iv > 0 {
totalInterval += iv
validSamples++
}
}
if validSamples == 0 {
return ft.cachedFPS
}
avgInterval := totalInterval / time.Duration(validSamples)
if avgInterval == 0 {
return ft.cachedFPS
}
ft.cachedFPS = float64(time.Second) / float64(avgInterval)
return ft.cachedFPS
}
// fps returns the most recent FPS estimate without recording a new sample.
func (ft *fpsTracker) fps() float64 {
ft.mu.Lock()
defer ft.mu.Unlock()
return ft.cachedFPS
}
// Init function
@@ -312,26 +103,12 @@ func init() {
}
// Connect to the RTSP server.
func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err error) {
_, span := tracer.Start(ctxOtel, "Connect")
defer span.End()
func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
transport := gortsplib.TransportTCP
g.health = newStreamHealth()
g.Client = gortsplib.Client{
RequestBackChannels: false,
Transport: &transport,
// Route gortsplib's packet-loss / decode-error reporting through our
// structured logger with stream context (replaces its plain stdout
// logging). These hooks are what let us tell whether the camera is
// dropping packets because we can't drain the socket fast enough.
OnPacketsLost: func(lost uint64) {
g.health.observeLost(g.streamLabel, lost)
},
OnDecodeError: func(err error) {
g.health.observeDecodeError(g.streamLabel, err)
},
}
// parse URL
@@ -354,9 +131,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
return
}
// Initialize the mutex and FPS calculation.
// Iniatlise the mutex.
g.VideoDecoderMutex = &sync.Mutex{}
g.initFPSCalculation()
// find the H264 media and format
var formaH264 *format.H264
@@ -380,9 +156,7 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
// but try to fetch it later on.
if errSPS != nil {
log.Log.Debug("capture.golibrtsp.Connect(H264): " + errSPS.Error())
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: formaH264.Codec(),
IsVideo: true,
IsAudio: false,
@@ -394,9 +168,7 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
IsBackChannel: false,
})
} else {
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: formaH264.Codec(),
IsVideo: true,
IsAudio: false,
@@ -444,9 +216,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
log.Log.Info("capture.golibrtsp.Connect(H265): " + err.Error())
return
}
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: formaH265.Codec(),
IsVideo: true,
IsAudio: false,
@@ -494,9 +265,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
log.Log.Error("capture.golibrtsp.Connect(G711): " + err.Error())
} else {
g.AudioG711Decoder = audiortpDec
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "PCM_MULAW",
IsVideo: false,
IsAudio: true,
@@ -530,9 +300,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
log.Log.Error("capture.golibrtsp.Connect(Opus): " + err.Error())
} else {
g.AudioOpusDecoder = audiortpDec
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "OPUS",
IsVideo: false,
IsAudio: true,
@@ -559,15 +328,11 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
// Something went wrong .. Do something
log.Log.Error("capture.golibrtsp.Connect(MPEG4): " + err.Error())
} else {
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "AAC",
IsVideo: false,
IsAudio: true,
IsBackChannel: false,
SampleRate: audioFormaMPEG4.Config.SampleRate,
Channels: audioFormaMPEG4.Config.ChannelCount,
})
// Set the index for the audio
@@ -587,11 +352,7 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
return
}
func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.Context) (err error) {
_, span := tracer.Start(ctxRunAgent, "ConnectBackChannel")
defer span.End()
func (g *Golibrtsp) ConnectBackChannel(ctx context.Context) (err error) {
// Transport TCP
transport := gortsplib.TransportTCP
g.Client = gortsplib.Client{
@@ -636,9 +397,7 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
g.HasBackChannel = false
} else {
g.HasBackChannel = true
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "PCM_MULAW",
IsVideo: false,
IsAudio: true,
@@ -651,45 +410,10 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
return
}
// dtsExtractor abstracts the codec-specific DTS extractors from mediacommon
// (h264.DTSExtractor2 and h265.DTSExtractor2), which expose the same method.
type dtsExtractor interface {
Extract(au [][]byte, pts int64) (int64, error)
}
// compositionOffsetMs returns the composition time offset (PTS - DTS) in
// milliseconds for a coded access unit. Streams that contain B-frames deliver
// access units in decode order with non-monotonic PTS; the fragmented MP4
// writer needs a monotonic DTS timeline plus a per-sample composition offset
// so browsers (Media Source Extensions) can decode the chained segments.
//
// It returns 0 when the codec has no frame reordering (the common case, e.g.
// baseline "IPPP" streams) or when extraction fails, making it a safe no-op.
func compositionOffsetMs(ext dtsExtractor, au [][]byte, pts int64, clockRate int) int64 {
if ext == nil || clockRate <= 0 {
return 0
}
dts, err := ext.Extract(au, pts)
if err != nil {
return 0
}
offset := pts - dts
if offset <= 0 {
return 0
}
return offset * 1000 / int64(clockRate)
}
// Start the RTSP client, and start reading packets.
func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets.Queue, configuration *models.Configuration, communication *models.Communication) (err error) {
log.Log.Debug("capture.golibrtsp.Start(): started")
// Label this client's loss/decode/health logging with the stream type.
g.streamLabel = streamType
if g.health == nil {
g.health = newStreamHealth()
}
// called when a MULAW audio RTP packet arrives
if g.AudioG711Media != nil && g.AudioG711Forma != nil {
g.Client.OnPacketRTP(g.AudioG711Media, g.AudioG711Forma, func(rtppkt *rtp.Packet) {
@@ -715,7 +439,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
Time: pts2,
TimeLegacy: pts,
CompositionTime: pts2,
CurrentTime: time.Now().UnixMilli(),
Idx: g.AudioG711Index,
IsVideo: false,
IsAudio: true,
@@ -757,7 +480,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
Time: pts2,
TimeLegacy: pts,
CompositionTime: pts2,
CurrentTime: time.Now().UnixMilli(),
Idx: g.AudioG711Index,
IsVideo: false,
IsAudio: true,
@@ -771,9 +493,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
var filteredAU [][]byte
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
// Extracts DTS from the bitstream to support B-frame H264 streams.
// Created once per stream (tracks reorder state across access units).
h264DTSExtractor := h264.NewDTSExtractor2()
//dtsExtractor := h264.NewDTSExtractor2()
g.Client.OnPacketRTP(g.VideoH264Media, g.VideoH264Forma, func(rtppkt *rtp.Packet) {
@@ -787,17 +507,18 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
if len(rtppkt.Payload) > 0 {
// decode timestamps — validate each call separately
pts, okPTS := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH264Media, rtppkt)
if !okPTS2 {
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS2 from PacketPTS2")
// decode timestamp
pts, ok := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
pts2, ok := g.Client.PacketPTS2(g.VideoH264Media, rtppkt)
if !ok {
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
return
}
// Extract access units from RTP packets.
// We need a complete access unit to determine whether
// this is a keyframe.
// Extract access units from RTP packets
// We need to do this, because the decoder expects a full
// access unit. Once we have a full access unit, we can
// decode it, and know if it's a keyframe or not.
au, errDecode := g.VideoH264Decoder.Decode(rtppkt)
if errDecode != nil {
if errDecode != rtph264.ErrNonStartingPacketAndNoPrevious && errDecode != rtph264.ErrMorePacketsNeeded {
@@ -806,18 +527,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
// Frame is complete — update per-stream FPS from PTS.
if okPTS {
ft := g.fpsTrackers[g.VideoH264Index]
if ft == nil {
ft = newFPSTracker(30)
g.fpsTrackers[g.VideoH264Index] = ft
}
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
g.Streams[g.VideoH264Index].FPS = ptsFPS
}
}
// We'll need to read out a few things.
// prepend an AUD. This is required by some players
filteredAU = [][]byte{
@@ -828,10 +537,8 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
nonIDRPresent := false
idrPresent := false
var naluTypes []string
for _, nalu := range au {
typ := h264.NALUType(nalu[0] & 0x1F)
naluTypes = append(naluTypes, fmt.Sprintf("%s(%d,sz=%d)", typ.String(), int(typ), len(nalu)))
switch typ {
case h264.NALUTypeAccessUnitDelimiter:
continue
@@ -844,9 +551,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
var sps h264.SPS
errSPS := sps.Unmarshal(nalu)
if errSPS == nil {
// Debug SPS information
g.debugSPSInfo(&sps, streamType)
// Get width
g.Streams[g.VideoH264Index].Width = sps.Width()
if streamType == "main" {
@@ -861,51 +565,21 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
} else if streamType == "sub" {
configuration.Config.Capture.IPCamera.SubHeight = sps.Height()
}
// Get FPS using enhanced method
fps := g.getEnhancedFPS(&sps, g.VideoH264Index)
g.Streams[g.VideoH264Index].FPS = fps
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Final FPS=%.2f", streamType, fps))
// Get FPS
g.Streams[g.VideoH264Index].FPS = sps.FPS()
g.VideoH264Forma.SPS = nalu
if streamType == "main" && len(nalu) > 0 {
// Fallback: store SPS from in-band NALUs when SDP was missing it.
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{nalu}
}
}
case h264.NALUTypePPS:
// Read out pps
g.VideoH264Forma.PPS = nalu
if streamType == "main" && len(nalu) > 0 {
// Fallback: store PPS from in-band NALUs when SDP was missing it.
configuration.Config.Capture.IPCamera.PPSNALUs = [][]byte{nalu}
}
}
filteredAU = append(filteredAU, nalu)
}
if idrPresent && streamType == "main" {
// Ensure config has parameter sets before recordings start.
if len(configuration.Config.Capture.IPCamera.SPSNALUs) == 0 && len(g.VideoH264Forma.SPS) > 0 {
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{g.VideoH264Forma.SPS}
log.Log.Warning("capture.golibrtsp.Start(main): fallback SPS set from keyframe")
}
if len(configuration.Config.Capture.IPCamera.PPSNALUs) == 0 && len(g.VideoH264Forma.PPS) > 0 {
configuration.Config.Capture.IPCamera.PPSNALUs = [][]byte{g.VideoH264Forma.PPS}
log.Log.Warning("capture.golibrtsp.Start(main): fallback PPS set from keyframe")
}
if len(configuration.Config.Capture.IPCamera.SPSNALUs) == 0 || len(configuration.Config.Capture.IPCamera.PPSNALUs) == 0 {
log.Log.Warning("capture.golibrtsp.Start(main): SPS/PPS still missing after IDR keyframe")
}
}
if len(filteredAU) <= 1 || (!nonIDRPresent && !idrPresent) {
return
}
if idrPresent {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): IDR frame NALUs: [%s]",
streamType, fmt.Sprintf("%v", naluTypes)))
}
// Convert to packet.
enc, err := h264.AnnexBMarshal(filteredAU)
if err != nil {
@@ -913,10 +587,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
// Composition time offset (PTS - DTS) in milliseconds. Non-zero
// only for streams with B-frames; the MP4 writer uses it to keep a
// monotonic decode timeline and present frames in PTS order.
compositionOffset := compositionOffsetMs(h264DTSExtractor, au, pts2, g.VideoH264Forma.ClockRate())
// Extract DTS from RTP packets
//dts2, err := dtsExtractor.Extract(filteredAU, pts2)
//if err != nil {
// log.Log.Error("capture.golibrtsp.Start(): " + err.Error())
// return
//}
pkt := packets.Packet{
IsKeyFrame: idrPresent,
@@ -924,67 +600,24 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
Data: enc,
Time: pts2,
TimeLegacy: pts,
CurrentTime: time.Now().UnixMilli(),
CompositionTime: compositionOffset,
CompositionTime: pts2,
Idx: g.VideoH264Index,
IsVideo: true,
IsAudio: false,
Codec: "H264",
}
// Track keyframe intervals
keyframeInterval := g.trackKeyframeInterval(idrPresent)
if idrPresent && keyframeInterval > 0 {
avgInterval := g.getAverageKeyframeInterval()
fps := g.Streams[g.VideoH264Index].FPS
if fps <= 0 {
fps = 25.0 // Default fallback FPS
}
gopDuration := float64(keyframeInterval) / fps
gopSize := int(avgInterval) // Store GOP size in a separate variable
g.Streams[g.VideoH264Index].GopSize = gopSize
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Keyframe interval=%d packets, Avg=%.1f, GOP=%.1fs, GOPSize=%d",
streamType, keyframeInterval, avgInterval, gopDuration, gopSize))
preRecording := configuration.Config.Capture.PreRecording
if preRecording > 0 && int(gopDuration) > 0 {
queue.SetMaxGopCount(int(preRecording)/int(gopDuration) + 1)
}
}
pkt.Data = pkt.Data[4:]
if pkt.IsKeyFrame {
// Prepend SPS/PPS (when available) in front of every keyframe so the
// access unit is self-contained. Downstream decoders (and the MP4 writer's
// in-band parameter-set recovery) rely on this; a recording whose first
// frame lacks SPS/PPS produces an MP4 with an empty avcC, which makes FFmpeg
// report "non-existing PPS 0 referenced".
//
// Build the payload in a freshly allocated buffer. The previous code
// did append(g.VideoH264Forma.PPS, pkt.Data...): because the SPS/PPS
// slices are sub-slices of the RTP reassembly buffer (spare capacity),
// that append wrote into - and corrupted - the shared parameter-set
// backing arrays, occasionally poisoning the SPS/PPS stored for the
// recording.
startCode := []byte{0x00, 0x00, 0x00, 0x01}
out := make([]byte, 0, len(g.VideoH264Forma.SPS)+len(g.VideoH264Forma.PPS)+len(pkt.Data)+12)
if len(g.VideoH264Forma.SPS) > 0 {
out = append(out, startCode...)
out = append(out, g.VideoH264Forma.SPS...)
}
if len(g.VideoH264Forma.PPS) > 0 {
out = append(out, startCode...)
out = append(out, g.VideoH264Forma.PPS...)
}
out = append(out, startCode...)
out = append(out, pkt.Data...)
pkt.Data = out
annexbNALUStartCode := func() []byte { return []byte{0x00, 0x00, 0x00, 0x01} }
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
pkt.Data = append(g.VideoH264Forma.PPS, pkt.Data...)
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
pkt.Data = append(g.VideoH264Forma.SPS, pkt.Data...)
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
}
writeStart := time.Now()
queue.WritePacket(pkt)
// Records WritePacket() blocking time and frame arrival cadence so
// we can tell back-pressure from a network/camera stall.
g.health.observePacket(streamType, time.Since(writeStart))
// This will check if we need to stop the thread,
// because of a reconfiguration.
@@ -1016,11 +649,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
// called when a video RTP packet arrives for H265
if g.VideoH265Media != nil && g.VideoH265Forma != nil {
// Extracts DTS from the bitstream to support B-frame H265 streams.
// Created once per stream (tracks reorder state across access units).
h265DTSExtractor := h265.NewDTSExtractor2()
g.Client.OnPacketRTP(g.VideoH265Media, g.VideoH265Forma, func(rtppkt *rtp.Packet) {
// This will check if we need to stop the thread,
@@ -1033,17 +661,18 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
if len(rtppkt.Payload) > 0 {
// decode timestamps — validate each call separately
pts, okPTS := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH265Media, rtppkt)
if !okPTS2 {
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
// decode timestamp
pts, ok := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
pts2, ok := g.Client.PacketPTS2(g.VideoH265Media, rtppkt)
if !ok {
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
return
}
// Extract access units from RTP packets.
// We need a complete access unit to determine whether
// this is a keyframe.
// Extract access units from RTP packets
// We need to do this, because the decoder expects a full
// access unit. Once we have a full access unit, we can
// decode it, and know if it's a keyframe or not.
au, errDecode := g.VideoH265Decoder.Decode(rtppkt)
if errDecode != nil {
if errDecode != rtph265.ErrNonStartingPacketAndNoPrevious && errDecode != rtph265.ErrMorePacketsNeeded {
@@ -1052,22 +681,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
// Frame is complete — update per-stream FPS from PTS.
if okPTS {
ft := g.fpsTrackers[g.VideoH265Index]
if ft == nil {
ft = newFPSTracker(30)
g.fpsTrackers[g.VideoH265Index] = ft
}
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
g.Streams[g.VideoH265Index].FPS = ptsFPS
}
}
// Preserve the decoded access unit (in decode order) for DTS
// extraction before we rewrite it into the filtered/annexb form.
decodedAU := au
filteredAU = [][]byte{
{byte(h265.NALUType_AUD_NUT) << 1, 1, 0x50},
}
@@ -1110,47 +723,20 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
// Composition time offset (PTS - DTS) in milliseconds; see H264 handler.
compositionOffset := compositionOffsetMs(h265DTSExtractor, decodedAU, pts2, g.VideoH265Forma.ClockRate())
pkt := packets.Packet{
IsKeyFrame: isRandomAccess,
Packet: rtppkt,
Data: enc,
Time: pts2,
TimeLegacy: pts,
CurrentTime: time.Now().UnixMilli(),
CompositionTime: compositionOffset,
CompositionTime: pts2,
Idx: g.VideoH265Index,
IsVideo: true,
IsAudio: false,
Codec: "H265",
}
// Track keyframe intervals for H265
keyframeInterval := g.trackKeyframeInterval(isRandomAccess)
if isRandomAccess && keyframeInterval > 0 {
avgInterval := g.getAverageKeyframeInterval()
fps := g.Streams[g.VideoH265Index].FPS
if fps <= 0 {
fps = 25.0 // Default fallback FPS
}
gopDuration := float64(keyframeInterval) / fps
gopSize := int(avgInterval) // Store GOP size in a separate variable
g.Streams[g.VideoH265Index].GopSize = gopSize
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Keyframe interval=%d packets, Avg=%.1f, GOP=%.1fs, GOPSize=%d",
streamType, keyframeInterval, avgInterval, gopDuration, gopSize))
preRecording := configuration.Config.Capture.PreRecording
if preRecording > 0 && int(gopDuration) > 0 {
queue.SetMaxGopCount(int(preRecording)/int(gopDuration) + 1)
}
}
writeStart := time.Now()
queue.WritePacket(pkt)
// Records WritePacket() blocking time and frame arrival cadence so
// we can tell back-pressure from a network/camera stall.
g.health.observePacket(streamType, time.Since(writeStart))
// This will check if we need to stop the thread,
// because of a reconfiguration.
@@ -1192,7 +778,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
}
// Start the RTSP client, and start reading packets.
func (g *Golibrtsp) StartBackChannel(ctx context.Context, ctxRunAgent context.Context) (err error) {
func (g *Golibrtsp) StartBackChannel(ctx context.Context) (err error) {
log.Log.Info("capture.golibrtsp.StartBackChannel(): started")
// Wait for a second, so we can be sure the stream is playing.
time.Sleep(1 * time.Second)
@@ -1274,8 +860,8 @@ func (g *Golibrtsp) DecodePacketRaw(pkt packets.Packet) (image.Gray, error) {
}
// Get a list of streams from the RTSP server.
func (g *Golibrtsp) GetStreams() ([]packets.Stream, error) {
return g.Streams, nil
func (j *Golibrtsp) GetStreams() ([]packets.Stream, error) {
return j.Streams, nil
}
// Get a list of video streams from the RTSP server.
@@ -1301,11 +887,7 @@ func (g *Golibrtsp) GetAudioStreams() ([]packets.Stream, error) {
}
// Close the connection to the RTSP server.
func (g *Golibrtsp) Close(ctxOtel context.Context) error {
_, span := tracer.Start(ctxOtel, "Close")
defer span.End()
func (g *Golibrtsp) Close() error {
// Close the demuxer.
g.Client.Close()
@@ -1507,7 +1089,7 @@ func WriteMPEG4Audio(forma *format.MPEG4Audio, aus [][]byte) ([]byte, error) {
pkts := make(mpeg4audio.ADTSPackets, len(aus))
for i, au := range aus {
pkts[i] = &mpeg4audio.ADTSPacket{
Type: mpeg4audio.ObjectType(forma.Config.Type),
Type: forma.Config.Type,
SampleRate: forma.Config.SampleRate,
ChannelCount: forma.Config.ChannelCount,
AU: au,
@@ -1519,149 +1101,3 @@ func WriteMPEG4Audio(forma *format.MPEG4Audio, aus [][]byte) ([]byte, error) {
}
return enc, nil
}
// Initialize FPS calculation buffers
func (g *Golibrtsp) initFPSCalculation() {
// Ensure the per-stream FPS trackers map exists. Individual trackers
// can be created lazily when a given stream index is first used.
if g.fpsTrackers == nil {
g.fpsTrackers = make(map[int8]*fpsTracker)
}
// Initialize I-frame interval tracking
g.keyframeBufferSize = 10 // Store last 10 keyframe intervals
g.keyframeIntervals = make([]int, g.keyframeBufferSize)
g.keyframeBufferIndex = 0
g.packetsSinceLastKeyframe = 0
g.lastKeyframePacketCount = 0
}
// Get enhanced FPS information from SPS with fallback to PTS-based calculation.
// The PTS-based FPS is computed per completed frame via fpsTracker.update(),
// so by the time this is called we already have a good estimate.
func (g *Golibrtsp) getEnhancedFPS(sps *h264.SPS, streamIndex int8) float64 {
// First try to get FPS from SPS VUI parameters
spsFPS := sps.FPS()
// Check if SPS FPS is reasonable (between 1 and 120 fps)
if spsFPS > 0 && spsFPS <= 120 {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.getEnhancedFPS(): SPS FPS: %.2f", spsFPS))
return spsFPS
}
// Fallback to PTS-based FPS (already calculated per-frame)
if ft := g.fpsTrackers[streamIndex]; ft != nil {
ptsFPS := ft.fps()
if ptsFPS > 0 && ptsFPS <= 120 {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.getEnhancedFPS(): PTS FPS: %.2f", ptsFPS))
return ptsFPS
}
}
// Return SPS FPS even if it seems unreasonable, or default
if spsFPS > 0 {
return spsFPS
}
return 25.0 // Default fallback FPS
}
// Track I-frame intervals by counting packets between keyframes
func (g *Golibrtsp) trackKeyframeInterval(isKeyframe bool) int {
g.keyframeMutex.Lock()
defer g.keyframeMutex.Unlock()
g.packetsSinceLastKeyframe++
if isKeyframe {
// Store the interval since the last keyframe
if g.lastKeyframePacketCount > 0 {
interval := g.packetsSinceLastKeyframe
g.keyframeIntervals[g.keyframeBufferIndex] = interval
g.keyframeBufferIndex = (g.keyframeBufferIndex + 1) % g.keyframeBufferSize
}
// Reset counter for next interval
g.lastKeyframePacketCount = g.packetsSinceLastKeyframe
g.packetsSinceLastKeyframe = 0
return g.lastKeyframePacketCount
}
return 0
}
// Get average keyframe interval (GOP size)
func (g *Golibrtsp) getAverageKeyframeInterval() float64 {
g.keyframeMutex.Lock()
defer g.keyframeMutex.Unlock()
var totalInterval int
validSamples := 0
for _, interval := range g.keyframeIntervals {
if interval > 0 {
totalInterval += interval
validSamples++
}
}
if validSamples == 0 {
return 0
}
return float64(totalInterval) / float64(validSamples)
}
// Calculate GOP size in seconds based on FPS and keyframe interval
func (g *Golibrtsp) getGOPDuration(fps float64) float64 {
avgInterval := g.getAverageKeyframeInterval()
if avgInterval > 0 && fps > 0 {
return avgInterval / fps
}
return 0
}
// Get detailed SPS timing information
func (g *Golibrtsp) getSPSTimingInfo(sps *h264.SPS) (hasVUI bool, timeScale uint32, numUnitsInTick uint32, fps float64) {
// Try to get FPS from SPS
fps = sps.FPS()
// Note: The gortsplib SPS struct may not expose VUI parameters directly
// but we can still work with the calculated FPS
if fps > 0 {
hasVUI = true
// These are estimated values based on common patterns
if fps == 25.0 {
timeScale = 50
numUnitsInTick = 1
} else if fps == 30.0 {
timeScale = 60
numUnitsInTick = 1
} else if fps == 24.0 {
timeScale = 48
numUnitsInTick = 1
} else {
// Generic calculation
timeScale = uint32(fps * 2)
numUnitsInTick = 1
}
}
return hasVUI, timeScale, numUnitsInTick, fps
}
// Debug SPS information
func (g *Golibrtsp) debugSPSInfo(sps *h264.SPS, streamType string) {
hasVUI, timeScale, numUnitsInTick, fps := g.getSPSTimingInfo(sps)
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.debugSPSInfo(%s): Width=%d, Height=%d",
streamType, sps.Width(), sps.Height()))
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.debugSPSInfo(%s): HasVUI=%t, FPS=%.2f",
streamType, hasVUI, fps))
if hasVUI {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.debugSPSInfo(%s): TimeScale=%d, NumUnitsInTick=%d",
streamType, timeScale, numUnitsInTick))
}
}
+207 -346
View File
@@ -16,8 +16,7 @@ import (
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/kerberos-io/agent/machinery/src/video"
"go.opentelemetry.io/otel/trace"
"github.com/yapingcat/gomedia/go-mp4"
)
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
@@ -64,46 +63,26 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
} else {
log.Log.Debug("capture.main.HandleRecordStream(): started")
preRecording := config.Capture.PreRecording * 1000
postRecording := config.Capture.PostRecording * 1000 // number of seconds to record.
maxRecordingPeriod := config.Capture.MaxLengthRecording * 1000 // maximum number of seconds to record.
recordingPeriod := config.Capture.PostRecording // number of seconds to record.
maxRecordingPeriod := config.Capture.MaxLengthRecording // maximum number of seconds to record.
// We will calculate the maxRecordingPeriod based on the preRecording and postRecording values.
if maxRecordingPeriod == 0 {
// If maxRecordingPeriod is not set, we will use the preRecording and postRecording values
maxRecordingPeriod = preRecording + postRecording
}
if maxRecordingPeriod < preRecording+postRecording {
log.Log.Error("capture.main.HandleRecordStream(): maxRecordingPeriod is less than preRecording + postRecording, this is not allowed. Setting maxRecordingPeriod to preRecording + postRecording.")
maxRecordingPeriod = preRecording + postRecording
}
// Synchronise the last synced time
now := time.Now().Unix()
startRecording := now
timestamp := now
if config.FriendlyName != "" {
config.Name = config.FriendlyName
}
// Get the audio and video codec from the camera.
// We only expect one audio and one video codec.
// If there are multiple audio or video streams, we will use the first one.
audioCodec := ""
videoCodec := ""
audioStreams, _ := rtspClient.GetAudioStreams()
videoStreams, _ := rtspClient.GetVideoStreams()
if len(audioStreams) > 0 {
audioCodec = audioStreams[0].Name
config.Capture.IPCamera.SampleRate = audioStreams[0].SampleRate
config.Capture.IPCamera.Channels = audioStreams[0].Channels
}
if len(videoStreams) > 0 {
videoCodec = videoStreams[0].Name
}
// For continuous and motion based recording we will use a single file.
var file *os.File
// Check if continuous recording.
if config.Capture.Continuous == "true" {
//var cws *cacheWriterSeeker
var mp4Video *video.MP4
var myMuxer *mp4.Movmuxer
var videoTrack uint32
var audioTrack uint32
var name string
@@ -111,15 +90,15 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Do not do anything!
log.Log.Info("capture.main.HandleRecordStream(continuous): start recording")
now = time.Now().Unix()
timestamp = now
start := false
// If continuous record the full length
postRecording = maxRecordingPeriod
recordingPeriod = maxRecordingPeriod
// Recording file name
fullName := ""
var startRecording int64 = 0 // start recording timestamp in milliseconds
// Get as much packets we need.
var cursorError error
var pkt packets.Packet
@@ -135,65 +114,43 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
nextPkt, cursorError = recordingCursor.ReadPacket()
now := time.Now().UnixMilli()
now := time.Now().Unix()
if start && // If already recording and current frame is a keyframe and we should stop recording
nextPkt.IsKeyFrame && (startRecording+postRecording-now <= 0 || now-startRecording > maxRecordingPeriod-500) {
nextPkt.IsKeyFrame && (timestamp+recordingPeriod-now <= 0 || now-startRecording >= maxRecordingPeriod) {
// Write the last packet before closing the recording.
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
// Write the last packet
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
}
// Close mp4
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
mp4Video.SPSNALUs = configuration.Config.Capture.IPCamera.SPSNALUs
// This will write the trailer a well.
if err := myMuxer.WriteTrailer(); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
if len(mp4Video.PPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.PPSNALUs) > 0 {
mp4Video.PPSNALUs = configuration.Config.Capture.IPCamera.PPSNALUs
}
if len(mp4Video.VPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.VPSNALUs) > 0 {
mp4Video.VPSNALUs = configuration.Config.Capture.IPCamera.VPSNALUs
}
if (videoCodec == "H264" && (len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) ||
(videoCodec == "H265" && (len(mp4Video.VPSNALUs) == 0 || len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) {
log.Log.Warning("capture.main.HandleRecordStream(continuous): closing MP4 without full parameter sets, moov may be incomplete")
}
mp4Video.Close(&config)
log.Log.Info("capture.main.HandleRecordStream(continuous): recording finished: file save: " + name)
// Cleanup muxer
start = false
file.Close()
file = nil
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
if mp4Video.VideoTotalDuration > 0 {
duration := mp4Video.VideoTotalDuration
// Update the name with the duration in milliseconds.
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
config.Name + "_" +
"0-0-0-0" + "_" + // region coordinates, we
"-1" + "_" + // token
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
// Check if need to convert to fragmented using bento
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
}
// Check if we need to encrypt the recording.
@@ -240,6 +197,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
start = true
timestamp = now
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
@@ -250,17 +208,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// - Number of changes
// - Token
startRecording = pkt.CurrentTime
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" + // start timestamp in seconds
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" + // length of milliseconds
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" + // milliseconds
config.Name + "_" + // device name
"0-0-0-0" + "_" + // region coordinates, we will not use this for continuous recording
"0" + "_" + // token
"0" + "_" //+ // duration of recording in milliseconds
//utils.VERSION // version of the agent
startRecording = time.Now().Unix() // we mark the current time when the record started.ss
s := strconv.FormatInt(startRecording, 10) + "_" +
"6" + "-" +
"967003" + "_" +
config.Name + "_" +
"200-200-400-400" + "_0_" +
"769"
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
@@ -268,41 +222,62 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Running...
log.Log.Info("capture.main.HandleRecordStream(continuous): recording started")
// Get width and height from the camera.
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
// Get SPS and PPS NALUs from the camera.
spsNALUS := configuration.Config.Capture.IPCamera.SPSNALUs
ppsNALUS := configuration.Config.Capture.IPCamera.PPSNALUs
vpsNALUS := configuration.Config.Capture.IPCamera.VPSNALUs
if len(spsNALUS) == 0 || len(ppsNALUS) == 0 {
log.Log.Warning("capture.main.HandleRecordStream(continuous): missing SPS/PPS at recording start")
}
// Create a video file, and set the dimensions.
mp4Video = video.NewMP4(fullName, spsNALUS, ppsNALUS, vpsNALUS, configuration.Config.Capture.MaxLengthRecording)
mp4Video.SetWidth(width)
mp4Video.SetHeight(height)
if videoCodec == "H264" {
videoTrack = mp4Video.AddVideoTrack("H264")
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
file, err = os.Create(fullName)
if err == nil {
//cws = newCacheWriterSeeker(4096)
myMuxer, _ = mp4.CreateMp4Muxer(file)
// We choose between H264 and H265
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
widthOption := mp4.WithVideoWidth(uint32(width))
heightOption := mp4.WithVideoHeight(uint32(height))
if pkt.Codec == "H264" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H264, widthOption, heightOption)
} else if pkt.Codec == "H265" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H265, widthOption, heightOption)
}
// For an MP4 container, AAC is the only audio codec supported.
audioTrack = myMuxer.AddAudioTrack(mp4.MP4_CODEC_AAC)
} else {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
}
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
recordingStatus = "started"
} else if start {
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
}
}
pkt = nextPkt
}
@@ -310,43 +285,21 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// If this happens we need to check to properly close the recording.
if cursorError != nil {
if recordingStatus == "started" {
// This will write the trailer a well.
if err := myMuxer.WriteTrailer(); err != nil {
log.Log.Error(err.Error())
}
log.Log.Info("capture.main.HandleRecordStream(continuous): Recording finished: file save: " + name)
// Cleanup muxer
start = false
file.Close()
file = nil
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
if mp4Video.VideoTotalDuration > 0 {
duration := mp4Video.VideoTotalDuration
// Update the name with the duration in milliseconds.
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
config.Name + "_" +
"0-0-0-0" + "_" + // region coordinates, we
"-1" + "_" + // token
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
// Check if need to convert to fragmented using bento
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
}
// Check if we need to encrypt the recording.
@@ -384,44 +337,33 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Start motion based recording ")
var lastRecordingTime int64 = 0 // last recording timestamp in milliseconds
var displayTime int64 = 0 // display time in milliseconds
var lastDuration int64
var lastRecordingTime int64
//var cws *cacheWriterSeeker
var myMuxer *mp4.Movmuxer
var videoTrack uint32
var audioTrack uint32
for motion := range communication.HandleMotion {
// Get as much packets we need.
var cursorError error
var pkt packets.Packet
var nextPkt packets.Packet
recordingCursor := queue.Oldest() // Start from the latest packet in the queue)
timestamp = time.Now().Unix()
startRecording = time.Now().Unix() // we mark the current time when the record started.
numberOfChanges := motion.NumberOfChanges
now := time.Now().UnixMilli()
motionTimestamp := now
// If we have prerecording we will substract the number of seconds.
// Taking into account FPS = GOP size (Keyfram interval)
if config.Capture.PreRecording > 0 {
start := false
// Might be that recordings are coming short after each other.
// Therefore we do some math with the current time and the last recording time.
if cursorError == nil {
pkt, cursorError = recordingCursor.ReadPacket()
}
displayTime = pkt.CurrentTime
startRecording := pkt.CurrentTime
// We have more packets in the queue (which might still be older than where we close the previous recording).
// In that case we will use the last recording time to determine the start time of the recording, otherwise
// we will have duplicate frames in the recording.
if startRecording < lastRecordingTime {
displayTime = lastRecordingTime
startRecording = lastRecordingTime
}
// If startRecording is 0, we will continue as it might be we are in a state of restarting the agent.
if startRecording == 0 {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): startRecording is 0, we will continue as it might be we are in a state of restarting the agent.")
continue
timeBetweenNowAndLastRecording := startRecording - lastRecordingTime
if timeBetweenNowAndLastRecording > int64(config.Capture.PreRecording) {
startRecording = startRecording - int64(config.Capture.PreRecording) + 1
} else {
startRecording = startRecording - timeBetweenNowAndLastRecording
}
}
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
@@ -433,47 +375,48 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// - Number of changes
// - Token
displayTimeSeconds := displayTime / 1000 // convert to seconds
displayTimeMilliseconds := displayTime % 1000 // convert to milliseconds
motionRectangleString := "0-0-0-0"
if motion.Rectangle.X != 0 || motion.Rectangle.Y != 0 ||
motion.Rectangle.Width != 0 || motion.Rectangle.Height != 0 {
motionRectangleString = strconv.Itoa(motion.Rectangle.X) + "-" + strconv.Itoa(motion.Rectangle.Y) + "-" +
strconv.Itoa(motion.Rectangle.Width) + "-" + strconv.Itoa(motion.Rectangle.Height)
}
// Get the number of changes from the motion detection.
numberOfChanges := motion.NumberOfChanges
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" + // start timestamp in seconds
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" + // length of milliseconds
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" + // milliseconds
config.Name + "_" + // device name
motionRectangleString + "_" + // region coordinates, we will not use this for continuous recording
strconv.Itoa(numberOfChanges) + "_" + // number of changes
"0" // + "_" + // duration of recording in milliseconds
//utils.VERSION // version of the agent
s := strconv.FormatInt(startRecording, 10) + "_" +
"6" + "-" +
"967003" + "_" +
config.Name + "_" +
"200-200-400-400" + "_" +
strconv.Itoa(numberOfChanges) + "_" +
"769"
name := s + ".mp4"
fullName := configDirectory + "/data/recordings/" + name
// Running...
log.Log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
log.Log.Info("capture.main.HandleRecordStream(motiondetection): recording started")
file, _ = os.Create(fullName)
myMuxer, _ = mp4.CreateMp4Muxer(file)
// Get width and height from the camera.
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
// Get SPS and PPS NALUs from the camera.
spsNALUS := configuration.Config.Capture.IPCamera.SPSNALUs
ppsNALUS := configuration.Config.Capture.IPCamera.PPSNALUs
vpsNALUS := configuration.Config.Capture.IPCamera.VPSNALUs
if len(spsNALUS) == 0 || len(ppsNALUS) == 0 {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): missing SPS/PPS at recording start")
// Check which video codec we need to use.
videoSteams, _ := rtspClient.GetVideoStreams()
for _, stream := range videoSteams {
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
widthOption := mp4.WithVideoWidth(uint32(width))
heightOption := mp4.WithVideoHeight(uint32(height))
if stream.Name == "H264" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H264, widthOption, heightOption)
} else if stream.Name == "H265" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H265, widthOption, heightOption)
}
}
// For an MP4 container, AAC is the only audio codec supported.
audioTrack = myMuxer.AddAudioTrack(mp4.MP4_CODEC_AAC)
start := false
// Get as much packets we need.
var cursorError error
var pkt packets.Packet
var nextPkt packets.Packet
recordingCursor := queue.DelayedGopCount(int(config.Capture.PreRecording + 1))
if cursorError == nil {
pkt, cursorError = recordingCursor.ReadPacket()
}
// Create the MP4 only once the first keyframe arrives.
var mp4Video *video.MP4
for cursorError == nil {
@@ -482,111 +425,68 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + cursorError.Error())
}
now = time.Now().UnixMilli()
now := time.Now().Unix()
select {
case motion := <-communication.HandleMotion:
motionTimestamp = now
timestamp = now
log.Log.Info("capture.main.HandleRecordStream(motiondetection): motion detected while recording. Expanding recording.")
numberOfChanges := motion.NumberOfChanges
numberOfChanges = motion.NumberOfChanges
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Received message with recording data, detected changes to save: " + strconv.Itoa(numberOfChanges))
default:
}
if start && (motionTimestamp+postRecording-now < 0 || now-startRecording > maxRecordingPeriod-500) && nextPkt.IsKeyFrame {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): timestamp+postRecording-now < 0 - " + strconv.FormatInt(motionTimestamp+postRecording-now, 10) + " < 0")
log.Log.Info("capture.main.HandleRecordStream(motiondetection): now-startRecording > maxRecordingPeriod-500 - " + strconv.FormatInt(now-startRecording, 10) + " > " + strconv.FormatInt(maxRecordingPeriod-500, 10))
log.Log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(motionTimestamp, 10) + ", postRecording: " + strconv.FormatInt(postRecording, 10) + ", now: " + strconv.FormatInt(now, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
if (timestamp+recordingPeriod-now < 0 || now-startRecording > maxRecordingPeriod) && nextPkt.IsKeyFrame {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(timestamp, 10) + ", recordingPeriod: " + strconv.FormatInt(recordingPeriod, 10) + ", now: " + strconv.FormatInt(now, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
break
}
if pkt.IsKeyFrame && !start && pkt.CurrentTime >= startRecording {
// We start the recording if we have a keyframe and the last duration is 0 or less than the current packet time.
// It could be start we start from the beginning of the recording.
if pkt.IsKeyFrame && !start && pkt.Time >= lastDuration {
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): write frames")
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): recording started on keyframe")
// Align duration timers with the first keyframe.
startRecording = pkt.CurrentTime
// Create a video file, and set the dimensions.
mp4Video = video.NewMP4(fullName, spsNALUS, ppsNALUS, vpsNALUS, configuration.Config.Capture.MaxLengthRecording)
mp4Video.SetWidth(width)
mp4Video.SetHeight(height)
if videoCodec == "H264" {
videoTrack = mp4Video.AddVideoTrack("H264")
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
start = true
}
if start {
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): no AAC audio codec detected, skipping audio track.")
}
}
// We will sync to file every keyframe.
if pkt.IsKeyFrame {
err := file.Sync()
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
} else {
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): synced file " + name)
}
}
}
pkt = nextPkt
}
// Update the last duration and last recording time.
// This is used to determine if we need to start a new recording.
lastRecordingTime = pkt.CurrentTime
// This will write the trailer a well.
myMuxer.WriteTrailer()
if mp4Video == nil {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): recording closed without keyframe; no MP4 created")
continue
}
// This will close the recording and write the last packet.
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
mp4Video.SPSNALUs = configuration.Config.Capture.IPCamera.SPSNALUs
}
if len(mp4Video.PPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.PPSNALUs) > 0 {
mp4Video.PPSNALUs = configuration.Config.Capture.IPCamera.PPSNALUs
}
if len(mp4Video.VPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.VPSNALUs) > 0 {
mp4Video.VPSNALUs = configuration.Config.Capture.IPCamera.VPSNALUs
}
if (videoCodec == "H264" && (len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) ||
(videoCodec == "H265" && (len(mp4Video.VPSNALUs) == 0 || len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): closing MP4 without full parameter sets, moov may be incomplete")
}
mp4Video.Close(&config)
log.Log.Info("capture.main.HandleRecordStream(motiondetection): file save: " + name)
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
if mp4Video.VideoTotalDuration > 0 {
duration := mp4Video.VideoTotalDuration
lastDuration = pkt.Time
lastRecordingTime = time.Now().Unix()
file.Close()
file = nil
// Update the name with the duration in milliseconds.
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" +
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" +
config.Name + "_" +
motionRectangleString + "_" +
strconv.Itoa(numberOfChanges) + "_" + // number of changes
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording in milliseconds
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): no video data recorded, not renaming file.")
// Check if need to convert to fragmented using bento
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
}
// Check if we need to encrypt the recording.
@@ -634,10 +534,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// @Success 200 {object} models.APIResponse
func VerifyCamera(c *gin.Context) {
// Start OpenTelemetry tracing
ctxVerifyCamera, span := tracer.Start(context.Background(), "VerifyCamera", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
var cameraStreams models.CameraStreams
err := c.BindJSON(&cameraStreams)
@@ -663,11 +559,12 @@ func VerifyCamera(c *gin.Context) {
Url: rtspUrl,
}
err := rtspClient.Connect(ctx, ctxVerifyCamera)
err := rtspClient.Connect(ctx)
if err == nil {
// Get the streams from the rtsp client.
streams, _ := rtspClient.GetStreams()
videoIdx := -1
audioIdx := -1
for i, stream := range streams {
@@ -678,7 +575,7 @@ func VerifyCamera(c *gin.Context) {
}
}
err := rtspClient.Close(ctxVerifyCamera)
err := rtspClient.Close()
if err == nil {
if videoIdx > -1 {
c.JSON(200, models.APIResponse{
@@ -707,7 +604,7 @@ func VerifyCamera(c *gin.Context) {
}
}
func Base64Image(captureDevice *Capture, communication *models.Communication, configuration *models.Configuration) string {
func Base64Image(captureDevice *Capture, communication *models.Communication) string {
// We'll try to get a snapshot from the camera.
var queue *packets.Queue
var cursor *packets.QueueCursor
@@ -737,8 +634,7 @@ func Base64Image(captureDevice *Capture, communication *models.Communication, co
var img image.YCbCr
img, err = (*rtspClient).DecodePacket(pkt)
if err == nil {
imageResized, _ := utils.ResizeImage(&img, uint(configuration.Config.Capture.IPCamera.BaseWidth), uint(configuration.Config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
bytes, _ := utils.ImageToBytes(&img)
encodedImage = base64.StdEncoding.EncodeToString(bytes)
break
} else {
@@ -799,41 +695,6 @@ func convertPTS(v time.Duration) uint64 {
return uint64(v.Milliseconds())
}
// writeSampleToMP4 writes a single capture packet to the fragmented MP4.
//
// For video it derives the decode timestamp (DTS) from the packet PTS using the
// per-packet composition offset (PTS - DTS), which is non-zero only for streams
// that contain B-frames. Passing the monotonic DTS as the sample timestamp keeps
// the fragment timeline (tfdt/sidx) monotonic, while the composition offset is
// forwarded so frames are still presented in PTS order.
func writeSampleToMP4(mp4Video *video.MP4, videoTrack, audioTrack uint32, pkt packets.Packet) {
if mp4Video == nil {
return
}
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
compositionOffset := pkt.CompositionTime
dts := pts
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
dts = pts - uint64(compositionOffset)
}
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, dts, compositionOffset); err != nil {
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts, 0); err != nil {
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Log.Debug("capture.main.writeSampleToMP4(): no AAC audio codec detected, skipping audio track.")
}
}
}
/*func convertPTS2(v int64) uint64 {
func convertPTS2(v int64) uint64 {
return uint64(v) / 100
}*/
}
@@ -2,11 +2,11 @@ package cloud
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"io"
"os"
"strings"
@@ -22,7 +22,6 @@ import (
"time"
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/cloud/livesnapshot"
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
@@ -133,7 +132,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
log.Log.Error("HandleUpload: " + err.Error())
}
} else {
delay = 5 * time.Second // slow down
delay = 20 * time.Second // slow down
if err != nil {
log.Log.Error("HandleUpload: " + err.Error())
}
@@ -530,7 +529,6 @@ loop:
"onvif_events_list": %s,
"cameraConnected": "%s",
"hasBackChannel": "%s",
"livePreviewHttp": true,
"numberoffiles" : "33",
"timestamp" : 1564747908,
"cameratype" : "IPCamera",
@@ -675,7 +673,6 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
// Check if we need to enable the live stream
if config.Capture.Liveview != "false" {
deviceId := config.Key
hubKey := ""
if config.Cloud == "s3" && config.S3 != nil && config.S3.Publickey != "" {
hubKey = config.S3.Publickey
@@ -687,183 +684,52 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
hubKey = config.HubKey
}
lastLivestreamRequestMQTT := int64(0)
lastLivestreamRequestHTTP := int64(0)
// HTTP transport (preferred when this agent is paired with a Kerberos
// Hub): ship preview frames to hub-api over HTTPS instead of pushing
// (large, base64) images through the MQTT broker. Viewers opt in per
// session via the "http" transport on their keepalive; the legacy MQTT
// push is kept for viewers (older frontends) that don't, and as a fallback.
region := ""
if config.S3 != nil {
region = config.S3.Region
}
var snapshotPublisher *livesnapshot.Publisher
if config.HubURI != "" && config.HubKey != "" {
snapshotPublisher = livesnapshot.NewPublisher(livesnapshot.PublisherConfig{
HubURI: config.HubURI,
HubKey: config.HubKey,
HubPrivateKey: config.HubPrivateKey,
Region: region,
DeviceKey: deviceId,
})
log.Log.Info("cloud.HandleLiveStreamSD(): HTTP preview transport ENABLED; frames go to " + strings.TrimRight(config.HubURI, "/") + "/storage/snapshot when a viewer requests it (kept off MQTT).")
} else {
log.Log.Info("cloud.HandleLiveStreamSD(): HTTP preview transport DISABLED (Hub not configured: HubURI/HubKey empty); preview frames are pushed over MQTT.")
}
// Track the transport actually used so we log only when it changes; the
// loop runs once per keyframe and logging every frame would be noise.
lastTransport := ""
lastLivestreamRequest := int64(0)
var cursorError error
var pkt packets.Packet
for cursorError == nil {
now := time.Now().Unix()
select {
case <-communication.HandleLiveSD:
lastLivestreamRequest = now
default:
}
if now-lastLivestreamRequest > 3 {
continue
}
pkt, cursorError = livestreamCursor.ReadPacket()
if len(pkt.Data) == 0 || !pkt.IsKeyFrame {
continue
}
now := time.Now().Unix()
// Drain both viewer keepalive channels (non-blocking): one for the
// HTTP transport, one for the legacy MQTT push.
select {
case <-communication.HandleLiveSD:
lastLivestreamRequestMQTT = now
default:
}
select {
case <-communication.HandleLiveSDHTTP:
lastLivestreamRequestHTTP = now
default:
}
mqttViewerActive := now-lastLivestreamRequestMQTT <= 3
httpViewerActive := now-lastLivestreamRequestHTTP <= 3
if !mqttViewerActive && !httpViewerActive {
continue
}
log.Log.Info("cloud.HandleLiveStreamSD(): Sending base64 encoded images to MQTT.")
img, err := rtspClient.DecodePacket(pkt)
if err != nil {
continue
}
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
// Prefer HTTP for viewers that asked for it. Only if that did not
// deliver (Hub not configured, or the upload failed) do we also push
// over MQTT, so a new frontend can still fall back to its MQTT path.
httpPushed := false
var httpErr error
if httpViewerActive && snapshotPublisher != nil {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
httpErr = snapshotPublisher.PublishSnapshot(ctx, bytes)
if httpErr == nil {
httpPushed = true
if err == nil {
bytes, _ := utils.ImageToBytes(&img)
encoded := base64.StdEncoding.EncodeToString(bytes)
valueMap := make(map[string]interface{})
valueMap["image"] = encoded
message := models.Message{
Payload: models.Payload{
Action: "receive-sd-stream",
DeviceId: configuration.Config.Key,
Value: valueMap,
},
}
cancel()
}
pushMQTT := mqttViewerActive || (httpViewerActive && !httpPushed)
// Log only when the effective transport changes, so an operator can
// tell at a glance whether a device's preview travels over HTTP or
// MQTT (and why it fell back) without per-frame log spam.
transport := ""
if httpPushed {
transport = "http"
} else if pushMQTT {
transport = "mqtt"
}
if transport != "" && transport != lastTransport {
if transport == "http" {
log.Log.Info("cloud.HandleLiveStreamSD(): delivering preview frames over HTTP for device " + deviceId + ".")
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
reason := "viewer requested MQTT (older frontend)"
if httpViewerActive && snapshotPublisher == nil {
reason = "viewer asked for HTTP but Hub is not configured"
} else if httpViewerActive && httpErr != nil {
reason = "HTTP upload failed, falling back: " + httpErr.Error()
}
log.Log.Info("cloud.HandleLiveStreamSD(): delivering preview frames over MQTT for device " + deviceId + " (" + reason + ").")
}
lastTransport = transport
}
if pushMQTT {
log.Log.Debug("cloud.HandleLiveStreamSD(): Sending base64 encoded images to MQTT.")
chunking := config.Capture.LiveviewChunking
if chunking == "true" {
// Split encoded image into chunks of 2kb
// This is to prevent the MQTT message to be too large.
// By default, bytes are not encoded to base64 here; you are splitting the raw JPEG/PNG bytes.
// However, in MQTT and web contexts, binary data may not be handled well, so base64 is often used.
// To avoid base64 encoding, just send the raw []byte chunks as you do here.
// If you want to avoid base64, make sure the receiver can handle binary payloads.
chunkSize := 25 * 1024 // 25KB chunks
var chunks [][]byte
for i := 0; i < len(bytes); i += chunkSize {
end := i + chunkSize
if end > len(bytes) {
end = len(bytes)
}
chunk := bytes[i:end]
chunks = append(chunks, chunk)
}
log.Log.Infof("cloud.HandleLiveStreamSD(): Sending %d chunks of size %d bytes.", len(chunks), chunkSize)
timestamp := time.Now().Unix()
for i, chunk := range chunks {
valueMap := make(map[string]interface{})
valueMap["id"] = timestamp
valueMap["chunk"] = chunk
valueMap["chunkIndex"] = i
valueMap["chunkSize"] = chunkSize
valueMap["chunkCount"] = len(chunks)
message := models.Message{
Payload: models.Payload{
Version: "v1.0.0",
Action: "receive-sd-stream",
DeviceId: deviceId,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey+"/"+deviceId, 1, false, payload)
log.Log.Infof("cloud.HandleLiveStreamSD(): sent chunk %d/%d to MQTT topic kerberos/hub/%s/%s", i+1, len(chunks), hubKey, deviceId)
time.Sleep(33 * time.Millisecond) // Sleep to avoid flooding the MQTT broker with messages
} else {
log.Log.Info("cloud.HandleLiveStreamSD(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
}
} else {
valueMap := make(map[string]interface{})
valueMap["image"] = bytes
message := models.Message{
Payload: models.Payload{
Action: "receive-sd-stream",
DeviceId: configuration.Config.Key,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("cloud.HandleLiveStreamSD(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
log.Log.Info("cloud.HandleLiveStreamSD(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
}
time.Sleep(1000 * time.Millisecond) // Sleep to avoid flooding the MQTT broker with messages
// Cleaning image
img = image.YCbCr{}
}
} else {
@@ -885,19 +751,11 @@ func HandleLiveStreamHD(livestreamCursor *packets.QueueCursor, configuration *mo
// Check if we need to enable the live stream
if config.Capture.Liveview != "false" {
// Create per-peer broadcasters instead of shared tracks.
// Each viewer gets its own track with independent, non-blocking writes
// so a slow/congested peer cannot stall the others.
// Should create a track here.
streams, _ := rtspClient.GetStreams()
videoBroadcaster := webrtc.NewVideoBroadcaster(streams)
audioBroadcaster := webrtc.NewAudioBroadcaster(streams)
if videoBroadcaster == nil && audioBroadcaster == nil {
log.Log.Error("cloud.HandleLiveStreamHD(): failed to create both video and audio broadcasters")
return
}
go webrtc.WriteToTrack(livestreamCursor, configuration, communication, mqttClient, videoBroadcaster, audioBroadcaster, rtspClient)
videoTrack := webrtc.NewVideoTrack(streams)
audioTrack := webrtc.NewAudioTrack(streams)
go webrtc.WriteToTrack(livestreamCursor, configuration, communication, mqttClient, videoTrack, audioTrack, rtspClient)
if config.Capture.ForwardWebRTC == "true" {
@@ -905,7 +763,7 @@ func HandleLiveStreamHD(livestreamCursor *packets.QueueCursor, configuration *mo
log.Log.Info("cloud.HandleLiveStreamHD(): Waiting for peer connections.")
for handshake := range communication.HandleLiveHDHandshake {
log.Log.Info("cloud.HandleLiveStreamHD(): setting up a peer connection.")
go webrtc.InitializeWebRTCConnection(configuration, communication, mqttClient, videoBroadcaster, audioBroadcaster, handshake)
go webrtc.InitializeWebRTCConnection(configuration, communication, mqttClient, videoTrack, audioTrack, handshake)
}
}
@@ -958,8 +816,7 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
log.Log.Info("cloud.RealtimeProcessing(): Sending base64 encoded images to MQTT.")
img, err := rtspClient.DecodePacket(pkt)
if err == nil {
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
bytes, _ := utils.ImageToBytes(&img)
encoded := base64.StdEncoding.EncodeToString(bytes)
valueMap := make(map[string]interface{})
@@ -973,6 +830,7 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish(realtimeProcessingTopic, 0, false, payload)
} else {
log.Log.Info("cloud.RealtimeProcessing(): something went wrong while sending acknowledge config to hub: " + string(payload))
@@ -1311,184 +1169,3 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
})
}
}
// VerifySecondaryPersistence godoc
// @Router /api/persistence/secondary/verify [post]
// @ID verify-persistence
// @Security Bearer
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @Tags persistence
// @Param config body models.Config true "Config"
// @Summary Will verify the secondary persistence.
// @Description Will verify the secondary persistence.
// @Success 200 {object} models.APIResponse
func VerifySecondaryPersistence(c *gin.Context, configDirectory string) {
var config models.Config
err := c.BindJSON(&config)
if err != nil || config.Cloud != "" {
if config.Cloud == "kstorage" || config.Cloud == "kerberosvault" {
if config.KStorageSecondary == nil {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): please fill-in the required Kerberos Vault credentials."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
} else {
uri := config.KStorageSecondary.URI
accessKey := config.KStorageSecondary.AccessKey
secretAccessKey := config.KStorageSecondary.SecretAccessKey
directory := config.KStorageSecondary.Directory
provider := config.KStorageSecondary.Provider
if err == nil && uri != "" && accessKey != "" && secretAccessKey != "" {
var client *http.Client
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
} else {
client = &http.Client{}
}
req, err := http.NewRequest("POST", uri+"/ping", nil)
if err == nil {
req.Header.Add("X-Kerberos-Storage-AccessKey", accessKey)
req.Header.Add("X-Kerberos-Storage-SecretAccessKey", secretAccessKey)
resp, err := client.Do(req)
if err == nil {
body, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err == nil && resp.StatusCode == http.StatusOK {
if provider != "" || directory != "" {
// Generate a random name.
timestamp := time.Now().Unix()
fileName := strconv.FormatInt(timestamp, 10) +
"_6-967003_" + config.Name + "_200-200-400-400_24_769.mp4"
// Open test-480p.mp4
file, err := os.Open(configDirectory + "/data/test-480p.mp4")
if err != nil {
msg := "cloud.VerifyPersistence(kerberosvault): error reading test-480p.mp4: " + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
defer file.Close()
req, err := http.NewRequest("POST", uri+"/storage", file)
if err == nil {
req.Header.Set("Content-Type", "video/mp4")
req.Header.Set("X-Kerberos-Storage-CloudKey", config.HubKey)
req.Header.Set("X-Kerberos-Storage-AccessKey", accessKey)
req.Header.Set("X-Kerberos-Storage-SecretAccessKey", secretAccessKey)
req.Header.Set("X-Kerberos-Storage-Provider", provider)
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
req.Header.Set("X-Kerberos-Storage-Directory", directory)
var client *http.Client
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
} else {
client = &http.Client{}
}
resp, err := client.Do(req)
if err == nil {
if resp != nil {
body, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err == nil {
if resp.StatusCode == 200 {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Upload allowed using the credentials provided (" + accessKey + ", " + secretAccessKey + ")"
log.Log.Info(msg)
c.JSON(200, models.APIResponse{
Data: body,
})
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying your persistence settings. Make sure your provider is the same as the storage provider in your Kerberos Vault, and the relevant storage provider is configured properly."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
}
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Upload of fake recording failed: " + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while creating /storage POST request." + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Provider and/or directory is missing from the request."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying storage credentials: " + string(body)
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying storage credentials:" + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying storage credentials:" + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): please fill-in the required Kerberos Vault credentials."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
}
}
} else {
msg := "cloud.VerifySecondaryPersistence(): No persistence was specified, so do not know what to verify:" + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
}
@@ -46,11 +46,7 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
if file != nil {
defer func() {
if cerr := file.Close(); cerr != nil {
log.Log.Error("UploadDropbox: Error closing file: " + cerr.Error())
}
}()
defer file.Close()
}
if err == nil {
+2 -42
View File
@@ -34,29 +34,6 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
log.Log.Info("UploadKerberosHub: Uploading to Kerberos Hub (" + config.HubURI + ")")
log.Log.Info("UploadKerberosHub: Upload started for " + fileName)
// Prefer the resumable (tus) upload when enabled (the default). Kerberos Hub
// authenticates the agent with its Hub public/private key and proxies the
// resumable upload to the Kerberos Vault. When Hub does not expose a tus
// endpoint (older deployments) we transparently fall back to the legacy
// single-POST upload below.
if resumableUploadsEnabled() {
uploaded, _, supported, body, rerr := uploadHubResumable(&config, fileName, "UploadKerberosHub", "hub")
if supported {
if uploaded {
log.Log.Info("UploadKerberosHub: Upload Finished (resumable), " + body)
return true, true, nil
}
if rerr != nil {
log.Log.Info("UploadKerberosHub: resumable upload failed, " + rerr.Error())
} else {
log.Log.Info("UploadKerberosHub: resumable upload incomplete, " + body)
}
return false, true, rerr
}
log.Log.Info("UploadKerberosHub: resumable (tus) endpoint not available, falling back to legacy upload")
}
fullname := "data/recordings/" + fileName
// Check if we still have the file otherwise we abort the request.
@@ -91,9 +68,9 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr, CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
client = &http.Client{Transport: tr}
} else {
client = &http.Client{CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
client = &http.Client{}
}
resp, err := client.Do(req)
@@ -152,20 +129,3 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
log.Log.Info(errorMessage)
return false, true, errors.New(errorMessage)
}
// stripHubCredentialsOnCrossHostRedirect removes the custom Kerberos Hub
// credential headers on a redirect that crosses to a different host. net/http
// already strips the standard sensitive headers (Authorization, Cookie,
// WWW-Authenticate) on a cross-host redirect, but it does NOT strip
// custom-named headers, so without this the Hub private/public keys would be
// forwarded to any host the configured HubURI redirects to.
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
req.Header.Del("X-Kerberos-Hub-PrivateKey")
req.Header.Del("X-Kerberos-Hub-PublicKey")
}
return nil
}
+47 -157
View File
@@ -3,20 +3,14 @@ package cloud
import (
"crypto/tls"
"errors"
"io"
"io/ioutil"
"net/http"
"os"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
)
// We will count the number of retries we have done.
// If we have done more than "kstorageRetryPolicy" retries, we will stop, and start sending to the secondary storage.
var kstorageRetryCount = 0
var kstorageRetryTimeout = time.Now().Unix()
func UploadKerberosVault(configuration *models.Configuration, fileName string) (bool, bool, error) {
config := configuration.Config
@@ -25,20 +19,11 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
config.KStorage.SecretAccessKey == "" ||
config.KStorage.Directory == "" ||
config.KStorage.URI == "" {
err := "UploadKerberosVault: Kerberos Vault not properly configured"
err := "UploadKerberosVault: Kerberos Vault not properly configured."
log.Log.Info(err)
return false, false, errors.New(err)
}
// If the recording no longer exists on disk there is nothing to upload.
// This can happen when the file was already removed (e.g. cleanup, or an
// earlier successful upload). Skip it so the watcher drops the marker
// instead of retrying indefinitely.
if _, err := os.Stat("data/recordings/" + fileName); err != nil {
log.Log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
return false, false, nil
}
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
// - Timestamp
@@ -50,166 +35,71 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
// KerberosCloud, this means storage is disabled and proxy enabled.
log.Log.Info("UploadKerberosVault: Uploading to Kerberos Vault (" + config.KStorage.URI + ")")
log.Log.Info("UploadKerberosVault: Upload started for " + fileName)
publicKey := config.KStorage.CloudKey
if config.HubKey != "" {
publicKey = config.HubKey
}
// We need to check if we are in a retry timeout.
if kstorageRetryTimeout <= time.Now().Unix() {
uploaded, responded, body, err := sendToVault(*config.KStorage, publicKey, config.Key, fileName, "UploadKerberosVault", "primary")
if uploaded {
kstorageRetryCount = 0
log.Log.Info("UploadKerberosVault: Upload Finished, " + body)
return true, true, nil
}
if err != nil {
log.Log.Info("UploadKerberosVault: Upload Failed, " + err.Error())
} else {
log.Log.Info("UploadKerberosVault: Upload Failed, " + body)
}
// We only advance the retry policy when the vault gave a definitive
// response (mirroring the original behaviour where transient network
// errors did not consume retries). When the retry count reaches the
// configured maximum we back off for the configured timeout.
if responded {
if kstorageRetryCount < config.KStorage.MaxRetries {
kstorageRetryCount = (kstorageRetryCount + 1)
}
if kstorageRetryCount == config.KStorage.MaxRetries {
kstorageRetryTimeout = time.Now().Add(time.Duration(config.KStorage.Timeout) * time.Second).Unix()
}
}
}
// We might need to check if we can upload to our secondary storage.
if config.KStorageSecondary.AccessKey == "" ||
config.KStorageSecondary.SecretAccessKey == "" ||
config.KStorageSecondary.Directory == "" ||
config.KStorageSecondary.URI == "" {
log.Log.Info("UploadKerberosVault (Secondary): Secondary Kerberos Vault not properly configured.")
} else {
if kstorageRetryCount < config.KStorage.MaxRetries {
log.Log.Info("UploadKerberosVault (Secondary): Do not upload to secondary storage, we are still in retry policy.")
return false, true, nil
}
log.Log.Info("UploadKerberosVault (Secondary): Uploading to Secondary Kerberos Vault (" + config.KStorageSecondary.URI + ")")
uploaded, _, body, err := sendToVault(*config.KStorageSecondary, publicKey, config.Key, fileName, "UploadKerberosVault (Secondary)", "secondary")
if uploaded {
log.Log.Info("UploadKerberosVault (Secondary): Upload Finished to secondary, " + body)
return true, true, nil
}
if err != nil {
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + err.Error())
} else {
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + body)
}
}
return false, true, nil
}
// sendToVault uploads a single recording to one Kerberos Vault. When resumable
// uploads are enabled (the default) it attempts the tus protocol first and, if
// the vault does not expose a tus endpoint (older deployments), transparently
// falls back to the legacy single-shot POST.
//
// It returns whether the upload succeeded, whether the vault gave a definitive
// HTTP response (so the caller can advance its retry policy), a short message
// for logging, and a transport error if any.
func sendToVault(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, string, error) {
if resumableUploadsEnabled() {
uploaded, responded, supported, body, err := uploadVaultResumable(vault, publicKey, deviceKey, fileName, label, slot)
if supported {
return uploaded, responded, body, err
}
log.Log.Info(label + ": resumable (tus) endpoint not available, falling back to legacy upload")
}
return uploadVaultLegacy(vault, publicKey, deviceKey, fileName, label)
}
// uploadVaultLegacy performs the original single-request upload: the whole file
// is sent as the body of a POST to {URI}/storage. Kept for backwards
// compatibility with vault deployments that do not support resumable uploads.
func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, label string) (bool, bool, string, error) {
fullname := "data/recordings/" + fileName
file, err := os.Open(fullname)
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
if file != nil {
defer file.Close()
}
if err != nil {
msg := label + ": Upload Failed, file doesn't exists anymore"
log.Log.Info(msg)
return false, false, "", errors.New(msg)
err := "UploadKerberosVault: Upload Failed, file doesn't exists anymore."
log.Log.Info(err)
return false, false, errors.New(err)
}
uri := vault.URI
for len(uri) > 0 && uri[len(uri)-1] == '/' {
uri = uri[:len(uri)-1]
publicKey := config.KStorage.CloudKey
// This is the new way ;)
if config.HubKey != "" {
publicKey = config.HubKey
}
req, err := http.NewRequest("POST", uri+"/storage", file)
req, err := http.NewRequest("POST", config.KStorage.URI+"/storage", file)
if err != nil {
errorMessage := label + ": error reading request, " + uri + "/storage: " + err.Error()
errorMessage := "UploadKerberosVault: error reading request, " + config.KStorage.URI + "/storage: " + err.Error()
log.Log.Error(errorMessage)
return false, false, "", errors.New(errorMessage)
return false, true, errors.New(errorMessage)
}
req.Header.Set("Content-Type", "video/mp4")
setVaultHeaders(req.Header, vault, publicKey, deviceKey, fileName)
req.Header.Set("X-Kerberos-Storage-CloudKey", publicKey)
req.Header.Set("X-Kerberos-Storage-AccessKey", config.KStorage.AccessKey)
req.Header.Set("X-Kerberos-Storage-SecretAccessKey", config.KStorage.SecretAccessKey)
req.Header.Set("X-Kerberos-Storage-Provider", config.KStorage.Provider)
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
req.Header.Set("X-Kerberos-Storage-Directory", config.KStorage.Directory)
var client *http.Client
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
} else {
client = &http.Client{}
}
client := newVaultHTTPClient(0)
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return false, false, "", err
}
body, rerr := io.ReadAll(resp.Body)
if rerr != nil {
return false, false, "", rerr
}
if resp.StatusCode == 200 {
return true, true, resp.Status + ", " + string(body), nil
}
return false, true, resp.Status + ", " + string(body), nil
}
// setVaultHeaders sets the standard Kerberos Vault headers used by the legacy
// single-POST upload.
func setVaultHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
h.Set("X-Kerberos-Storage-FileName", fileName)
h.Set("X-Kerberos-Storage-Device", deviceKey)
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
}
// newVaultHTTPClient builds an HTTP client honouring the AGENT_TLS_INSECURE
// escape hatch. A timeout of 0 disables the client-level timeout, which is
// required for streaming large upload bodies.
func newVaultHTTPClient(timeout time.Duration) *http.Client {
client := &http.Client{}
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
if err == nil {
if resp != nil {
body, err := ioutil.ReadAll(resp.Body)
if err == nil {
if resp.StatusCode == 200 {
log.Log.Info("UploadKerberosVault: Upload Finished, " + resp.Status + ", " + string(body))
return true, true, nil
} else {
log.Log.Info("UploadKerberosVault: Upload Failed, " + resp.Status + ", " + string(body))
return false, true, nil
}
}
}
}
if timeout > 0 {
client.Timeout = timeout
}
return client
errorMessage := "UploadKerberosVault: Upload Failed, " + err.Error()
log.Log.Info(errorMessage)
return false, true, errors.New(errorMessage)
}
-261
View File
@@ -1,261 +0,0 @@
package cloud
import (
"os"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/cloud/livehls"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
)
// hlsViewerTimeoutSeconds is how long the agent keeps shipping live HLS segments
// after the last viewer keepalive. It is a few seconds longer than the segment
// duration so a viewer whose keepalive is briefly delayed does not cause the
// session to flap. When it lapses the session is torn down to stop wasting
// upload bandwidth when nobody is watching.
const hlsViewerTimeoutSeconds = 8
// hlsReadyReannounceSeconds throttles how often the agent re-announces an
// already-ready session over MQTT in response to viewer keepalives. The initial
// "receive-hls-ready" is a one-shot fired when the first segment lands; a viewer
// that connects or hard-refreshes after that (while the session is still alive)
// missed it, so we re-announce on subsequent keepalives. Viewers dedupe by
// session id, so a re-announce for a session they already play is a no-op. ~2s
// gets a refreshed viewer playing well within its connection timeout without
// spamming the control plane.
const hlsReadyReannounceSeconds = 2
// HandleLiveStreamHLS drives the live HLS producer. It mirrors HandleLiveStreamSD:
// it reads the camera's packet stream from a Latest() cursor, and while a viewer
// is active (kept alive via communication.HandleLiveHLS) it muxes the packets
// into CMAF segments and ships them to hub-api, which stores each segment in an
// ephemeral, short-TTL live window and serves the rolling playlist to viewers.
//
// A session is created lazily on the first keyframe seen while a viewer is active
// and torn down once viewers go away, so an idle camera produces no live traffic.
//
// By default (AGENT_LIVE_HLS_PREWARM unset or != "false") the agent instead keeps
// one long-lived session muxing continuously into a small in-memory ring buffer
// while idle (uploading nothing) and, the moment a viewer arrives, flushes the
// already-encoded init + most-recent segment(s) and starts uploading live. This
// trades a little idle CPU for a near-instant "requesting stream", so viewers no
// longer wait a full GOP for the first segment to be cut. Set
// AGENT_LIVE_HLS_PREWARM=false to fall back to the lazy on-demand path above.
func HandleLiveStreamHLS(livestreamCursor *packets.QueueCursor, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, _ capture.RTSPClient) {
log.Log.Debug("cloud.HandleLiveStreamHLS(): started")
config := configuration.Config
if config.Offline == "true" {
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as Offline is enabled.")
return
}
if config.Capture.Liveview == "false" {
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as Liveview is disabled.")
return
}
if config.HubURI == "" || config.HubKey == "" {
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as the Hub is not configured (HubURI/HubKey).")
return
}
hubKey := config.HubKey
deviceId := config.Key
region := ""
if config.S3 != nil {
region = config.S3.Region
}
publisher := livehls.NewPublisher(livehls.PublisherConfig{
HubURI: config.HubURI,
HubKey: config.HubKey,
HubPrivateKey: config.HubPrivateKey,
Region: region,
DeviceKey: deviceId,
})
// Encoded dimensions are only needed for the avcC fallback path (an SPS that
// mp4ff's strict parser rejects); the main stream dimensions are a safe value.
width := uint16(config.Capture.IPCamera.Width)
height := uint16(config.Capture.IPCamera.Height)
// prewarm keeps a single long-lived session muxing into an in-memory ring
// buffer while idle and flushes it the instant a viewer arrives, eliminating
// the per-request GOP wait. Enabled by default; set AGENT_LIVE_HLS_PREWARM=false
// to fall back to the lazy on-demand path.
prewarm := os.Getenv("AGENT_LIVE_HLS_PREWARM") != "false"
if prewarm {
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm ENABLED (set AGENT_LIVE_HLS_PREWARM=false to disable)")
} else {
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm DISABLED (AGENT_LIVE_HLS_PREWARM=false)")
}
// lowLatency enables LL-HLS: each segment is sliced into CMAF parts shipped the
// instant they close and advertised via #EXT-X-PART, taking glass-to-glass HLS
// latency from ~4-6s down to ~1-2s. Enabled by default; set
// AGENT_LIVE_HLS_LOW_LATENCY=false to fall back to whole-segment HLS.
partTargetMs := uint64(0)
if os.Getenv("AGENT_LIVE_HLS_LOW_LATENCY") != "false" {
partTargetMs = livehls.DefaultPartTargetMs
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) ENABLED (set AGENT_LIVE_HLS_LOW_LATENCY=false to disable)")
} else {
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) DISABLED (AGENT_LIVE_HLS_LOW_LATENCY=false)")
}
var session *livehls.Session
lastViewerRequest := int64(0)
lastReadyAnnounce := int64(0)
var cursorError error
var pkt packets.Packet
for cursorError == nil {
pkt, cursorError = livestreamCursor.ReadPacket()
now := time.Now().Unix()
select {
case <-communication.HandleLiveHLS:
lastViewerRequest = now
// A keepalive may come from a viewer that just connected or hard-
// refreshed and therefore missed the one-shot readiness announcement
// fired when this session's first segment landed. Re-announce (throttled)
// so late/refreshed viewers learn the active session id; the frontend
// dedupes by session id, so this is a no-op for viewers already playing.
// UploadsActive() is always true for the on-demand path; for prewarm it
// suppresses a stale re-announce while idle (the flush-on-arrival path
// below announces once the buffer has actually been shipped).
if session != nil && session.IsReady() && session.UploadsActive() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
lastReadyAnnounce = now
}
default:
}
viewerActive := now-lastViewerRequest <= hlsViewerTimeoutSeconds
if prewarm {
// Keep one long-lived session muxing into the ring buffer. Create it on
// the first keyframe (so the buffer opens on a random-access point) and
// never tear it down for idleness; uploads, not muxing, are what we gate
// on viewer presence.
if session == nil {
if len(pkt.Data) == 0 || !pkt.IsVideo || !pkt.IsKeyFrame {
continue
}
session = livehls.NewSession(publisher, livehls.SessionOptions{
Codec: pkt.Codec,
SPSNALUs: config.Capture.IPCamera.SPSNALUs,
PPSNALUs: config.Capture.IPCamera.PPSNALUs,
VPSNALUs: config.Capture.IPCamera.VPSNALUs,
Width: width,
Height: height,
PartTargetMs: partTargetMs,
StartBuffering: true,
})
session.SetOnReady(func(sessionID string) {
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS session ready, announcing " + sessionID)
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
lastReadyAnnounce = time.Now().Unix()
})
log.Log.Info("cloud.HandleLiveStreamHLS(): prewarming live HLS session " + session.SessionID())
}
if viewerActive {
// Activating flushes the cached init + buffered segment(s). onReady
// announces the first-ever readiness; on a later re-activation it has
// already fired, so announce here (throttled, so the first activation
// does not double up) once the buffer has actually been shipped.
if session.SetUploadsActive(true) && session.IsReady() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
lastReadyAnnounce = now
}
} else {
// No viewer: keep muxing into the buffer but stop uploading.
session.SetUploadsActive(false)
}
if len(pkt.Data) > 0 && pkt.IsVideo {
if err := session.WritePacket(pkt); err != nil {
log.Log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
}
}
continue
}
if !viewerActive {
// No viewer: stop and discard the session so we stop shipping segments.
if session != nil {
_ = session.Close()
log.Log.Info("cloud.HandleLiveStreamHLS(): no active viewers, stopped live HLS session " + session.SessionID())
session = nil
}
continue
}
if len(pkt.Data) == 0 || !pkt.IsVideo {
continue
}
// Start a session lazily, but only on a keyframe so the first segment opens
// on a random-access point.
if session == nil {
if !pkt.IsKeyFrame {
continue
}
session = livehls.NewSession(publisher, livehls.SessionOptions{
Codec: pkt.Codec,
SPSNALUs: config.Capture.IPCamera.SPSNALUs,
PPSNALUs: config.Capture.IPCamera.PPSNALUs,
VPSNALUs: config.Capture.IPCamera.VPSNALUs,
Width: width,
Height: height,
PartTargetMs: partTargetMs,
})
session.SetOnReady(func(sessionID string) {
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS session ready, announcing " + sessionID)
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
lastReadyAnnounce = time.Now().Unix()
})
log.Log.Info("cloud.HandleLiveStreamHLS(): started live HLS session " + session.SessionID())
}
if err := session.WritePacket(pkt); err != nil {
log.Log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
}
}
if session != nil {
_ = session.Close()
}
log.Log.Debug("cloud.HandleLiveStreamHLS(): finished")
}
// publishHLSReady announces, over MQTT, that a live HLS session is available so
// viewers can load the rolling playlist hub-api serves for {device}/{session}.
func publishHLSReady(configuration *models.Configuration, mqttClient mqtt.Client, hubKey, deviceId, sessionID string) {
valueMap := map[string]interface{}{
"session": sessionID,
"device": deviceId,
}
message := models.Message{
Payload: models.Payload{
Action: "receive-hls-ready",
DeviceId: deviceId,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
log.Log.Info("cloud.HandleLiveStreamHLS(): announced live HLS session " + sessionID)
} else {
log.Log.Error("cloud.HandleLiveStreamHLS(): failed to package receive-hls-ready message: " + err.Error())
}
}
-236
View File
@@ -1,236 +0,0 @@
// Package livehls implements the agent-side producer for live HLS streaming.
//
// It complements the recording pipeline: where recordings are muxed into one
// fragmented MP4 and uploaded resumably (TUS) when complete, live HLS ships a
// continuous series of small, independently-decodable CMAF segments to hub-api
// the instant each is produced, so a browser can play a near-live HLS stream
// without WebRTC/TURN (outbound HTTPS only).
//
// The wire contract (agent -> hub-api) intentionally mirrors the existing
// header-based storage convention (X-Kerberos-Storage-Device / -FileName, plus
// the Hub public/private key auth headers). hub-api authenticates the agent and
// stores each segment in an ephemeral, short-TTL live window keyed by
// {device}/{session}, which it serves straight back to the browser. The live
// window is deliberately kept out of the vault and the recordings collection;
// durable archival/DVR is a separate, later concern.
//
// Unlike recordings, live segments are NOT uploaded resumably: a 1-2s segment
// that fails to upload is stale by the time a retry would land, so the publisher
// is fire-and-forget and drops on failure (logged) rather than blocking the live
// pipeline behind a retry/handshake.
package livehls
import (
"bytes"
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/video"
)
const (
// liveIngestPath is the hub-api endpoint that accepts a single live segment
// (or the init segment) and stores it in the ephemeral live window. hub-api
// distinguishes init vs media segment and the object name via the
// X-Kerberos-Live-* headers below, keeping a single route (mirrors the
// existing /storage/upload convention).
liveIngestPath = "/storage/live"
// Object names within a session. The init segment (ftyp+moov) is uploaded
// once per session; media segments are seg-<sequence>.m4s.
initObjectName = "init.mp4"
contentTypeInit = "video/mp4"
contentTypeSegment = "video/iso.segment"
// Header names for the live ingest contract.
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
headerHubRegion = "X-Kerberos-Hub-Region"
headerStorageDevice = "X-Kerberos-Storage-Device"
headerLiveSession = "X-Kerberos-Live-Session"
headerLiveName = "X-Kerberos-Live-Name"
headerLiveSequence = "X-Kerberos-Live-Sequence"
headerLiveDuration = "X-Kerberos-Live-Duration"
// Low-latency (LL-HLS) part headers. A part belongs to media segment
// X-Kerberos-Live-Sequence and is the X-Kerberos-Live-Part-th chunk within it;
// X-Kerberos-Live-Part-Independent flags a part that starts on a keyframe.
headerLivePart = "X-Kerberos-Live-Part"
headerLivePartIndependent = "X-Kerberos-Live-Part-Independent"
// defaultPublishTimeout bounds a single segment upload. A live segment that
// cannot be delivered within roughly its own duration is stale, so the upload
// is abandoned (dropped) rather than allowed to back up the pipeline.
defaultPublishTimeout = 4 * time.Second
)
// PublisherConfig carries the hub endpoint and credentials needed to ship live
// segments. It is populated from the agent's models.Config (HubURI/HubKey/...).
type PublisherConfig struct {
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
Region string // storage region (X-Kerberos-Hub-Region), may be empty
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
// Timeout optionally overrides defaultPublishTimeout (used by tests).
Timeout time.Duration
// HTTPClient optionally injects a client (used by tests). When nil a
// redirect-credential-stripping client is created.
HTTPClient *http.Client
}
// Publisher ships init and media segments to hub-api over plain HTTP POST.
//
// It is safe for sequential use from a single live-stream goroutine. Methods are
// fire-and-forget: they return an error for the caller to log, but the caller is
// expected to continue (drop-on-fail) rather than retry.
type Publisher struct {
cfg PublisherConfig
client *http.Client
}
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
// headers on a cross-host redirect (net/http does this for standard auth headers
// but not custom-named ones), matching the recording upload client.
func NewPublisher(cfg PublisherConfig) *Publisher {
client := cfg.HTTPClient
if client == nil {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultPublishTimeout
}
client = &http.Client{
Timeout: timeout,
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
}
}
return &Publisher{cfg: cfg, client: client}
}
// PublishInit uploads the session's init segment (ftyp+moov). It must be called
// (and succeed) before the player can use any media segment, so the caller
// should treat a failure here as "session not yet established" and retry on the
// next init opportunity rather than shipping media segments blindly.
func (p *Publisher) PublishInit(ctx context.Context, sessionID string, data []byte) error {
return p.post(ctx, postParams{
sessionID: sessionID,
name: initObjectName,
contentType: contentTypeInit,
body: data,
})
}
// PublishSegment uploads one media segment (styp+moof+mdat). The segment's
// sequence number and duration travel in headers so hub-api can update the
// rolling playlist window without parsing the box structure.
func (p *Publisher) PublishSegment(ctx context.Context, sessionID string, seg video.LiveSegment) error {
return p.post(ctx, postParams{
sessionID: sessionID,
name: fmt.Sprintf("seg-%d.m4s", seg.SequenceNumber),
sequence: seg.SequenceNumber,
durationMs: seg.DurationMs,
hasSegment: true,
contentType: contentTypeSegment,
body: seg.Data,
})
}
// PublishPart uploads one CMAF partial segment (LL-HLS). The part is named
// seg-<segment>.<part>.m4s and carries its segment sequence, part index,
// independence flag and duration in headers so hub-api can advertise it via
// #EXT-X-PART and reconstruct the full segment by concatenating its parts.
func (p *Publisher) PublishPart(ctx context.Context, sessionID string, part video.LivePart) error {
return p.post(ctx, postParams{
sessionID: sessionID,
name: fmt.Sprintf("seg-%d.%d.m4s", part.SegmentSeq, part.PartIndex),
sequence: part.SegmentSeq,
durationMs: part.DurationMs,
partIndex: part.PartIndex,
independent: part.Independent,
hasPart: true,
contentType: contentTypeSegment,
body: part.Data,
})
}
type postParams struct {
sessionID string
name string
sequence uint32
durationMs uint64
hasSegment bool
partIndex uint32
independent bool
hasPart bool
contentType string
body []byte
}
// post performs a single fire-and-forget upload to the live ingest endpoint.
func (p *Publisher) post(ctx context.Context, params postParams) error {
if p.cfg.HubURI == "" {
return fmt.Errorf("livehls: HubURI not configured")
}
if params.sessionID == "" {
return fmt.Errorf("livehls: empty session id")
}
url := strings.TrimRight(p.cfg.HubURI, "/") + liveIngestPath
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(params.body))
if err != nil {
return fmt.Errorf("livehls: build request: %w", err)
}
req.Header.Set("Content-Type", params.contentType)
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
req.Header.Set(headerLiveSession, params.sessionID)
req.Header.Set(headerLiveName, params.name)
if params.hasSegment || params.hasPart {
req.Header.Set(headerLiveSequence, strconv.FormatUint(uint64(params.sequence), 10))
req.Header.Set(headerLiveDuration, strconv.FormatUint(params.durationMs, 10))
}
if params.hasPart {
req.Header.Set(headerLivePart, strconv.FormatUint(uint64(params.partIndex), 10))
independent := "0"
if params.independent {
independent = "1"
}
req.Header.Set(headerLivePartIndependent, independent)
}
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
req.Header.Set(headerHubRegion, p.cfg.Region)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("livehls: upload %s: %w", params.name, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("livehls: upload %s rejected: %s", params.name, resp.Status)
}
log.Log.Debug("livehls.Publisher.post(): shipped " + params.name + " for session " + params.sessionID)
return nil
}
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
// a redirect crosses to a different host. net/http strips standard sensitive
// headers on a cross-host redirect but not custom-named ones, so without this the
// Hub keys could leak to a redirect target.
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
req.Header.Del(headerHubPrivateKey)
req.Header.Del(headerHubPublicKey)
}
return nil
}
@@ -1,312 +0,0 @@
package livehls
import (
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/video"
)
// captured records one received upload for assertions.
type captured struct {
path string
method string
contentType string
device string
session string
name string
sequence string
duration string
hubPublic string
hubPrivate string
region string
body []byte
}
// newCapturingServer returns an httptest server that records every upload and
// replies with the given status code.
func newCapturingServer(t *testing.T, status int) (*httptest.Server, *[]captured, *sync.Mutex) {
t.Helper()
var mu sync.Mutex
var got []captured
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
got = append(got, captured{
path: r.URL.Path,
method: r.Method,
contentType: r.Header.Get("Content-Type"),
device: r.Header.Get(headerStorageDevice),
session: r.Header.Get(headerLiveSession),
name: r.Header.Get(headerLiveName),
sequence: r.Header.Get(headerLiveSequence),
duration: r.Header.Get(headerLiveDuration),
hubPublic: r.Header.Get(headerHubPublicKey),
hubPrivate: r.Header.Get(headerHubPrivateKey),
region: r.Header.Get(headerHubRegion),
body: body,
})
mu.Unlock()
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return srv, &got, &mu
}
func testPublisher(hubURI string) *Publisher {
return NewPublisher(PublisherConfig{
HubURI: hubURI,
HubKey: "pub-key",
HubPrivateKey: "priv-key",
Region: "eu-west",
DeviceKey: "cam-1",
Timeout: 2 * time.Second,
})
}
func TestPublisherPublishInitSendsContractHeaders(t *testing.T) {
srv, got, mu := newCapturingServer(t, http.StatusOK)
p := testPublisher(srv.URL)
if err := p.PublishInit(context.Background(), "sess-1", []byte("INITBYTES")); err != nil {
t.Fatalf("PublishInit: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(*got) != 1 {
t.Fatalf("server received %d requests, want 1", len(*got))
}
c := (*got)[0]
if c.method != http.MethodPost {
t.Errorf("method=%s, want POST", c.method)
}
if c.path != liveIngestPath {
t.Errorf("path=%s, want %s", c.path, liveIngestPath)
}
if c.contentType != contentTypeInit {
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeInit)
}
if c.device != "cam-1" {
t.Errorf("device=%s, want cam-1", c.device)
}
if c.session != "sess-1" {
t.Errorf("session=%s, want sess-1", c.session)
}
if c.name != initObjectName {
t.Errorf("name=%s, want %s", c.name, initObjectName)
}
if c.hubPublic != "pub-key" || c.hubPrivate != "priv-key" || c.region != "eu-west" {
t.Errorf("auth headers wrong: pub=%q priv=%q region=%q", c.hubPublic, c.hubPrivate, c.region)
}
if string(c.body) != "INITBYTES" {
t.Errorf("body=%q, want INITBYTES", string(c.body))
}
// init must NOT carry segment-only headers.
if c.sequence != "" || c.duration != "" {
t.Errorf("init should not send sequence/duration, got seq=%q dur=%q", c.sequence, c.duration)
}
}
func TestPublisherPublishSegmentSendsSequenceAndDuration(t *testing.T) {
srv, got, mu := newCapturingServer(t, http.StatusOK)
p := testPublisher(srv.URL)
seg := video.LiveSegment{SequenceNumber: 7, DurationMs: 1960, Data: []byte("SEGMENT")}
if err := p.PublishSegment(context.Background(), "sess-9", seg); err != nil {
t.Fatalf("PublishSegment: %v", err)
}
mu.Lock()
defer mu.Unlock()
c := (*got)[0]
if c.contentType != contentTypeSegment {
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeSegment)
}
if c.name != "seg-7.m4s" {
t.Errorf("name=%s, want seg-7.m4s", c.name)
}
if c.sequence != "7" {
t.Errorf("sequence=%s, want 7", c.sequence)
}
if c.duration != "1960" {
t.Errorf("duration=%s, want 1960", c.duration)
}
if string(c.body) != "SEGMENT" {
t.Errorf("body=%q, want SEGMENT", string(c.body))
}
}
func TestPublisherReturnsErrorOnNon2xx(t *testing.T) {
srv, _, _ := newCapturingServer(t, http.StatusInternalServerError)
p := testPublisher(srv.URL)
err := p.PublishSegment(context.Background(), "s", video.LiveSegment{SequenceNumber: 1, Data: []byte("x")})
if err == nil {
t.Fatal("expected an error on 500 response")
}
}
func TestPublisherErrorsWithoutHubURI(t *testing.T) {
p := NewPublisher(PublisherConfig{DeviceKey: "cam"})
if err := p.PublishInit(context.Background(), "s", []byte("x")); err == nil {
t.Fatal("expected error when HubURI is empty")
}
}
// makeAnnexBVideoPacket builds a synthetic capture packet carrying one Annex B
// H.264 access unit at the given decode time (ms).
func makeAnnexBVideoPacket(isKey bool, timeMs int64) packets.Packet {
nalType := byte(0x01)
if isKey {
nalType = 0x65
}
data := []byte{0x00, 0x00, 0x00, 0x01, nalType}
for i := 0; i < 80; i++ {
data = append(data, byte(i))
}
return packets.Packet{
IsVideo: true,
IsKeyFrame: isKey,
Codec: "H264",
Data: data,
TimeLegacy: time.Duration(timeMs) * time.Millisecond,
}
}
func TestSessionShipsInitThenSegmentsAndFiresReady(t *testing.T) {
srv, got, mu := newCapturingServer(t, http.StatusOK)
p := testPublisher(srv.URL)
sess := NewSession(p, SessionOptions{
Codec: "H264",
SPSNALUs: [][]byte{liveTestSPSForSession()},
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
Width: 640,
Height: 480,
TargetSegmentMs: 2000,
})
var readyCalls int
var readySession string
sess.SetOnReady(func(id string) {
readyCalls++
readySession = id
})
// 4 GOPs of 25 frames @ 40ms = 1s GOPs => with 2s target, 2 segments emitted
// during streaming and a final one on Close.
const gopFrames, gops = 25, 4
for i := 0; i < gopFrames*gops; i++ {
isKey := i%gopFrames == 0
pkt := makeAnnexBVideoPacket(isKey, int64(i*40))
if err := sess.WritePacket(pkt); err != nil {
t.Fatalf("WritePacket(%d): %v", i, err)
}
}
// A non-video packet must be ignored.
if err := sess.WritePacket(packets.Packet{IsAudio: true, Data: []byte{1, 2, 3}}); err != nil {
t.Fatalf("WritePacket(audio): %v", err)
}
if err := sess.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
mu.Lock()
defer mu.Unlock()
var initCount, segCount int
for _, c := range *got {
if c.name == initObjectName {
initCount++
if string(c.body[4:8]) != "ftyp" {
t.Errorf("init body is not an ftyp box: % x", c.body[:12])
}
} else {
segCount++
if c.session != sess.SessionID() {
t.Errorf("segment session=%s, want %s", c.session, sess.SessionID())
}
}
}
if initCount != 1 {
t.Errorf("init uploaded %d times, want exactly 1", initCount)
}
if segCount < 2 {
t.Errorf("got %d segment uploads, want >= 2", segCount)
}
if readyCalls != 1 {
t.Errorf("OnReady fired %d times, want exactly 1", readyCalls)
}
if readySession != sess.SessionID() {
t.Errorf("OnReady session=%s, want %s", readySession, sess.SessionID())
}
}
func TestSessionRetriesInitWhenFirstAttemptFails(t *testing.T) {
// Server fails the first N requests, then succeeds. This proves init is
// re-attempted (not dropped) so the session can still establish.
var mu sync.Mutex
var inits, segs int
failFirst := 1
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
name := r.Header.Get(headerLiveName)
if name == initObjectName {
inits++
if inits <= failFirst {
w.WriteHeader(http.StatusBadGateway)
return
}
} else {
segs++
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
sess := NewSession(testPublisher(srv.URL), SessionOptions{
Codec: "H264",
SPSNALUs: [][]byte{liveTestSPSForSession()},
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
Width: 640,
Height: 480,
})
var ready int
sess.SetOnReady(func(string) { ready++ })
for i := 0; i < 60; i++ {
isKey := i%25 == 0
if err := sess.WritePacket(makeAnnexBVideoPacket(isKey, int64(i*40))); err != nil {
t.Fatalf("WritePacket(%d): %v", i, err)
}
}
if err := sess.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
mu.Lock()
defer mu.Unlock()
if inits < 2 {
t.Errorf("init attempted %d times, want >= 2 (first failed then retried)", inits)
}
if segs < 1 {
t.Errorf("no segments delivered after init recovered (segs=%d)", segs)
}
if ready != 1 {
t.Errorf("OnReady fired %d times, want 1", ready)
}
}
// liveTestSPSForSession is the known-good baseline SPS reused across tests.
func liveTestSPSForSession() []byte {
return []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
}
-457
View File
@@ -1,457 +0,0 @@
package livehls
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/video"
)
// DefaultTargetSegmentMs is the nominal live segment length. ~2s keeps standard
// HLS latency reasonable (a player typically buffers ~3 segments) while staying
// large enough that per-segment HTTP overhead is negligible.
const DefaultTargetSegmentMs = 2000
// DefaultPartTargetMs is the nominal LL-HLS part length used when low latency is
// enabled. ~300ms parts yield ~6-7 parts per 2s segment; with the playlist's
// PART-HOLD-BACK at ~3x the part target this lands glass-to-glass latency around
// 1-2s (versus ~4-6s for whole-segment HLS).
const DefaultPartTargetMs = 300
// Session ties a video.LiveSegmenter to a Publisher: it converts capture packets
// into CMAF segments and ships each one to hub-api. Exactly one init segment is
// delivered per session (re-attempted until it lands), after which media
// segments are published and the OnReady signal fires once so the control plane
// (MQTT) can tell viewers the live playlist exists.
//
// A Session is driven from a single goroutine (the live-stream loop); its methods
// are not safe for concurrent use except SessionID, which is immutable.
type Session struct {
id string
publisher *Publisher
segmenter *video.LiveSegmenter
// newContext produces the per-upload context (timeout). Overridable in tests.
newContext func() (context.Context, context.CancelFunc)
mu sync.Mutex
initBytes []byte
initPublished bool
// lastInitAt is when the init segment was last (re)uploaded. The init is
// re-sent periodically so its short TTL in the hub live window never lapses
// mid-session; see refreshInitIfStale.
lastInitAt time.Time
readyFired bool
onReady func(sessionID string)
// uploadsActive gates whether the init and completed segments are shipped to
// hub-api. It is true for the default on-demand path. The prewarm path starts
// it false so the session keeps muxing into bufferedSegments without producing
// any live traffic until a viewer actually arrives; see SetUploadsActive.
uploadsActive bool
// bufferedSegments is the in-memory ring buffer (the most recent
// prewarmMaxBufferedSegments segments) kept while uploadsActive is false, so a
// viewer that arrives can be served an already-encoded segment immediately
// instead of waiting a full GOP for the next one to be cut.
bufferedSegments []video.LiveSegment
// bufferedParts is the LL-HLS counterpart of bufferedSegments: while idle it
// retains the parts of the most recent (prewarmMaxBufferedSegments+1) segments,
// pruned a WHOLE segment at a time so a flushed segment is never partial.
bufferedParts []video.LivePart
}
// SessionOptions configures a live HLS session.
type SessionOptions struct {
Codec string // "H264" or "H265"
SPSNALUs [][]byte // parameter sets (raw or Annex B)
PPSNALUs [][]byte //
VPSNALUs [][]byte // H.265 only
Width uint16 // encoded width (for the avcC fallback path)
Height uint16 // encoded height
TargetSegmentMs uint64 // 0 => DefaultTargetSegmentMs
// PartTargetMs, when > 0, enables LL-HLS: each segment is additionally sliced
// into ~PartTargetMs CMAF parts that are published (and advertised via
// #EXT-X-PART) the instant they close, for ~1-2s glass-to-glass latency. 0
// keeps the classic whole-segment path.
PartTargetMs uint64
// StartBuffering starts the session in prewarm (buffer-only) mode: it muxes
// segments into an in-memory ring buffer but uploads nothing until
// SetUploadsActive(true) is called. Default false => uploads are live
// immediately (the on-demand path's behaviour).
StartBuffering bool
}
// NewSession builds a session with a fresh random id and wires the segmenter's
// init/segment callbacks to the publisher.
func NewSession(publisher *Publisher, opts SessionOptions) *Session {
target := opts.TargetSegmentMs
if target == 0 {
target = DefaultTargetSegmentMs
}
seg := video.NewLiveSegmenter(opts.Codec, opts.SPSNALUs, opts.PPSNALUs, opts.VPSNALUs, target)
seg.SetDimensions(opts.Width, opts.Height)
if opts.PartTargetMs > 0 {
seg.EnableLowLatency(opts.PartTargetMs)
}
s := &Session{
id: newSessionID(),
publisher: publisher,
segmenter: seg,
// Uploads are live by default; the prewarm path opts into buffer-only mode.
uploadsActive: !opts.StartBuffering,
newContext: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), defaultPublishTimeout)
},
}
// The segmenter emits the init segment exactly once; capture it and try to
// ship it. Failures here are non-fatal - publishInitIfNeeded re-attempts
// before the next media segment so a transient hub hiccup at startup does not
// permanently break the session.
seg.OnInit = func(initBytes []byte) error {
s.mu.Lock()
s.initBytes = append([]byte(nil), initBytes...)
active := s.uploadsActive
s.mu.Unlock()
// While prewarming we cache the init in memory but ship nothing; it is
// uploaded on the first SetUploadsActive(true) flush.
if active {
s.publishInitIfNeeded()
}
return nil
}
// Each completed media segment is shipped. We only publish a segment once the
// init segment has landed (a media segment is useless without it), and we fire
// OnReady after the first successfully shipped segment.
seg.OnSegment = func(segment video.LiveSegment) error {
s.mu.Lock()
active := s.uploadsActive
s.mu.Unlock()
if !active {
// Prewarm: retain the most recent segments in memory but upload nothing
// until a viewer arrives (SetUploadsActive flushes them).
s.bufferSegment(segment)
return nil
}
if !s.publishInitIfNeeded() {
log.Log.Warning("livehls.Session: dropping segment " +
fmt.Sprintf("%d", segment.SequenceNumber) + " because init has not been delivered yet")
return nil
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishSegment(ctx, s.id, segment); err != nil {
log.Log.Warning("livehls.Session: " + err.Error())
return nil
}
s.fireReadyOnce()
// Keep the (write-once) init segment from ageing out of the live window
// while the session is still producing media.
s.refreshInitIfStale()
return nil
}
// In LL-HLS mode the segmenter emits parts (not whole segments); ship each one
// the instant it closes. Mirrors OnSegment: buffer while prewarming, otherwise
// publish after the init has landed and fire OnReady on the first part.
if opts.PartTargetMs > 0 {
seg.OnPart = func(part video.LivePart) error {
s.mu.Lock()
active := s.uploadsActive
s.mu.Unlock()
if !active {
s.bufferPart(part)
return nil
}
if !s.publishInitIfNeeded() {
log.Log.Warning("livehls.Session: dropping part " +
fmt.Sprintf("%d.%d", part.SegmentSeq, part.PartIndex) +
" because init has not been delivered yet")
return nil
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishPart(ctx, s.id, part); err != nil {
log.Log.Warning("livehls.Session: " + err.Error())
return nil
}
s.fireReadyOnce()
s.refreshInitIfStale()
return nil
}
}
return s
}
// SessionID returns the immutable session identifier used in object keys and the
// MQTT ready signal.
func (s *Session) SessionID() string { return s.id }
// IsReady reports whether the session has delivered its init segment and at
// least one media segment, i.e. the playlist hub-api serves is now playable. It
// lets the live-stream loop re-announce "receive-hls-ready" to viewers that join
// or hard-refresh after the initial one-shot signal (which they would otherwise
// never receive, leaving the stream blank until the session is recreated).
func (s *Session) IsReady() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.readyFired
}
// SetOnReady registers a callback fired exactly once, after the first media
// segment has been successfully delivered. Used to publish the MQTT
// "receive-hls-ready" signal so viewers can load the playlist.
func (s *Session) SetOnReady(fn func(sessionID string)) {
s.mu.Lock()
s.onReady = fn
s.mu.Unlock()
}
// prewarmMaxBufferedSegments is how many of the most recent completed segments
// the prewarm path keeps in memory while idle and flushes to a viewer on arrival.
// One segment keeps startup instant (the viewer immediately gets a playable
// segment) while starting as close to the live edge as possible, so the HLS view
// tracks the WebRTC/live edge instead of opening several seconds behind; hls.js
// then converges to the edge via maxLiveSyncPlaybackRate. Raising it trades
// latency-from-live for a little more startup cushion.
const prewarmMaxBufferedSegments = 1
// SetUploadsActive toggles whether the session ships its init and segments to
// hub-api, and reports whether this call flipped it from inactive to active.
//
// While uploads are inactive the session keeps muxing capture packets into an
// in-memory ring buffer (the cached init plus the most recent
// prewarmMaxBufferedSegments segments) but uploads nothing, so an idle camera
// produces no live traffic. Switching from inactive to active immediately
// flushes the cached init and buffered segments so a viewer can start almost
// instantly instead of waiting a full GOP for the next segment to be cut.
// Switching from active to inactive resets the init-published flag so the next
// activation re-uploads the init (it may have aged out of the hub's short-TTL
// live window while idle). All other transitions are no-ops. Driven from the
// live-stream goroutine; not safe for concurrent use.
func (s *Session) SetUploadsActive(active bool) bool {
s.mu.Lock()
if s.uploadsActive == active {
s.mu.Unlock()
return false
}
s.uploadsActive = active
if !active {
// Going idle: force the next activation to re-deliver the init segment,
// which may have expired from the hub live window while nobody was watching.
s.initPublished = false
s.mu.Unlock()
return false
}
// Inactive -> active: take the cached buffered segments/parts and flush them
// outside the lock (the publish calls take their own time and re-acquire the
// mutex).
buffered := s.bufferedSegments
bufferedParts := s.bufferedParts
s.bufferedSegments = nil
s.bufferedParts = nil
s.mu.Unlock()
// Deliver the init first; media segments are useless without it.
for i := range buffered {
if !s.publishInitIfNeeded() {
break
}
ctx, cancel := s.newContext()
if err := s.publisher.PublishSegment(ctx, s.id, buffered[i]); err != nil {
log.Log.Warning("livehls.Session: prewarm flush: " + err.Error())
cancel()
continue
}
cancel()
s.fireReadyOnce()
s.refreshInitIfStale()
}
// LL-HLS: flush the buffered parts in order (oldest first) so the viewer gets a
// playable, near-live window immediately.
for i := range bufferedParts {
if !s.publishInitIfNeeded() {
break
}
ctx, cancel := s.newContext()
if err := s.publisher.PublishPart(ctx, s.id, bufferedParts[i]); err != nil {
log.Log.Warning("livehls.Session: prewarm flush (part): " + err.Error())
cancel()
continue
}
cancel()
s.fireReadyOnce()
s.refreshInitIfStale()
}
return true
}
// UploadsActive reports whether the session is currently shipping segments (as
// opposed to buffering them while prewarming). Always true for the on-demand
// path.
func (s *Session) UploadsActive() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.uploadsActive
}
// bufferSegment appends a completed segment to the in-memory prewarm ring buffer,
// discarding the oldest so at most prewarmMaxBufferedSegments are retained.
func (s *Session) bufferSegment(seg video.LiveSegment) {
s.mu.Lock()
s.bufferedSegments = append(s.bufferedSegments, seg)
if overflow := len(s.bufferedSegments) - prewarmMaxBufferedSegments; overflow > 0 {
// Drop the oldest segment(s) and shrink the backing array so retained bytes
// stay bounded.
s.bufferedSegments = append([]video.LiveSegment(nil), s.bufferedSegments[overflow:]...)
}
s.mu.Unlock()
}
// bufferPart appends a part to the LL-HLS prewarm ring buffer, pruning whole
// older segments (never individual parts) so the retained window always consists
// of complete segments plus the in-progress one. Pruning on a part-0 boundary
// keeps at most prewarmMaxBufferedSegments fully-buffered segments behind the
// current one, which guarantees a flushed segment can be reconstructed in full.
func (s *Session) bufferPart(part video.LivePart) {
s.mu.Lock()
s.bufferedParts = append(s.bufferedParts, part)
if part.PartIndex == 0 && part.SegmentSeq > uint32(prewarmMaxBufferedSegments) {
minSeg := part.SegmentSeq - uint32(prewarmMaxBufferedSegments)
kept := make([]video.LivePart, 0, len(s.bufferedParts))
for _, p := range s.bufferedParts {
if p.SegmentSeq >= minSeg {
kept = append(kept, p)
}
}
s.bufferedParts = kept
}
s.mu.Unlock()
}
// WritePacket feeds one capture packet into the segmenter. Non-video packets are
// ignored (the spike is video-only). The decode timestamp is derived exactly as
// the recording muxer does: DTS = PTS - compositionOffset, with the composition
// offset forwarded for correct B-frame presentation order.
func (s *Session) WritePacket(pkt packets.Packet) error {
if !pkt.IsVideo {
return nil
}
pts := uint64(pkt.TimeLegacy.Milliseconds())
compositionOffset := pkt.CompositionTime
dts := pts
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
dts = pts - uint64(compositionOffset)
} else if compositionOffset < 0 || uint64(compositionOffset) > pts {
// Guard against invalid offsets to avoid producing a CTS (DTS+CTO) jump.
compositionOffset = 0
}
return s.segmenter.WriteSample(pkt.IsKeyFrame, pkt.Data, dts, int32(compositionOffset))
}
// Close flushes any buffered sample and ships the final segment.
func (s *Session) Close() error {
return s.segmenter.Close()
}
// publishInitIfNeeded ensures the init segment has been delivered, attempting an
// upload if it has not. Returns true once init is known to be published.
func (s *Session) publishInitIfNeeded() bool {
s.mu.Lock()
if s.initPublished {
s.mu.Unlock()
return true
}
initBytes := s.initBytes
s.mu.Unlock()
if len(initBytes) == 0 {
return false
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
log.Log.Warning("livehls.Session: init upload failed, will retry: " + err.Error())
return false
}
s.mu.Lock()
s.initPublished = true
s.lastInitAt = time.Now()
s.mu.Unlock()
log.Log.Info("livehls.Session: init segment delivered for session " + s.id)
return true
}
// initRefreshInterval is how often the init segment is re-uploaded so its TTL in
// the hub-api live window never lapses mid-session. The init segment is otherwise
// written only once per session; because the live window expires objects after a
// short TTL (LiveSegmentTTLSeconds, 45s on the hub) the init would age out after
// ~1 minute and the playlist's #EXT-X-MAP would start 404ing, stalling playback.
// Re-uploading well inside that TTL keeps the init alive for the life of the
// session while still letting it expire naturally once the session ends.
const initRefreshInterval = 15 * time.Second
// refreshInitIfStale re-uploads the init segment if it has not been refreshed
// within initRefreshInterval, keeping its created_at (and thus its TTL) current
// for as long as the session is producing segments. It is a no-op until the init
// has first been published. Failures are non-fatal: the next segment retries.
func (s *Session) refreshInitIfStale() {
s.mu.Lock()
if !s.initPublished || time.Since(s.lastInitAt) < initRefreshInterval {
s.mu.Unlock()
return
}
initBytes := s.initBytes
s.mu.Unlock()
if len(initBytes) == 0 {
return
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
log.Log.Warning("livehls.Session: init refresh failed, will retry: " + err.Error())
return
}
s.mu.Lock()
s.lastInitAt = time.Now()
s.mu.Unlock()
log.Log.Debug("livehls.Session: refreshed init segment TTL for session " + s.id)
}
// fireReadyOnce invokes the OnReady callback the first time it is called.
func (s *Session) fireReadyOnce() {
s.mu.Lock()
if s.readyFired || s.onReady == nil {
s.mu.Unlock()
return
}
s.readyFired = true
fn := s.onReady
s.mu.Unlock()
fn(s.id)
}
// newSessionID returns a short, unique, URL-safe session identifier of the form
// <unix-seconds>-<random-hex>.
func newSessionID() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
// rand.Read essentially never fails; fall back to a time-only id.
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return fmt.Sprintf("%d-%s", time.Now().Unix(), hex.EncodeToString(b))
}
@@ -1,151 +0,0 @@
// Package livesnapshot implements the agent-side producer for the live-view
// "preview" (SD) mode over HTTP.
//
// Historically the preview pipeline shipped each resized keyframe (a base64
// JPEG, often chunked) to viewers over the MQTT broker. MQTT is a control plane
// for small messages, so pushing ~1 image/second of base64 image data per
// watched camera congests the broker and delays genuine control traffic. This
// package moves those frames off MQTT: the agent POSTs the latest resized JPEG
// straight to hub-api over plain HTTPS (outbound only), and viewers fetch it
// back with their session token. Only the tiny "a viewer is watching" keepalive
// stays on MQTT.
//
// The wire contract (agent -> hub-api) deliberately mirrors the live HLS ingest
// and the existing storage-upload convention (X-Kerberos-Storage-Device plus the
// Hub public/private key auth headers). hub-api authenticates the agent and
// stores the frame in an ephemeral, short-TTL per-device slot which it serves
// straight back to authorized viewers; the frame never enters the vault or the
// recordings collection.
//
// Like live HLS segments, a preview frame is worthless once stale: a frame that
// fails to upload is superseded by the next one a second later, so the publisher
// is fire-and-forget and drops on failure (logged) rather than retrying.
package livesnapshot
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
)
const (
// snapshotIngestPath is the hub-api endpoint that accepts the latest preview
// frame and stores it in the device's ephemeral snapshot slot (mirrors the
// /storage/live live-HLS ingest convention).
snapshotIngestPath = "/storage/snapshot"
contentTypeJPEG = "image/jpeg"
// Header names for the snapshot ingest contract (shared with live HLS / storage).
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
headerHubRegion = "X-Kerberos-Hub-Region"
headerStorageDevice = "X-Kerberos-Storage-Device"
// defaultPublishTimeout bounds a single snapshot upload. Preview frames are
// produced roughly once a second from a single goroutine, so an upload that
// cannot land in a few seconds is abandoned rather than allowed to back up the
// preview loop behind a slow request.
defaultPublishTimeout = 4 * time.Second
)
// PublisherConfig carries the hub endpoint and credentials needed to ship
// preview frames. It is populated from the agent's models.Config (the same
// HubURI/HubKey/HubPrivateKey used by recordings and live HLS).
type PublisherConfig struct {
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
Region string // storage region (X-Kerberos-Hub-Region), may be empty
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
// Timeout optionally overrides defaultPublishTimeout (used by tests).
Timeout time.Duration
// HTTPClient optionally injects a client (used by tests). When nil a
// redirect-credential-stripping client is created.
HTTPClient *http.Client
}
// Publisher ships the latest preview frame to hub-api over plain HTTP POST.
//
// It is safe for sequential use from a single live-stream goroutine. PublishSnapshot
// is fire-and-forget: it returns an error for the caller to log, but the caller is
// expected to continue (drop-on-fail) rather than retry.
type Publisher struct {
cfg PublisherConfig
client *http.Client
}
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
// headers on a cross-host redirect (net/http does this for standard auth headers
// but not custom-named ones), matching the recording/live-HLS upload clients.
func NewPublisher(cfg PublisherConfig) *Publisher {
client := cfg.HTTPClient
if client == nil {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultPublishTimeout
}
client = &http.Client{
Timeout: timeout,
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
}
}
return &Publisher{cfg: cfg, client: client}
}
// PublishSnapshot uploads a single resized preview frame (JPEG) as the device's
// latest snapshot. It overwrites whatever frame was there before, so viewers
// always fetch the most recent frame.
func (p *Publisher) PublishSnapshot(ctx context.Context, jpeg []byte) error {
if p.cfg.HubURI == "" {
return fmt.Errorf("livesnapshot: HubURI not configured")
}
if len(jpeg) == 0 {
return fmt.Errorf("livesnapshot: empty snapshot body")
}
url := strings.TrimRight(p.cfg.HubURI, "/") + snapshotIngestPath
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jpeg))
if err != nil {
return fmt.Errorf("livesnapshot: build request: %w", err)
}
req.Header.Set("Content-Type", contentTypeJPEG)
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
req.Header.Set(headerHubRegion, p.cfg.Region)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("livesnapshot: upload snapshot: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("livesnapshot: upload snapshot rejected: %s", resp.Status)
}
log.Log.Debug("livesnapshot.Publisher.PublishSnapshot(): shipped preview frame for device " + p.cfg.DeviceKey)
return nil
}
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
// a redirect crosses to a different host. net/http strips standard sensitive
// headers on a cross-host redirect but not custom-named ones, so without this the
// Hub keys could leak to a redirect target.
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
req.Header.Del(headerHubPrivateKey)
req.Header.Del(headerHubPublicKey)
}
return nil
}
-546
View File
@@ -1,546 +0,0 @@
package cloud
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
)
// tusResumableVersion is the tus protocol version implemented by this client.
const tusResumableVersion = "1.0.0"
// tusUploadPath is appended to the configured Kerberos Vault URI to reach the
// resumable upload endpoint. It mirrors how the legacy uploader appends
// "/storage".
const tusUploadPath = "/storage/tus/"
// tusResumeState is persisted in a sidecar file next to the agent data so an
// interrupted upload can be resumed across retries and even agent restarts.
type tusResumeState struct {
UploadURL string `json:"upload_url"`
VaultURI string `json:"vault_uri"`
Size int64 `json:"size"`
}
// resumableUploadsEnabled reports whether the resumable (tus) upload path should
// be attempted. It is enabled by default and can be disabled (falling back to
// the legacy single POST) by setting AGENT_DISABLE_RESUMABLE_UPLOAD=true.
func resumableUploadsEnabled() bool {
return os.Getenv("AGENT_DISABLE_RESUMABLE_UPLOAD") != "true"
}
// tusDefaultChunkSize is the number of bytes uploaded per PATCH request when no
// explicit size is configured. Splitting the upload into chunks keeps each HTTP
// request small enough for intermediary proxies/load balancers and checkpoints
// progress frequently, so an interruption resumes with minimal re-upload.
const tusDefaultChunkSize int64 = 8 << 20 // 8 MiB (>= S3 multipart minimum part size)
const tusProgressBucketPercent int64 = 10
// tusChunkSize returns the number of bytes to send per PATCH request. It
// defaults to tusDefaultChunkSize (8 MiB) and can be overridden with the
// AGENT_TUS_CHUNK_SIZE_BYTES environment variable. A value of 0 (or negative)
// disables chunking and sends the remaining bytes in a single PATCH.
func tusChunkSize() int64 {
v := os.Getenv("AGENT_TUS_CHUNK_SIZE_BYTES")
if v == "" {
return tusDefaultChunkSize
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return tusDefaultChunkSize
}
if n <= 0 {
return 0 // chunking disabled: send everything in one PATCH
}
return n
}
func tusProgressBucket(offset, size int64) int64 {
if size <= 0 {
return 100
}
percent := (offset * 100) / size
if percent > 100 {
percent = 100
}
return percent / tusProgressBucketPercent
}
func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64) {
bucket := tusProgressBucket(offset, size)
if bucket <= *loggedBucket {
return
}
*loggedBucket = bucket
percent := bucket * tusProgressBucketPercent
if percent > 100 {
percent = 100
}
log.Log.Infof("%s: resumable upload progress %d%% (%d/%d bytes)", label, percent, offset, size)
}
// tusHeaderFunc sets the authentication and routing headers required on every
// tus request for a particular upload target (Kerberos Vault directly, or
// Kerberos Hub which proxies to a vault). fileName is only meaningful on the
// creation request; it is empty on HEAD/PATCH/DELETE.
type tusHeaderFunc func(h http.Header, fileName string)
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
// to baseURL, sending target-specific authentication/routing headers via
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
// upload paths.
//
// Return values:
// - uploaded: the recording was fully received and persisted by the server.
// - responded: the server returned a definitive HTTP response (used by the
// caller to advance its retry/secondary-failover policy).
// - supported: the server exposes a tus endpoint. When false, the caller
// should fall back to the legacy single-POST upload (older deployments).
// - body: a short message for logging.
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
fullname := "data/recordings/" + fileName
file, ferr := os.Open(fullname)
if file != nil {
defer file.Close()
}
if ferr != nil {
msg := label + ": resumable upload failed, file doesn't exist anymore"
log.Log.Info(msg)
// The file is gone, so the legacy path cannot help either. Report it as
// "supported" to avoid a pointless fallback attempt.
return false, false, true, "", errors.New(msg)
}
info, serr := file.Stat()
if serr != nil {
return false, false, true, "", serr
}
size := info.Size()
client := newVaultHTTPClient(0)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
for k := range req.Header {
if strings.HasPrefix(http.CanonicalHeaderKey(k), "X-Kerberos-") {
req.Header.Del(k)
}
}
}
return nil
}
sidecar := tusSidecarPath(fileName, slot)
uploadURL := loadTusResumeState(sidecar, baseURL)
const maxAttempts = 4
restartedAfterComplete := false
for attempt := 0; attempt < maxAttempts; attempt++ {
// (1) Ensure we have an active upload URL, creating one if needed.
if uploadURL == "" {
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
if cerr != nil {
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
// The vault does not implement tus; let the caller fall back.
return false, false, false, "", cerr
}
log.Log.Info(label + ": resumable create failed, " + cerr.Error())
tusBackoff(attempt)
continue
}
uploadURL = created
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
}
// (2) Query the current server-side offset.
offset, status, herr := tusHead(client, uploadURL, setHeaders)
if herr != nil {
if status == http.StatusNotFound || status == http.StatusGone {
// The upload expired/was removed server-side; start over.
removeTusResumeState(sidecar)
uploadURL = ""
continue
}
log.Log.Info(label + ": resumable head failed, " + herr.Error())
tusBackoff(attempt)
continue
}
// (3) All bytes are present but the upload was not finalized (e.g. the
// completion hook failed). A completed tus upload cannot be re-finalized
// with another PATCH, so delete it and re-upload to force a clean finalize.
if offset >= size {
if restartedAfterComplete {
return false, true, true, "resumable finalize did not complete", errors.New(label + ": resumable finalize did not complete")
}
tusTerminate(client, uploadURL, setHeaders)
removeTusResumeState(sidecar)
uploadURL = ""
restartedAfterComplete = true
continue
}
// (4) Stream the remaining bytes to the vault via PATCH, reading directly
// from disk so the recording is never fully buffered in memory. When a chunk
// size is configured the data is sent across several PATCH requests,
// checkpointing the offset after each one so an interruption resumes from the
// last completed chunk instead of re-uploading everything.
chunkSize := tusChunkSize()
progressed := false
patchFailed := false
var lastBody string
loggedProgressBucket := tusProgressBucket(offset, size)
for offset < size {
// Re-seek every chunk so the on-disk position always matches the
// server-acknowledged offset, even if a PATCH was partially accepted.
if _, sErr := file.Seek(offset, io.SeekStart); sErr != nil {
return false, false, true, "", sErr
}
patchLen := size - offset
if chunkSize > 0 && chunkSize < patchLen {
patchLen = chunkSize
}
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
if perr != nil {
if status >= 400 {
// Definitive rejection (e.g. provider push failed during finalize).
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
log.Log.Info(label + ": resumable patch rejected, " + perr.Error())
} else {
log.Log.Info(label + ": resumable patch failed, " + perr.Error())
}
tusBackoff(attempt)
patchFailed = true
break
}
if newOffset > offset {
progressed = true
}
offset = newOffset
lastBody = respBody
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
if offset < size {
// Partial progress: persist so a later retry resumes from here.
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
}
}
if patchFailed {
if progressed {
// Forward progress refreshes the retry budget: maxAttempts bounds the
// number of consecutive failures, not the number of chunks needed for
// a large recording.
attempt = -1
}
continue
}
// All declared bytes have been sent and acknowledged: the upload is done.
removeTusResumeState(sidecar)
return true, true, true, lastBody, nil
}
return false, true, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
}
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
// the tus resumable upload protocol. Credentials travel in the
// X-Kerberos-Storage-* headers on every request and routing (directory/provider)
// is additionally carried in the tus Upload-Metadata.
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, bool, string, error) {
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
metadata := encodeTusMetadata(map[string]string{
"filename": fileName,
"device": deviceKey,
"directory": vault.Directory,
"provider": vault.Provider,
"capture": "IPCamera",
"cloudkey": publicKey,
})
setHeaders := func(h http.Header, fn string) {
setVaultTusHeaders(h, vault, publicKey, deviceKey, fn)
}
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
}
// uploadHubResumable uploads a recording to Kerberos Hub's tus endpoint, which
// authenticates the agent with its Hub public/private key and proxies the
// resumable upload to the Kerberos Vault on the agent's behalf. The vault
// directory and provider are resolved and injected by Kerberos Hub, so they are
// intentionally omitted from the metadata here.
func uploadHubResumable(config *models.Config, fileName, label, slot string) (bool, bool, bool, string, error) {
baseURL := strings.TrimRight(config.HubURI, "/") + tusUploadPath
metadata := encodeTusMetadata(map[string]string{
"filename": fileName,
"device": config.Key,
"capture": "IPCamera",
})
setHeaders := func(h http.Header, fn string) {
setHubTusHeaders(h, config, fn)
}
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
}
// tusCreate performs the tus "creation" request (POST). On success it returns
// the resolved upload URL the agent should use for subsequent HEAD/PATCH calls.
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, setHeaders tusHeaderFunc, fileName string) (string, int, error) {
req, err := http.NewRequest("POST", baseURL, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
req.Header.Set("Upload-Length", strconv.FormatInt(size, 10))
if metadata != "" {
req.Header.Set("Upload-Metadata", metadata)
}
setHeaders(req.Header, fileName)
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return "", 0, err
}
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusCreated {
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
}
location := resp.Header.Get("Location")
if location == "" {
return "", resp.StatusCode, errors.New("missing Location header in create response")
}
return resolveTusLocation(baseURL, location), resp.StatusCode, nil
}
// tusHead performs the tus "offset" request (HEAD) and returns the current
// server-side upload offset.
func tusHead(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) (int64, int, error) {
req, err := http.NewRequest("HEAD", uploadURL, nil)
if err != nil {
return 0, 0, err
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setHeaders(req.Header, "")
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return 0, 0, err
}
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return 0, resp.StatusCode, fmt.Errorf("unexpected status on HEAD: %s", resp.Status)
}
offsetStr := resp.Header.Get("Upload-Offset")
offset, perr := strconv.ParseInt(offsetStr, 10, 64)
if perr != nil {
return 0, resp.StatusCode, fmt.Errorf("invalid Upload-Offset header: %q", offsetStr)
}
return offset, resp.StatusCode, nil
}
// tusPatch streams up to length bytes of the file (starting at offset) to the
// upload URL using a single PATCH request. The body is read straight from the
// *os.File, so the recording is never fully buffered in memory.
func tusPatch(client *http.Client, uploadURL string, offset, length int64, file io.Reader, setHeaders tusHeaderFunc) (int64, int, string, error) {
req, err := http.NewRequest("PATCH", uploadURL, io.LimitReader(file, length))
if err != nil {
return offset, 0, "", err
}
req.ContentLength = length
req.Header.Set("Tus-Resumable", tusResumableVersion)
req.Header.Set("Content-Type", "application/offset+octet-stream")
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
setHeaders(req.Header, "")
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return offset, 0, "", err
}
bodyBytes, _ := io.ReadAll(resp.Body)
respBody := string(bodyBytes)
if resp.StatusCode != http.StatusNoContent {
return offset, resp.StatusCode, respBody, fmt.Errorf("unexpected status on PATCH: %s, %s", resp.Status, respBody)
}
newOffsetStr := resp.Header.Get("Upload-Offset")
newOffset, perr := strconv.ParseInt(newOffsetStr, 10, 64)
if perr != nil {
// A 204 without a parseable offset means this PATCH was fully accepted.
return offset + length, resp.StatusCode, respBody, nil
}
return newOffset, resp.StatusCode, respBody, nil
}
// tusTerminate best-effort deletes an upload server-side (DELETE).
func tusTerminate(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) {
req, err := http.NewRequest("DELETE", uploadURL, nil)
if err != nil {
return
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setHeaders(req.Header, "")
resp, derr := client.Do(req)
if resp != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
_ = derr
}
// setVaultTusHeaders sets the Kerberos Vault authentication and routing headers
// on every tus request. Credentials are sent on each request (and never stored
// server-side in the upload metadata). When fileName is empty it is omitted, as
// it is only useful on the creation request (routing also travels in the tus
// Upload-Metadata).
func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
h.Set("X-Kerberos-Storage-Device", deviceKey)
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
if fileName != "" {
h.Set("X-Kerberos-Storage-FileName", fileName)
}
}
// setHubTusHeaders sets the Kerberos Hub authentication headers on every tus
// request of a hub-proxied resumable upload. The agent authenticates with its
// Hub public/private key (exactly as the legacy single-POST hub upload does);
// Kerberos Hub validates the subscription and injects the vault credentials and
// directory/provider on the agent's behalf.
func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
h.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
h.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
h.Set("X-Kerberos-Hub-Region", config.S3.Region)
h.Set("X-Kerberos-Storage-Device", config.Key)
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
if fileName != "" {
h.Set("X-Kerberos-Storage-FileName", fileName)
}
}
// encodeTusMetadata serializes a map into the tus Upload-Metadata header format:
// a comma separated list of "key base64(value)" pairs. Keys are sorted for a
// deterministic header value. Empty values are skipped.
func encodeTusMetadata(pairs map[string]string) string {
parts := make([]string, 0, len(pairs))
for k, v := range pairs {
if v == "" {
continue
}
parts = append(parts, k+" "+base64.StdEncoding.EncodeToString([]byte(v)))
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
// resolveTusLocation turns the Location header returned by the create request
// into an absolute URL. To keep talking to the agent's configured vault host
// (and avoid issues when the vault sits behind a proxy that rewrites the host),
// it keeps the configured base URL and only appends the server-assigned upload
// id taken from the Location.
func resolveTusLocation(baseURL, location string) string {
if ref, err := url.Parse(location); err == nil {
trimmed := strings.Trim(ref.Path, "/")
if trimmed != "" {
segments := strings.Split(trimmed, "/")
id := segments[len(segments)-1]
if id != "" {
return strings.TrimRight(baseURL, "/") + "/" + id
}
}
}
// Fallback: resolve the reference against the base URL as-is.
if base, err := url.Parse(baseURL); err == nil {
if ref, err := url.Parse(location); err == nil {
return base.ResolveReference(ref).String()
}
}
return location
}
// tusSidecarDir is the directory where resume state files are kept. It is
// intentionally separate from data/cloud (which is scanned for recordings to
// upload) so the sidecar files are never mistaken for recordings.
func tusSidecarDir() string {
return "data/tus"
}
func tusSidecarPath(fileName, slot string) string {
safe := strings.ReplaceAll(fileName, "/", "_")
safe = strings.ReplaceAll(safe, string(os.PathSeparator), "_")
return filepath.Join(tusSidecarDir(), safe+"."+slot+".json")
}
// loadTusResumeState returns a previously stored upload URL for the given
// sidecar, but only if it was created against the same vault base URL. Any
// mismatch or read/parse error yields an empty string (start fresh).
func loadTusResumeState(path, baseURL string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
var state tusResumeState
if err := json.Unmarshal(b, &state); err != nil {
return ""
}
if state.UploadURL == "" || state.VaultURI != baseURL {
return ""
}
return state.UploadURL
}
func saveTusResumeState(path string, state tusResumeState) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
b, err := json.Marshal(state)
if err != nil {
return
}
_ = os.WriteFile(path, b, 0o644)
}
func removeTusResumeState(path string) {
_ = os.Remove(path)
}
// tusBackoff sleeps for an exponentially increasing duration (capped) between
// resume attempts to avoid hammering a temporarily unavailable vault.
func tusBackoff(attempt int) {
delay := time.Duration(500*(1<<uint(attempt))) * time.Millisecond
if delay > 3*time.Second {
delay = 3 * time.Second
}
time.Sleep(delay)
}
-608
View File
@@ -1,608 +0,0 @@
package cloud
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/kerberos-io/agent/machinery/src/models"
)
// fakeUpload tracks the state of a single resumable upload on the fake server.
type fakeUpload struct {
size int64
offset int64
}
// recordedRequest captures the method and headers of a request received by the
// fake tus server, so tests can assert the client's per-method auth headers.
type recordedRequest struct {
method string
header http.Header
}
// fakeTus is a tiny in-memory implementation of the tus 1.0.0 server protocol,
// sufficient to exercise the agent's resumable client.
type fakeTus struct {
mu sync.Mutex
uploads map[string]*fakeUpload
counter int
creates int
lastPatchBytes int64
patchSizes []int64
// unsupported makes the creation endpoint return 404, simulating an older
// vault without a tus endpoint.
unsupported bool
// failFinalize causes the next N completing PATCH requests to return 502
// after storing the bytes, simulating a failed completion hook.
failFinalize int
// requests records the headers of every received request (in order) so
// tests can assert which auth/routing headers the client sent per method.
requests []recordedRequest
}
func newFakeTus() *fakeTus {
return &fakeTus{uploads: map[string]*fakeUpload{}}
}
func (s *fakeTus) seed(size, offset int64) string {
s.mu.Lock()
defer s.mu.Unlock()
s.counter++
id := fmt.Sprintf("seed-%d", s.counter)
s.uploads[id] = &fakeUpload{size: size, offset: offset}
return id
}
func (s *fakeTus) totalBytes() int64 {
s.mu.Lock()
defer s.mu.Unlock()
var total int64
for _, u := range s.uploads {
total += u.offset
}
return total
}
func (s *fakeTus) lastPatch() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastPatchBytes
}
// patchCounts returns the number of PATCH requests received and the size of each.
func (s *fakeTus) patchCounts() (int, []int64) {
s.mu.Lock()
defer s.mu.Unlock()
sizes := make([]int64, len(s.patchSizes))
copy(sizes, s.patchSizes)
return len(s.patchSizes), sizes
}
func (s *fakeTus) createCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.creates
}
// requestsForMethod returns the recorded requests for the given HTTP method.
func (s *fakeTus) requestsForMethod(method string) []recordedRequest {
s.mu.Lock()
defer s.mu.Unlock()
var out []recordedRequest
for _, req := range s.requests {
if req.method == method {
out = append(out, req)
}
}
return out
}
func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, tusUploadPath)
w.Header().Set("Tus-Resumable", tusResumableVersion)
s.mu.Lock()
s.requests = append(s.requests, recordedRequest{method: r.Method, header: r.Header.Clone()})
s.mu.Unlock()
switch r.Method {
case http.MethodPost:
if s.unsupported {
w.WriteHeader(http.StatusNotFound)
return
}
length, _ := strconv.ParseInt(r.Header.Get("Upload-Length"), 10, 64)
s.mu.Lock()
s.counter++
s.creates++
newID := fmt.Sprintf("up-%d", s.counter)
s.uploads[newID] = &fakeUpload{size: length}
s.mu.Unlock()
w.Header().Set("Location", tusUploadPath+newID)
w.WriteHeader(http.StatusCreated)
case http.MethodHead:
s.mu.Lock()
u, ok := s.uploads[id]
s.mu.Unlock()
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Upload-Offset", strconv.FormatInt(u.offset, 10))
w.Header().Set("Upload-Length", strconv.FormatInt(u.size, 10))
w.WriteHeader(http.StatusOK)
case http.MethodPatch:
s.mu.Lock()
u, ok := s.uploads[id]
s.mu.Unlock()
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
n, _ := io.Copy(io.Discard, r.Body)
s.mu.Lock()
u.offset += n
s.lastPatchBytes = n
s.patchSizes = append(s.patchSizes, n)
complete := u.offset >= u.size
failNow := complete && s.failFinalize > 0
if failNow {
s.failFinalize--
}
offset := u.offset
s.mu.Unlock()
w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10))
if failNow {
// Bytes are stored but the (simulated) completion hook failed.
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusNoContent)
case http.MethodDelete:
s.mu.Lock()
delete(s.uploads, id)
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// withRecording switches into a fresh temp working directory containing a
// recording at data/recordings/<fileName>. The working directory is restored on
// cleanup. Tests using this helper must not run in parallel.
func withRecording(t *testing.T, fileName string, payload []byte) {
t.Helper()
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if err := os.MkdirAll("data/recordings", 0o755); err != nil {
t.Fatalf("mkdir recordings: %v", err)
}
if err := os.WriteFile(filepath.Join("data/recordings", fileName), payload, 0o644); err != nil {
t.Fatalf("write recording: %v", err)
}
}
func testVault(uri string) models.KStorage {
return models.KStorage{
URI: uri,
AccessKey: "ak",
SecretAccessKey: "sk",
Provider: "gcp",
Directory: "dir",
}
}
func TestUploadVaultResumable_HappyPath(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("x"), 4096)
withRecording(t, fileName, payload)
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !responded || !supported {
t.Fatalf("uploaded/responded/supported = %v/%v/%v, want all true", uploaded, responded, supported)
}
if got := srv.totalBytes(); got != int64(len(payload)) {
t.Fatalf("server received %d bytes, want %d", got, len(payload))
}
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
t.Fatalf("expected sidecar to be removed after success, stat err = %v", err)
}
}
func TestUploadVaultResumable_Chunked(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
// 10 KiB payload uploaded in 4 KiB chunks => 3 PATCH requests (4096+4096+2048).
payload := bytes.Repeat([]byte("c"), 10240)
withRecording(t, fileName, payload)
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected chunked upload success, got uploaded=%v supported=%v", uploaded, supported)
}
if got := srv.totalBytes(); got != int64(len(payload)) {
t.Fatalf("server received %d bytes, want %d", got, len(payload))
}
count, sizes := srv.patchCounts()
if count != 3 {
t.Fatalf("expected 3 chunked PATCH requests, got %d (sizes=%v)", count, sizes)
}
want := []int64{4096, 4096, 2048}
for i, w := range want {
if sizes[i] != w {
t.Fatalf("chunk %d size = %d, want %d (sizes=%v)", i, sizes[i], w, sizes)
}
}
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
t.Fatalf("expected sidecar removed after success, stat err = %v", err)
}
}
func TestUploadVaultResumable_ChunkingDisabled(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("d"), 10240)
withRecording(t, fileName, payload)
// 0 disables chunking: the whole file should go out in a single PATCH.
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "0")
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected success, got uploaded=%v supported=%v", uploaded, supported)
}
count, sizes := srv.patchCounts()
if count != 1 {
t.Fatalf("expected a single PATCH when chunking is disabled, got %d (sizes=%v)", count, sizes)
}
if sizes[0] != int64(len(payload)) {
t.Fatalf("single PATCH size = %d, want %d", sizes[0], len(payload))
}
}
func TestTusChunkSize(t *testing.T) {
cases := []struct {
name string
env string
set bool
want int64
}{
{name: "default when unset", set: false, want: tusDefaultChunkSize},
{name: "default on invalid", env: "notanumber", set: true, want: tusDefaultChunkSize},
{name: "explicit value", env: "65536", set: true, want: 65536},
{name: "zero disables", env: "0", set: true, want: 0},
{name: "negative disables", env: "-5", set: true, want: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.set {
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", tc.env)
} else {
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "")
}
if got := tusChunkSize(); got != tc.want {
t.Fatalf("tusChunkSize() = %d, want %d", got, tc.want)
}
})
}
}
func TestUploadVaultResumable_Unsupported(t *testing.T) {
srv := newFakeTus()
srv.unsupported = true
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "f.mp4"
withRecording(t, fileName, []byte("hello"))
uploaded, _, supported, _, _ := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if uploaded {
t.Fatal("expected uploaded=false against a vault without a tus endpoint")
}
if supported {
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
}
}
func TestUploadVaultResumable_FinalizeRetry(t *testing.T) {
srv := newFakeTus()
srv.failFinalize = 1
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("y"), 2048)
withRecording(t, fileName, payload)
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected success after a failed finalize + restart, got uploaded=%v supported=%v", uploaded, supported)
}
if got := srv.createCount(); got < 2 {
t.Fatalf("expected at least 2 create requests (restart after failed finalize), got %d", got)
}
}
func TestUploadVaultResumable_ResumeFromSidecar(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
total := 8192
half := 4096
payload := bytes.Repeat([]byte("z"), total)
withRecording(t, fileName, payload)
// Simulate a previous run that uploaded half the file before being interrupted.
id := srv.seed(int64(total), int64(half))
baseURL := strings.TrimRight(ts.URL, "/") + tusUploadPath
saveTusResumeState(tusSidecarPath(fileName, "primary"), tusResumeState{
UploadURL: strings.TrimRight(baseURL, "/") + "/" + id,
VaultURI: baseURL,
Size: int64(total),
})
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected resume success, got uploaded=%v supported=%v", uploaded, supported)
}
if got := srv.lastPatch(); got != int64(total-half) {
t.Fatalf("resume should only send the remaining %d bytes, sent %d", total-half, got)
}
if srv.createCount() != 0 {
t.Fatalf("resume should not create a new upload, got %d creates", srv.createCount())
}
}
func testHubConfig(hubURI string) *models.Config {
return &models.Config{
Key: "device-key",
HubURI: hubURI,
HubKey: "hubpub",
HubPrivateKey: "hubpriv",
S3: &models.S3{Region: "eu-west"},
}
}
// decodeTusMetadata parses a tus Upload-Metadata header value ("key b64,key b64")
// back into a map of decoded key/value pairs.
func decodeTusMetadata(meta string) map[string]string {
out := map[string]string{}
if meta == "" {
return out
}
for _, pair := range strings.Split(meta, ",") {
parts := strings.SplitN(strings.TrimSpace(pair), " ", 2)
if parts[0] == "" {
continue
}
val := ""
if len(parts) == 2 {
if b, err := base64.StdEncoding.DecodeString(parts[1]); err == nil {
val = string(b)
}
}
out[parts[0]] = val
}
return out
}
func TestUploadHubResumable_HappyPath(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("h"), 4096)
withRecording(t, fileName, payload)
uploaded, _, supported, _, err := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("uploaded/supported = %v/%v, want both true", uploaded, supported)
}
if got := srv.totalBytes(); got != int64(len(payload)) {
t.Fatalf("server received %d bytes, want %d", got, len(payload))
}
// The Hub auth headers must be present on every request type (POST/HEAD/PATCH),
// because Kerberos Hub validates them on each proxied request. Conversely the
// vault credentials/routing are injected by Kerberos Hub on the agent's behalf
// and must never be sent by the agent on the hub path.
for _, method := range []string{http.MethodPost, http.MethodHead, http.MethodPatch} {
reqs := srv.requestsForMethod(method)
if len(reqs) == 0 {
t.Fatalf("expected at least one %s request", method)
}
for _, req := range reqs {
if got := req.header.Get("X-Kerberos-Hub-PublicKey"); got != "hubpub" {
t.Errorf("%s: X-Kerberos-Hub-PublicKey = %q, want %q", method, got, "hubpub")
}
if got := req.header.Get("X-Kerberos-Hub-PrivateKey"); got != "hubpriv" {
t.Errorf("%s: X-Kerberos-Hub-PrivateKey = %q, want %q", method, got, "hubpriv")
}
if got := req.header.Get("X-Kerberos-Hub-Region"); got != "eu-west" {
t.Errorf("%s: X-Kerberos-Hub-Region = %q, want %q", method, got, "eu-west")
}
if got := req.header.Get("X-Kerberos-Storage-Device"); got != "device-key" {
t.Errorf("%s: X-Kerberos-Storage-Device = %q, want %q", method, got, "device-key")
}
for _, h := range []string{
"X-Kerberos-Storage-AccessKey",
"X-Kerberos-Storage-SecretAccessKey",
"X-Kerberos-Storage-CloudKey",
"X-Kerberos-Storage-Provider",
"X-Kerberos-Storage-Directory",
} {
if got := req.header.Get(h); got != "" {
t.Errorf("%s: %s should be empty on the hub path, got %q", method, h, got)
}
}
}
}
// The creation request carries the upload metadata; on the hub path it must
// omit directory/provider/cloudkey (Hub resolves those) but include
// filename/device/capture. The filename header is also set on create.
posts := srv.requestsForMethod(http.MethodPost)
if got := posts[0].header.Get("X-Kerberos-Storage-FileName"); got != fileName {
t.Errorf("POST X-Kerberos-Storage-FileName = %q, want %q", got, fileName)
}
meta := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
for _, omitted := range []string{"directory", "provider", "cloudkey"} {
if _, ok := meta[omitted]; ok {
t.Errorf("hub metadata must omit %q, got %v", omitted, meta)
}
}
if meta["filename"] != fileName {
t.Errorf("hub metadata filename = %q, want %q", meta["filename"], fileName)
}
if meta["device"] != "device-key" {
t.Errorf("hub metadata device = %q, want %q", meta["device"], "device-key")
}
if meta["capture"] != "IPCamera" {
t.Errorf("hub metadata capture = %q, want %q", meta["capture"], "IPCamera")
}
}
func TestUploadHubResumable_Unsupported(t *testing.T) {
srv := newFakeTus()
srv.unsupported = true
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "f.mp4"
withRecording(t, fileName, []byte("hello"))
uploaded, _, supported, _, _ := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
if uploaded {
t.Fatal("expected uploaded=false against a hub without a tus endpoint")
}
if supported {
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
}
}
func TestEncodeTusMetadata(t *testing.T) {
got := encodeTusMetadata(map[string]string{
"b": "2",
"a": "1",
"empty": "",
})
// keys sorted, empty values skipped, values base64-encoded.
want := "a MQ==,b Mg=="
if got != want {
t.Fatalf("encodeTusMetadata = %q, want %q", got, want)
}
}
func TestResolveTusLocation(t *testing.T) {
cases := []struct {
name string
base string
location string
want string
}{
{
name: "absolute path location",
base: "http://host/storage/tus/",
location: "/storage/tus/abc",
want: "http://host/storage/tus/abc",
},
{
name: "absolute url keeps configured host",
base: "http://host/storage/tus/",
location: "http://internal:8080/storage/tus/xyz",
want: "http://host/storage/tus/xyz",
},
{
name: "relative id",
base: "http://host/api/storage/tus/",
location: "abc",
want: "http://host/api/storage/tus/abc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := resolveTusLocation(tc.base, tc.location); got != tc.want {
t.Fatalf("resolveTusLocation(%q, %q) = %q, want %q", tc.base, tc.location, got, tc.want)
}
})
}
}
func TestTusResumeStateRoundTrip(t *testing.T) {
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
defer os.Chdir(old)
path := tusSidecarPath("file.mp4", "primary")
state := tusResumeState{UploadURL: "http://host/storage/tus/abc", VaultURI: "http://host/storage/tus/", Size: 123}
saveTusResumeState(path, state)
if got := loadTusResumeState(path, state.VaultURI); got != state.UploadURL {
t.Fatalf("loadTusResumeState = %q, want %q", got, state.UploadURL)
}
// A mismatched vault URI must not be reused.
if got := loadTusResumeState(path, "http://other/storage/tus/"); got != "" {
t.Fatalf("loadTusResumeState with mismatched vault = %q, want empty", got)
}
}
@@ -2,7 +2,6 @@ package components
import (
"context"
"fmt"
"os"
"strconv"
"sync/atomic"
@@ -10,7 +9,6 @@ import (
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/cloud"
@@ -22,19 +20,12 @@ import (
"github.com/kerberos-io/agent/machinery/src/packets"
routers "github.com/kerberos-io/agent/machinery/src/routers/mqtt"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/kerberos-io/agent/machinery/src/webrtc"
"github.com/tevino/abool"
)
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/components")
func Bootstrap(ctx context.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
func Bootstrap(configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
log.Log.Debug("components.Kerberos.Bootstrap(): bootstrapping the kerberos agent.")
bootstrapContext := context.Background()
_, span := tracer.Start(bootstrapContext, "Bootstrap")
// We will keep track of the Kerberos Agent up time
// This is send to Kerberos Hub in a heartbeat.
uptimeStart := time.Now()
@@ -70,10 +61,8 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
communication.HandleUpload = make(chan string, 1)
communication.HandleHeartBeat = make(chan string, 1)
communication.HandleLiveSD = make(chan int64, 1)
communication.HandleLiveSDHTTP = make(chan int64, 1)
communication.HandleLiveHDKeepalive = make(chan string, 1)
communication.HandleLiveHDPeers = make(chan string, 1)
communication.HandleLiveHLS = make(chan int64, 1)
communication.IsConfiguring = abool.New()
cameraSettings := &models.Camera{}
@@ -89,8 +78,6 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
// Configure a MQTT client which helps for a bi-directional communication
mqttClient := routers.ConfigureMQTT(configDirectory, configuration, communication)
span.End()
// Run the agent and fire up all the other
// goroutines which do image capture, motion detection, onvif, etc.
for {
@@ -127,9 +114,6 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
func RunAgent(configDirectory string, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, uptimeStart time.Time, cameraSettings *models.Camera, captureDevice *capture.Capture) string {
ctx := context.Background()
ctxRunAgent, span := tracer.Start(ctx, "RunAgent")
log.Log.Info("components.Kerberos.RunAgent(): Creating camera and processing threads.")
config := configuration.Config
@@ -140,10 +124,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
rtspUrl := config.Capture.IPCamera.RTSP
rtspClient := captureDevice.SetMainClient(rtspUrl)
if rtspUrl != "" {
err := rtspClient.Connect(ctx, ctxRunAgent)
err := rtspClient.Connect(context.Background())
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP stream: " + err.Error())
rtspClient.Close(ctxRunAgent)
rtspClient.Close()
rtspClient = nil
time.Sleep(time.Second * 3)
return status
@@ -161,7 +145,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
videoStreams, err := rtspClient.GetVideoStreams()
if err != nil || len(videoStreams) == 0 {
log.Log.Error("components.Kerberos.RunAgent(): no video stream found, might be the wrong codec (we only support H264 for the moment)")
rtspClient.Close(ctxRunAgent)
rtspClient.Close()
time.Sleep(time.Second * 3)
return status
}
@@ -177,18 +161,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
configuration.Config.Capture.IPCamera.Width = width
configuration.Config.Capture.IPCamera.Height = height
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
// ResolveBaseDimensions gates the aspect-ratio compute on width/height > 0
// so a not-yet-probed stream can't poison the dimensions and crash resize.
configuration.Config.Capture.IPCamera.BaseWidth, configuration.Config.Capture.IPCamera.BaseHeight =
utils.ResolveBaseDimensions(config.Capture.IPCamera.BaseWidth, config.Capture.IPCamera.BaseHeight, width, height)
// Set the SPS and PPS values in the configuration.
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{videoStream.SPS}
configuration.Config.Capture.IPCamera.PPSNALUs = [][]byte{videoStream.PPS}
configuration.Config.Capture.IPCamera.VPSNALUs = [][]byte{videoStream.VPS}
// Define queues for the main and sub stream.
var queue *packets.Queue
var subQueue *packets.Queue
@@ -210,7 +182,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
rtspSubClient := captureDevice.SetSubClient(subRtspUrl)
captureDevice.RTSPSubClient = rtspSubClient
err := rtspSubClient.Connect(ctx, ctxRunAgent)
err := rtspSubClient.Connect(context.Background())
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP sub stream: " + err.Error())
time.Sleep(time.Second * 3)
@@ -222,7 +194,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
videoSubStreams, err = rtspSubClient.GetVideoStreams()
if err != nil || len(videoSubStreams) == 0 {
log.Log.Error("components.Kerberos.RunAgent(): no video sub stream found, might be the wrong codec (we only support H264 for the moment)")
rtspSubClient.Close(ctxRunAgent)
rtspSubClient.Close()
time.Sleep(time.Second * 3)
return status
}
@@ -236,11 +208,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// Set config values as well
configuration.Config.Capture.IPCamera.SubWidth = width
configuration.Config.Capture.IPCamera.SubHeight = height
// If we have a substream, we need to set the width and height of the substream. (so we will override above information)
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
configuration.Config.Capture.IPCamera.BaseWidth, configuration.Config.Capture.IPCamera.BaseHeight =
utils.ResolveBaseDimensions(config.Capture.IPCamera.BaseWidth, config.Capture.IPCamera.BaseHeight, width, height)
}
// We are creating a queue to store the RTSP frames in, these frames will be
@@ -250,28 +217,28 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// Set the maximum GOP count, this is used to determine the pre-recording time.
log.Log.Info("components.Kerberos.RunAgent(): SetMaxGopCount was set with: " + strconv.Itoa(int(config.Capture.PreRecording)+1))
queue.SetMaxGopCount(1) // We will adjust this later on, when we have the GOP size.
queue.SetMaxGopCount(int(config.Capture.PreRecording) + 1) // GOP time frame is set to prerecording (we'll add 2 gops to leave some room).
queue.WriteHeader(videoStreams)
go rtspClient.Start(ctx, "main", queue, configuration, communication)
go rtspClient.Start(context.Background(), "main", queue, configuration, communication)
// Main stream is connected and ready to go.
communication.MainStreamConnected = true
// Try to create backchannel
rtspBackChannelClient := captureDevice.SetBackChannelClient(rtspUrl)
err = rtspBackChannelClient.ConnectBackChannel(ctx, ctxRunAgent)
err = rtspBackChannelClient.ConnectBackChannel(context.Background())
if err == nil {
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP backchannel stream: " + rtspUrl)
go rtspBackChannelClient.StartBackChannel(ctx, ctxRunAgent)
go rtspBackChannelClient.StartBackChannel(context.Background())
}
rtspSubClient := captureDevice.RTSPSubClient
if subStreamEnabled && rtspSubClient != nil {
subQueue = packets.NewQueue()
communication.SubQueue = subQueue
subQueue.SetMaxGopCount(1) // GOP time frame is set to 1 for motion detection and livestreaming.
subQueue.SetMaxGopCount(3) // GOP time frame is set to prerecording (we'll add 2 gops to leave some room).
subQueue.WriteHeader(videoSubStreams)
go rtspSubClient.Start(ctx, "sub", subQueue, configuration, communication)
go rtspSubClient.Start(context.Background(), "sub", subQueue, configuration, communication)
// Sub stream is connected and ready to go.
communication.SubStreamConnected = true
@@ -286,20 +253,8 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
go cloud.HandleLiveStreamSD(livestreamCursor, configuration, communication, mqttClient, rtspClient)
}
// Handle livestream HLS (adaptive segments over HTTP via hub-api -> vault).
// Uses the sub stream when available (lower bitrate, browser-friendly), else
// the main stream. Like SD it is viewer-keepalive gated and produces no
// traffic while nobody is watching.
if subStreamEnabled {
livestreamHLSCursor := subQueue.Latest()
go cloud.HandleLiveStreamHLS(livestreamHLSCursor, configuration, communication, mqttClient, rtspSubClient)
} else {
livestreamHLSCursor := queue.Latest()
go cloud.HandleLiveStreamHLS(livestreamHLSCursor, configuration, communication, mqttClient, rtspClient)
}
// Handle livestream HD (high resolution over WEBRTC)
communication.HandleLiveHDHandshake = make(chan models.LiveHDHandshake, 100)
communication.HandleLiveHDHandshake = make(chan models.RequestHDStreamPayload, 1)
if subStreamEnabled {
livestreamHDCursor := subQueue.Latest()
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, rtspSubClient)
@@ -312,7 +267,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient)
// Handle processing of motion
communication.HandleMotion = make(chan models.MotionDataPartial, 10)
communication.HandleMotion = make(chan models.MotionDataPartial, 1)
if subStreamEnabled {
motionCursor := subQueue.Latest()
go computervision.ProcessMotion(motionCursor, configuration, communication, mqttClient, rtspSubClient)
@@ -334,10 +289,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
go cloud.HandleUpload(configDirectory, configuration, communication)
// Handle ONVIF actions
communication.HandleONVIF = make(chan models.OnvifAction, 10)
communication.HandleONVIF = make(chan models.OnvifAction, 1)
go onvif.HandleONVIFActions(configuration, communication)
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
communication.HandleAudio = make(chan models.AudioDataPartial, 1)
if rtspBackChannelClient.HasBackChannel {
communication.HasBackChannel = true
go WriteAudioToBackchannel(communication, rtspBackChannelClient)
@@ -346,9 +301,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// If we reach this point, we have a working RTSP connection.
communication.CameraConnected = true
// Otel end span
span.End()
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// This will go into a blocking state, once this channel is triggered
// the agent will cleanup and restart.
@@ -392,7 +344,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
time.Sleep(time.Second * 3)
err = rtspClient.Close(ctxRunAgent)
err = rtspClient.Close()
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP stream: " + err.Error())
time.Sleep(time.Second * 3)
@@ -404,7 +356,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
communication.Queue = nil
if subStreamEnabled {
err = rtspSubClient.Close(ctxRunAgent)
err = rtspSubClient.Close()
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP sub stream: " + err.Error())
time.Sleep(time.Second * 3)
@@ -415,7 +367,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
communication.SubQueue = nil
}
err = rtspBackChannelClient.Close(ctxRunAgent)
err = rtspBackChannelClient.Close()
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP backchannel stream: " + err.Error())
}
@@ -441,37 +393,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
return status
}
// packetAgeString returns a human readable age (e.g. "12s") since the last
// packet timestamp stored in the given atomic.Value, or "unknown" when no
// packet has been received yet. Used to add context to watchdog restart logs.
func packetAgeString(timer *atomic.Value) string {
if timer == nil {
return "unknown"
}
// atomic.Value panics on Load() if it was never initialized via Store().
var v any
func() {
defer func() {
if recover() != nil {
v = nil
}
}()
v = timer.Load()
}()
last, ok := v.(int64)
if !ok || last == 0 {
return "unknown"
}
age := time.Now().Unix() - last
if age < 0 {
age = 0
}
return strconv.FormatInt(age, 10) + "s"
}
// ControlAgent will check if the camera is still connected, if not it will restart the agent.
// In the other thread we are keeping track of the number of packets received, and particular the keyframe packets.
// Once we are not receiving any packets anymore, we will restart the agent.
@@ -506,8 +427,7 @@ func ControlAgent(communication *models.Communication) {
// After 15 seconds without activity this is thrown..
if occurence == 3 {
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream. (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
packetsR, packetAgeString(communication.LastPacketTimer), communication.IsConfiguring.IsSet()))
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream.")
select {
case communication.HandleBootstrap <- "restart":
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
@@ -534,8 +454,6 @@ func ControlAgent(communication *models.Communication) {
// After 15 seconds without activity this is thrown..
if occurenceSub == 3 {
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): substream stalled (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
packetsSubR, packetAgeString(communication.LastPacketTimerSub), communication.IsConfiguring.IsSet()))
select {
case communication.HandleBootstrap <- "restart":
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
@@ -582,11 +500,6 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
// The total number of recordings stored in the directory.
recordingDirectory := configDirectory + "/data/recordings"
numberOfRecordings := utils.NumberOfMP4sInDirectory(recordingDirectory)
activeWebRTCReaders := webrtc.GetActivePeerConnectionCount()
pendingWebRTCHandshakes := 0
if communication.HandleLiveHDHandshake != nil {
pendingWebRTCHandshakes = len(communication.HandleLiveHDHandshake)
}
// All days stored in this agent.
days := []string{}
@@ -609,8 +522,6 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
"cameraOnline": cameraIsOnline,
"cloudOnline": cloudIsOnline,
"numberOfRecordings": numberOfRecordings,
"webrtcReaders": activeWebRTCReaders,
"webrtcPending": pendingWebRTCHandshakes,
"days": days,
"latestEvents": latestEvents,
})
@@ -744,7 +655,7 @@ func MakeRecording(c *gin.Context, communication *models.Communication) {
// @Success 200
func GetSnapshotBase64(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
// We'll try to get a snapshot from the camera.
base64Image := capture.Base64Image(captureDevice, communication, configuration)
base64Image := capture.Base64Image(captureDevice, communication)
if base64Image != "" {
communication.Image = base64Image
}
@@ -766,8 +677,7 @@ func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuratio
image := capture.JpegImage(captureDevice, communication)
// encode image to jpeg
imageResized, _ := utils.ResizeImage(&image, uint(configuration.Config.Capture.IPCamera.BaseWidth), uint(configuration.Config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
bytes, _ := utils.ImageToBytes(&image)
// Return image/jpeg
c.Data(200, "image/jpeg", bytes)
@@ -781,24 +691,10 @@ func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuratio
// @Description Get the current configuration.
// @Success 200
func GetConfig(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
// We'll try to get a fresh snapshot from the camera. Capturing a snapshot
// reads a keyframe from the live stream, which blocks until one arrives.
// When the camera is offline or the stream is stalled (no packets being
// received) this would block the /config endpoint indefinitely, making the
// agent appear unreachable even though its HTTP server is healthy. We
// therefore bound the snapshot fetch with a short timeout and fall back to
// the last cached snapshot, so /config always responds promptly.
snapshot := make(chan string, 1)
go func() {
snapshot <- capture.Base64Image(captureDevice, communication, configuration)
}()
select {
case base64Image := <-snapshot:
if base64Image != "" {
communication.Image = base64Image
}
case <-time.After(2 * time.Second):
log.Log.Info("components.Kerberos.GetConfig(): snapshot timed out (stream stalled or camera offline), returning configuration with the last cached snapshot.")
// We'll try to get a snapshot from the camera.
base64Image := capture.Base64Image(captureDevice, communication)
if base64Image != "" {
communication.Image = base64Image
}
c.JSON(200, gin.H{
+10 -64
View File
@@ -21,7 +21,6 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
var isPixelChangeThresholdReached = false
var changesToReturn = 0
var motionRectangle models.MotionRectangle
pixelThreshold := config.Capture.PixelChangeThreshold
// Might not be set in the config file, so set it to 150
@@ -63,34 +62,16 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
}
// A user might have set the base width and height for the IPCamera.
// This means also the polygon coordinates are set to a specific width and height (which might be different than the actual packets
// received from the IPCamera). So we will resize the polygon coordinates to the base width and height.
baseWidthRatio := 1.0
baseHeightRatio := 1.0
baseWidth := config.Capture.IPCamera.BaseWidth
baseHeight := config.Capture.IPCamera.BaseHeight
if baseWidth > 0 && baseHeight > 0 {
// We'll get the first image to calculate the ratio
img := imageArray[0]
if img != nil {
bounds := img.Bounds()
rows := bounds.Dy()
cols := bounds.Dx()
baseWidthRatio = float64(cols) / float64(baseWidth)
baseHeightRatio = float64(rows) / float64(baseHeight)
}
}
// Calculate mask
var polyObjects []geo.Polygon
if config.Region != nil {
for _, polygon := range config.Region.Polygon {
coords := polygon.Coordinates
poly := geo.Polygon{}
for _, c := range coords {
x := c.X * baseWidthRatio
y := c.Y * baseHeightRatio
x := c.X
y := c.Y
p := geo.NewPoint(x, y)
if !poly.Contains(p) {
poly.Add(p)
@@ -151,7 +132,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
if detectMotion {
// Remember additional information about the result of findmotion
isPixelChangeThresholdReached, changesToReturn, motionRectangle = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
isPixelChangeThresholdReached, changesToReturn = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
if isPixelChangeThresholdReached {
// If offline mode is disabled, send a message to the hub
@@ -169,7 +150,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("computervision.main.ProcessMotion(): failed to package MQTT message: " + err.Error())
}
@@ -183,7 +164,6 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
dataToPass := models.MotionDataPartial{
Timestamp: time.Now().Unix(),
NumberOfChanges: changesToReturn,
Rectangle: motionRectangle,
}
communication.HandleMotion <- dataToPass //Save data to the channel
}
@@ -205,58 +185,24 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
log.Log.Debug("computervision.main.ProcessMotion(): stop the motion detection.")
}
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle) {
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int) {
image1 := imageArray[0]
image2 := imageArray[1]
image3 := imageArray[2]
threshold := 60
changes, motionRectangle := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
return changes > pixelChangeThreshold, changes, motionRectangle
changes := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
return changes > pixelChangeThreshold, changes
}
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle) {
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) int {
changes := 0
var pixelList [][]int
for i := 0; i < len(coordinatesToCheck); i++ {
pixel := coordinatesToCheck[i]
diff := int(img3.Pix[pixel]) - int(img1.Pix[pixel])
diff2 := int(img3.Pix[pixel]) - int(img2.Pix[pixel])
if (diff > threshold || diff < -threshold) && (diff2 > threshold || diff2 < -threshold) {
changes++
// Store the pixel coordinates where the change is detected
pixelList = append(pixelList, []int{pixel % img1.Bounds().Dx(), pixel / img1.Bounds().Dx()})
}
}
// Calculate rectangle of pixelList (startX, startY, endX, endY)
var motionRectangle models.MotionRectangle
if len(pixelList) > 0 {
startX := pixelList[0][0]
startY := pixelList[0][1]
endX := startX
endY := startY
for _, pixel := range pixelList {
if pixel[0] < startX {
startX = pixel[0]
}
if pixel[1] < startY {
startY = pixel[1]
}
if pixel[0] > endX {
endX = pixel[0]
}
if pixel[1] > endY {
endY = pixel[1]
}
}
log.Log.Debugf("Rectangle of changes detected: startX: %d, startY: %d, endX: %d, endY: %d", startX, startY, endX, endY)
motionRectangle = models.MotionRectangle{
X: startX,
Y: startY,
Width: endX - startX,
Height: endY - startY,
}
log.Log.Debugf("Motion rectangle: %+v", motionRectangle)
}
return changes, motionRectangle
return changes
}
+8 -187
View File
@@ -135,12 +135,6 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
conjungo.Merge(&kerberosvault, configuration.CustomConfig.KStorage, opts)
configuration.Config.KStorage = &kerberosvault
// Merge Secondary Kerberos Vault settings
var kerberosvaultSecondary models.KStorage
conjungo.Merge(&kerberosvaultSecondary, configuration.GlobalConfig.KStorageSecondary, opts)
conjungo.Merge(&kerberosvaultSecondary, configuration.CustomConfig.KStorageSecondary, opts)
configuration.Config.KStorageSecondary = &kerberosvaultSecondary
// Merge Kerberos S3 settings
var s3 models.S3
conjungo.Merge(&s3, configuration.GlobalConfig.S3, opts)
@@ -189,120 +183,19 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
}
jsonFile.Close()
}
}
return
}
// OverrideWithEnvironmentVariables builds the effective configuration from the
// environment variables.
//
// In ConfigMap/standalone mode (DEPLOYMENT empty or "agent") the global
// configuration is delivered as GLOBAL_AGENT_* environment variables and the
// per-agent configuration as AGENT_* environment variables. We parse them into
// the separate global and custom configurations and build the effective
// configuration as "global overridden by custom", mirroring the MongoDB-backed
// factory behaviour. This keeps the global and per-agent (custom) configuration
// separated so the factory edit page can distinguish inherited global settings
// from per-agent overrides.
// This function will override the configuration with environment variables.
func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
if os.Getenv("DEPLOYMENT") == "" || os.Getenv("DEPLOYMENT") == "agent" {
initConfigPointers(&configuration.Config)
// Parse the global configuration from the GLOBAL_AGENT_* variables.
globalWrap := &models.Configuration{Config: configuration.GlobalConfig}
initConfigPointers(&globalWrap.Config)
applyAgentEnvVars(globalWrap, "GLOBAL_", false)
configuration.GlobalConfig = globalWrap.Config
// Parse the per-agent (custom) configuration from the AGENT_* variables.
// In ConfigMap mode the per-agent overrides are delivered exclusively
// through AGENT_* environment variables, so we must start from an empty
// configuration rather than the bundled config.json that OpenConfig loaded
// into CustomConfig. Otherwise defaults from that file (e.g. cloud="s3")
// would leak into the custom config and be mistaken for explicit per-agent
// overrides, hiding inherited global settings (the factory edit page would
// show the local default instead of the inherited global persistence).
customBase := configuration.CustomConfig
if isConfigMapMode() {
customBase = models.Config{}
}
customWrap := &models.Configuration{Config: customBase}
initConfigPointers(&customWrap.Config)
applyAgentEnvVars(customWrap, "", false)
configuration.CustomConfig = customWrap.Config
// Build the effective configuration: global base, then per-agent
// overrides on top. Defaults (e.g. signing) are applied on the last
// pass only.
applyAgentEnvVars(configuration, "GLOBAL_", false)
applyAgentEnvVars(configuration, "", true)
} else {
// Factory/MongoDB mode: the global and custom configurations are already
// loaded and merged from MongoDB; we only override the effective
// configuration with any AGENT_* environment variables.
applyAgentEnvVars(configuration, "", true)
}
}
// isConfigMapMode reports whether the agent is running in ConfigMap mode, i.e.
// whether a global configuration layer is delivered separately through
// GLOBAL_AGENT_* environment variables. In that mode the per-agent (custom)
// configuration must be built solely from the AGENT_* overrides and must not be
// seeded with the bundled config.json defaults, so that inherited global
// settings remain distinguishable from explicit per-agent overrides.
func isConfigMapMode() bool {
for _, env := range os.Environ() {
if strings.HasPrefix(env, "GLOBAL_AGENT_") {
return true
}
}
return false
}
// initConfigPointers ensures all pointer sub-structs are non-nil so that the
// environment-variable parsing can assign into them without dereferencing a nil
// pointer.
func initConfigPointers(config *models.Config) {
if config.KStorage == nil {
config.KStorage = &models.KStorage{}
}
if config.KStorageSecondary == nil {
config.KStorageSecondary = &models.KStorage{}
}
if config.S3 == nil {
config.S3 = &models.S3{}
}
if config.Encryption == nil {
config.Encryption = &models.Encryption{}
}
if config.Signing == nil {
config.Signing = &models.Signing{}
}
if config.Dropbox == nil {
config.Dropbox = &models.Dropbox{}
}
if config.Region == nil {
config.Region = &models.Region{}
}
}
// applyAgentEnvVars applies the AGENT_* environment variables (optionally
// carrying the given prefix, e.g. "GLOBAL_") onto configuration.Config. When
// applyDefaults is true, defaults (such as the signing key) are applied after
// parsing; this should only be done for the effective configuration.
func applyAgentEnvVars(configuration *models.Configuration, prefix string, applyDefaults bool) {
environmentVariables := os.Environ()
// Initialize the configuration for some new fields.
if configuration.Config.KStorageSecondary == nil {
configuration.Config.KStorageSecondary = &models.KStorage{}
}
for _, env := range environmentVariables {
fullKey := strings.SplitN(env, "=", 2)[0]
if strings.HasPrefix(fullKey, prefix+"AGENT_") && !(prefix == "" && strings.HasPrefix(fullKey, "GLOBAL_AGENT_")) {
key := strings.TrimPrefix(fullKey, prefix)
value := os.Getenv(fullKey)
if strings.Contains(env, "AGENT_") {
key := strings.Split(env, "=")[0]
value := os.Getenv(key)
switch key {
/* General configuration */
@@ -336,15 +229,7 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.Capture.IPCamera.SubRTSP = value
break
/* Base width and height for the liveview and motion regions */
case "AGENT_CAPTURE_IPCAMERA_BASE_WIDTH":
configuration.Config.Capture.IPCamera.BaseWidth, _ = strconv.Atoi(value)
break
case "AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT":
configuration.Config.Capture.IPCamera.BaseHeight, _ = strconv.Atoi(value)
break
/* ONVIF connnection settings */
/* ONVIF connnection settings */
case "AGENT_CAPTURE_IPCAMERA_ONVIF":
configuration.Config.Capture.IPCamera.ONVIF = value
break
@@ -497,11 +382,6 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.MQTTPassword = value
break
/* MQTT chunking of low-resolution images into multiple messages */
case "AGENT_CAPTURE_LIVEVIEW_CHUNKING":
configuration.Config.Capture.LiveviewChunking = value
break
/* Real-time streaming of keyframes to a MQTT topic */
case "AGENT_REALTIME_PROCESSING":
configuration.Config.RealtimeProcessing = value
@@ -556,7 +436,7 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.S3.Region = value
break
/* When storing in a Vault */
/* When storing in a Kerberos Vault */
case "AGENT_KERBEROSVAULT_URI":
configuration.Config.KStorage.URI = value
break
@@ -573,37 +453,6 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.KStorage.Directory = value
break
/* Retry policy and timeout */
case "AGENT_KERBEROSVAULT_MAX_RETRIES":
maxRetries, err := strconv.Atoi(value)
if err == nil {
configuration.Config.KStorage.MaxRetries = maxRetries
}
break
case "AGENT_KERBEROSVAULT_TIMEOUT":
timeout, err := strconv.Atoi(value)
if err == nil {
configuration.Config.KStorage.Timeout = timeout
}
break
/* When storing in a secondary Vault */
case "AGENT_KERBEROSVAULT_SECONDARY_URI":
configuration.Config.KStorageSecondary.URI = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY":
configuration.Config.KStorageSecondary.AccessKey = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY":
configuration.Config.KStorageSecondary.SecretAccessKey = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_PROVIDER":
configuration.Config.KStorageSecondary.Provider = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY":
configuration.Config.KStorageSecondary.Directory = value
break
/* When storing in dropbox */
case "AGENT_DROPBOX_ACCESS_TOKEN":
configuration.Config.Dropbox.AccessToken = value
@@ -629,33 +478,9 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
case "AGENT_ENCRYPTION_SYMMETRIC_KEY":
configuration.Config.Encryption.SymmetricKey = value
break
/* When signing is enabled */
case "AGENT_SIGNING":
configuration.Config.Signing.Enabled = value
break
case "AGENT_SIGNING_PRIVATE_KEY":
signingPrivateKey := strings.ReplaceAll(value, "\\n", "\n")
configuration.Config.Signing.PrivateKey = signingPrivateKey
break
}
}
}
// Signing is a new feature, so if empty we set default values. Only applied
// for the effective configuration (applyDefaults), not for the separate
// global/custom views.
if applyDefaults && (configuration.Config.Signing == nil || configuration.Config.Signing.PrivateKey == "") {
configuration.Config.Signing = &models.Signing{
Enabled: "true",
PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDoSxjyw08lRxF4Yoqmcaewjq3XjB55dMy4tlN5MGLdr8aAPuNR9Mwh3jlh1bDpwQXNgZkHDV/q9bpdPGGi7SQo2xw+rDuo5Y1f3wdzz+iuCTPbzoGFalE+1PZlU5TEtUtlbt7MRc4pxTaLP3u0P3EtW3KnzcUarcJWZJYxzv7gqVNCA/47BN+1ptqjwz3LAlah5yaftEvVjkaANOsafUswbS4VT44XfSlbKgebORCKDuNgQiyhuV5gU+J0TOaqRWwwMAWV0UoScyJLfhHRBCrUwrCUTwqH9jfkB7pgRFsYoZJd4MKMeHJjFSum+QXCBqInSnwu8c2kJChiLMWqJ+mhpTdfUAmSkeUSStfbbcavIPbDABvMgzOcmYMIVXXe57twU0xdu3AqWLtc9kw1BkUgZblM9pSSpYrIDheEyMs2/hiLgXsIaM0nVQtqwrA7rbeEGuPblzA6hvHgwN9K6HaBqdlGSlpYZ0v3SWIMwmxRB+kIojlyuggm8Qa4mqL97GFDGl6gOBGlNUFTBUVEa3EaJ7NJpGobRGsh/9dXzcW4aYmT9WxlzTlIKksI1ro6KdRfuVWfEs4AnG8bVEJmofK8EUrueB9IdXlcJZB49xolnOZPFohtMe/0U7evQOQP3sZnX+KotCsE7OXJvL09oF58JKoqmK9lPp0+pFBU4g6NjQIDAQABAoICAA+RSWph1t+q5R3nxUxFTYMrhv5IjQe2mDxJpF3B409zolC9OHxgGUisobTY3pBqs0DtKbxUeH2A0ehUH/axEosWHcz3cmIbgxHE9kdlJ9B3Lmss6j/uw+PWutu1sgm5phaIFIvuNNRWhPB6yXUwU4sLRat1+Z9vTmIQiKdtLIrtJz/n2VDvrJxn1N+yAsE20fnrksFKyZuxVsJaZPiX/t5Yv1/z0LjFjVoL7GUA5/Si7csN4ftqEhUrkNr2BvcZlTyffrF4lZCXrtl76RNUaxhqIu3H0gFbV2UfBpuckkfAhNRpXJ4iFSxm4nQbk4ojV8+l21RFOBeDN2Z7Ocu6auP5MnzpopR66vmDCmPoid498VGgDzFQEVkOar8WAa4v9h85QgLKrth6FunmaWJUT6OggQD3yY58GSwp5+ARMETMBP2x6Eld+PGgqoJvPT1+l/e9gOw7/SJ+Wz6hRXZAm/eiXMppHtB7sfea5rscNanPjJkK9NvPM0MX9cq/iA6QjXuETkMbubjo+Cxk3ydZiIQmWQDAx/OgxTyHbeRCVhLPcAphX0clykCuHZpI9Mvvj643/LoE0mjTByWJXf/WuGJA8ElHkjSdokVJ7jumz8OZZHfq0+V7+la2opsObeQANHW5MLWrnHlRVzTGV0IRZDXh7h1ptUJ4ubdvw/GJ2NeTAoIBAQD0lXXdjYKWC4uZ4YlgydP8b1CGda9cBV5RcPt7q9Ya1R2E4ieYyohmzltopvdaOXdsTZzhtdzOzKF+2qNcbBKhBTleYZ8GN5RKbo7HwXWpzfCTjseKHOD/QPwvBKXzLVWNtXn1NrLR79Rv0wbkYF6DtoqpEPf5kMs4bx79yW+mz8FUgdEeMjKphx6Jd5RYlTUxS64K6bnK7gjHNCF2cwdxsh4B6EB649GKeNz4JXi+oQBmOcX5ncXnkJrbju+IjtCkQ40HINVNdX7XeEaaw6KGaImVjw61toPUuDaioYUojufayoyXaUJnDbHQ2tNekEpq5iwnenZCbUKWmSeRe7dLAoIBAQDzIscYujsrmPxiTj2prhG0v36NRNP99mShnnJGowiIs+UBS0EMdOmBFa2sC9uFs/VnreQNYPDJdfr7O5VK9kfbH/PSiiKJ+wVebfdAlWkJYH27JN2Kl2l/OsvRVelNvF3BWIYF46qzGxIM0axaz3T2ZAJ9SrUgeAYhak6uyM4fbexEWXxDgPGu6C0jB6IAzmHJnnh+j5+4ZXqjVyUxBYtUsWXF/TXomVcT9jxj7aUmS2/Us0XTVOVNpALqqYcekrzsX/wX0OEi5HkivYXHcNaDHx3NuUf6KdYof5DwPUM76qe+5/kWlSIHP3M6rIFK3pYFUnkHn2E8jNWcO97Aio+HAoIBAA+bcff/TbPxbKkXIUMR3fsfx02tONFwbkJYKVQM9Q6lRsrx+4Dee7HDvUWCUgpp3FsG4NnuVvbDTBLiNMZzBwVLZgvFwvYMmePeBjJs/+sj/xQLamQ/z4O6S91cOJK589mlGPEy2lpXKYExQCFWnPFetp5vPMOqH62sOZgMQJmubDHOTt/UaDM1Mhenj8nPS6OnpqV/oKF4awr7Ip+CW5k/unZ4sZSl8PsbF06mZXwUngfn6+Av1y8dpSQZjONz6ZBx1w/7YmEc/EkXnbnGfhqBlTX7+P5TdTofvyzFjc+2vsjRYANRbjFRSGWBcTd5kaYcpfim8eDvQ+6EO2gnMt0CggEAH2ln1Y8B5AEQ4lZ/avOdP//ZhsDUrqPtnl/NHckkahzrwj4JumVEYbP+SxMBGoYEd4+kvgG/OhfvBBRPlm65G9tF8fZ8vdzbdba5UfO7rUV1GP+LS8OCErjy6imySaPDbR5Vul8Oh7NAor1YCidxUf/bvnovanF3QUvtvHEfCDp4YuA4yLPZBaLjaforePUw9w5tPNSravRZYs74dBvmQ1vj7S9ojpN5B5AxfyuNwaPPX+iFZec69MvywISEe3Ozysof1Kfc3lgsOkvIA9tVK32SqSh93xkWnQbWH+OaUxxe7bAko0FDMzKEXZk53wVg1nEwR8bUljEPy+6EOdXs8wKCAQEAsEOWYMY5m7HkeG2XTTvX7ECmmdGl/c4ZDVwzB4IPxqUG7XfLmtsON8YoKOEUpJoc4ANafLXzmU+esUGbH4Ph22IWgP9jzws7jxaN/Zoku64qrSjgEZFTRIpKyhFk/ImWbS9laBW4l+m0tqTTRqoE0QEJf/2uv/04q65zrA70X9z2+KTrAtqOiRQPWl/IxRe9U4OEeGL+oD+YlXKCDsnJ3rwUIOZgJx0HWZg7K35DKwqs1nVi56FBdljiTRKAjVLRedjgDCSfGS1yUZ3krHzpaPt1qgnT3rdtYcIdbYDr66V2/gEEaz6XMGHuTk/ewjzUJxq9UTVeXOCbkRPXgVJg1w==\n-----END PRIVATE KEY-----",
}
}
// When the agent is configured through environment variables the global and
// custom configurations were already parsed separately (see
// OverrideWithEnvironmentVariables), so there is no need to mirror the
// effective configuration into CustomConfig anymore.
}
func SaveConfig(configDirectory string, config models.Config, configuration *models.Configuration, communication *models.Communication) error {
@@ -695,10 +520,6 @@ func StoreConfig(configDirectory string, config models.Config) error {
config.Encryption.PrivateKey = encryptionPrivateKey
}
// Reset the basewidth and baseheight
config.Capture.IPCamera.BaseWidth = 0
config.Capture.IPCamera.BaseHeight = 0
// Save into database
if os.Getenv("DEPLOYMENT") == "factory" || os.Getenv("MACHINERY_ENVIRONMENT") == "kubernetes" {
// Write to mongodb
-20
View File
@@ -118,16 +118,6 @@ func (self *Logging) Info(sentence string) {
}
}
func (self *Logging) Infof(format string, args ...interface{}) {
switch self.Logger {
case "go-logging":
gologging.Infof(format, args...)
case "logrus":
logrus.Infof(format, args...)
default:
}
}
func (self *Logging) Warning(sentence string) {
switch self.Logger {
case "go-logging":
@@ -148,16 +138,6 @@ func (self *Logging) Debug(sentence string) {
}
}
func (self *Logging) Debugf(format string, args ...interface{}) {
switch self.Logger {
case "go-logging":
gologging.Debugf(format, args...)
case "logrus":
logrus.Debugf(format, args...)
default:
}
}
func (self *Logging) Error(sentence string) {
switch self.Logger {
case "go-logging":
@@ -8,17 +8,6 @@ import (
"github.com/tevino/abool"
)
type LiveHDSignalingCallbacks struct {
SendAnswer func(sessionID string, sdp string) error
SendCandidate func(sessionID string, candidate string) error
SendError func(sessionID string, message string) error
}
type LiveHDHandshake struct {
Payload RequestHDStreamPayload
Signaling *LiveHDSignalingCallbacks
}
// The communication struct that is managing
// all the communication between the different goroutines.
type Communication struct {
@@ -37,11 +26,9 @@ type Communication struct {
HandleUpload chan string
HandleHeartBeat chan string
HandleLiveSD chan int64
HandleLiveSDHTTP chan int64
HandleLiveHDKeepalive chan string
HandleLiveHDHandshake chan LiveHDHandshake
HandleLiveHDHandshake chan RequestHDStreamPayload
HandleLiveHDPeers chan string
HandleLiveHLS chan int64
HandleONVIF chan OnvifAction
IsConfiguring *abool.AtomicBool
Queue *packets.Queue
@@ -28,7 +28,6 @@ type Config struct {
Cloud string `json:"cloud" bson:"cloud"`
S3 *S3 `json:"s3,omitempty" bson:"s3,omitempty"`
KStorage *KStorage `json:"kstorage,omitempty" bson:"kstorage,omitempty"`
KStorageSecondary *KStorage `json:"kstorage_secondary,omitempty" bson:"kstorage_secondary,omitempty"`
Dropbox *Dropbox `json:"dropbox,omitempty" bson:"dropbox,omitempty"`
MQTTURI string `json:"mqtturi" bson:"mqtturi,omitempty"`
MQTTUsername string `json:"mqtt_username" bson:"mqtt_username"`
@@ -46,7 +45,6 @@ type Config struct {
HubSite string `json:"hub_site" bson:"hub_site"`
ConditionURI string `json:"condition_uri" bson:"condition_uri"`
Encryption *Encryption `json:"encryption,omitempty" bson:"encryption,omitempty"`
Signing *Signing `json:"signing,omitempty" bson:"signing,omitempty"`
RealtimeProcessing string `json:"realtimeprocessing,omitempty" bson:"realtimeprocessing,omitempty"`
RealtimeProcessingTopic string `json:"realtimeprocessing_topic" bson:"realtimeprocessing_topic"`
}
@@ -62,11 +60,9 @@ type Capture struct {
Snapshots string `json:"snapshots,omitempty"`
Motion string `json:"motion,omitempty"`
Liveview string `json:"liveview,omitempty"`
LiveviewChunking string `json:"liveview_chunking,omitempty" bson:"liveview_chunking,omitempty"`
Continuous string `json:"continuous,omitempty"`
PostRecording int64 `json:"postrecording"`
PreRecording int64 `json:"prerecording"`
GopSize int `json:"gopsize,omitempty" bson:"gopsize,omitempty"` // GOP size in seconds, used for pre-recording
MaxLengthRecording int64 `json:"maxlengthrecording"`
TranscodingWebRTC string `json:"transcodingwebrtc"`
TranscodingResolution int64 `json:"transcodingresolution"`
@@ -79,28 +75,18 @@ type Capture struct {
// IPCamera configuration, such as the RTSP url of the IPCamera and the FPS.
// Also includes ONVIF integration
type IPCamera struct {
RTSP string `json:"rtsp"`
Width int `json:"width"`
Height int `json:"height"`
FPS string `json:"fps"`
SubRTSP string `json:"sub_rtsp"`
SubWidth int `json:"sub_width"`
SubHeight int `json:"sub_height"`
BaseWidth int `json:"base_width"`
BaseHeight int `json:"base_height"`
SubFPS string `json:"sub_fps"`
ONVIF string `json:"onvif,omitempty" bson:"onvif"`
ONVIFXAddr string `json:"onvif_xaddr" bson:"onvif_xaddr"`
ONVIFUsername string `json:"onvif_username" bson:"onvif_username"`
ONVIFPassword string `json:"onvif_password" bson:"onvif_password"`
SPSNALUs [][]byte `json:"sps_nalus,omitempty" bson:"sps_nalus,omitempty"`
PPSNALUs [][]byte `json:"pps_nalus,omitempty" bson:"pps_nalus,omitempty"`
VPSNALUs [][]byte `json:"vps_nalus,omitempty" bson:"vps_nalus,omitempty"`
SampleRate int `json:"sample_rate,omitempty" bson:"sample_rate,omitempty"`
Channels int `json:"channels,omitempty" bson:"channels,omitempty"`
RTSP string `json:"rtsp"`
Width int `json:"width"`
Height int `json:"height"`
FPS string `json:"fps"`
SubRTSP string `json:"sub_rtsp"`
SubWidth int `json:"sub_width"`
SubHeight int `json:"sub_height"`
SubFPS string `json:"sub_fps"`
ONVIF string `json:"onvif,omitempty" bson:"onvif"`
ONVIFXAddr string `json:"onvif_xaddr" bson:"onvif_xaddr"`
ONVIFUsername string `json:"onvif_username" bson:"onvif_username"`
ONVIFPassword string `json:"onvif_password" bson:"onvif_password"`
}
// USBCamera configuration, such as the device path (/dev/video*)
@@ -172,8 +158,6 @@ type KStorage struct {
SecretAccessKey string `json:"secret_access_key,omitempty" bson:"secret_access_key,omitempty"`
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
Directory string `json:"directory,omitempty" bson:"directory,omitempty"`
MaxRetries int `json:"max_retries,omitempty" bson:"max_retries,omitempty"`
Timeout int `json:"timeout,omitempty" bson:"timeout,omitempty"`
}
// Dropbox integration
@@ -190,9 +174,3 @@ type Encryption struct {
PrivateKey string `json:"private_key" bson:"private_key"`
SymmetricKey string `json:"symmetric_key" bson:"symmetric_key"`
}
// Signing
type Signing struct {
Enabled string `json:"enabled" bson:"enabled"`
PrivateKey string `json:"private_key" bson:"private_key"`
}
@@ -132,7 +132,6 @@ type Message struct {
// The payload structure which is used to send over
// and receive messages from the MQTT broker
type Payload struct {
Version string `json:"version"` // Version of the message, e.g. "1.0"
Action string `json:"action"`
DeviceId string `json:"device_id"`
Signature string `json:"signature"`
@@ -171,19 +170,6 @@ type UpdateConfigPayload struct {
// We received a request SD stream request
type RequestSDStreamPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp
// Transport selects how the agent should deliver the preview frames for this
// viewer. "http" asks the agent to POST frames to hub-api (keeping them off
// MQTT); empty/absent means the legacy MQTT image push. Older agents simply
// ignore this unknown field and keep doing MQTT, and older frontends never set
// it — so new/old agents and frontends interoperate in every combination.
Transport string `json:"transport,omitempty"`
}
// We received a live HLS stream request. Like SD it is a simple viewer
// keepalive: the agent owns the live HLS session, so the request only needs to
// signal "a viewer is watching" to keep the segment pipeline alive.
type RequestHLSStreamPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp
}
// We received a request HD stream request
+2 -10
View File
@@ -1,9 +1,8 @@
package models
type MotionDataPartial struct {
Timestamp int64 `json:"timestamp" bson:"timestamp"`
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
Rectangle MotionRectangle `json:"rectangle" bson:"rectangle"`
Timestamp int64 `json:"timestamp" bson:"timestamp"`
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
}
type MotionDataFull struct {
@@ -15,10 +14,3 @@ type MotionDataFull struct {
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
Token int `json:"token" bson:"token"`
}
type MotionRectangle struct {
X int `json:"x" bson:"x"`
Y int `json:"y" bson:"y"`
Width int `json:"width" bson:"width"`
Height int `json:"height" bson:"height"`
}
+1 -3
View File
@@ -14,10 +14,8 @@ type Packet struct {
IsKeyFrame bool // video packet is key frame
Idx int8 // stream index in container format
Codec string // codec name
CompositionTime int64 // composition offset (PTS - DTS) in milliseconds, non-zero for H264/H265 B-frames
CompositionTime int64 // packet presentation time minus decode time for H264 B-Frame
Time int64 // packet decode time
TimeLegacy time.Duration
CurrentTime int64 // current time in milliseconds (UNIX timestamp)
Data []byte // packet data
Gopsize int // size of the GOP
}
-5
View File
@@ -45,11 +45,6 @@ func (self *Queue) SetMaxGopCount(n int) {
return
}
func (self *Queue) GetMaxGopCount() int {
n := self.maxgopcount
return n
}
func (self *Queue) WriteHeader(streams []Stream) error {
self.lock.Lock()
-12
View File
@@ -1,9 +1,6 @@
package packets
type Stream struct {
// The ID of the stream.
Index int `json:"index" bson:"index"`
// The name of the stream.
Name string
@@ -42,13 +39,4 @@ type Stream struct {
// IsBackChannel is true if this stream is a back channel.
IsBackChannel bool
// SampleRate is the sample rate of the audio stream.
SampleRate int
// Channels is the number of audio channels.
Channels int
// GopSize is the size of the GOP (Group of Pictures).
GopSize int
}
+2 -13
View File
@@ -14,14 +14,7 @@ import (
func JWTMiddleWare() jwt.GinJWTMiddleware {
identityKey := "id"
// Allow the JWT signing secret to be configured through an environment
// variable so that tokens issued by another service (e.g. the Kerberos
// Factory) can be validated by the agent. Falls back to the historic
// default to preserve backwards compatibility.
myKey := os.Getenv("AGENT_JWT_SECRET")
if myKey == "" {
myKey = "TOBECHANGED"
}
myKey := "TOBECHANGED"
m := jwt.GinJWTMiddleware{
Realm: "kerberosio",
@@ -113,11 +106,7 @@ func JWTMiddleWare() jwt.GinJWTMiddleware {
// - "query:<name>"
// - "cookie:<name>"
// - "param:<name>"
// X-Authorization is included because requests proxied through the
// Kubernetes apiserver service-proxy have their Authorization header
// consumed by the apiserver; the original bearer token is forwarded in
// the X-Authorization header instead.
TokenLookup: "header: Authorization, header: X-Authorization, query: token, cookie: jwt",
TokenLookup: "header: Authorization, query: token, cookie: jwt",
// TokenLookup: "query:token",
// TokenLookup: "cookie:token",
+72 -74
View File
@@ -15,98 +15,96 @@ import (
func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) *gin.RouterGroup {
r.GET("/ws", func(c *gin.Context) {
websocket.WebsocketHandler(c, configuration, communication, captureDevice)
websocket.WebsocketHandler(c, communication, captureDevice)
})
// This is legacy should be removed in future! Now everything
// lives under the /api prefix.
r.GET("/config", authMiddleware.MiddlewareFunc(), func(c *gin.Context) {
r.GET("/config", func(c *gin.Context) {
components.GetConfig(c, captureDevice, configuration, communication)
})
// This is legacy should be removed in future! Now everything
// lives under the /api prefix.
r.POST("/config", authMiddleware.MiddlewareFunc(), func(c *gin.Context) {
r.POST("/config", func(c *gin.Context) {
components.UpdateConfig(c, configDirectory, configuration, communication)
})
api := r.Group("/api")
{
// Public endpoints (no authentication required)
api.POST("/login", authMiddleware.LoginHandler)
// Apply JWT authentication middleware.
// All routes registered below this line require a valid JWT token.
api.GET("/dashboard", func(c *gin.Context) {
components.GetDashboard(c, configDirectory, configuration, communication)
})
api.POST("/latest-events", func(c *gin.Context) {
components.GetLatestEvents(c, configDirectory, configuration, communication)
})
api.GET("/days", func(c *gin.Context) {
components.GetDays(c, configDirectory, configuration, communication)
})
api.GET("/config", func(c *gin.Context) {
components.GetConfig(c, captureDevice, configuration, communication)
})
api.POST("/config", func(c *gin.Context) {
components.UpdateConfig(c, configDirectory, configuration, communication)
})
// Will verify the current hub settings.
api.POST("/hub/verify", func(c *gin.Context) {
cloud.VerifyHub(c)
})
// Will verify the current persistence settings.
api.POST("/persistence/verify", func(c *gin.Context) {
cloud.VerifyPersistence(c, configDirectory)
})
// Camera specific methods. Doesn't require any authorization.
// These are available for anyone, but require the agent, to reach
// the camera.
api.POST("/camera/restart", func(c *gin.Context) {
components.RestartAgent(c, communication)
})
api.POST("/camera/stop", func(c *gin.Context) {
components.StopAgent(c, communication)
})
api.POST("/camera/record", func(c *gin.Context) {
components.MakeRecording(c, communication)
})
api.GET("/camera/snapshot/jpeg", func(c *gin.Context) {
components.GetSnapshotRaw(c, captureDevice, configuration, communication)
})
api.GET("/camera/snapshot/base64", func(c *gin.Context) {
components.GetSnapshotBase64(c, captureDevice, configuration, communication)
})
// Onvif specific methods. Doesn't require any authorization.
// Will verify the current onvif settings.
api.POST("/camera/onvif/verify", onvif.VerifyOnvifConnection)
api.POST("/camera/onvif/login", LoginToOnvif)
api.POST("/camera/onvif/capabilities", GetOnvifCapabilities)
api.POST("/camera/onvif/presets", GetOnvifPresets)
api.POST("/camera/onvif/gotopreset", GoToOnvifPreset)
api.POST("/camera/onvif/pantilt", DoOnvifPanTilt)
api.POST("/camera/onvif/zoom", DoOnvifZoom)
api.POST("/camera/onvif/inputs", DoGetDigitalInputs)
api.POST("/camera/onvif/outputs", DoGetRelayOutputs)
api.POST("/camera/onvif/outputs/:output", DoTriggerRelayOutput)
api.POST("/camera/verify/:streamType", capture.VerifyCamera)
// Secured endpoints..
api.Use(authMiddleware.MiddlewareFunc())
{
api.GET("/dashboard", func(c *gin.Context) {
components.GetDashboard(c, configDirectory, configuration, communication)
})
api.POST("/latest-events", func(c *gin.Context) {
components.GetLatestEvents(c, configDirectory, configuration, communication)
})
api.GET("/days", func(c *gin.Context) {
components.GetDays(c, configDirectory, configuration, communication)
})
api.GET("/config", func(c *gin.Context) {
components.GetConfig(c, captureDevice, configuration, communication)
})
api.POST("/config", func(c *gin.Context) {
components.UpdateConfig(c, configDirectory, configuration, communication)
})
// Will verify the hub settings.
api.POST("/hub/verify", func(c *gin.Context) {
cloud.VerifyHub(c)
})
// Will verify the persistence settings.
api.POST("/persistence/verify", func(c *gin.Context) {
cloud.VerifyPersistence(c, configDirectory)
})
// Will verify the secondary persistence settings.
api.POST("/persistence/secondary/verify", func(c *gin.Context) {
cloud.VerifySecondaryPersistence(c, configDirectory)
})
// Camera specific methods.
api.POST("/camera/restart", func(c *gin.Context) {
components.RestartAgent(c, communication)
})
api.POST("/camera/stop", func(c *gin.Context) {
components.StopAgent(c, communication)
})
api.POST("/camera/record", func(c *gin.Context) {
components.MakeRecording(c, communication)
})
api.GET("/camera/snapshot/jpeg", func(c *gin.Context) {
components.GetSnapshotRaw(c, captureDevice, configuration, communication)
})
api.GET("/camera/snapshot/base64", func(c *gin.Context) {
components.GetSnapshotBase64(c, captureDevice, configuration, communication)
})
// Onvif specific methods.
api.POST("/camera/onvif/verify", onvif.VerifyOnvifConnection)
api.POST("/camera/onvif/login", LoginToOnvif)
api.POST("/camera/onvif/capabilities", GetOnvifCapabilities)
api.POST("/camera/onvif/presets", GetOnvifPresets)
api.POST("/camera/onvif/gotopreset", GoToOnvifPreset)
api.POST("/camera/onvif/pantilt", DoOnvifPanTilt)
api.POST("/camera/onvif/zoom", DoOnvifZoom)
api.POST("/camera/onvif/inputs", DoGetDigitalInputs)
api.POST("/camera/onvif/outputs", DoGetRelayOutputs)
api.POST("/camera/onvif/outputs/:output", DoTriggerRelayOutput)
api.POST("/camera/verify/:streamType", capture.VerifyCamera)
}
}
return api
+21 -149
View File
@@ -11,7 +11,6 @@ import (
"math/rand"
"strconv"
"strings"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
@@ -91,32 +90,10 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
// Some extra options to make sure the connection behaves
// properly. More information here: github.com/eclipse/paho.mqtt.golang.
//opts.SetCleanSession(true)
opts.SetCleanSession(false)
opts.SetResumeSubs(true)
opts.SetStore(mqtt.NewMemoryStore())
opts.SetCleanSession(true)
opts.SetConnectRetry(true)
opts.SetAutoReconnect(true)
opts.SetConnectRetryInterval(5 * time.Second)
opts.SetMaxReconnectInterval(1 * time.Minute)
opts.SetKeepAlive(30 * time.Second)
opts.SetPingTimeout(10 * time.Second)
opts.SetWriteTimeout(10 * time.Second)
opts.SetOrderMatters(false)
//opts.SetAutoReconnect(true)
opts.SetConnectTimeout(30 * time.Second)
opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
if err != nil {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): MQTT connection lost: " + err.Error())
} else {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): MQTT connection lost")
}
})
opts.SetReconnectingHandler(func(client mqtt.Client, options *mqtt.ClientOptions) {
log.Log.Warning("routers.mqtt.main.ConfigureMQTT(): reconnecting to MQTT broker")
})
opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Log.Info("routers.mqtt.main.ConfigureMQTT(): MQTT session is online")
})
hubKey := ""
// This is the old way ;)
@@ -146,6 +123,7 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
opts.SetClientID(mqttClientID)
log.Log.Info("routers.mqtt.main.ConfigureMQTT(): Set ClientID " + mqttClientID)
rand.Seed(time.Now().UnixNano())
webrtc.CandidateArrays = make(map[string](chan string))
opts.OnConnect = func(c mqtt.Client) {
// We managed to connect to the MQTT broker, hurray!
@@ -156,14 +134,10 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
}
}
mqc := mqtt.NewClient(opts)
if token := mqc.Connect(); token.WaitTimeout(30 * time.Second) {
if token := mqc.Connect(); token.WaitTimeout(3 * time.Second) {
if token.Error() != nil {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): unable to establish mqtt broker connection, error was: " + token.Error().Error())
} else {
log.Log.Info("routers.mqtt.main.ConfigureMQTT(): initial MQTT connection established")
}
} else {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): timed out while establishing mqtt broker connection")
}
return mqc
}
@@ -171,52 +145,12 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
return nil
}
// recentHDSessions tracks recently-seen WebRTC viewer session IDs so we can
// dedupe duplicate request-hd-stream messages without relying on the broker's
// (and the viewer's) wall clock. The viewer's offer-republish loop can fire
// the same request several times for the same session_id while waiting for an
// answer; the broker can also redeliver a message after a reconnect with
// CleanSession=false. In both cases we want to handle the session exactly
// once.
//
// Entries expire after recentHDSessionTTL. The map is small (one entry per
// active viewer over the TTL window) so a periodic sweep is sufficient.
const recentHDSessionTTL = 60 * time.Second
var (
recentHDSessionsMu sync.Mutex
recentHDSessions = make(map[string]time.Time)
)
// markHDSessionSeen returns true if this session_id was already processed
// within the TTL window (i.e. this message should be treated as a duplicate).
// It also opportunistically prunes expired entries.
func markHDSessionSeen(sessionID string) bool {
if sessionID == "" {
return false
}
recentHDSessionsMu.Lock()
defer recentHDSessionsMu.Unlock()
now := time.Now()
// Lazy GC — cheap given the expected map size.
for k, t := range recentHDSessions {
if now.Sub(t) > recentHDSessionTTL {
delete(recentHDSessions, k)
}
}
if _, exists := recentHDSessions[sessionID]; exists {
return true
}
recentHDSessions[sessionID] = now
return false
}
func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
if hubKey == "" {
log.Log.Info("routers.mqtt.main.MQTTListenerHandler(): no hub key provided, not subscribing to kerberos/hub/{hubkey}")
} else {
agentListener := fmt.Sprintf("kerberos/agent/%s", hubKey)
token := mqttClient.Subscribe(agentListener, 1, func(c mqtt.Client, msg mqtt.Message) {
mqttClient.Subscribe(agentListener, 1, func(c mqtt.Client, msg mqtt.Message) {
// Decode the message, we are expecting following format.
// {
@@ -316,15 +250,6 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
// We'll find out which message we received, and act accordingly.
log.Log.Info("routers.mqtt.main.MQTTListenerHandler(): received message with action: " + payload.Action)
// NOTE: We intentionally do NOT discard request-hd-stream /
// receive-hd-candidates messages based on a wall-clock age. The
// viewer and agent clocks can drift (especially on embedded
// devices), which previously caused valid requests to be
// silently dropped and forced the user to refresh the page.
// Duplicate handling for request-hd-stream is done by session_id
// inside HandleRequestHDStream (see markHDSessionSeen).
switch payload.Action {
case "record":
go HandleRecording(mqttClient, hubKey, payload, configuration, communication)
@@ -344,8 +269,6 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
go HandleRequestSDStream(mqttClient, hubKey, payload, configuration, communication)
case "request-hd-stream":
go HandleRequestHDStream(mqttClient, hubKey, payload, configuration, communication)
case "request-hls-stream":
go HandleRequestHLSStream(mqttClient, hubKey, payload, configuration, communication)
case "receive-hd-candidates":
go HandleReceiveHDCandidates(mqttClient, hubKey, payload, configuration, communication)
case "trigger-relay":
@@ -354,16 +277,6 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
}
})
if token.WaitTimeout(10 * time.Second) {
if token.Error() != nil {
log.Log.Error("routers.mqtt.main.MQTTListenerHandler(): failed to subscribe to " + agentListener + ": " + token.Error().Error())
} else {
log.Log.Info("routers.mqtt.main.MQTTListenerHandler(): subscribed to " + agentListener)
}
} else {
log.Log.Error("routers.mqtt.main.MQTTListenerHandler(): timed out while subscribing to " + agentListener)
}
}
}
@@ -428,7 +341,7 @@ func HandleGetPTZPosition(mqttClient mqtt.Client, hubKey string, payload models.
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("routers.mqtt.main.HandlePTZPosition(): something went wrong while sending position to hub: " + string(payload))
}
@@ -476,6 +389,13 @@ func HandleRequestConfig(mqttClient mqtt.Client, hubKey string, payload models.P
// Copy the config, as we don't want to share the encryption part.
deepCopy := configuration.Config
// We need a fix for the width and height if a substream.
// The ROI requires the width and height of the sub stream.
if configuration.Config.Capture.IPCamera.SubRTSP != "" {
deepCopy.Capture.IPCamera.Width = configuration.Config.Capture.IPCamera.SubWidth
deepCopy.Capture.IPCamera.Height = configuration.Config.Capture.IPCamera.SubHeight
}
var configMap map[string]interface{}
inrec, _ := json.Marshal(deepCopy)
json.Unmarshal(inrec, &configMap)
@@ -492,7 +412,7 @@ func HandleRequestConfig(mqttClient mqtt.Client, hubKey string, payload models.P
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("routers.mqtt.main.HandleRequestConfig(): something went wrong while sending config to hub: " + string(payload))
}
@@ -531,7 +451,7 @@ func HandleUpdateConfig(mqttClient mqtt.Client, hubKey string, payload models.Pa
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("routers.mqtt.main.HandleUpdateConfig(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
@@ -550,20 +470,9 @@ func HandleRequestSDStream(mqttClient mqtt.Client, hubKey string, payload models
if requestSDStreamPayload.Timestamp != 0 {
if communication.CameraConnected {
// A viewer that opted into the HTTP transport is signalled on a separate
// channel so the producer ships its frames to hub-api over HTTP instead of
// publishing them over MQTT. Any other (or absent) transport keeps the
// legacy MQTT image push, so older frontends behave exactly as before.
if requestSDStreamPayload.Transport == "http" {
select {
case communication.HandleLiveSDHTTP <- time.Now().Unix():
default:
}
} else {
select {
case communication.HandleLiveSD <- time.Now().Unix():
default:
}
select {
case communication.HandleLiveSD <- time.Now().Unix():
default:
}
log.Log.Info("routers.mqtt.main.HandleRequestSDStream(): received request to livestream.")
} else {
@@ -572,30 +481,6 @@ func HandleRequestSDStream(mqttClient mqtt.Client, hubKey string, payload models
}
}
// HandleRequestHLSStream is the viewer keepalive for live HLS. Like the SD
// stream it simply signals that a viewer is watching; the agent owns the live
// HLS session, so a single non-zero timestamp on the channel keeps the segment
// pipeline alive (see cloud.HandleLiveStreamHLS). Viewers republish this
// periodically; when the keepalives stop, the agent tears the session down.
func HandleRequestHLSStream(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
value := payload.Value
jsonData, _ := json.Marshal(value)
var requestHLSStreamPayload models.RequestHLSStreamPayload
json.Unmarshal(jsonData, &requestHLSStreamPayload)
if requestHLSStreamPayload.Timestamp != 0 {
if communication.CameraConnected {
select {
case communication.HandleLiveHLS <- time.Now().Unix():
default:
}
log.Log.Info("routers.mqtt.main.HandleRequestHLSStream(): received request to livestream over HLS.")
} else {
log.Log.Info("routers.mqtt.main.HandleRequestHLSStream(): received request to livestream over HLS, but camera is not connected.")
}
}
}
func HandleRequestHDStream(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
value := payload.Value
// Convert map[string]interface{} to RequestHDStreamPayload
@@ -605,24 +490,11 @@ func HandleRequestHDStream(mqttClient mqtt.Client, hubKey string, payload models
if requestHDStreamPayload.Timestamp != 0 {
if communication.CameraConnected {
// Dedupe by session_id: the viewer republishes its offer while
// waiting for an answer (and the broker may redeliver), and we
// don't want to spawn multiple peer connections for the same
// browser session.
if markHDSessionSeen(requestHDStreamPayload.SessionID) {
log.Log.Info("routers.mqtt.main.HandleRequestHDStream(): duplicate request for session " +
requestHDStreamPayload.SessionID + ", ignoring")
return
}
// Set the Hub key, so we can send back the answer.
requestHDStreamPayload.HubKey = hubKey
if communication.HandleLiveHDHandshake == nil {
log.Log.Error("routers.mqtt.main.HandleRequestHDStream(): handshake channel is nil, dropping request")
return
}
communication.HandleLiveHDHandshake <- models.LiveHDHandshake{
Payload: requestHDStreamPayload,
select {
case communication.HandleLiveHDHandshake <- requestHDStreamPayload:
default:
}
log.Log.Info("routers.mqtt.main.HandleRequestHDStream(): received request to setup webrtc.")
} else {
+4 -102
View File
@@ -6,7 +6,6 @@ import (
"image"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -15,7 +14,6 @@ import (
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/kerberos-io/agent/machinery/src/webrtc"
)
type Message struct {
@@ -30,23 +28,6 @@ type Connection struct {
Cancels map[string]context.CancelFunc
}
func writeWebRTCError(connection *Connection, clientID string, sessionID string, errorMessage string) {
if connection == nil {
return
}
if err := connection.WriteJson(Message{
ClientID: clientID,
MessageType: "webrtc-error",
Message: map[string]string{
"session_id": sessionID,
"message": errorMessage,
},
}); err != nil {
log.Log.Error("routers.websocket.main.writeWebRTCError(): " + err.Error())
}
}
// Concurrency handling - sending messages
func (c *Connection) WriteJson(message Message) error {
c.mu.Lock()
@@ -68,7 +49,7 @@ var upgrader = websocket.Upgrader{
},
}
func WebsocketHandler(c *gin.Context, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
func WebsocketHandler(c *gin.Context, communication *models.Communication, captureDevice *capture.Capture) {
w := c.Writer
r := c.Request
conn, err := upgrader.Upgrade(w, r, nil)
@@ -131,85 +112,9 @@ func WebsocketHandler(c *gin.Context, configuration *models.Configuration, commu
ctx, cancel := context.WithCancel(context.Background())
sockets[clientID].Cancels["stream-sd"] = cancel
go ForwardSDStream(ctx, clientID, sockets[clientID], configuration, communication, captureDevice)
go ForwardSDStream(ctx, clientID, sockets[clientID], communication, captureDevice)
}
}
case "stream-hd":
sessionID := message.Message["session_id"]
sessionDescription := message.Message["sdp"]
if sessionID == "" || sessionDescription == "" {
writeWebRTCError(sockets[clientID], clientID, sessionID, "missing session_id or sdp")
break
}
if !communication.CameraConnected {
writeWebRTCError(sockets[clientID], clientID, sessionID, "camera is not connected")
break
}
if communication.HandleLiveHDHandshake == nil {
writeWebRTCError(sockets[clientID], clientID, sessionID, "webrtc liveview is not available")
break
}
handshake := models.LiveHDHandshake{
Payload: models.RequestHDStreamPayload{
Timestamp: time.Now().Unix(),
SessionID: sessionID,
SessionDescription: sessionDescription,
},
Signaling: &models.LiveHDSignalingCallbacks{
SendAnswer: func(callbackSessionID string, sdp string) error {
return sockets[clientID].WriteJson(Message{
ClientID: clientID,
MessageType: "webrtc-answer",
Message: map[string]string{
"session_id": callbackSessionID,
"sdp": sdp,
},
})
},
SendCandidate: func(callbackSessionID string, candidate string) error {
return sockets[clientID].WriteJson(Message{
ClientID: clientID,
MessageType: "webrtc-candidate",
Message: map[string]string{
"session_id": callbackSessionID,
"candidate": candidate,
},
})
},
SendError: func(callbackSessionID string, errorMessage string) error {
writeWebRTCError(sockets[clientID], clientID, callbackSessionID, errorMessage)
return nil
},
},
}
communication.HandleLiveHDHandshake <- handshake
case "webrtc-candidate":
sessionID := message.Message["session_id"]
candidate := message.Message["candidate"]
if sessionID == "" || candidate == "" {
writeWebRTCError(sockets[clientID], clientID, sessionID, "missing session_id or candidate")
break
}
if !communication.CameraConnected {
writeWebRTCError(sockets[clientID], clientID, sessionID, "camera is not connected")
break
}
key := configuration.Config.Key + "/" + sessionID
go webrtc.RegisterCandidates(key, models.ReceiveHDCandidatesPayload{
Timestamp: time.Now().Unix(),
SessionID: sessionID,
Candidate: candidate,
})
}
err = conn.ReadJSON(&message)
@@ -226,7 +131,7 @@ func WebsocketHandler(c *gin.Context, configuration *models.Configuration, commu
}
}
func ForwardSDStream(ctx context.Context, clientID string, connection *Connection, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
func ForwardSDStream(ctx context.Context, clientID string, connection *Connection, communication *models.Communication, captureDevice *capture.Capture) {
var queue *packets.Queue
var cursor *packets.QueueCursor
@@ -254,10 +159,7 @@ logreader:
var img image.YCbCr
img, err = (*rtspClient).DecodePacket(pkt)
if err == nil {
config := configuration.Config
// Resize the image to the base width and height
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
bytes, _ := utils.ImageToBytes(&img)
encodedImage = base64.StdEncoding.EncodeToString(bytes)
} else {
continue
+3 -75
View File
@@ -21,15 +21,9 @@ import (
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/nfnt/resize"
)
// VERSION is the agent version. It defaults to "0.0.0" for local dev builds
// and is overridden at build time via:
//
// go build -ldflags "-X github.com/kerberos-io/agent/machinery/src/utils.VERSION=v1.2.3"
var VERSION = "0.0.0"
const VERSION = "3.2.6"
const letterBytes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -199,13 +193,6 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
if err == nil {
if eventFilter.TimestampOffsetStart > 0 {
// TimestampOffsetStart represents the newest lower bound to include.
if timestampInt < eventFilter.TimestampOffsetStart {
continue
}
}
// If we have an offset we will check if we should skip or not
if eventFilter.TimestampOffsetEnd > 0 {
// Medias are sorted from new to older. TimestampOffsetEnd holds the oldest
@@ -414,68 +401,9 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
}
}
func ImageToBytes(img *image.Image) ([]byte, error) {
func ImageToBytes(img image.Image) ([]byte, error) {
buffer := new(bytes.Buffer)
w := bufio.NewWriter(buffer)
err := jpeg.Encode(w, *img, &jpeg.Options{Quality: 35})
log.Log.Debug("ImageToBytes() - buffer size: " + strconv.Itoa(buffer.Len()))
err := jpeg.Encode(w, img, &jpeg.Options{Quality: 15})
return buffer.Bytes(), err
}
func ResizeImage(img image.Image, newWidth uint, newHeight uint) (*image.Image, error) {
if img == nil {
return nil, errors.New("image is nil")
}
// Callers cast int->uint, so a negative or poisoned int (e.g. MinInt from
// `int(float * +Inf)` when the source width is 0) wraps to a near-MaxUint
// value here and crashes nfnt/resize's allocator with "makeslice: len out
// of range". Clamp anything past a sane camera ceiling to 0 ("auto" in
// nfnt — preserves aspect from the source).
const maxDim uint = 8192
if newWidth > maxDim {
newWidth = 0
}
if newHeight > maxDim {
newHeight = 0
}
// resize to width 640 using Lanczos resampling
// and preserve aspect ratio
m := resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
return &m, nil
}
// ResolveBaseDimensions resolves the liveview/motion base dimensions for a
// stream given the (optionally configured) base width/height and the camera's
// probed source width/height. It returns the width and height that should be
// stored on the configuration.
//
// The aspect-ratio branch is gated on width>0 && height>0: a not-yet-probed
// stream has width=height=0, which previously made the ratio +Inf and
// int(float * +Inf) yield MinInt. That poisoned value, later cast to uint at
// the ResizeImage call sites, wrapped to ~MaxUint and crashed resize with
// "makeslice: len out of range". When the source isn't probed yet we fall back
// to the source dimensions (0,0 -> "auto") instead.
func ResolveBaseDimensions(baseWidth, baseHeight, width, height int) (int, int) {
if baseWidth > 0 && baseHeight == 0 && width > 0 && height > 0 {
// Derive the height from the configured width and the source aspect ratio.
widthAspectRatio := float64(baseWidth) / float64(width)
return baseWidth, int(float64(height) * widthAspectRatio)
} else if baseHeight > 0 && baseWidth > 0 {
// Both base dimensions are configured; honor them as-is.
return baseWidth, baseHeight
}
// Nothing usable configured (or source not probed yet): use source dimensions.
return width, height
}
func ResizeHeightWithAspectRatio(newWidth int, width int, height int) (int, int) {
if newWidth <= 0 || width <= 0 || height <= 0 {
return width, height
}
// Calculate the new height based on the aspect ratio
newHeight := (newWidth * height) / width
// Return the new dimensions
return newWidth, newHeight
}
-54
View File
@@ -1,54 +0,0 @@
package utils
import (
"os"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
type stubFileInfo struct {
name string
}
func (s stubFileInfo) Name() string { return s.name }
func (s stubFileInfo) Size() int64 { return 0 }
func (s stubFileInfo) Mode() os.FileMode { return 0 }
func (s stubFileInfo) ModTime() time.Time { return time.Unix(0, 0) }
func (s stubFileInfo) IsDir() bool { return false }
func (s stubFileInfo) Sys() interface{} { return nil }
func TestGetMediaFormattedHonorsTimestampRange(t *testing.T) {
configuration := &models.Configuration{}
configuration.Config.Timezone = "UTC"
configuration.Config.Name = "Front Door"
configuration.Config.Key = "camera-1"
files := []os.FileInfo{
stubFileInfo{name: "1700000200_6_7_8_9_10.mp4"},
stubFileInfo{name: "1700000100_6_7_8_9_10.mp4"},
stubFileInfo{name: "1700000000_6_7_8_9_10.mp4"},
}
media := GetMediaFormatted(files, "/tmp/recordings", configuration, models.EventFilter{
TimestampOffsetStart: 1700000050,
TimestampOffsetEnd: 1700000200,
NumberOfElements: 10,
})
if len(media) != 1 {
t.Fatalf("expected 1 media item in time range, got %d", len(media))
}
if media[0].Timestamp != "1700000100" {
t.Fatalf("expected timestamp 1700000100, got %s", media[0].Timestamp)
}
if media[0].CameraName != "Front Door" {
t.Fatalf("expected camera name to be preserved, got %s", media[0].CameraName)
}
if media[0].CameraKey != "camera-1" {
t.Fatalf("expected camera key to be preserved, got %s", media[0].CameraKey)
}
}
-124
View File
@@ -1,124 +0,0 @@
package utils
import (
"image"
"math"
"testing"
)
func TestResolveBaseDimensions(t *testing.T) {
tests := []struct {
name string
baseWidth, baseHeight int
width, height int
wantWidth, wantHeight int
}{
{
name: "base width set, height derived from aspect ratio",
baseWidth: 640, baseHeight: 0,
width: 1920, height: 1080,
wantWidth: 640, wantHeight: 360,
},
{
name: "both base dimensions configured are honored",
baseWidth: 640, baseHeight: 480,
width: 1920, height: 1080,
wantWidth: 640, wantHeight: 480,
},
{
name: "no base configured falls back to source dimensions",
baseWidth: 0, baseHeight: 0,
width: 1920, height: 1080,
wantWidth: 1920, wantHeight: 1080,
},
{
// Regression: a not-yet-probed stream has width=height=0. The old
// aspect-ratio branch divided by zero (float * +Inf -> MinInt) and
// poisoned BaseHeight, later crashing resize with makeslice panic.
name: "unprobed stream (width=0) does not poison dimensions",
baseWidth: 640, baseHeight: 0,
width: 0, height: 0,
wantWidth: 0, wantHeight: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotWidth, gotHeight := ResolveBaseDimensions(tt.baseWidth, tt.baseHeight, tt.width, tt.height)
if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight {
t.Fatalf("ResolveBaseDimensions(%d,%d,%d,%d) = (%d,%d), want (%d,%d)",
tt.baseWidth, tt.baseHeight, tt.width, tt.height,
gotWidth, gotHeight, tt.wantWidth, tt.wantHeight)
}
})
}
}
func TestResolveBaseDimensionsNeverNegative(t *testing.T) {
// Whatever the inputs, the resolved dimensions must never be negative,
// otherwise the uint cast at the resize call sites wraps to ~MaxUint.
for _, c := range [][4]int{
{640, 0, 0, 0},
{640, 0, 0, 1080},
{640, 0, 1920, 0},
{0, 0, 0, 0},
} {
w, h := ResolveBaseDimensions(c[0], c[1], c[2], c[3])
if w < 0 || h < 0 {
t.Fatalf("ResolveBaseDimensions(%v) produced negative dims (%d,%d)", c, w, h)
}
}
}
func TestResizeImageClampsPoisonedDimensions(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 320, 240))
// uint(math.MinInt) is the value produced when a poisoned int (from
// int(float * +Inf)) is cast to uint at a call site. It must not panic
// nfnt/resize's allocator; it should fall back to source-aspect resize.
// Compute via a runtime int so the conversion doesn't overflow at compile time.
minInt := math.MinInt
poison := uint(minInt)
resized, err := ResizeImage(src, poison, poison)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resized == nil {
t.Fatalf("expected an image, got nil")
}
b := (*resized).Bounds()
if b.Dx() != 320 || b.Dy() != 240 {
t.Fatalf("poisoned dims should fall back to source size, got %dx%d", b.Dx(), b.Dy())
}
}
func TestResizeImageClampsAboveCameraCeiling(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 320, 240))
// A width beyond any sane camera resolution is treated as "auto" (0).
resized, err := ResizeImage(src, 100000, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b := (*resized).Bounds()
if b.Dx() != 320 || b.Dy() != 240 {
t.Fatalf("oversized width should fall back to source size, got %dx%d", b.Dx(), b.Dy())
}
}
func TestResizeImageNormalResizeStillWorks(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 1920, 1080))
resized, err := ResizeImage(src, 640, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b := (*resized).Bounds()
if b.Dx() != 640 {
t.Fatalf("expected width 640, got %d", b.Dx())
}
if b.Dy() != 360 {
t.Fatalf("expected aspect-preserved height 360, got %d", b.Dy())
}
}
-586
View File
@@ -1,586 +0,0 @@
package video
import (
"bytes"
"fmt"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/log"
)
// LiveSegmenter turns a live stream of Annex B video samples into HLS-ready
// fragmented-MP4 (CMAF) output: ONE init segment (ftyp+moov) followed by a
// series of INDEPENDENT media segments (styp+moof+mdat), each beginning with a
// keyframe and carrying its own tfdt. This is the building block for the live
// HLS pipeline (agent -> hub-api -> vault -> hub-frontend) and is intentionally
// kept separate from the recording muxer in mp4.go:
//
// - mp4.go writes ONE fragmented MP4 per recording (free-box placeholder up
// front, back-filled on Close). That layout is great for archived files but
// useless for live, where each segment must be shippable the instant it is
// produced and must decode on its own after the init segment.
// - LiveSegmenter emits discrete, self-contained segments via callbacks, so
// the transport (single-POST to hub-api, drop-on-failure) never has to wait
// for the recording to finish.
//
// Both producers use the SAME mp4ff fragment format, so live and archived video
// share one toolchain on the player side (hls.js #EXT-X-MAP + byte-range parts).
//
// The spike scope is video-only H.264/H.265. Audio and multi-track interleaving
// can be layered on later by adding tracks to the init segment and a second trun
// to each fragment; nothing here precludes that.
type LiveSegmenter struct {
// codec is "H264"/"H265" (case handled in buildInit).
codec string
// timescale is the media timescale used in the init segment. The agent's
// capture path feeds presentation timestamps in milliseconds, so a 1000-tick
// timescale keeps sample durations exact with no rescaling.
timescale uint32
// targetSegmentMs is the minimum amount of media a segment accumulates before
// the next keyframe is allowed to start a fresh segment. Keeping segments
// keyframe-aligned is what makes each one independently decodable.
targetSegmentMs uint64
spsNALUs [][]byte
ppsNALUs [][]byte
vpsNALUs [][]byte
// width/height are written into the visual sample entry. They are optional:
// on a successful strict SPS parse mp4ff derives them, but the manual avcC
// fallback (used for SPS that mp4ff cannot parse) needs them supplied.
width uint16
height uint16
videoTrackID uint32
initSegment *mp4ff.InitSegment
initBytes []byte
initEmitted bool
seg *mp4ff.MediaSegment
frag *mp4ff.Fragment
seqNr uint32
// started becomes true once the first segment has been opened.
started bool
// segStartPTS is the decode time (ms) of the first sample in the open
// segment; elapsed media is measured against it to decide segment cuts.
segStartPTS uint64
// segDurationMs accumulates the committed sample durations of the open
// segment so the playlist can advertise an accurate #EXTINF.
segDurationMs uint64
// pending holds the most recently received sample. Its duration is only known
// once the NEXT sample arrives (duration = nextPTS - thisPTS), mirroring the
// pending-sample pattern used by the recording muxer.
pending *mp4ff.FullSample
// lastDurationMs is the previous committed duration, reused to close out the
// final pending sample (and to bridge non-monotonic timestamps).
lastDurationMs uint64
// OnInit is invoked exactly once with the encoded init segment bytes before
// the first media segment is emitted. Optional.
OnInit func(initBytes []byte) error
// OnSegment is invoked once per completed media segment. Optional. It is left
// unused in low-latency mode (see OnPart).
OnSegment func(seg LiveSegment) error
// --- Low-latency (LL-HLS) partial-segment mode ---
//
// When partTargetMs > 0 the segmenter additionally slices each segment into
// ~partTargetMs CMAF "parts" (chunks) and emits them via OnPart the instant
// each one closes, instead of waiting for the whole segment. The classic
// per-segment OnSegment path above is left untouched (and unused) in this mode.
// Each part is one mp4ff fragment (moof+mdat); part 0 of a segment also carries
// the CMAF styp, so concatenating a segment's parts yields one valid segment.
partTargetMs uint64
// partFrag is the open part's fragment; partIndex is its 0-based index within
// the current segment; fragSeq is the globally monotonic moof sequence number
// shared across all parts (MSE wants increasing moof sequence numbers).
partFrag *mp4ff.Fragment
partIndex uint32
fragSeq uint32
partSampleCount int
partDurationMs uint64
partIndependent bool
// OnPart is invoked once per completed CMAF part when partTargetMs > 0.
OnPart func(part LivePart) error
}
// LiveSegment is one independently-decodable CMAF media segment.
type LiveSegment struct {
// SequenceNumber is the monotonically increasing fragment sequence number
// (also used as the moof sequence number and the seg-N.m4s index).
SequenceNumber uint32
// DurationMs is the summed sample duration of the segment, for #EXTINF.
DurationMs uint64
// Data is the complete styp+moof+mdat segment, ready to append after the init
// segment and hand to hls.js / a vault object.
Data []byte
}
// LivePart is one CMAF partial segment (chunk) of a media segment, emitted in
// low-latency mode the instant it closes - before the whole segment is done - so
// the playlist can advertise it via #EXT-X-PART for near-live playback.
type LivePart struct {
// SegmentSeq is the parent media segment's sequence number (the N in
// seg-N.K.m4s); PartIndex is K within that segment (0-based).
SegmentSeq uint32
PartIndex uint32
// Independent is true when the part begins with a keyframe (its first sample is
// an IDR), i.e. it is independently decodable (#EXT-X-PART INDEPENDENT=YES).
Independent bool
// DurationMs is the summed sample duration of the part (for #EXT-X-PART).
DurationMs uint64
// Data of part 0 is styp+moof+mdat; later parts are bare moof+mdat, so
// concatenating a segment's parts in order yields one valid CMAF segment.
Data []byte
}
// Sample-entry flags matching the recording muxer so live and archived fragments
// describe random access points identically.
//
// keyframe 0x02000000 = sampleDependsOn=2 (depends on nothing), sync sample
// non-keyframe 0x01010000 = sampleDependsOn=1, sampleIsNonSyncSample=1
const (
liveSyncSampleFlags uint32 = 0x02000000
liveNonSyncSampleFlags uint32 = 0x01010000
// liveFallbackDurationMs is used when a duration cannot be derived (first
// frame at Close, or non-monotonic timestamps) and no prior duration exists.
// ~33 ms approximates 30 fps and is only ever a single-frame nicety.
liveFallbackDurationMs uint64 = 33
)
// NewLiveSegmenter creates a video-only live segmenter for the given codec.
// spsNALUs/ppsNALUs (and vpsNALUs for H.265) may be raw NAL units or Annex B
// blobs with start codes; both are normalized. targetSegmentMs is clamped to a
// sane floor so a misconfiguration cannot produce one-frame segments.
func NewLiveSegmenter(codec string, spsNALUs, ppsNALUs, vpsNALUs [][]byte, targetSegmentMs uint64) *LiveSegmenter {
if targetSegmentMs < 500 {
targetSegmentMs = 500
}
return &LiveSegmenter{
codec: codec,
timescale: 1000,
targetSegmentMs: targetSegmentMs,
spsNALUs: spsNALUs,
ppsNALUs: ppsNALUs,
vpsNALUs: vpsNALUs,
}
}
// SetDimensions records the encoded video width/height in pixels. They are
// written into the avc1/hvc1 visual sample entry and are required for the manual
// descriptor fallback path (SPS that mp4ff's strict parser rejects).
func (ls *LiveSegmenter) SetDimensions(width, height uint16) {
ls.width = width
ls.height = height
}
// EnableLowLatency switches the segmenter into LL-HLS mode, additionally slicing
// each segment into ~partTargetMs CMAF parts emitted via OnPart as they close.
// partTargetMs is clamped to a sane floor. Call before the first WriteSample.
func (ls *LiveSegmenter) EnableLowLatency(partTargetMs uint64) {
if partTargetMs < 100 {
partTargetMs = 100
}
ls.partTargetMs = partTargetMs
}
// InitSegment returns the encoded init segment bytes, building them on demand.
// Useful for tests and for serving the #EXT-X-MAP target without waiting for the
// first media segment.
func (ls *LiveSegmenter) InitSegment() ([]byte, error) {
if ls.initBytes == nil {
if err := ls.buildInit(); err != nil {
return nil, err
}
}
return ls.initBytes, nil
}
// buildInit constructs the ftyp+moov init segment from the parameter sets.
func (ls *LiveSegmenter) buildInit() error {
init := mp4ff.CreateEmptyInit()
init.AddEmptyTrack(ls.timescale, "video", "und")
trak := init.Moov.Traks[0]
switch ls.codec {
case "H264", "h264", "AVC", "avc", "AVC1", "avc1":
sps, pps := normalizeH264ParameterSets(ls.spsNALUs, ls.ppsNALUs)
if len(sps) == 0 || len(pps) == 0 {
return fmt.Errorf("livehls: missing H264 SPS/PPS (sps=%d pps=%d)", len(sps), len(pps))
}
// includePS=true stores SPS/PPS in the avcC so segments need not carry
// in-band parameter sets - browsers read them from the init segment. Some
// camera SPS variants trip mp4ff's strict parser (e.g. unusual VUI/SAR);
// fall back to a manually built avcC just like the recording muxer does so
// those cameras still produce a valid init segment.
if err := trak.SetAVCDescriptor("avc1", sps, pps, true); err != nil {
log.Log.Warning("livehls: SetAVCDescriptor failed, using manual avcC fallback: " + err.Error())
if fbErr := addAVCDescriptorFallback(trak, sps, pps, ls.width, ls.height); fbErr != nil {
return fmt.Errorf("livehls: AVC descriptor fallback: %w", fbErr)
}
}
case "H265", "h265", "HEVC", "hevc", "HVC1", "hvc1":
vps, sps, pps := normalizeH265ParameterSets(ls.vpsNALUs, ls.spsNALUs, ls.ppsNALUs)
if len(vps) == 0 || len(sps) == 0 || len(pps) == 0 {
return fmt.Errorf("livehls: missing H265 VPS/SPS/PPS (vps=%d sps=%d pps=%d)", len(vps), len(sps), len(pps))
}
if err := trak.SetHEVCDescriptor("hvc1", vps, sps, pps, [][]byte{}, true); err != nil {
return fmt.Errorf("livehls: SetHEVCDescriptor: %w", err)
}
default:
return fmt.Errorf("livehls: unsupported codec %q", ls.codec)
}
// Record the encoded dimensions in the track header when known.
if ls.width > 0 && ls.height > 0 {
trak.Tkhd.Width = mp4ff.Fixed32(uint32(ls.width) << 16)
trak.Tkhd.Height = mp4ff.Fixed32(uint32(ls.height) << 16)
}
// mdhd.Duration MUST be 0 for fragmented MP4 so players derive duration from
// the fragments rather than a (here unknown) total.
trak.Mdia.Mdhd.Duration = 0
ls.videoTrackID = trak.Tkhd.TrackID
var buf bytes.Buffer
if err := init.Encode(&buf); err != nil {
return fmt.Errorf("livehls: encode init: %w", err)
}
ls.initSegment = init
ls.initBytes = buf.Bytes()
return nil
}
// WriteSample feeds one Annex B access unit with its decode timestamp (DTS) in
// milliseconds. The first sample of a session MUST be a keyframe; a non-keyframe
// first sample is dropped (it could not be decoded without a preceding IDR).
//
// compositionOffsetMs is the CTS offset (PTS-DTS, for B-frame reordering) in
// timescale ticks; pass 0 for streams without B-frames.
func (ls *LiveSegmenter) WriteSample(isKeyframe bool, annexB []byte, ptsMs uint64, compositionOffsetMs int32) error {
// Lazily build + emit the init segment on the first accepted sample.
if ls.initBytes == nil {
if err := ls.buildInit(); err != nil {
return err
}
}
if !ls.initEmitted {
ls.initEmitted = true
if ls.OnInit != nil {
if err := ls.OnInit(ls.initBytes); err != nil {
return err
}
}
}
// A session must open on a random-access point; otherwise the first segment
// would reference frames that never arrived.
if !ls.started && !isKeyframe {
log.Log.Debug("LiveSegmenter.WriteSample(): dropping leading non-keyframe before first IDR")
return nil
}
lengthPrefixed, err := annexBToLengthPrefixed(annexB)
if err != nil {
return fmt.Errorf("livehls: convert AnnexB: %w", err)
}
// Low-latency mode slices each segment into parts; the classic per-segment path
// below is left exactly as-is for the default (non-LL) configuration.
if ls.partTargetMs > 0 {
return ls.writeSampleLL(isKeyframe, lengthPrefixed, ptsMs, compositionOffsetMs)
}
// The previous sample's duration is the gap to this sample's PTS. Commit it
// to the (still open) current fragment before we consider rolling segments,
// because the pending sample always precedes this one in decode order.
if ls.pending != nil {
dur := ls.lastDurationMs
if ptsMs > ls.pending.DecodeTime {
dur = ptsMs - ls.pending.DecodeTime
}
if dur == 0 {
dur = liveFallbackDurationMs
}
ls.lastDurationMs = dur
ls.pending.Sample.Dur = uint32(dur)
if err := ls.commitPending(); err != nil {
return err
}
}
// At every keyframe, decide whether enough media has accumulated to close the
// open segment and start a new one. Cutting only on keyframes guarantees each
// segment is independently decodable.
if isKeyframe {
shouldCut := !ls.started || (ptsMs-ls.segStartPTS) >= ls.targetSegmentMs
if shouldCut {
if ls.started {
if err := ls.emitSegment(); err != nil {
return err
}
}
ls.openSegment(ptsMs)
}
}
// Stage this sample; its duration is filled in when the next sample arrives
// (or at Close()).
flags := liveNonSyncSampleFlags
if isKeyframe {
flags = liveSyncSampleFlags
}
ls.pending = &mp4ff.FullSample{
Sample: mp4ff.Sample{
Flags: flags,
Size: uint32(len(lengthPrefixed)),
CompositionTimeOffset: compositionOffsetMs,
},
DecodeTime: ptsMs,
Data: lengthPrefixed,
}
return nil
}
// openSegment starts a fresh media segment (with CMAF styp) and an empty
// single-track fragment whose moof sequence number is the segment index.
func (ls *LiveSegmenter) openSegment(startPTS uint64) {
ls.seqNr++
ls.seg = mp4ff.NewMediaSegment() // includes a CMAF styp box by default
frag, err := mp4ff.CreateFragment(ls.seqNr, ls.videoTrackID)
if err != nil {
log.Log.Error("LiveSegmenter.openSegment(): CreateFragment failed: " + err.Error())
return
}
ls.seg.AddFragment(frag)
ls.frag = frag
ls.segStartPTS = startPTS
ls.segDurationMs = 0
ls.started = true
}
// commitPending appends the staged sample to the open fragment. The first sample
// of a fragment seeds the tfdt baseMediaDecodeTime from its absolute DecodeTime,
// which is what makes the segment independently seekable/decodable.
func (ls *LiveSegmenter) commitPending() error {
if ls.pending == nil {
return nil
}
if ls.frag == nil {
// No open segment yet (e.g. pending set before the first keyframe cut). The
// keyframe path always opens a segment before staging, so this only guards
// against logic drift; drop rather than panic.
ls.pending = nil
return nil
}
if err := ls.frag.AddFullSampleToTrack(*ls.pending, ls.videoTrackID); err != nil {
return fmt.Errorf("livehls: AddFullSampleToTrack: %w", err)
}
ls.segDurationMs += uint64(ls.pending.Sample.Dur)
ls.pending = nil
return nil
}
// emitSegment encodes the open segment and hands it to OnSegment.
func (ls *LiveSegmenter) emitSegment() error {
if ls.seg == nil {
return nil
}
var buf bytes.Buffer
if err := ls.seg.Encode(&buf); err != nil {
return fmt.Errorf("livehls: encode segment %d: %w", ls.seqNr, err)
}
out := LiveSegment{
SequenceNumber: ls.seqNr,
DurationMs: ls.segDurationMs,
Data: buf.Bytes(),
}
ls.seg = nil
ls.frag = nil
if ls.OnSegment != nil {
return ls.OnSegment(out)
}
return nil
}
// Close flushes the final pending sample and emits the last open segment (or, in
// low-latency mode, the last open part). Call once when the live session ends so
// no trailing media is lost.
func (ls *LiveSegmenter) Close() error {
if ls.partTargetMs > 0 {
if ls.pending != nil {
dur := ls.lastDurationMs
if dur == 0 {
dur = liveFallbackDurationMs
}
ls.pending.Sample.Dur = uint32(dur)
if err := ls.commitPendingPart(); err != nil {
return err
}
}
return ls.closePart()
}
if ls.pending != nil {
dur := ls.lastDurationMs
if dur == 0 {
dur = liveFallbackDurationMs
}
ls.pending.Sample.Dur = uint32(dur)
if err := ls.commitPending(); err != nil {
return err
}
}
return ls.emitSegment()
}
// writeSampleLL is the low-latency counterpart of the per-segment staging in
// WriteSample: it commits the previous sample into the open part, rolls the part
// (every ~partTargetMs) and the segment (at keyframes, every ~targetSegmentMs),
// then stages the current sample. Parts are emitted via OnPart as they close.
func (ls *LiveSegmenter) writeSampleLL(isKeyframe bool, lengthPrefixed []byte, ptsMs uint64, compositionOffsetMs int32) error {
if ls.pending != nil {
dur := ls.lastDurationMs
if ptsMs > ls.pending.DecodeTime {
dur = ptsMs - ls.pending.DecodeTime
}
if dur == 0 {
dur = liveFallbackDurationMs
}
ls.lastDurationMs = dur
ls.pending.Sample.Dur = uint32(dur)
if err := ls.commitPendingPart(); err != nil {
return err
}
}
// Roll the segment at keyframes once enough media accumulated; otherwise roll a
// part once it reaches the part target. The two are mutually exclusive: a
// keyframe cut also closes the current part.
cut := false
if isKeyframe {
cut = !ls.started || (ptsMs-ls.segStartPTS) >= ls.targetSegmentMs
}
switch {
case cut:
if ls.started {
if err := ls.closePart(); err != nil {
return err
}
}
ls.openSegmentLL(ptsMs)
case ls.started && ls.partDurationMs >= ls.partTargetMs:
if err := ls.closePart(); err != nil {
return err
}
ls.openPartLL()
}
flags := liveNonSyncSampleFlags
if isKeyframe {
flags = liveSyncSampleFlags
}
ls.pending = &mp4ff.FullSample{
Sample: mp4ff.Sample{
Flags: flags,
Size: uint32(len(lengthPrefixed)),
CompositionTimeOffset: compositionOffsetMs,
},
DecodeTime: ptsMs,
Data: lengthPrefixed,
}
return nil
}
// commitPendingPart appends the staged sample to the open part fragment, marking
// the part independent when its first sample is a keyframe.
func (ls *LiveSegmenter) commitPendingPart() error {
if ls.pending == nil {
return nil
}
if ls.partFrag == nil {
// No open part yet (pending staged before the first keyframe cut). The cut
// path always opens a part before staging, so this only guards against logic
// drift; drop rather than panic.
ls.pending = nil
return nil
}
first := ls.partSampleCount == 0
if err := ls.partFrag.AddFullSampleToTrack(*ls.pending, ls.videoTrackID); err != nil {
return fmt.Errorf("livehls: AddFullSampleToTrack: %w", err)
}
if first && ls.pending.Sample.Flags == liveSyncSampleFlags {
ls.partIndependent = true
}
ls.partSampleCount++
ls.partDurationMs += uint64(ls.pending.Sample.Dur)
ls.segDurationMs += uint64(ls.pending.Sample.Dur)
ls.pending = nil
return nil
}
// openSegmentLL starts a fresh media segment at a keyframe by opening its part 0.
func (ls *LiveSegmenter) openSegmentLL(startPTS uint64) {
ls.seqNr++
ls.partIndex = 0
ls.segStartPTS = startPTS
ls.segDurationMs = 0
ls.started = true
ls.openPartFragment()
}
// openPartLL starts the next part within the current segment.
func (ls *LiveSegmenter) openPartLL() {
ls.partIndex++
ls.openPartFragment()
}
// openPartFragment allocates a fresh single-track fragment (one moof+mdat) for
// the next part, with a globally monotonic moof sequence number.
func (ls *LiveSegmenter) openPartFragment() {
ls.fragSeq++
frag, err := mp4ff.CreateFragment(ls.fragSeq, ls.videoTrackID)
if err != nil {
log.Log.Error("LiveSegmenter.openPartFragment(): CreateFragment failed: " + err.Error())
return
}
ls.partFrag = frag
ls.partSampleCount = 0
ls.partDurationMs = 0
ls.partIndependent = false
}
// closePart encodes the open part and hands it to OnPart. Part 0 of a segment
// carries the CMAF styp; later parts are bare moof+mdat, so a segment's parts
// concatenate into one valid segment. Empty parts are skipped.
func (ls *LiveSegmenter) closePart() error {
if ls.partFrag == nil || ls.partSampleCount == 0 {
return nil
}
var buf bytes.Buffer
if ls.partIndex == 0 {
seg := mp4ff.NewMediaSegment() // includes a CMAF styp box by default
seg.AddFragment(ls.partFrag)
if err := seg.Encode(&buf); err != nil {
return fmt.Errorf("livehls: encode part %d.%d: %w", ls.seqNr, ls.partIndex, err)
}
} else {
if err := ls.partFrag.Encode(&buf); err != nil {
return fmt.Errorf("livehls: encode part %d.%d: %w", ls.seqNr, ls.partIndex, err)
}
}
out := LivePart{
SegmentSeq: ls.seqNr,
PartIndex: ls.partIndex,
Independent: ls.partIndependent,
DurationMs: ls.partDurationMs,
Data: buf.Bytes(),
}
ls.partFrag = nil
if ls.OnPart != nil {
return ls.OnPart(out)
}
return nil
}
-554
View File
@@ -1,554 +0,0 @@
package video
import (
"bytes"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
)
// Known-good minimal H.264 baseline parameter sets (640x480), reused from the
// recording-muxer tests so the live segmenter is exercised against the exact
// SPS/PPS mp4ff is already known to parse into an avcC descriptor.
var (
liveTestSPS = []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
liveTestPPS = []byte{0x68, 0xce, 0x38, 0x80}
)
// makeAnnexBFrame builds a single-NALU Annex B access unit: a 4-byte start code,
// the NAL header (IDR=0x65 for keyframes, non-IDR=0x01 otherwise) and padding.
func makeAnnexBFrame(isKey bool) []byte {
nalType := byte(0x01)
if isKey {
nalType = 0x65
}
frame := []byte{0x00, 0x00, 0x00, 0x01, nalType}
for i := 0; i < 100; i++ {
frame = append(frame, byte(i))
}
return frame
}
// isSyncSample reports whether a parsed sample is a random-access point
// (sample_depends_on == 2 => "depends on nothing" => IDR/sync).
func isSyncSample(s mp4ff.Sample) bool {
return (s.Flags>>24)&0x03 == 0x02
}
// TestLiveSegmenterProducesIndependentCMAFSegments feeds a synthetic H.264
// stream (25 fps, 1s GOPs) through the live segmenter and asserts that:
// - exactly one init segment (ftyp+moov, single avc1 video track) is produced;
// - segments are cut on keyframe boundaries honoring the target duration;
// - every media segment carries a CMAF styp + exactly one moof+mdat fragment;
// - each segment begins with a sync sample and its tfdt equals the absolute
// decode time of that first sample (the property that makes it independently
// decodable after the init segment);
// - sample counts and durations are preserved end to end.
func TestLiveSegmenterProducesIndependentCMAFSegments(t *testing.T) {
const (
frameDurMs = uint64(40) // 25 fps
gopFrames = 25 // keyframe every 1000 ms
numGOPs = 6
numFrames = gopFrames * numGOPs // 150 frames, 6000 ms
targetMs = uint64(2000) // 2s segments => 2 GOPs each
)
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, targetMs)
seg.SetDimensions(640, 480)
var initBytes []byte
var initCalls int
var segments []LiveSegment
seg.OnInit = func(b []byte) error {
initCalls++
initBytes = append([]byte(nil), b...)
return nil
}
seg.OnSegment = func(s LiveSegment) error {
segments = append(segments, s)
return nil
}
for i := 0; i < numFrames; i++ {
isKey := i%gopFrames == 0
pts := uint64(i) * frameDurMs
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), pts, 0); err != nil {
t.Fatalf("WriteSample(frame=%d): %v", i, err)
}
}
if err := seg.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// --- Init segment: emitted exactly once, well-formed, single video track. ---
if initCalls != 1 {
t.Fatalf("OnInit called %d times, want 1", initCalls)
}
if len(initBytes) == 0 {
t.Fatal("init segment is empty")
}
parsedInit, err := mp4ff.DecodeFile(bytes.NewReader(initBytes))
if err != nil {
t.Fatalf("decode init: %v", err)
}
if parsedInit.Init == nil || parsedInit.Init.Ftyp == nil || parsedInit.Init.Moov == nil {
t.Fatal("init segment missing ftyp/moov")
}
if got := len(parsedInit.Init.Moov.Traks); got != 1 {
t.Fatalf("init moov has %d traks, want 1", got)
}
// --- Segment cut cadence: 6 GOPs at 2s target => 3 segments of 2 GOPs each. ---
const wantSegments = 3
if len(segments) != wantSegments {
t.Fatalf("got %d media segments, want %d", len(segments), wantSegments)
}
for i, s := range segments {
if want := uint32(i + 1); s.SequenceNumber != want {
t.Errorf("segment %d: SequenceNumber=%d, want %d", i, s.SequenceNumber, want)
}
if s.DurationMs != targetMs {
t.Errorf("segment %d: DurationMs=%d, want %d", i, s.DurationMs, targetMs)
}
}
// --- Each segment must decode INDEPENDENTLY after the init segment. ---
// Parsing init+oneSegment in isolation mirrors exactly what hls.js does with
// an #EXT-X-MAP init and a single media part.
var totalSamples, totalSync int
wantTFDT := []uint64{0, 2000, 4000}
for i, s := range segments {
standalone := append(append([]byte(nil), initBytes...), s.Data...)
parsed, err := mp4ff.DecodeFile(bytes.NewReader(standalone))
if err != nil {
t.Fatalf("segment %d: decode init+segment: %v", i, err)
}
if len(parsed.Segments) != 1 {
t.Fatalf("segment %d: parsed %d media segments, want 1", i, len(parsed.Segments))
}
mseg := parsed.Segments[0]
if mseg.Styp == nil {
t.Errorf("segment %d: missing CMAF styp box", i)
}
if len(mseg.Fragments) != 1 {
t.Fatalf("segment %d: %d fragments, want 1", i, len(mseg.Fragments))
}
fr := mseg.Fragments[0]
if got := fr.Moof.Mfhd.SequenceNumber; got != s.SequenceNumber {
t.Errorf("segment %d: moof sequence=%d, want %d", i, got, s.SequenceNumber)
}
traf := fr.Moof.Traf
if traf.Tfhd.TrackID != 1 {
t.Errorf("segment %d: track id=%d, want 1", i, traf.Tfhd.TrackID)
}
if got := traf.Tfdt.BaseMediaDecodeTime(); got != wantTFDT[i] {
t.Errorf("segment %d: tfdt baseMediaDecodeTime=%d, want %d", i, got, wantTFDT[i])
}
var samples []mp4ff.Sample
for _, trun := range traf.Truns {
samples = append(samples, trun.Samples...)
}
if len(samples) == 0 {
t.Fatalf("segment %d: no samples", i)
}
if !isSyncSample(samples[0]) {
t.Errorf("segment %d: first sample is not a keyframe/sync sample", i)
}
var segDur uint64
for j, smp := range samples {
totalSamples++
if isSyncSample(smp) {
totalSync++
}
segDur += uint64(smp.Dur)
if smp.Size == 0 {
t.Errorf("segment %d sample %d: zero size", i, j)
}
}
if segDur != s.DurationMs {
t.Errorf("segment %d: summed sample dur=%d, reported DurationMs=%d", i, segDur, s.DurationMs)
}
}
if totalSamples != numFrames {
t.Errorf("total samples across segments=%d, want %d", totalSamples, numFrames)
}
if totalSync != numGOPs {
t.Errorf("total sync samples=%d, want %d (one per GOP)", totalSync, numGOPs)
}
}
// TestLiveSegmenterDropsLeadingNonKeyframe verifies a session cannot open on a
// non-IDR frame (which would reference frames that never arrived); such leading
// samples are dropped until the first keyframe.
func TestLiveSegmenterDropsLeadingNonKeyframe(t *testing.T) {
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, 1000)
seg.SetDimensions(640, 480)
var segments []LiveSegment
seg.OnSegment = func(s LiveSegment) error { segments = append(segments, s); return nil }
// Two P-frames before any IDR must be ignored.
if err := seg.WriteSample(false, makeAnnexBFrame(false), 0, 0); err != nil {
t.Fatalf("WriteSample(p0): %v", err)
}
if err := seg.WriteSample(false, makeAnnexBFrame(false), 40, 0); err != nil {
t.Fatalf("WriteSample(p1): %v", err)
}
// First IDR opens the session at decode time 0.
for i := 0; i < 25; i++ {
isKey := i == 0
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), uint64(i)*40, 0); err != nil {
t.Fatalf("WriteSample(%d): %v", i, err)
}
}
if err := seg.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if len(segments) == 0 {
t.Fatal("expected at least one segment after the first IDR")
}
initBytes, err := seg.InitSegment()
if err != nil {
t.Fatalf("InitSegment: %v", err)
}
standalone := append(append([]byte(nil), initBytes...), segments[0].Data...)
parsed, err := mp4ff.DecodeFile(bytes.NewReader(standalone))
if err != nil {
t.Fatalf("decode: %v", err)
}
traf := parsed.Segments[0].Fragments[0].Moof.Traf
if got := traf.Tfdt.BaseMediaDecodeTime(); got != 0 {
t.Errorf("first segment tfdt=%d, want 0 (session opens on the IDR)", got)
}
var first mp4ff.Sample
for _, trun := range traf.Truns {
if len(trun.Samples) > 0 {
first = trun.Samples[0]
break
}
}
if !isSyncSample(first) {
t.Error("first committed sample must be the IDR, not a dropped P-frame")
}
}
// renderLiveMediaPlaylist renders a live (no #EXT-X-ENDLIST) fMP4 HLS media
// playlist for the given segments. This mirrors the shape hub-api will serve for
// live streams: an #EXT-X-MAP init segment followed by one #EXTINF per CMAF part.
// In production hub-api emits a sliding WINDOW of the most recent segments and
// advances #EXT-X-MEDIA-SEQUENCE; here we list the whole synthetic capture for a
// self-contained, inspectable bundle.
func renderLiveMediaPlaylist(initURI string, segs []LiveSegment, mediaSequence uint32) string {
var maxDurMs uint64
for _, s := range segs {
if s.DurationMs > maxDurMs {
maxDurMs = s.DurationMs
}
}
target := uint64(math.Ceil(float64(maxDurMs) / 1000.0))
if target == 0 {
target = 1
}
var b strings.Builder
b.WriteString("#EXTM3U\n")
b.WriteString("#EXT-X-VERSION:7\n")
fmt.Fprintf(&b, "#EXT-X-TARGETDURATION:%d\n", target)
fmt.Fprintf(&b, "#EXT-X-MEDIA-SEQUENCE:%d\n", mediaSequence)
b.WriteString("#EXT-X-INDEPENDENT-SEGMENTS\n")
fmt.Fprintf(&b, "#EXT-X-MAP:URI=%q\n", initURI)
for _, s := range segs {
fmt.Fprintf(&b, "#EXTINF:%.3f,\n", float64(s.DurationMs)/1000.0)
fmt.Fprintf(&b, "seg-%d.m4s\n", s.SequenceNumber)
}
// NOTE: deliberately no #EXT-X-ENDLIST - its absence is what marks the
// playlist as live so hls.js keeps polling for new segments.
return b.String()
}
// TestLiveSegmenterWritesHLSBundle runs the segmenter over a synthetic stream and
// writes a complete on-disk fMP4 HLS bundle (init.mp4 + seg-N.m4s + a live
// stream.m3u8). It validates the playlist shape and that every referenced file
// exists, then logs the output directory so the structure can be eyeballed.
//
// Set LIVEHLS_OUT=/some/dir to keep the bundle for manual inspection (e.g. serve
// it and point hls.js at stream.m3u8); otherwise a temp dir is used and removed.
//
// The frames here are synthetic (valid fMP4 boxing, non-decodable payloads), so
// this validates CONTAINER/playlist structure, not pixel decode - the round-trip
// assertions in TestLiveSegmenterProducesIndependentCMAFSegments cover decodable
// box layout.
func TestLiveSegmenterWritesHLSBundle(t *testing.T) {
const (
frameDurMs = uint64(40)
gopFrames = 25
numGOPs = 6
numFrames = gopFrames * numGOPs
targetMs = uint64(2000)
)
outDir := os.Getenv("LIVEHLS_OUT")
if outDir == "" {
outDir = t.TempDir()
} else {
if err := os.MkdirAll(outDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", outDir, err)
}
}
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, targetMs)
seg.SetDimensions(640, 480)
var segments []LiveSegment
seg.OnInit = func(b []byte) error {
return os.WriteFile(filepath.Join(outDir, "init.mp4"), b, 0o644)
}
seg.OnSegment = func(s LiveSegment) error {
segments = append(segments, s)
name := fmt.Sprintf("seg-%d.m4s", s.SequenceNumber)
return os.WriteFile(filepath.Join(outDir, name), s.Data, 0o644)
}
for i := 0; i < numFrames; i++ {
isKey := i%gopFrames == 0
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), uint64(i)*frameDurMs, 0); err != nil {
t.Fatalf("WriteSample(%d): %v", i, err)
}
}
if err := seg.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if len(segments) == 0 {
t.Fatal("no segments produced")
}
playlist := renderLiveMediaPlaylist("init.mp4", segments, segments[0].SequenceNumber)
if err := os.WriteFile(filepath.Join(outDir, "stream.m3u8"), []byte(playlist), 0o644); err != nil {
t.Fatalf("write playlist: %v", err)
}
// --- Validate the live playlist shape. ---
mustContain := []string{
"#EXTM3U",
"#EXT-X-VERSION:7",
"#EXT-X-TARGETDURATION:2",
"#EXT-X-MEDIA-SEQUENCE:1",
`#EXT-X-MAP:URI="init.mp4"`,
"#EXT-X-INDEPENDENT-SEGMENTS",
}
for _, tag := range mustContain {
if !strings.Contains(playlist, tag) {
t.Errorf("playlist missing %q\n---\n%s", tag, playlist)
}
}
if strings.Contains(playlist, "#EXT-X-ENDLIST") {
t.Error("live playlist must NOT contain #EXT-X-ENDLIST")
}
if got, want := strings.Count(playlist, "#EXTINF:"), len(segments); got != want {
t.Errorf("playlist has %d #EXTINF entries, want %d", got, want)
}
// --- Every referenced file must exist on disk. ---
if _, err := os.Stat(filepath.Join(outDir, "init.mp4")); err != nil {
t.Errorf("init.mp4 missing: %v", err)
}
for _, s := range segments {
name := fmt.Sprintf("seg-%d.m4s", s.SequenceNumber)
if _, err := os.Stat(filepath.Join(outDir, name)); err != nil {
t.Errorf("%s missing: %v", name, err)
}
}
t.Logf("wrote HLS bundle to %s (%d segments)\n%s", outDir, len(segments), playlist)
}
// boxTypeAt returns the 4CC box type at the front of a top-level box blob (the
// 4 bytes following the 32-bit size), or "" if the blob is too short.
func boxTypeAt(b []byte) string {
if len(b) < 8 {
return ""
}
return string(b[4:8])
}
// TestLiveSegmenterLowLatencyParts runs the segmenter in LL-HLS mode over the
// same synthetic stream and asserts that:
// - each ~2s segment is sliced into multiple CMAF parts (more parts than
// segments overall);
// - part 0 of every segment carries the CMAF styp and is INDEPENDENT (begins
// with the segment keyframe); later parts are bare moof+mdat (no styp);
// - moof sequence numbers are globally monotonic across all parts (MSE needs
// increasing moof sequence numbers);
// - concatenating a segment's parts in order yields exactly the same bytes the
// classic per-segment path would emit, decoding into one independent CMAF
// segment whose first sample is a sync sample with the expected tfdt;
// - every sample and keyframe of the input is preserved end to end.
func TestLiveSegmenterLowLatencyParts(t *testing.T) {
const (
frameDurMs = uint64(40) // 25 fps
gopFrames = 25 // keyframe every 1000 ms
numGOPs = 6
numFrames = gopFrames * numGOPs // 150 frames, 6000 ms
targetMs = uint64(2000) // 2s segments => 2 GOPs each
partMs = uint64(300) // ~300 ms parts => ~6-7 parts/segment
)
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, targetMs)
seg.SetDimensions(640, 480)
seg.EnableLowLatency(partMs)
var initBytes []byte
var initCalls int
var parts []LivePart
seg.OnInit = func(b []byte) error {
initCalls++
initBytes = append([]byte(nil), b...)
return nil
}
seg.OnPart = func(p LivePart) error {
parts = append(parts, p)
return nil
}
for i := 0; i < numFrames; i++ {
isKey := i%gopFrames == 0
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), uint64(i)*frameDurMs, 0); err != nil {
t.Fatalf("WriteSample(frame=%d): %v", i, err)
}
}
if err := seg.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if initCalls != 1 {
t.Fatalf("OnInit called %d times, want 1", initCalls)
}
if len(parts) == 0 {
t.Fatal("no parts produced in low-latency mode")
}
// --- Parts are globally moof-monotonic, and group into 3 segments whose part
// indices are contiguous from 0. ---
bySeg := map[uint32][]LivePart{}
var order []uint32
var lastMoof uint32
for i, p := range parts {
if _, seen := bySeg[p.SegmentSeq]; !seen {
order = append(order, p.SegmentSeq)
}
bySeg[p.SegmentSeq] = append(bySeg[p.SegmentSeq], p)
// Decode the part to read its moof sequence number and confirm the styp
// convention (part 0 => styp present, later parts => bare moof+mdat).
front := boxTypeAt(p.Data)
if p.PartIndex == 0 {
if front != "styp" {
t.Errorf("seg %d part 0: leading box=%q, want styp", p.SegmentSeq, front)
}
if !p.Independent {
t.Errorf("seg %d part 0: Independent=false, want true (starts on keyframe)", p.SegmentSeq)
}
} else if front != "moof" {
t.Errorf("seg %d part %d: leading box=%q, want moof (no styp on later parts)", p.SegmentSeq, p.PartIndex, front)
}
parsed, err := mp4ff.DecodeFile(bytes.NewReader(p.Data))
if err != nil {
t.Fatalf("seg %d part %d: decode: %v", p.SegmentSeq, p.PartIndex, err)
}
if len(parsed.Segments) != 1 || len(parsed.Segments[0].Fragments) != 1 {
t.Fatalf("seg %d part %d: want exactly one fragment", p.SegmentSeq, p.PartIndex)
}
moof := parsed.Segments[0].Fragments[0].Moof.Mfhd.SequenceNumber
if i > 0 && moof <= lastMoof {
t.Errorf("part %d: moof sequence=%d not greater than previous %d", i, moof, lastMoof)
}
lastMoof = moof
}
if len(order) != 3 {
t.Fatalf("got %d segments, want 3", len(order))
}
if len(parts) <= len(order) {
t.Fatalf("got %d parts for %d segments, expected each segment to be sliced into multiple parts", len(parts), len(order))
}
for _, segSeq := range order {
for idx, p := range bySeg[segSeq] {
if p.PartIndex != uint32(idx) {
t.Errorf("seg %d: part index %d out of order (want %d)", segSeq, p.PartIndex, idx)
}
}
}
// --- Concatenating a segment's parts must reconstruct one independent CMAF
// segment that decodes against the init segment. ---
wantTFDT := map[uint32]uint64{1: 0, 2: 2000, 3: 4000}
var totalSamples, totalSync int
for _, segSeq := range order {
segParts := bySeg[segSeq]
var full []byte
var wantPartDur uint64
for _, p := range segParts {
full = append(full, p.Data...)
wantPartDur += p.DurationMs
}
standalone := append(append([]byte(nil), initBytes...), full...)
parsed, err := mp4ff.DecodeFile(bytes.NewReader(standalone))
if err != nil {
t.Fatalf("seg %d: decode concatenated parts: %v", segSeq, err)
}
if len(parsed.Segments) != 1 {
t.Fatalf("seg %d: parsed %d media segments, want 1", segSeq, len(parsed.Segments))
}
mseg := parsed.Segments[0]
if mseg.Styp == nil {
t.Errorf("seg %d: reconstructed segment missing CMAF styp", segSeq)
}
if len(mseg.Fragments) != len(segParts) {
t.Errorf("seg %d: %d fragments, want %d (one per part)", segSeq, len(mseg.Fragments), len(segParts))
}
firstTraf := mseg.Fragments[0].Moof.Traf
if got := firstTraf.Tfdt.BaseMediaDecodeTime(); got != wantTFDT[segSeq] {
t.Errorf("seg %d: first fragment tfdt=%d, want %d", segSeq, got, wantTFDT[segSeq])
}
var segDur uint64
var firstSample mp4ff.Sample
var haveFirst bool
for _, fr := range mseg.Fragments {
for _, trun := range fr.Moof.Traf.Truns {
for _, smp := range trun.Samples {
if !haveFirst {
firstSample = smp
haveFirst = true
}
totalSamples++
if isSyncSample(smp) {
totalSync++
}
segDur += uint64(smp.Dur)
}
}
}
if !isSyncSample(firstSample) {
t.Errorf("seg %d: first sample is not a sync sample", segSeq)
}
if segDur != wantPartDur {
t.Errorf("seg %d: summed sample dur=%d, summed part dur=%d", segSeq, segDur, wantPartDur)
}
}
if totalSamples != numFrames {
t.Errorf("total samples across parts=%d, want %d", totalSamples, numFrames)
}
if totalSync != numGOPs {
t.Errorf("total sync samples=%d, want %d (one per GOP)", totalSync, numGOPs)
}
}
File diff suppressed because it is too large Load Diff
-176
View File
@@ -1,176 +0,0 @@
package video
import (
"fmt"
"os"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/models"
)
// TestMP4Duration creates an MP4 file simulating a 5-second video recording
// and verifies that the durations in all boxes match the sum of sample durations.
func TestMP4Duration(t *testing.T) {
tmpFile := "/tmp/test_duration.mp4"
defer os.Remove(tmpFile)
// Minimal SPS for H.264 (baseline, 640x480) - proper Annex B format with start code
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
pps := []byte{0x68, 0xce, 0x38, 0x80}
mp4Video := NewMP4(tmpFile, [][]byte{sps}, [][]byte{pps}, nil, 10)
mp4Video.SetWidth(640)
mp4Video.SetHeight(480)
videoTrack := mp4Video.AddVideoTrack("H264")
// Simulate 5 seconds at 25fps (200 frames, keyframe every 50 frames = 2s)
// PTS in milliseconds (timescale=1000)
frameDuration := uint64(40) // 40ms per frame = 25fps
numFrames := 150
gopSize := 50
// Create a fake Annex B NAL unit (keyframe IDR = type 5, non-keyframe = type 1)
makeFrame := func(isKey bool) []byte {
nalType := byte(0x01) // non-IDR slice
if isKey {
nalType = 0x65 // IDR slice
}
// Start code (4 bytes) + NAL header + some data
frame := []byte{0x00, 0x00, 0x00, 0x01, nalType}
// Add some padding data
for i := 0; i < 100; i++ {
frame = append(frame, byte(i))
}
return frame
}
var expectedDuration uint64
for i := 0; i < numFrames; i++ {
pts := uint64(i) * frameDuration
isKeyframe := i%gopSize == 0
err := mp4Video.AddSampleToTrack(videoTrack, isKeyframe, makeFrame(isKeyframe), pts, 0)
if err != nil {
t.Fatalf("AddSampleToTrack failed at frame %d: %v", i, err)
}
}
expectedDuration = uint64(numFrames) * frameDuration // Should be 6000ms (150 * 40)
// Close with config that has signing key to avoid nil panics
config := &models.Config{
Signing: &models.Signing{
PrivateKey: "",
},
}
mp4Video.Close(config)
// Log what the code computed
t.Logf("VideoTotalDuration: %d ms", mp4Video.VideoTotalDuration)
t.Logf("Expected duration: %d ms", expectedDuration)
t.Logf("Segments: %d", len(mp4Video.SegmentDurations))
var sumSegDur uint64
for i, d := range mp4Video.SegmentDurations {
t.Logf(" Segment %d: duration=%d ms", i, d)
sumSegDur += d
}
t.Logf("Sum of segment durations: %d ms", sumSegDur)
// Now read back the file and inspect the boxes
f, err := os.Open(tmpFile)
if err != nil {
t.Fatalf("Failed to open output file: %v", err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
t.Fatalf("Failed to stat output file: %v", err)
}
parsedFile, err := mp4ff.DecodeFile(f)
if err != nil {
t.Fatalf("Failed to decode MP4: %v", err)
}
t.Logf("File size: %d bytes", fi.Size())
// Check moov box
if parsedFile.Moov == nil {
t.Fatal("No moov box found")
}
// Check mvhd duration
mvhd := parsedFile.Moov.Mvhd
t.Logf("mvhd.Duration: %d (timescale=%d) = %.2f seconds", mvhd.Duration, mvhd.Timescale, float64(mvhd.Duration)/float64(mvhd.Timescale))
t.Logf("mvhd.Rate: 0x%08x", mvhd.Rate)
t.Logf("mvhd.Volume: 0x%04x", mvhd.Volume)
// Check each trak
for i, trak := range parsedFile.Moov.Traks {
t.Logf("Track %d:", i)
t.Logf(" tkhd.Duration: %d", trak.Tkhd.Duration)
t.Logf(" mdhd.Duration: %d (timescale=%d) = %.2f seconds", trak.Mdia.Mdhd.Duration, trak.Mdia.Mdhd.Timescale, float64(trak.Mdia.Mdhd.Duration)/float64(trak.Mdia.Mdhd.Timescale))
}
// Check mvex/mehd
if parsedFile.Moov.Mvex != nil && parsedFile.Moov.Mvex.Mehd != nil {
t.Logf("mehd.FragmentDuration: %d", parsedFile.Moov.Mvex.Mehd.FragmentDuration)
}
// Sum up actual sample durations from trun boxes in all segments
var actualTrunDuration uint64
var sampleCount int
for _, seg := range parsedFile.Segments {
for _, frag := range seg.Fragments {
for _, traf := range frag.Moof.Trafs {
// Only count video track (track 1)
if traf.Tfhd.TrackID == 1 {
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
actualTrunDuration += uint64(s.Dur)
sampleCount++
}
}
}
}
}
}
t.Logf("Actual trun sample count: %d", sampleCount)
t.Logf("Actual trun total duration: %d ms", actualTrunDuration)
// Check sidx
if parsedFile.Sidx != nil {
var sidxDuration uint64
for _, ref := range parsedFile.Sidx.SidxRefs {
sidxDuration += uint64(ref.SubSegmentDuration)
}
t.Logf("sidx total duration: %d ms", sidxDuration)
}
// VERIFY: All duration values should be consistent
// The expected duration for 150 frames at 40ms each:
// - The sample-buffering pattern means the LAST sample uses LastVideoSampleDTS as duration
// - So all 150 samples should produce 150 * 40ms = 6000ms total
// But due to the pending sample pattern, the actual trun durations might differ
fmt.Println()
fmt.Println("=== DURATION CONSISTENCY CHECK ===")
fmt.Printf("Expected (150 * 40ms): %d ms\n", expectedDuration)
fmt.Printf("mvhd.Duration: %d ms\n", mvhd.Duration)
fmt.Printf("tkhd.Duration: %d ms\n", parsedFile.Moov.Traks[0].Tkhd.Duration)
fmt.Printf("mdhd.Duration: %d ms\n", parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration)
fmt.Printf("Actual trun durations sum: %d ms\n", actualTrunDuration)
fmt.Printf("VideoTotalDuration: %d ms\n", mp4Video.VideoTotalDuration)
fmt.Printf("Sum of SegmentDurations: %d ms\n", sumSegDur)
fmt.Println()
// The key assertion: header duration must equal trun sum
if mvhd.Duration != actualTrunDuration {
t.Errorf("MISMATCH: mvhd.Duration (%d) != actual trun sum (%d), diff = %d ms",
mvhd.Duration, actualTrunDuration, int64(mvhd.Duration)-int64(actualTrunDuration))
}
if parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration != 0 {
t.Errorf("MISMATCH: mdhd.Duration should be 0 for fragmented MP4, got %d",
parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration)
}
}
-194
View File
@@ -1,194 +0,0 @@
package video
import (
"os"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/models"
)
// runLoopSeamScenario builds a fragmented MP4 that reproduces the loop-seam
// pattern observed in the failing virtual-rtsp recordings (e.g.
// thales_1781196512_3-138_2top_0-0-0-0_-1_30219.mp4): a steady GOP cadence, but
// at the source-MP4 loop boundary the source restarts and emits a fresh IDR far
// sooner than a normal GOP. In the real recordings the short tail GOP left just
// before that premature IDR contains a truncated inter-frame - software decoders
// conceal the missing macroblocks, but hardware decoders (macOS VideoToolbox,
// kVTVideoDecoderBadDataErr / -12909) and MSE players reject it and freeze
// playback at the seam (~10s in the original file).
//
// The fix detects the premature seam IDR and drops the truncated tail GOP that
// precedes it. The seam IDR is itself a clean random-access point, so playback
// resumes seamlessly. This scenario asserts that the tail GOP is removed -
// exactly one GOP fewer than emitted - while every healthy GOP is preserved in
// full and no two IDRs are left bunched in a fragment.
//
// gopFrames is the number of frames per GOP, so the same scenario can be
// exercised at different (configurable) camera GOP sizes. The fix derives its
// threshold from the observed keyframe cadence, so the truncated tail GOP is
// dropped regardless of GOP size.
func runLoopSeamScenario(t *testing.T, gopFrames int) {
t.Helper()
tmpFile, err := os.CreateTemp("", "test_loop_seam_*.mp4")
if err != nil {
t.Fatalf("create temp: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
pps := []byte{0x68, 0xce, 0x38, 0x80}
mp4Video := NewMP4(tmpFile.Name(), [][]byte{sps}, [][]byte{pps}, nil, 60)
mp4Video.SetWidth(1920)
mp4Video.SetHeight(1080)
v := mp4Video.AddVideoTrack("H264")
mk := func(k bool) []byte {
nt := byte(0x01)
if k {
nt = 0x65
}
f := []byte{0, 0, 0, 1, nt}
for i := 0; i < 200; i++ {
f = append(f, byte(i))
}
return f
}
frameDur := uint64(33)
normalGOPms := uint64(gopFrames) * frameDur
pts := uint64(0)
emitFrame := func(isKey bool) {
// compositionOffset is 0: synthetic stream has no B-frames.
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts, 0)
pts += frameDur
}
emitP := func(n int) {
for i := 0; i < n; i++ {
emitFrame(false)
}
}
// emitGOP emits one GOP: a leading keyframe followed by gopFrames-1 P-frames.
emitGOP := func() {
emitFrame(true)
emitP(gopFrames - 1)
}
// Several healthy GOPs to establish the cadence and fill a couple of
// fragments, then the truncated tail GOP: a keyframe followed by only a few
// P-frames before the source loops. This is the GOP that must be dropped.
for g := 0; g < 9; g++ {
emitGOP()
}
emitFrame(true)
seamLead := gopFrames / 5 // tail GOP is only ~20% of a normal GOP before the loop
if seamLead < 1 {
seamLead = 1
}
emitP(seamLead)
// Loop seam: the source recording restarts, emitting a fresh IDR far sooner
// than the normal GOP. The short tail GOP emitted just above is the truncated
// one that must be dropped; this seam IDR opens a fresh, healthy GOP.
emitFrame(true)
emitP(gopFrames - 1)
// The recording continues with normal GOPs to the end.
for g := 0; g < 10; g++ {
emitGOP()
}
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
f, err := os.Open(tmpFile.Name())
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
parsed, err := mp4ff.DecodeFile(f)
if err != nil {
t.Fatalf("decode: %v", err)
}
// After the fix, the truncated tail GOP that precedes the premature seam IDR
// is dropped entirely (its first inter-frame is the incomplete one that
// freezes hardware decoders), while every other GOP is preserved in full.
//
// 9 lead GOPs + the seam's own (healthy) GOP + 10 trailing GOPs = 20 committed
// GOPs. The standalone "tail" keyframe and its seamLead P-frames are the
// dropped truncated GOP, so the output must contain exactly one GOP fewer than
// emitted and a whole number of complete GOPs.
const committedGOPs = 9 + 1 + 10
wantSync := committedGOPs
wantSamples := committedGOPs * gopFrames
// A healthy fragment only ever contains keyframes spaced ~normalGOPms apart.
// If any fragment contains two keyframes closer than half a normal GOP, the
// premature seam IDR was not dropped and the file will freeze on playback.
maxBunchMs := normalGOPms / 2
totalSamples := 0
totalSync := 0
fragIdx := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
tfdt := traf.Tfdt.BaseMediaDecodeTime()
offset := uint64(0)
var keys []uint64
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
totalSamples++
// sample_depends_on == 2 => "does not depend on others" => IDR/sync.
if (s.Flags>>24)&0x03 == 0x02 {
keys = append(keys, offset)
totalSync++
}
offset += uint64(s.Dur)
}
}
t.Logf("gop=%dframes frag %d tfdt=%d samples_dur=%d keys@%v", gopFrames, fragIdx, tfdt, offset, keys)
for i := 1; i < len(keys); i++ {
gap := keys[i] - keys[i-1]
if gap < maxBunchMs {
t.Errorf("gop=%dframes frag %d (tfdt=%d): two IDRs only %d ms apart in same fragment (< %d) - seam was not dropped",
gopFrames, fragIdx, tfdt, gap, maxBunchMs)
}
}
fragIdx++
}
}
}
if totalSync != wantSync {
t.Errorf("gop=%dframes: got %d keyframes in output, want %d - the truncated seam GOP was not dropped exactly once",
gopFrames, totalSync, wantSync)
}
if totalSamples != wantSamples {
t.Errorf("gop=%dframes: got %d video samples in output, want %d (= %d committed GOPs x %d frames) - the seam GOP drop removed the wrong frames",
gopFrames, totalSamples, wantSamples, committedGOPs, gopFrames)
}
}
// TestMP4LoopSeamDrop exercises the ~1s GOP case (30 frames @ ~33ms),
// matching the original failing recording.
func TestMP4LoopSeamDrop(t *testing.T) {
runLoopSeamScenario(t, 30)
}
// TestMP4LoopSeamDropLargeGOP exercises a larger ~2s GOP (60 frames). The
// GOP size is configurable per camera; this guards against regressing to a
// fixed-millisecond threshold that would only work for ~1s GOPs.
func TestMP4LoopSeamDropLargeGOP(t *testing.T) {
runLoopSeamScenario(t, 60)
}
// TestMP4LoopSeamDropShortGOP exercises a short ~0.5s GOP (15 frames),
// where a fixed ~1s threshold would misfire on every keyframe. The relative
// detection must only drop the genuine premature seam's truncated tail GOP.
func TestMP4LoopSeamDropShortGOP(t *testing.T) {
runLoopSeamScenario(t, 15)
}
-129
View File
@@ -1,129 +0,0 @@
package video
import (
"os"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/models"
)
// TestMP4VariableGOPKeepsHealthyShortGOP reproduces the adam-drive regression:
// a variable-GOP ("smart codec") camera lengthens its keyframe interval during a
// static scene (e.g. 500ms -> 1500/2000ms) and then drops back to its normal
// 500ms cadence on motion. That normal, FULL 500ms GOP arrives much sooner than
// the immediately preceding (long, static) GOP.
//
// The previous heuristic compared the new keyframe interval against the *previous*
// interval and dropped the GOP whenever gap < previousInterval/2 — so every normal
// 500ms keyframe following a long static GOP was misclassified as a premature
// loop/restart seam and a whole healthy GOP (~15 frames) was discarded. In the
// field this silently deleted ~0.5s of video on virtually every recording from
// such cameras, producing a freeze/jump artifact.
//
// After the fix the seam check compares against the running MINIMUM cadence and
// additionally requires the buffered GOP to be genuinely truncated, so a full
// healthy GOP is always kept regardless of how long the preceding GOP was. This
// test asserts that NO frames are dropped for a pure variable-GOP stream.
func TestMP4VariableGOPKeepsHealthyShortGOP(t *testing.T) {
tmpFile, err := os.CreateTemp("", "test_variable_gop_*.mp4")
if err != nil {
t.Fatalf("create temp: %v", err)
}
tmpFile.Close()
defer os.Remove(tmpFile.Name())
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
pps := []byte{0x68, 0xce, 0x38, 0x80}
mp4Video := NewMP4(tmpFile.Name(), [][]byte{sps}, [][]byte{pps}, nil, 60)
mp4Video.SetWidth(1920)
mp4Video.SetHeight(1080)
v := mp4Video.AddVideoTrack("H264")
mk := func(k bool) []byte {
nt := byte(0x01)
if k {
nt = 0x65
}
f := []byte{0, 0, 0, 1, nt}
for i := 0; i < 200; i++ {
f = append(f, byte(i))
}
return f
}
const frameDur = uint64(33)
pts := uint64(0)
emitFrame := func(isKey bool) {
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts, 0)
pts += frameDur
}
// emitGOP emits a complete GOP of exactly frames frames: a leading keyframe
// followed by frames-1 P-frames. Every GOP here is healthy and complete; only
// its length varies, exactly as a smart-codec camera varies the GOP.
emitGOP := func(frames int) {
emitFrame(true)
for i := 0; i < frames-1; i++ {
emitFrame(false)
}
}
// Normal cadence is 15 frames (~500ms). The camera then lengthens the GOP for
// several static scenes (45 and 60 frames, ~1500ms and ~2000ms) before
// dropping back to the normal 15-frame GOP on motion — the transition the old
// heuristic wrongly treated as a seam. The whole sequence is then repeated to
// cover multiple long->short transitions.
gopLengths := []int{15, 15, 45, 15, 60, 15, 15, 45, 15, 15, 60, 15}
totalEmittedFrames := 0
emittedKeyframes := 0
for _, n := range gopLengths {
emitGOP(n)
totalEmittedFrames += n
emittedKeyframes++
}
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
f, err := os.Open(tmpFile.Name())
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
parsed, err := mp4ff.DecodeFile(f)
if err != nil {
t.Fatalf("decode: %v", err)
}
totalSamples := 0
totalSync := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
totalSamples++
// sample_depends_on == 2 => "does not depend on others" => IDR/sync.
if (s.Flags>>24)&0x03 == 0x02 {
totalSync++
}
}
}
}
}
}
// Every GOP is healthy, so nothing must be dropped: all keyframes and all
// frames must survive. A shortfall means a normal variable-GOP keyframe was
// misclassified as a seam.
if totalSync != emittedKeyframes {
t.Errorf("got %d keyframes in output, want %d - a healthy variable-GOP keyframe was wrongly dropped as a seam",
totalSync, emittedKeyframes)
}
if totalSamples != totalEmittedFrames {
t.Errorf("got %d video samples in output, want %d - a healthy variable-GOP GOP was wrongly dropped as a seam",
totalSamples, totalEmittedFrames)
}
}
@@ -1,270 +0,0 @@
// AAC to G.711 µ-law transcoder using FFmpeg (libavcodec + libswresample).
// Build with: go build -tags ffmpeg ...
//
// Requires: libavcodec-dev, libavutil-dev, libswresample-dev (FFmpeg ≥ 5.x)
// and an AAC decoder compiled into the FFmpeg build (usually the default).
//
//go:build ffmpeg
package webrtc
/*
#cgo pkg-config: libavcodec libavutil libswresample
#cgo CFLAGS: -Wno-deprecated-declarations
#include <libavcodec/avcodec.h>
#include <libavutil/channel_layout.h>
#include <libavutil/frame.h>
#include <libavutil/mem.h>
#include <libavutil/opt.h>
#include <libswresample/swresample.h>
#include <stdlib.h>
#include <string.h>
// ── Transcoder handle ───────────────────────────────────────────────────
typedef struct {
AVCodecContext *codec_ctx;
AVCodecParserContext *parser;
SwrContext *swr_ctx;
AVFrame *frame;
AVPacket *pkt;
int swr_initialized;
int in_sample_rate;
int in_channels;
} aac_transcoder_t;
// ── Create / Destroy ────────────────────────────────────────────────────
static aac_transcoder_t* aac_transcoder_create(void) {
const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_AAC);
if (!codec) return NULL;
aac_transcoder_t *t = (aac_transcoder_t*)calloc(1, sizeof(aac_transcoder_t));
if (!t) return NULL;
t->codec_ctx = avcodec_alloc_context3(codec);
if (!t->codec_ctx) { free(t); return NULL; }
if (avcodec_open2(t->codec_ctx, codec, NULL) < 0) {
avcodec_free_context(&t->codec_ctx);
free(t);
return NULL;
}
t->parser = av_parser_init(AV_CODEC_ID_AAC);
if (!t->parser) {
avcodec_free_context(&t->codec_ctx);
free(t);
return NULL;
}
t->frame = av_frame_alloc();
t->pkt = av_packet_alloc();
if (!t->frame || !t->pkt) {
if (t->frame) av_frame_free(&t->frame);
if (t->pkt) av_packet_free(&t->pkt);
av_parser_close(t->parser);
avcodec_free_context(&t->codec_ctx);
free(t);
return NULL;
}
return t;
}
static void aac_transcoder_destroy(aac_transcoder_t *t) {
if (!t) return;
if (t->swr_ctx) swr_free(&t->swr_ctx);
if (t->frame) av_frame_free(&t->frame);
if (t->pkt) av_packet_free(&t->pkt);
if (t->parser) av_parser_close(t->parser);
if (t->codec_ctx) avcodec_free_context(&t->codec_ctx);
free(t);
}
// ── Lazy resampler init (called after the first decoded frame) ──────────
static int aac_init_swr(aac_transcoder_t *t) {
int64_t in_ch_layout = (int64_t)t->codec_ctx->channel_layout;
if (in_ch_layout == 0)
in_ch_layout = av_get_default_channel_layout(t->codec_ctx->channels);
t->swr_ctx = swr_alloc_set_opts(
NULL,
AV_CH_LAYOUT_MONO, // out: mono
AV_SAMPLE_FMT_S16, // out: signed 16-bit
8000, // out: 8 kHz
in_ch_layout, // in: from decoder
t->codec_ctx->sample_fmt, // in: from decoder
t->codec_ctx->sample_rate, // in: from decoder
0, NULL);
if (!t->swr_ctx) return -1;
if (swr_init(t->swr_ctx) < 0) {
swr_free(&t->swr_ctx);
return -1;
}
t->in_sample_rate = t->codec_ctx->sample_rate;
t->in_channels = t->codec_ctx->channels;
t->swr_initialized = 1;
return 0;
}
// ── Transcode ADTS → 8 kHz mono S16 PCM ────────────────────────────────
// Caller must free *out_pcm with av_free() when non-NULL.
static int aac_transcode_to_pcm(aac_transcoder_t *t,
const uint8_t *data, int data_size,
uint8_t **out_pcm, int *out_size) {
*out_pcm = NULL;
*out_size = 0;
if (!data || data_size <= 0) return 0;
int buf_cap = 8192;
uint8_t *buf = (uint8_t*)av_malloc(buf_cap);
if (!buf) return -1;
int buf_len = 0;
while (data_size > 0) {
uint8_t *pout = NULL;
int pout_size = 0;
int used = av_parser_parse2(t->parser, t->codec_ctx,
&pout, &pout_size,
data, data_size,
AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
if (used < 0) break;
data += used;
data_size -= used;
if (pout_size == 0) continue;
// Feed parsed frame to decoder
t->pkt->data = pout;
t->pkt->size = pout_size;
if (avcodec_send_packet(t->codec_ctx, t->pkt) < 0) continue;
// Pull all decoded frames
while (avcodec_receive_frame(t->codec_ctx, t->frame) == 0) {
if (!t->swr_initialized) {
if (aac_init_swr(t) < 0) {
av_frame_unref(t->frame);
av_free(buf);
return -1;
}
}
int out_samples = swr_get_out_samples(t->swr_ctx,
t->frame->nb_samples);
if (out_samples <= 0) out_samples = t->frame->nb_samples;
int needed = buf_len + out_samples * 2; // S16 = 2 bytes/sample
if (needed > buf_cap) {
buf_cap = needed * 2;
uint8_t *tmp = (uint8_t*)av_realloc(buf, buf_cap);
if (!tmp) { av_frame_unref(t->frame); av_free(buf); return -1; }
buf = tmp;
}
uint8_t *dst = buf + buf_len;
int converted = swr_convert(t->swr_ctx,
&dst, out_samples,
(const uint8_t**)t->frame->extended_data,
t->frame->nb_samples);
if (converted > 0)
buf_len += converted * 2;
av_frame_unref(t->frame);
}
}
if (buf_len == 0) {
av_free(buf);
return 0;
}
*out_pcm = buf;
*out_size = buf_len;
return 0;
}
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/zaf/g711"
)
// AACTranscodingAvailable reports whether AAC→PCMU transcoding
// is compiled in (requires the "ffmpeg" build tag).
func AACTranscodingAvailable() bool { return true }
// AACTranscoder decodes ADTS-wrapped AAC audio to 8 kHz mono PCM
// and encodes it as G.711 µ-law for WebRTC transport.
type AACTranscoder struct {
handle *C.aac_transcoder_t
}
// NewAACTranscoder creates a transcoder backed by FFmpeg's AAC decoder.
func NewAACTranscoder() (*AACTranscoder, error) {
h := C.aac_transcoder_create()
if h == nil {
return nil, errors.New("failed to create AAC transcoder (FFmpeg AAC decoder not available?)")
}
log.Log.Info("webrtc.aac_transcoder: AAC → G.711 µ-law transcoder initialised (FFmpeg)")
return &AACTranscoder{handle: h}, nil
}
// Transcode converts an ADTS buffer (one or more AAC frames) into
// G.711 µ-law encoded audio suitable for a PCMU WebRTC track.
func (t *AACTranscoder) Transcode(adtsData []byte) ([]byte, error) {
if t == nil || t.handle == nil || len(adtsData) == 0 {
return nil, nil
}
var outPCM *C.uint8_t
var outSize C.int
ret := C.aac_transcode_to_pcm(
t.handle,
(*C.uint8_t)(unsafe.Pointer(&adtsData[0])),
C.int(len(adtsData)),
&outPCM, &outSize,
)
if ret < 0 {
return nil, errors.New("AAC decode/resample failed")
}
if outSize == 0 || outPCM == nil {
return nil, nil // decoder buffering, no output yet
}
defer C.av_free(unsafe.Pointer(outPCM))
// Copy S16LE PCM to Go slice, then encode to µ-law.
pcm := C.GoBytes(unsafe.Pointer(outPCM), outSize)
ulaw := g711.EncodeUlaw(pcm)
// Log resampler details once.
if t.handle.swr_initialized == 1 && t.handle.in_sample_rate != 0 {
log.Log.Info(fmt.Sprintf(
"webrtc.aac_transcoder: first output – resampling %d Hz / %d ch → 8000 Hz mono → µ-law",
int(t.handle.in_sample_rate), int(t.handle.in_channels)))
// Prevent repeated logging by zeroing the field we check.
t.handle.in_sample_rate = 0
}
return ulaw, nil
}
// Close releases all FFmpeg resources held by the transcoder.
func (t *AACTranscoder) Close() {
if t != nil && t.handle != nil {
C.aac_transcoder_destroy(t.handle)
t.handle = nil
log.Log.Info("webrtc.aac_transcoder: transcoder closed")
}
}
-205
View File
@@ -1,205 +0,0 @@
// AAC transcoding fallback that uses the ffmpeg binary at runtime.
// Build with -tags ffmpeg to use the in-process CGO implementation instead.
//
//go:build !ffmpeg
package webrtc
import (
"bytes"
"errors"
"io"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
)
// AACTranscodingAvailable reports whether AAC→PCMU transcoding
// is available in the current runtime.
func AACTranscodingAvailable() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
// AACTranscoder uses an ffmpeg subprocess to convert ADTS AAC to raw PCMU.
type AACTranscoder struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderrBuf bytes.Buffer
mu sync.Mutex
outMu sync.Mutex
outBuf bytes.Buffer
closed bool
closeOnce sync.Once
}
// NewAACTranscoder creates a runtime ffmpeg-based transcoder.
func NewAACTranscoder() (*AACTranscoder, error) {
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, errors.New("AAC transcoding not available: ffmpeg binary not found in PATH")
}
log.Log.Info("webrtc.aac_transcoder: using ffmpeg binary at " + ffmpegPath)
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-fflags", "+nobuffer",
"-flags", "low_delay",
"-f", "aac",
"-i", "pipe:0",
"-vn",
"-ac", "1",
"-ar", "8000",
"-acodec", "pcm_mulaw",
"-f", "mulaw",
"pipe:1",
)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cmd.Stderr = &bytes.Buffer{}
if err := cmd.Start(); err != nil {
return nil, err
}
t := &AACTranscoder{
cmd: cmd,
stdin: stdin,
stdout: stdout,
}
if stderrBuf, ok := cmd.Stderr.(*bytes.Buffer); ok {
t.stderrBuf = *stderrBuf
}
go func() {
buf := make([]byte, 4096)
for {
n, readErr := stdout.Read(buf)
if n > 0 {
t.outMu.Lock()
_, _ = t.outBuf.Write(buf[:n])
buffered := t.outBuf.Len()
t.outMu.Unlock()
if buffered <= 8192 || buffered%16000 == 0 {
log.Log.Debug("webrtc.aac_transcoder: ffmpeg produced PCMU bytes, buffered=" + strconv.Itoa(buffered))
}
}
if readErr != nil {
if readErr != io.EOF {
log.Log.Warning("webrtc.aac_transcoder: stdout reader stopped: " + readErr.Error())
}
return
}
}
}()
log.Log.Info("webrtc.aac_transcoder: AAC → PCMU transcoder initialised (ffmpeg process)")
return t, nil
}
// Transcode writes ADTS AAC to ffmpeg and returns any PCMU bytes produced.
func (t *AACTranscoder) Transcode(adtsData []byte) ([]byte, error) {
if t == nil || len(adtsData) == 0 {
return nil, nil
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return nil, errors.New("AAC transcoder is closed")
}
if _, err := t.stdin.Write(adtsData); err != nil {
return nil, err
}
if len(adtsData) <= 512 || len(adtsData)%1024 == 0 {
log.Log.Debug("webrtc.aac_transcoder: wrote AAC bytes to ffmpeg, input=" + strconv.Itoa(len(adtsData)))
}
deadline := time.Now().Add(75 * time.Millisecond)
for {
data := t.readAvailable()
if len(data) > 0 {
log.Log.Debug("webrtc.aac_transcoder: returning PCMU bytes=" + strconv.Itoa(len(data)))
return data, nil
}
if time.Now().After(deadline) {
if stderr := t.stderrString(); stderr != "" {
log.Log.Warning("webrtc.aac_transcoder: no output before deadline, ffmpeg stderr: " + stderr)
} else {
log.Log.Debug("webrtc.aac_transcoder: no PCMU output before deadline")
}
return nil, nil
}
time.Sleep(5 * time.Millisecond)
}
}
func (t *AACTranscoder) readAvailable() []byte {
t.outMu.Lock()
defer t.outMu.Unlock()
if t.outBuf.Len() == 0 {
return nil
}
out := make([]byte, t.outBuf.Len())
copy(out, t.outBuf.Bytes())
t.outBuf.Reset()
return out
}
func (t *AACTranscoder) stderrString() string {
if t == nil {
return ""
}
if stderrBuf, ok := t.cmd.Stderr.(*bytes.Buffer); ok {
return strings.TrimSpace(stderrBuf.String())
}
return strings.TrimSpace(t.stderrBuf.String())
}
// Close stops the ffmpeg subprocess.
func (t *AACTranscoder) Close() {
if t == nil {
return
}
t.closeOnce.Do(func() {
t.mu.Lock()
t.closed = true
if t.stdin != nil {
_ = t.stdin.Close()
}
t.mu.Unlock()
if t.stdout != nil {
_ = t.stdout.Close()
}
if t.cmd != nil {
_ = t.cmd.Process.Kill()
_, _ = t.cmd.Process.Wait()
if stderr := t.stderrString(); stderr != "" {
log.Log.Info("webrtc.aac_transcoder: ffmpeg stderr on close: " + stderr)
}
}
})
}
-137
View File
@@ -1,137 +0,0 @@
package webrtc
import (
"io"
"sync"
"github.com/kerberos-io/agent/machinery/src/log"
pionWebRTC "github.com/pion/webrtc/v4"
pionMedia "github.com/pion/webrtc/v4/pkg/media"
)
const (
// peerSampleBuffer controls how many samples can be buffered per peer before
// dropping. Keeps slow peers from blocking the broadcaster.
peerSampleBuffer = 60
)
// peerTrack is a per-peer track with its own non-blocking sample channel.
type peerTrack struct {
track *pionWebRTC.TrackLocalStaticSample
samples chan pionMedia.Sample
done chan struct{}
}
// TrackBroadcaster fans out media samples to multiple peer-specific tracks
// without blocking. Each peer gets its own TrackLocalStaticSample and a
// goroutine that drains samples independently, so a slow/congested peer
// cannot stall the others.
type TrackBroadcaster struct {
mu sync.RWMutex
peers map[string]*peerTrack
mimeType string
id string
streamID string
}
// NewTrackBroadcaster creates a new broadcaster for either video or audio.
func NewTrackBroadcaster(mimeType string, id string, streamID string) *TrackBroadcaster {
return &TrackBroadcaster{
peers: make(map[string]*peerTrack),
mimeType: mimeType,
id: id,
streamID: streamID,
}
}
// AddPeer creates a new per-peer track and starts a writer goroutine.
// Returns the track to be added to the PeerConnection via AddTrack().
func (b *TrackBroadcaster) AddPeer(sessionKey string) (*pionWebRTC.TrackLocalStaticSample, error) {
track, err := pionWebRTC.NewTrackLocalStaticSample(
pionWebRTC.RTPCodecCapability{MimeType: b.mimeType},
b.id,
b.streamID,
)
if err != nil {
return nil, err
}
pt := &peerTrack{
track: track,
samples: make(chan pionMedia.Sample, peerSampleBuffer),
done: make(chan struct{}),
}
b.mu.Lock()
b.peers[sessionKey] = pt
b.mu.Unlock()
// Per-peer writer goroutine — drains samples independently.
go func() {
defer close(pt.done)
for sample := range pt.samples {
if err := pt.track.WriteSample(sample); err != nil {
if err == io.ErrClosedPipe {
return
}
log.Log.Error("webrtc.broadcaster.peerWriter(): error writing sample for " + sessionKey + ": " + err.Error())
}
}
}()
log.Log.Info("webrtc.broadcaster.AddPeer(): added peer track for " + sessionKey)
return track, nil
}
// RemovePeer stops the writer goroutine and removes the peer.
func (b *TrackBroadcaster) RemovePeer(sessionKey string) {
b.mu.Lock()
pt, exists := b.peers[sessionKey]
if exists {
delete(b.peers, sessionKey)
}
b.mu.Unlock()
if exists {
close(pt.samples)
<-pt.done // wait for writer goroutine to finish
log.Log.Info("webrtc.broadcaster.RemovePeer(): removed peer track for " + sessionKey)
}
}
// WriteSample fans out a sample to all connected peers without blocking.
// If a peer's buffer is full (slow consumer), the sample is dropped for
// that peer only — other peers are unaffected.
func (b *TrackBroadcaster) WriteSample(sample pionMedia.Sample) {
b.mu.RLock()
defer b.mu.RUnlock()
for sessionKey, pt := range b.peers {
select {
case pt.samples <- sample:
default:
log.Log.Warning("webrtc.broadcaster.WriteSample(): dropping sample for slow peer " + sessionKey)
}
}
}
// PeerCount returns the current number of connected peers.
func (b *TrackBroadcaster) PeerCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.peers)
}
// Close removes all peers and stops all writer goroutines.
func (b *TrackBroadcaster) Close() {
b.mu.Lock()
keys := make([]string, 0, len(b.peers))
for k := range b.peers {
keys = append(keys, k)
}
b.mu.Unlock()
for _, key := range keys {
b.RemovePeer(key)
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
#!/bin/bash
swag init -g ./src/routers/http/server.go
swag init -g ./src/routers/http/Server.go
+3 -3
View File
@@ -45,9 +45,9 @@
"crypto": false
},
"scripts": {
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
"build": "DISABLE_ESLINT_PLUGIN=true GENERATE_SOURCEMAP=false REACT_APP_ENVIRONMENT=production react-scripts build && rm -rf ../machinery/www && mv build ../machinery/www",
"test": "DISABLE_ESLINT_PLUGIN=true react-scripts test",
"start": "react-scripts start",
"build": "GENERATE_SOURCEMAP=false REACT_APP_ENVIRONMENT=production react-scripts build && rm -rf ../machinery/www && mv build ../machinery/www",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint --debug 'src/**/*.{js,jsx,ts,tsx}'",
"format": "prettier --write \"**/*.{js,jsx,json,md}\""
+53 -59
View File
@@ -9,7 +9,7 @@
},
"navigation": {
"profile": "Profile",
"admin": "Admin",
"admin": "admin",
"management": "Management",
"dashboard": "Dashboard",
"recordings": "Recordings",
@@ -32,11 +32,11 @@
"latest_events": "Latest events",
"configure_connection": "Configure connection",
"no_events": "No events",
"no_events_description": "No recordings were found, make sure your Agent is properly configured.",
"no_events_description": "No recordings where found, make sure your Kerberos Agent is properly configured.",
"motion_detected": "Motion was detected",
"live_view": "Live view",
"loading_live_view": "Loading live view",
"loading_live_view_description": "Hold on, we are loading your live view here. If you didn't configure your camera connection, update it on the settings pages.",
"loading_live_view_description": "Hold on we are loading your live view here. If you didn't configure your camera connection, update it on the settings pages.",
"time": "Time",
"description": "Description",
"name": "Name"
@@ -59,32 +59,32 @@
"persistence": "Persistence"
},
"info": {
"kerberos_hub_demo": "Have a look at our Hub demo environment, to see Hub in action!",
"configuration_updated_success": "Your configuration has been updated successfully.",
"kerberos_hub_demo": "Have a look at our Kerberos Hub demo environment, to see Kerberos Hub in action!",
"configuration_updated_success": "Your configuration have been updated successfully.",
"configuration_updated_error": "Something went wrong while saving.",
"verify_hub": "Verifying your Hub settings.",
"verify_hub_success": "Hub settings are successfully verified.",
"verify_hub_error": "Something went wrong while verifying Hub.",
"verify_hub": "Verifying your Kerberos Hub settings.",
"verify_hub_success": "Kerberos Hub settings are successfully verified.",
"verify_hub_error": "Something went wrong while verifying Kerberos Hub",
"verify_persistence": "Verifying your persistence settings.",
"verify_persistence_success": "Persistence settings are successfully verified.",
"verify_persistence_error": "Something went wrong while verifying the persistence.",
"verify_persistence_error": "Something went wrong while verifying the persistence",
"verify_camera": "Verifying your camera settings.",
"verify_camera_success": "Camera settings are successfully verified.",
"verify_camera_error": "Something went wrong while verifying the camera settings.",
"verify_camera_error": "Something went wrong while verifying the camera settings",
"verify_onvif": "Verifying your ONVIF settings.",
"verify_onvif_success": "ONVIF settings are successfully verified.",
"verify_onvif_error": "Something went wrong while verifying the ONVIF settings."
"verify_onvif_error": "Something went wrong while verifying the ONVIF settings"
},
"overview": {
"general": "General",
"description_general": "General settings for your Agent",
"description_general": "General settings for your Kerberos Agent",
"key": "Key",
"camera_name": "Camera name",
"camera_friendly_name": "Friendly name",
"timezone": "Timezone",
"select_timezone": "Select a timezone",
"advanced_configuration": "Advanced configuration",
"description_advanced_configuration": "Detailed configuration options to enable or disable specific parts of the Agent",
"description_advanced_configuration": "Detailed configuration options to enable or disable specific parts of the Kerberos Agent",
"offline_mode": "Offline mode",
"description_offline_mode": "Disable all outgoing traffic",
"encryption": "Encryption",
@@ -101,9 +101,9 @@
"camera": "Camera",
"description_camera": "Camera settings are required to make a connection to your camera of choice.",
"only_h264": "Currently only H264/H265 RTSP streams are supported.",
"rtsp_url": "RTSP URL",
"rtsp_url": "RTSP url",
"rtsp_h264": "A H264/H265 RTSP connection to your camera.",
"sub_rtsp_url": "Sub RTSP URL (used for livestreaming)",
"sub_rtsp_url": "Sub RTSP url (used for livestreaming)",
"sub_rtsp_h264": "A secondary RTSP connection to the low resolution of your camera.",
"onvif": "ONVIF",
"description_onvif": "Credentials to communicate with ONVIF capabilities. These are used for PTZ or other capabilities provided by the camera.",
@@ -115,28 +115,28 @@
},
"recording": {
"recording": "Recording",
"description_recording": "Specify how you would like to make recordings. Having a continuous 24/7 setup or a motion-based recording.",
"description_recording": "Specify how you would like to make recordings. Having a continuous 24/7 setup or a motion based recording.",
"continuous_recording": "Continuous recording",
"description_continuous_recording": "Make 24/7 or motion-based recordings.",
"max_duration": "Max video duration (seconds)",
"description_continuous_recording": "Make 24/7 or motion based recordings.",
"max_duration": "max video duration (seconds)",
"description_max_duration": "The maximum duration of a recording.",
"pre_recording": "Pre recording (key frames buffered)",
"pre_recording": "pre recording (key frames buffered)",
"description_pre_recording": "Seconds before an event occurred.",
"post_recording": "Post recording (seconds)",
"post_recording": "post recording (seconds)",
"description_post_recording": "Seconds after an event occurred.",
"threshold": "Recording threshold (pixels)",
"description_threshold": "The number of pixels changed to record.",
"description_threshold": "The number of pixels changed to record",
"autoclean": "Auto clean",
"description_autoclean": "Specify if the Agent can clean up recordings when a specific storage capacity (MB) is reached. This will remove the oldest recordings when the capacity is reached.",
"description_autoclean": "Specify if the Kerberos Agent can cleanup recordings when a specific storage capacity (MB) is reached. This will remove the oldest recordings when the capacity is reached.",
"autoclean_enable": "Enable auto clean",
"autoclean_description_enable": "Remove oldest recording when capacity reached.",
"autoclean_max_directory_size": "Maximum directory size (MB)",
"autoclean_description_max_directory_size": "The maximum MBs of recordings stored.",
"autoclean_description_max_directory_size": "The maximum MB's of recordings stored.",
"fragmentedrecordings": "Fragmented recordings",
"description_fragmentedrecordings": "When recordings are fragmented they are suitable for an HLS stream. When turned on, the MP4 container will look a bit different.",
"description_fragmentedrecordings": "When recordings are fragmented they are suitable for an HLS stream. When turned on the MP4 container will look a bit different.",
"fragmentedrecordings_enable": "Enable fragmentation",
"fragmentedrecordings_description_enable": "Fragmented recordings are required for HLS.",
"fragmentedrecordings_duration": "Fragment duration",
"fragmentedrecordings_duration": "fragment duration",
"fragmentedrecordings_description_duration": "Duration of a single fragment."
},
"streaming": {
@@ -149,16 +149,16 @@
"force_turn": "Force TURN",
"force_turn_description": "Force TURN usage, even when STUN is available.",
"stun_turn_forward": "Forwarding and transcoding",
"stun_turn_description_forward": "Optimizations and enhancements for TURN/STUN communication.",
"stun_turn_description_forward": "Optimisations and enhancements for TURN/STUN communication.",
"stun_turn_webrtc": "Forwarding to WebRTC broker",
"stun_turn_description_webrtc": "Forward H264 stream through MQTT",
"stun_turn_description_webrtc": "Forward h264 stream through MQTT",
"stun_turn_transcode": "Transcode stream",
"stun_turn_description_transcode": "Convert stream to a lower resolution",
"stun_turn_downscale": "Downscale resolution (in % of original resolution)",
"mqtt": "MQTT",
"description_mqtt": "An MQTT broker is used to communicate from",
"description2_mqtt": "to the Agent, to achieve for example livestreaming or ONVIF (PTZ) capabilities.",
"mqtt_brokeruri": "Broker URI",
"description_mqtt": "A MQTT broker is used to communicate from",
"description2_mqtt": "to the Kerberos Agent, to achieve for example livestreaming or ONVIF (PTZ) capabilities.",
"mqtt_brokeruri": "Broker Uri",
"mqtt_username": "Username",
"mqtt_password": "Password",
"realtimeprocessing": "Realtime Processing",
@@ -180,61 +180,55 @@
"friday": "Friday",
"saturday": "Saturday",
"externalcondition": "External Condition",
"description_externalcondition": "Depending on an external web service, recording can be enabled or disabled.",
"description_externalcondition": "Depending on an external webservice recording can be enabled or disabled.",
"regionofinterest": "Region Of Interest",
"description_regionofinterest": "By defining one or more regions, motion will be tracked only in the regions you have defined."
},
"persistence": {
"kerberoshub": "Hub",
"description_kerberoshub": "Agents can send heartbeats to a central",
"description2_kerberoshub": "installation. Heartbeats and other relevant information are synced to Hub to show realtime information about your video landscape.",
"kerberoshub": "Kerberos Hub",
"description_kerberoshub": "Kerberos Agents can send heartbeats to a central",
"description2_kerberoshub": "installation. Heartbeats and other relevant information are synced to Kerberos Hub to show realtime information about your video landscape.",
"persistence": "Persistence",
"secondary_persistence": "Secondary Persistence",
"description_secondary_persistence": "Recordings will be sent to secondary persistence if the primary persistence is unavailable or fails. This can be useful for failover purposes.",
"saasoffering": "Hub (SaaS offering)",
"saasoffering": "Kerberos Hub (SAAS offering)",
"description_persistence": "Having the ability to store your recordings is the beginning of everything. You can choose between our",
"description2_persistence": ", or a 3rd party provider",
"select_persistence": "Select a persistence",
"kerberoshub_encryption": "Encryption",
"kerberoshub_encryption_description": "All traffic from/to Hub will be encrypted using AES-256.",
"kerberoshub_proxyurl": "Hub Proxy URL",
"kerberoshub_encryption_description": "All traffic from/to Kerberos Hub will encrypted using AES-256.",
"kerberoshub_proxyurl": "Kerberos Hub Proxy URL",
"kerberoshub_description_proxyurl": "The Proxy endpoint for uploading your recordings.",
"kerberoshub_apiurl": "Hub API URL",
"kerberoshub_apiurl": "Kerberos Hub API URL",
"kerberoshub_description_apiurl": "The API endpoint for uploading your recordings.",
"kerberoshub_publickey": "Public key",
"kerberoshub_description_publickey": "The public key granted to your Hub account.",
"kerberoshub_description_publickey": "The public key granted to your Kerberos Hub account.",
"kerberoshub_privatekey": "Private key",
"kerberoshub_description_privatekey": "The private key granted to your Hub account.",
"kerberoshub_description_privatekey": "The private key granted to your Kerberos Hub account.",
"kerberoshub_site": "Site",
"kerberoshub_description_site": "The site ID the Agents belong to in Hub.",
"kerberoshub_description_site": "The site ID the Kerberos Agents are belonging to in Kerberos Hub.",
"kerberoshub_region": "Region",
"kerberoshub_description_region": "The region we are storing our recordings in.",
"kerberoshub_bucket": "Bucket",
"kerberoshub_description_bucket": "The bucket we are storing our recordings in.",
"kerberoshub_username": "Username/Directory (should match Hub username)",
"kerberoshub_description_username": "The username of your Hub account.",
"kerberosvault_apiurl": "Vault API URL",
"kerberosvault_description_apiurl": "The Vault API",
"kerberoshub_username": "Username/Directory (should match Kerberos Hub username)",
"kerberoshub_description_username": "The username of your Kerberos Hub account.",
"kerberosvault_apiurl": "Kerberos Vault API URL",
"kerberosvault_description_apiurl": "The Kerberos Vault API",
"kerberosvault_provider": "Provider",
"kerberosvault_description_provider": "The provider to which your recordings will be sent.",
"kerberosvault_directory": "Directory (should match Hub username)",
"kerberosvault_description_directory": "Subdirectory the recordings will be stored in your provider.",
"kerberosvault_description_provider": "The provider to which your recordings will be send.",
"kerberosvault_directory": "Directory (should match Kerberos Hub username)",
"kerberosvault_description_directory": "Sub directory the recordings will be stored in your provider.",
"kerberosvault_accesskey": "Access key",
"kerberosvault_description_accesskey": "The access key of your Vault account.",
"kerberosvault_description_accesskey": "The access key of your Kerberos Vault account.",
"kerberosvault_secretkey": "Secret key",
"kerberosvault_description_secretkey": "The secret key of your Vault account.",
"kerberosvault_maxretries": "Max retries",
"kerberosvault_description_maxretries": "The maximum number of retries to upload a recording.",
"kerberosvault_timeout": "Timeout",
"kerberosvault_description_timeout": "If a timeout occurs, recordings will be sent directly to the secondary Vault.",
"kerberosvault_description_secretkey": "The secret key of your Kerberos Vault account.",
"dropbox_directory": "Directory",
"dropbox_description_directory": "The subdirectory where the recordings will be stored in your Dropbox account.",
"dropbox_description_directory": "The sub directory where the recordings will be stored in your Dropbox account.",
"dropbox_accesstoken": "Access token",
"dropbox_description_accesstoken": "The access token of your Dropbox account/app.",
"verify_connection": "Verify Connection",
"remove_after_upload": "Once recordings are uploaded to some persistence, you might want to remove them from the local Agent.",
"remove_after_upload": "Once recordings are uploaded to some persistence, you might want to remove them from the local Kerberos Agent.",
"remove_after_upload_description": "Remove recordings after they are uploaded successfully.",
"remove_after_upload_enabled": "Enable delete on upload"
"remove_after_upload_enabled": "Enabled delete on upload"
}
}
}
+1 -1
View File
@@ -226,4 +226,4 @@
"remove_after_upload_enabled": "Abilita cancellazione al caricamento"
}
}
}
}
+1 -1
View File
@@ -226,4 +226,4 @@
"remove_after_upload_enabled": "Enabled delete on upload"
}
}
}
}
-234
View File
@@ -1,234 +0,0 @@
{
"breadcrumb": {
"watch_recordings": "Xem bản ghi",
"configure": "Cấu hình"
},
"buttons": {
"save": "Lưu",
"verify_connection": "Xác minh kết nối"
},
"navigation": {
"profile": "Hồ sơ",
"admin": "Quản trị",
"management": "Quản lý",
"dashboard": "Bảng điều khiển",
"recordings": "Bản ghi",
"settings": "Cài đặt",
"help_support": "Trợ giúp & Hỗ trợ",
"swagger": "API Swagger",
"documentation": "Tài liệu",
"ui_library": "Thư viện UI",
"layout": "Ngôn ngữ & Bố cục",
"choose_language": "Chọn ngôn ngữ"
},
"dashboard": {
"title": "Bảng điều khiển",
"heading": "Tổng quan về giám sát video của bạn",
"number_of_days": "Số ngày",
"total_recordings": "Tổng số bản ghi",
"connected": "Đã kết nối",
"not_connected": "Chưa kết nối",
"offline_mode": "Chế độ ngoại tuyến",
"latest_events": "Sự kiện gần đây",
"configure_connection": "Cấu hình kết nối",
"no_events": "Không có sự kiện",
"no_events_description": "Không tìm thấy bản ghi nào, hãy đảm bảo Kerberos Agent của bạn được cấu hình đúng cách.",
"motion_detected": "Phát hiện chuyển động",
"live_view": "Xem trực tiếp",
"loading_live_view": "Đang tải xem trực tiếp",
"loading_live_view_description": "Vui lòng chờ trong khi chúng tôi tải xem trực tiếp của bạn. Nếu bạn chưa cấu hình kết nối camera, hãy cập nhật trong trang cài đặt.",
"time": "Thời gian",
"description": "Mô tả",
"name": "Tên"
},
"recordings": {
"title": "Bản ghi",
"heading": "Tất cả bản ghi của bạn ở một nơi",
"search_media": "Tìm kiếm phương tiện"
},
"settings": {
"title": "Cài đặt",
"heading": "Thiết lập camera của bạn",
"submenu": {
"all": "Tất cả",
"overview": "Tổng quan",
"camera": "Camera",
"recording": "Ghi hình",
"streaming": "Truyền phát",
"conditions": "Điều kiện",
"persistence": "Lưu trữ"
},
"info": {
"kerberos_hub_demo": "Xem thử môi trường demo của Kerberos Hub để thấy Kerberos Hub hoạt động như thế nào!",
"configuration_updated_success": "Cấu hình của bạn đã được cập nhật thành công.",
"configuration_updated_error": "Đã xảy ra lỗi khi lưu.",
"verify_hub": "Đang xác minh cài đặt Kerberos Hub của bạn.",
"verify_hub_success": "Cài đặt Kerberos Hub đã được xác minh thành công.",
"verify_hub_error": "Đã xảy ra lỗi khi xác minh Kerberos Hub",
"verify_persistence": "Đang xác minh cài đặt lưu trữ.",
"verify_persistence_success": "Cài đặt lưu trữ đã được xác minh thành công.",
"verify_persistence_error": "Đã xảy ra lỗi khi xác minh lưu trữ",
"verify_camera": "Đang xác minh cài đặt camera.",
"verify_camera_success": "Cài đặt camera đã được xác minh thành công.",
"verify_camera_error": "Đã xảy ra lỗi khi xác minh cài đặt camera",
"verify_onvif": "Đang xác minh cài đặt ONVIF.",
"verify_onvif_success": "Cài đặt ONVIF đã được xác minh thành công.",
"verify_onvif_error": "Đã xảy ra lỗi khi xác minh cài đặt ONVIF"
},
"overview": {
"general": "Chung",
"description_general": "Cài đặt chung cho Kerberos Agent của bạn",
"key": "Khóa",
"camera_name": "Tên camera",
"camera_friendly_name": "Tên thân thiện",
"timezone": "Múi giờ",
"select_timezone": "Chọn múi giờ",
"advanced_configuration": "Cấu hình nâng cao",
"description_advanced_configuration": "Tùy chọn cấu hình chi tiết để bật hoặc tắt các phần cụ thể của Kerberos Agent",
"offline_mode": "Chế độ ngoại tuyến",
"description_offline_mode": "Vô hiệu hóa toàn bộ lưu lượng đi",
"encryption": "Mã hóa",
"description_encryption": "Bật mã hóa cho toàn bộ lưu lượng đi. Các tin nhắn MQTT và/hoặc bản ghi sẽ được mã hóa bằng AES-256. Một khóa riêng tư được sử dụng để ký.",
"encryption_enabled": "Bật mã hóa MQTT",
"description_encryption_enabled": "Bật mã hóa cho toàn bộ tin nhắn MQTT.",
"encryption_recordings_enabled": "Bật mã hóa bản ghi",
"description_encryption_recordings_enabled": "Bật mã hóa cho tất cả các bản ghi.",
"encryption_fingerprint": "Dấu vân tay",
"encryption_privatekey": "Khóa riêng tư",
"encryption_symmetrickey": "Khóa đối xứng"
},
"camera": {
"camera": "Camera",
"description_camera": "Cài đặt camera là bắt buộc để kết nối với camera bạn chọn.",
"only_h264": "Hiện tại chỉ hỗ trợ luồng RTSP H264/H265.",
"rtsp_url": "URL RTSP",
"rtsp_h264": "Kết nối RTSP H264/H265 với camera của bạn.",
"sub_rtsp_url": "URL RTSP phụ (dùng để phát trực tiếp)",
"sub_rtsp_h264": "Kết nối RTSP phụ với độ phân giải thấp của camera.",
"onvif": "ONVIF",
"description_onvif": "Thông tin xác thực để giao tiếp với các chức năng ONVIF. Chúng được sử dụng cho PTZ hoặc các khả năng khác do camera cung cấp.",
"onvif_xaddr": "Địa chỉ ONVIF",
"onvif_username": "Tên người dùng ONVIF",
"onvif_password": "Mật khẩu ONVIF",
"verify_connection": "Xác minh kết nối",
"verify_sub_connection": "Xác minh kết nối phụ"
},
"recording": {
"recording": "Ghi hình",
"description_recording": "Chỉ định cách bạn muốn thực hiện ghi hình. Có thể ghi liên tục 24/7 hoặc dựa trên chuyển động.",
"continuous_recording": "Ghi hình liên tục",
"description_continuous_recording": "Ghi hình liên tục 24/7 hoặc dựa trên chuyển động.",
"max_duration": "Thời lượng video tối đa (giây)",
"description_max_duration": "Thời lượng tối đa của một bản ghi.",
"pre_recording": "Ghi trước (khung hình chính được lưu vào bộ đệm)",
"description_pre_recording": "Số giây trước khi sự kiện xảy ra.",
"post_recording": "Ghi sau (giây)",
"description_post_recording": "Số giây sau khi sự kiện xảy ra.",
"threshold": "Ngưỡng ghi hình (pixel)",
"description_threshold": "Số pixel thay đổi cần đạt để bắt đầu ghi hình.",
"autoclean": "Tự động dọn dẹp",
"description_autoclean": "Chỉ định xem Kerberos Agent có thể dọn dẹp các bản ghi khi dung lượng lưu trữ đạt giới hạn nhất định (MB) hay không. Hệ thống sẽ xóa bản ghi cũ nhất khi đạt giới hạn.",
"autoclean_enable": "Bật tự động dọn dẹp",
"autoclean_description_enable": "Xóa bản ghi cũ nhất khi đạt giới hạn dung lượng.",
"autoclean_max_directory_size": "Dung lượng thư mục tối đa (MB)",
"autoclean_description_max_directory_size": "Dung lượng tối đa (MB) của các bản ghi được lưu trữ.",
"fragmentedrecordings": "Ghi hình phân đoạn",
"description_fragmentedrecordings": "Khi các bản ghi được phân đoạn, chúng phù hợp để phát trực tuyến HLS. Khi bật, định dạng MP4 sẽ có một số khác biệt.",
"fragmentedrecordings_enable": "Bật ghi hình phân đoạn",
"fragmentedrecordings_description_enable": "Ghi hình phân đoạn là bắt buộc đối với HLS.",
"fragmentedrecordings_duration": "Thời lượng phân đoạn",
"fragmentedrecordings_description_duration": "Thời lượng của một phân đoạn duy nhất."
},
"streaming": {
"stun_turn": "STUN/TURN cho WebRTC",
"description_stun_turn": "Để phát trực tiếp độ phân giải đầy đủ, chúng tôi sử dụng khái niệm WebRTC. Một trong những tính năng chính là ICE-candidate, cho phép vượt qua NAT bằng STUN/TURN.",
"stun_server": "Máy chủ STUN",
"turn_server": "Máy chủ TURN",
"turn_username": "Tên người dùng",
"turn_password": "Mật khẩu",
"force_turn": "Buộc sử dụng TURN",
"force_turn_description": "Buộc sử dụng TURN ngay cả khi STUN có sẵn.",
"stun_turn_forward": "Chuyển tiếp và mã hóa",
"stun_turn_description_forward": "Tối ưu hóa và cải thiện giao tiếp TURN/STUN.",
"stun_turn_webrtc": "Chuyển tiếp đến WebRTC broker",
"stun_turn_description_webrtc": "Chuyển tiếp luồng H264 qua MQTT",
"stun_turn_transcode": "Chuyển mã luồng",
"stun_turn_description_transcode": "Chuyển đổi luồng sang độ phân giải thấp hơn",
"stun_turn_downscale": "Giảm độ phân giải (theo % của độ phân giải gốc)",
"mqtt": "MQTT",
"description_mqtt": "Một MQTT broker được sử dụng để giao tiếp từ",
"description2_mqtt": "đến Kerberos Agent, nhằm hỗ trợ phát trực tiếp hoặc chức năng ONVIF (PTZ).",
"mqtt_brokeruri": "Broker Uri",
"mqtt_username": "Tên người dùng",
"mqtt_password": "Mật khẩu",
"realtimeprocessing": "Xử lý thời gian thực",
"description_realtimeprocessing": "Bằng cách bật xử lý thời gian thực, bạn sẽ nhận được các khung hình video thời gian thực qua kết nối MQTT đã chỉ định.",
"realtimeprocessing_topic": "Chủ đề để xuất bản",
"realtimeprocessing_enabled": "Bật xử lý thời gian thực",
"description_realtimeprocessing_enabled": "Gửi khung hình video thời gian thực qua MQTT."
},
"conditions": {
"timeofinterest": "Thời gian quan tâm",
"description_timeofinterest": "Chỉ ghi hình trong các khoảng thời gian cụ thể (dựa trên múi giờ).",
"timeofinterest_enabled": "Đã bật",
"timeofinterest_description_enabled": "Nếu bật, bạn có thể chỉ định các khoảng thời gian ghi hình.",
"sunday": "Chủ nhật",
"monday": "Thứ hai",
"tuesday": "Thứ ba",
"wednesday": "Thứ tư",
"thursday": "Thứ năm",
"friday": "Thứ sáu",
"saturday": "Thứ bảy",
"externalcondition": "Điều kiện bên ngoài",
"description_externalcondition": "Tùy thuộc vào một dịch vụ web bên ngoài, việc ghi hình có thể được bật hoặc tắt.",
"regionofinterest": "Khu vực quan tâm",
"description_regionofinterest": "Bằng cách xác định một hoặc nhiều khu vực, hệ thống sẽ chỉ theo dõi chuyển động trong các khu vực bạn đã chọn."
},
"persistence": {
"kerberoshub": "Kerberos Hub",
"description_kerberoshub": "Các Kerberos Agent có thể gửi tín hiệu nhịp tim đến một hệ thống trung tâm",
"description2_kerberoshub": "để đồng bộ hóa thông tin quan trọng với Kerberos Hub, giúp hiển thị trạng thái giám sát video theo thời gian thực.",
"persistence": "Lưu trữ",
"saasoffering": "Kerberos Hub (dịch vụ SAAS)",
"description_persistence": "Khả năng lưu trữ bản ghi là bước khởi đầu của mọi thứ. Bạn có thể chọn giữa dịch vụ của chúng tôi",
"description2_persistence": "hoặc một nhà cung cấp bên thứ ba.",
"select_persistence": "Chọn phương thức lưu trữ",
"kerberoshub_encryption": "Mã hóa",
"kerberoshub_encryption_description": "Tất cả lưu lượng đến/từ Kerberos Hub sẽ được mã hóa bằng AES-256.",
"kerberoshub_proxyurl": "URL Proxy Kerberos Hub",
"kerberoshub_description_proxyurl": "Điểm cuối Proxy để tải bản ghi lên.",
"kerberoshub_apiurl": "URL API Kerberos Hub",
"kerberoshub_description_apiurl": "Điểm cuối API để tải bản ghi lên.",
"kerberoshub_publickey": "Khóa công khai",
"kerberoshub_description_publickey": "Khóa công khai được cấp cho tài khoản Kerberos Hub của bạn.",
"kerberoshub_privatekey": "Khóa riêng tư",
"kerberoshub_description_privatekey": "Khóa riêng tư được cấp cho tài khoản Kerberos Hub của bạn.",
"kerberoshub_site": "Trang web",
"kerberoshub_description_site": "ID trang web mà các Kerberos Agent thuộc về trong Kerberos Hub.",
"kerberoshub_region": "Khu vực",
"kerberoshub_description_region": "Khu vực nơi chúng tôi lưu trữ bản ghi.",
"kerberoshub_bucket": "Kho lưu trữ",
"kerberoshub_description_bucket": "Kho lưu trữ nơi chúng tôi lưu trữ bản ghi.",
"kerberoshub_username": "Tên người dùng / Thư mục (phải khớp với tên người dùng Kerberos Hub)",
"kerberoshub_description_username": "Tên người dùng tài khoản Kerberos Hub của bạn.",
"kerberosvault_apiurl": "URL API Kerberos Vault",
"kerberosvault_description_apiurl": "API của Kerberos Vault",
"kerberosvault_provider": "Nhà cung cấp",
"kerberosvault_description_provider": "Nhà cung cấp nơi bản ghi của bạn sẽ được gửi đến.",
"kerberosvault_directory": "Thư mục (phải khớp với tên người dùng Kerberos Hub)",
"kerberosvault_description_directory": "Thư mục con nơi các bản ghi sẽ được lưu trữ trong nhà cung cấp của bạn.",
"kerberosvault_accesskey": "Khóa truy cập",
"kerberosvault_description_accesskey": "Khóa truy cập của tài khoản Kerberos Vault của bạn.",
"kerberosvault_secretkey": "Khóa bí mật",
"kerberosvault_description_secretkey": "Khóa bí mật của tài khoản Kerberos Vault của bạn.",
"dropbox_directory": "Thư mục",
"dropbox_description_directory": "Thư mục con nơi bản ghi sẽ được lưu trữ trong tài khoản Dropbox của bạn.",
"dropbox_accesstoken": "Mã truy cập",
"dropbox_description_accesstoken": "Mã truy cập của tài khoản / ứng dụng Dropbox của bạn.",
"verify_connection": "Xác minh kết nối",
"remove_after_upload": "Sau khi bản ghi được tải lên một hệ thống lưu trữ, bạn có thể muốn xóa chúng khỏi Kerberos Agent cục bộ.",
"remove_after_upload_description": "Xóa bản ghi sau khi chúng được tải lên thành công.",
"remove_after_upload_enabled": "Bật xóa sau khi tải lên"
}
}
}
+1 -25
View File
@@ -4,7 +4,6 @@ import {
doVerifyOnvif,
doVerifyHub,
doVerifyPersistence,
doVerifySecondaryPersistence,
doGetKerberosAgentTags,
doGetDashboardInformation,
doGetEvents,
@@ -108,28 +107,6 @@ export const verifyPersistence = (config, onSuccess, onError) => {
};
};
export const verifySecondaryPersistence = (config, onSuccess, onError) => {
return (dispatch) => {
doVerifySecondaryPersistence(
config,
() => {
dispatch({
type: 'VERIFY_SECONDARY_PERSISTENCE',
});
if (onSuccess) {
onSuccess();
}
},
(error) => {
const { data } = error.response.data;
if (onError) {
onError(data);
}
}
);
};
};
export const verifyHub = (config, onSuccess, onError) => {
return (dispatch) => {
doVerifyHub(
@@ -194,7 +171,7 @@ export const getDashboardInformation = (onSuccess, onError) => {
};
};
export const getEvents = (eventfilter, onSuccess, onError, append = false) => {
export const getEvents = (eventfilter, onSuccess, onError) => {
return (dispatch) => {
doGetEvents(
eventfilter,
@@ -203,7 +180,6 @@ export const getEvents = (eventfilter, onSuccess, onError, append = false) => {
type: 'GET_EVENTS',
events: data.events,
filter: eventfilter,
append,
});
if (onSuccess) {
onSuccess();
-19
View File
@@ -72,25 +72,6 @@ export function doVerifyPersistence(config, onSuccess, onError) {
});
}
export function doVerifySecondaryPersistence(config, onSuccess, onError) {
const endpoint = API.post(`persistence/secondary/verify`, {
...config,
});
endpoint
.then((res) => {
if (res.status !== 200) {
throw new Error(res.data);
}
return res.data;
})
.then((data) => {
onSuccess(data);
})
.catch((error) => {
onError(error);
});
}
export function doVerifyHub(config, onSuccess, onError) {
const endpoint = API.post(`hub/verify`, {
...config,
+3 -64
View File
@@ -7,7 +7,6 @@ import './ImageCanvas.css';
class ImageCanvas extends React.Component {
componentDidMount() {
this.isUnmounted = false;
this.width = 0;
this.height = 0;
@@ -59,9 +58,6 @@ class ImageCanvas extends React.Component {
const { image } = this.props;
this.loadImage(image, (img) => {
if (this.isUnmounted || !this.editor) {
return;
}
if (this.width !== img.width || this.height !== img.height) {
this.width = img.width;
this.height = img.height;
@@ -75,9 +71,6 @@ class ImageCanvas extends React.Component {
componentDidUpdate() {
const { image } = this.props;
this.loadImage(image, (img) => {
if (this.isUnmounted || !this.editor) {
return;
}
if (this.width !== img.width || this.height !== img.height) {
this.width = img.width;
this.height = img.height;
@@ -89,57 +82,11 @@ class ImageCanvas extends React.Component {
});
}
componentWillUnmount() {
this.isUnmounted = true;
if (this.pendingImage) {
this.pendingImage.onload = null;
this.pendingImage.src = '';
this.pendingImage = null;
}
if (this.editor) {
this.editor.onSelectionEnd = null;
this.editor.onRegionMoveEnd = null;
this.editor.onRegionDelete = null;
if (this.editor.RM) {
this.editor.RM.deleteAllRegions();
}
if (typeof this.editor.dispose === 'function') {
this.editor.dispose();
} else if (typeof this.editor.destroy === 'function') {
this.editor.destroy();
}
this.editor = null;
}
if (this.toolbarContainer) {
this.toolbarContainer.innerHTML = '';
this.toolbarContainer = null;
}
if (this.editorContainer) {
this.editorContainer.innerHTML = '';
this.editorContainer = null;
}
}
loadData = (image) => {
if (!this.editor) {
return;
}
const w = image.width;
const h = image.height;
this.editor.addContentSource(image).then(() => {
if (this.isUnmounted || !this.editor) {
return;
}
// Add exisiting polygons
this.editor.RM.deleteAllRegions();
const { polygons } = this.props;
@@ -205,19 +152,11 @@ class ImageCanvas extends React.Component {
// eslint-disable-next-line class-methods-use-this
loadImage = (path, onready) => {
if (this.pendingImage) {
this.pendingImage.onload = null;
}
const image = new Image();
this.pendingImage = image;
image.onload = (e) => {
if (this.pendingImage === image) {
this.pendingImage = null;
}
onready(e.target);
};
image.src = path;
image.addEventListener('load', (e) => {
onready(e.target);
});
};
// eslint-disable-next-line class-methods-use-this
+3 -3
View File
@@ -9,9 +9,9 @@ const dev = {
ENV: 'dev',
// Comment the below lines, when using codespaces or other special DNS names (which you can't control)
HOSTNAME: hostname,
API_URL: `${protocol}//${hostname}:8080/api`,
URL: `${protocol}//${hostname}:8080`,
WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
API_URL: `${protocol}//${hostname}:80/api`,
URL: `${protocol}//${hostname}:80`,
WS_URL: `${websocketprotocol}//${hostname}:80/ws`,
MODE: window['env']['mode'],
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
// HOSTNAME: externalHost,
+47 -433
View File
@@ -26,23 +26,6 @@ import {
import './Dashboard.scss';
import ReactTooltip from 'react-tooltip';
import config from '../../config';
import { getConfig } from '../../actions/agent';
function createUUID() {
if (
typeof window !== 'undefined' &&
window.crypto &&
typeof window.crypto.randomUUID === 'function'
) {
return window.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
const random = Math.floor(Math.random() * 16);
const value = char === 'x' ? random : 8 + Math.floor(random / 4);
return value.toString(16);
});
}
// eslint-disable-next-line react/prefer-stateless-function
class Dashboard extends React.Component {
@@ -50,55 +33,43 @@ class Dashboard extends React.Component {
super();
this.state = {
liveviewLoaded: false,
liveviewMode: 'webrtc',
open: false,
currentRecording: '',
initialised: false,
};
this.videoRef = React.createRef();
this.pendingRemoteCandidates = [];
this.initialiseLiveview = this.initialiseLiveview.bind(this);
this.initialiseSDLiveview = this.initialiseSDLiveview.bind(this);
this.startWebRTCLiveview = this.startWebRTCLiveview.bind(this);
this.handleWebRTCSignalMessage = this.handleWebRTCSignalMessage.bind(this);
this.fallbackToSDLiveview = this.fallbackToSDLiveview.bind(this);
}
componentDidMount() {
const { dispatchGetConfig } = this.props;
dispatchGetConfig(() => this.initialiseLiveview());
const liveview = document.getElementsByClassName('videocard-video');
if (liveview && liveview.length > 0) {
liveview[0].addEventListener('load', () => {
this.setState({
liveviewLoaded: true,
});
});
}
this.initialiseLiveview();
}
componentDidUpdate(prevProps) {
const { images, dashboard } = this.props;
const { liveviewLoaded, liveviewMode } = this.state;
const configLoaded = this.hasAgentConfig(this.props);
const prevConfigLoaded = this.hasAgentConfig(prevProps);
if (!prevConfigLoaded && configLoaded) {
this.initialiseLiveview();
}
if (
liveviewMode === 'sd' &&
!liveviewLoaded &&
prevProps.images !== images &&
images.length > 0
) {
this.setState({
liveviewLoaded: true,
});
}
if (!prevProps.dashboard.cameraOnline && dashboard.cameraOnline) {
this.initialiseLiveview();
}
componentDidUpdate() {
this.initialiseLiveview();
}
componentWillUnmount() {
this.stopSDLiveview();
this.stopWebRTCLiveview();
const liveview = document.getElementsByClassName('videocard-video');
if (liveview && liveview.length > 0) {
liveview[0].remove();
}
if (this.requestStreamSubscription) {
this.requestStreamSubscription.unsubscribe();
}
const { dispatchSend } = this.props;
const message = {
message_type: 'stop-sd',
};
dispatchSend(message);
}
handleClose() {
@@ -108,378 +79,32 @@ class Dashboard extends React.Component {
});
}
// eslint-disable-next-line react/sort-comp
hasAgentConfig(props) {
const currentProps = props || this.props;
const { config: configResponse } = currentProps;
return !!(configResponse && configResponse.config);
}
browserSupportsWebRTC() {
return (
typeof window !== 'undefined' &&
typeof window.RTCPeerConnection !== 'undefined'
);
}
buildPeerConnectionConfig() {
const { config: configResponse } = this.props;
const agentConfig =
configResponse && configResponse.config ? configResponse.config : {};
const iceServers = [];
if (agentConfig.stunuri) {
iceServers.push({
urls: [agentConfig.stunuri],
});
}
if (agentConfig.turnuri) {
const turnServer = {
urls: [agentConfig.turnuri],
};
if (agentConfig.turn_username) {
turnServer.username = agentConfig.turn_username;
}
if (agentConfig.turn_password) {
turnServer.credential = agentConfig.turn_password;
}
iceServers.push(turnServer);
}
return {
iceServers,
iceTransportPolicy: agentConfig.turn_force === 'true' ? 'relay' : 'all',
};
getCurrentTimestamp() {
return Math.round(Date.now() / 1000);
}
initialiseLiveview() {
const { initialised } = this.state;
const { dashboard } = this.props;
if (initialised || !dashboard.cameraOnline) {
return;
}
if (!this.hasAgentConfig()) {
return;
}
if (this.browserSupportsWebRTC()) {
this.startWebRTCLiveview();
} else {
this.fallbackToSDLiveview('WebRTC is not supported in this browser.');
}
}
initialiseSDLiveview() {
if (this.requestStreamSubscription) {
return;
}
const message = {
message_type: 'stream-sd',
};
const { connected, dispatchSend } = this.props;
if (connected) {
dispatchSend(message);
}
const requestStreamInterval = interval(2000);
this.requestStreamSubscription = requestStreamInterval.subscribe(() => {
const { connected: isConnected } = this.props;
if (isConnected) {
if (!initialised) {
const message = {
message_type: 'stream-sd',
};
const { connected, dispatchSend } = this.props;
if (connected) {
dispatchSend(message);
}
});
}
stopSDLiveview() {
if (this.requestStreamSubscription) {
this.requestStreamSubscription.unsubscribe();
this.requestStreamSubscription = null;
}
const { dispatchSend } = this.props;
dispatchSend({
message_type: 'stop-sd',
});
}
stopWebRTCLiveview() {
if (this.webrtcTimeout) {
window.clearTimeout(this.webrtcTimeout);
this.webrtcTimeout = null;
}
if (this.webrtcSocket) {
this.webrtcSocket.onopen = null;
this.webrtcSocket.onmessage = null;
this.webrtcSocket.onerror = null;
this.webrtcSocket.onclose = null;
this.webrtcSocket.close();
this.webrtcSocket = null;
}
if (this.webrtcPeerConnection) {
this.webrtcPeerConnection.ontrack = null;
this.webrtcPeerConnection.onicecandidate = null;
this.webrtcPeerConnection.onconnectionstatechange = null;
this.webrtcPeerConnection.close();
this.webrtcPeerConnection = null;
}
this.pendingRemoteCandidates = [];
this.webrtcOfferStarted = false;
this.webrtcSessionId = null;
this.webrtcClientId = null;
if (this.videoRef.current) {
this.videoRef.current.srcObject = null;
}
}
sendWebRTCMessage(messageType, message = {}) {
if (!this.webrtcSocket || this.webrtcSocket.readyState !== WebSocket.OPEN) {
return;
}
this.webrtcSocket.send(
JSON.stringify({
client_id: this.webrtcClientId,
message_type: messageType,
message,
})
);
}
async handleWebRTCSignalMessage(event) {
let data;
try {
data = JSON.parse(event.data);
} catch (error) {
return;
}
const { message_type: messageType, message = {} } = data;
const { session_id: sessionID, sdp, candidate } = message;
if (messageType === 'hello-back') {
await this.beginWebRTCLiveview();
return;
}
if (sessionID && sessionID !== this.webrtcSessionId) {
return;
}
switch (messageType) {
case 'webrtc-answer':
try {
await this.webrtcPeerConnection.setRemoteDescription({
type: 'answer',
sdp: window.atob(sdp),
});
await this.flushPendingRemoteCandidates();
} catch (error) {
this.fallbackToSDLiveview(
`Unable to apply WebRTC answer: ${error.message}`
);
const requestStreamInterval = interval(2000);
this.requestStreamSubscription = requestStreamInterval.subscribe(() => {
const { connected: isConnected } = this.props;
if (isConnected) {
dispatchSend(message);
}
break;
case 'webrtc-candidate': {
try {
const candidateInit = JSON.parse(candidate);
if (
this.webrtcPeerConnection.remoteDescription &&
this.webrtcPeerConnection.remoteDescription.type
) {
await this.webrtcPeerConnection.addIceCandidate(candidateInit);
} else {
this.pendingRemoteCandidates.push(candidateInit);
}
} catch (error) {
this.fallbackToSDLiveview(
`Unable to apply WebRTC candidate: ${error.message}`
);
}
break;
}
case 'webrtc-error':
this.fallbackToSDLiveview(
message.message || 'The agent could not start the WebRTC liveview.'
);
break;
default:
break;
}
}
async beginWebRTCLiveview() {
if (!this.webrtcPeerConnection || this.webrtcOfferStarted) {
return;
}
try {
this.webrtcOfferStarted = true;
const offer = await this.webrtcPeerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await this.webrtcPeerConnection.setLocalDescription(offer);
this.sendWebRTCMessage('stream-hd', {
session_id: this.webrtcSessionId,
sdp: window.btoa(this.webrtcPeerConnection.localDescription.sdp),
});
} catch (error) {
this.fallbackToSDLiveview(
`Unable to initialise WebRTC liveview: ${error.message}`,
);
}
}
async flushPendingRemoteCandidates() {
if (
!this.webrtcPeerConnection ||
!this.webrtcPeerConnection.remoteDescription
) {
return;
}
while (this.pendingRemoteCandidates.length > 0) {
const candidateInit = this.pendingRemoteCandidates.shift();
try {
// eslint-disable-next-line no-await-in-loop
await this.webrtcPeerConnection.addIceCandidate(candidateInit);
} catch (error) {
this.fallbackToSDLiveview(
`Unable to add remote ICE candidate: ${error.message}`,
);
return;
}
}
}
startWebRTCLiveview() {
if (this.webrtcPeerConnection || this.webrtcSocket) {
return;
}
this.stopSDLiveview();
this.webrtcClientId = createUUID();
this.webrtcSessionId = createUUID();
this.pendingRemoteCandidates = [];
this.webrtcPeerConnection = new window.RTCPeerConnection(
this.buildPeerConnectionConfig()
);
this.webrtcPeerConnection.ontrack = (event) => {
const [remoteStream] = event.streams;
if (this.videoRef.current && remoteStream) {
this.videoRef.current.srcObject = remoteStream;
const playPromise = this.videoRef.current.play();
if (playPromise && playPromise.catch) {
playPromise.catch(() => {});
}
}
this.setState({
liveviewLoaded: true,
});
};
this.webrtcPeerConnection.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
this.sendWebRTCMessage('webrtc-candidate', {
session_id: this.webrtcSessionId,
candidate: JSON.stringify(event.candidate.toJSON()),
});
};
this.webrtcPeerConnection.onconnectionstatechange = () => {
const { connectionState } = this.webrtcPeerConnection;
if (connectionState === 'connected') {
this.setState({
liveviewLoaded: true,
});
}
if (
connectionState === 'failed' ||
connectionState === 'disconnected' ||
connectionState === 'closed'
) {
this.fallbackToSDLiveview(
`WebRTC connection ${connectionState}, falling back to SD liveview.`,
);
}
};
this.webrtcSocket = new WebSocket(config.WS_URL);
this.webrtcSocket.onopen = () => {
this.sendWebRTCMessage('hello', {});
};
this.webrtcSocket.onmessage = this.handleWebRTCSignalMessage;
this.webrtcSocket.onerror = () => {
this.fallbackToSDLiveview('Unable to open the WebRTC signaling channel.');
};
this.webrtcSocket.onclose = () => {
const { liveviewLoaded } = this.state;
if (!liveviewLoaded) {
this.fallbackToSDLiveview('WebRTC signaling channel closed early.');
}
};
this.webrtcTimeout = window.setTimeout(() => {
const { liveviewLoaded } = this.state;
if (!liveviewLoaded) {
this.fallbackToSDLiveview(
'WebRTC connection timed out, falling back to SD liveview.'
);
}
}, 10000);
this.setState({
initialised: true,
liveviewLoaded: false,
liveviewMode: 'webrtc',
});
}
fallbackToSDLiveview(errorMessage) {
const { liveviewMode } = this.state;
if (liveviewMode === 'sd' && this.requestStreamSubscription) {
return;
}
this.stopWebRTCLiveview();
if (errorMessage) {
// eslint-disable-next-line no-console
console.warn(errorMessage);
}
this.setState(
{
initialised: true,
liveviewLoaded: false,
liveviewMode: 'sd',
},
() => {
this.initialiseSDLiveview();
}
);
});
}
}
openModal(file) {
@@ -491,8 +116,7 @@ class Dashboard extends React.Component {
render() {
const { dashboard, t, images } = this.props;
const { liveviewLoaded, liveviewMode, open, currentRecording } = this.state;
const listenerCount = dashboard.webrtcReaders ? dashboard.webrtcReaders : 0;
const { liveviewLoaded, open, currentRecording } = this.state;
// We check if the camera was getting a valid frame
// during the last 5 seconds, otherwise we assume the camera is offline.
@@ -546,6 +170,7 @@ class Dashboard extends React.Component {
divider="0"
footer={t('dashboard.total_recordings')}
/>
<Link to="/settings">
<Card
title="IP Camera"
@@ -684,9 +309,7 @@ class Dashboard extends React.Component {
)}
</div>
<div>
<h2>
{t('dashboard.live_view')} ({listenerCount})
</h2>
<h2>{t('dashboard.live_view')}</h2>
{(!liveviewLoaded || !isCameraOnline) && (
<SetupBox
btnicon="preferences"
@@ -703,16 +326,12 @@ class Dashboard extends React.Component {
liveviewLoaded && isCameraOnline ? 'visible' : 'hidden',
}}
>
{liveviewMode === 'webrtc' ? (
<video ref={this.videoRef} autoPlay muted playsInline />
) : (
<ImageCard
imageSrc={`data:image/png;base64, ${
images.length ? images[0] : ''
}`}
onerror=""
/>
)}
<ImageCard
imageSrc={`data:image/png;base64, ${
images.length ? images[0] : ''
}`}
onerror=""
/>
</div>
</div>
</div>
@@ -724,25 +343,20 @@ class Dashboard extends React.Component {
const mapStateToProps = (state /* , ownProps */) => ({
dashboard: state.agent.dashboard,
config: state.agent.config,
connected: state.wss.connected,
images: state.wss.images,
});
const mapDispatchToProps = (dispatch) => ({
dispatchSend: (message) => dispatch(send(message)),
dispatchGetConfig: (onSuccess, onError) =>
dispatch(getConfig(onSuccess, onError)),
});
Dashboard.propTypes = {
dashboard: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
connected: PropTypes.bool.isRequired,
images: PropTypes.array.isRequired,
t: PropTypes.func.isRequired,
dispatchSend: PropTypes.func.isRequired,
dispatchGetConfig: PropTypes.func.isRequired,
};
export default withTranslation()(
+21 -178
View File
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import {
Breadcrumb,
ControlBar,
VideoCard,
Button,
Modal,
@@ -17,53 +16,14 @@ import { getEvents } from '../../actions/agent';
import config from '../../config';
import './Media.scss';
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function getDefaultTimeWindow() {
const endDate = new Date();
const startDate = new Date(endDate.getTime() - 60 * 60 * 1000);
return {
startDateTime: formatDateTimeLocal(startDate),
endDateTime: formatDateTimeLocal(endDate),
timestamp_offset_start: Math.floor(startDate.getTime() / 1000),
timestamp_offset_end: Math.floor(endDate.getTime() / 1000) + 59,
};
}
function normalizeInputValue(valueOrEvent) {
if (valueOrEvent && valueOrEvent.target) {
return valueOrEvent.target.value;
}
return valueOrEvent;
}
// eslint-disable-next-line react/prefer-stateless-function
class Media extends React.Component {
constructor() {
super();
const defaultTimeWindow = getDefaultTimeWindow();
const initialFilter = {
timestamp_offset_start: defaultTimeWindow.timestamp_offset_start,
timestamp_offset_end: defaultTimeWindow.timestamp_offset_end,
number_of_elements: 12,
};
this.state = {
appliedFilter: initialFilter,
startDateTime: defaultTimeWindow.startDateTime,
endDateTime: defaultTimeWindow.endDateTime,
timestamp_offset_start: 0,
timestamp_offset_end: 0,
number_of_elements: 12,
isScrolling: false,
open: false,
currentRecording: '',
@@ -72,8 +32,7 @@ class Media extends React.Component {
componentDidMount() {
const { dispatchGetEvents } = this.props;
const { appliedFilter } = this.state;
dispatchGetEvents(appliedFilter);
dispatchGetEvents(this.state);
document.addEventListener('scroll', this.trackScrolling);
}
@@ -90,107 +49,29 @@ class Media extends React.Component {
trackScrolling = () => {
const { events, dispatchGetEvents } = this.props;
const { isScrolling, appliedFilter } = this.state;
const { isScrolling } = this.state;
const wrappedElement = document.getElementById('loader');
if (!wrappedElement || isScrolling || !this.isBottom(wrappedElement)) {
return;
}
this.setState({
isScrolling: true,
});
// Get last element
const lastElement = events[events.length - 1];
if (lastElement) {
dispatchGetEvents(
{
...appliedFilter,
if (!isScrolling && this.isBottom(wrappedElement)) {
this.setState({
isScrolling: true,
});
// Get last element
const lastElement = events[events.length - 1];
if (lastElement) {
this.setState({
timestamp_offset_end: parseInt(lastElement.timestamp, 10),
},
() => {
});
dispatchGetEvents(this.state, () => {
setTimeout(() => {
this.setState({
isScrolling: false,
});
}, 1000);
},
() => {
this.setState({
isScrolling: false,
});
},
true
);
} else {
this.setState({
isScrolling: false,
});
});
}
}
};
buildEventFilter(startDateTime, endDateTime) {
const { appliedFilter } = this.state;
return {
timestamp_offset_start: this.getTimestampFromInput(
startDateTime,
'start'
),
timestamp_offset_end: this.getTimestampFromInput(endDateTime, 'end'),
number_of_elements: appliedFilter.number_of_elements,
};
}
handleDateFilterChange(field, value) {
const { dispatchGetEvents } = this.props;
const { startDateTime, endDateTime } = this.state;
const normalizedValue = normalizeInputValue(value);
const nextStartDateTime =
field === 'startDateTime' ? normalizedValue : startDateTime;
const nextEndDateTime =
field === 'endDateTime' ? normalizedValue : endDateTime;
const nextFilter = this.buildEventFilter(
nextStartDateTime,
nextEndDateTime
);
const shouldApplyFilter =
(nextStartDateTime === '' || nextStartDateTime.length === 16) &&
(nextEndDateTime === '' || nextEndDateTime.length === 16);
this.setState(
{
[field]: normalizedValue,
appliedFilter: shouldApplyFilter
? nextFilter
: this.state.appliedFilter,
isScrolling: false,
},
() => {
if (shouldApplyFilter) {
dispatchGetEvents(nextFilter);
}
}
);
}
getTimestampFromInput(value, boundary) {
if (!value) {
return 0;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 0;
}
const seconds = Math.floor(date.getTime() / 1000);
if (boundary === 'end') {
return seconds + 59;
}
return seconds;
}
isBottom(el) {
return el.getBoundingClientRect().bottom + 50 <= window.innerHeight;
}
@@ -204,9 +85,7 @@ class Media extends React.Component {
render() {
const { events, eventsLoaded, t } = this.props;
const { isScrolling, open, currentRecording, startDateTime, endDateTime } =
this.state;
const { isScrolling, open, currentRecording } = this.state;
return (
<div id="media">
<Breadcrumb
@@ -223,37 +102,6 @@ class Media extends React.Component {
</Link>
</Breadcrumb>
<div className="media-control-bar">
<ControlBar>
<div className="media-filters">
<div className="media-filters__field">
<label htmlFor="recordings-start-time">Start time</label>
<input
className="media-filters__input"
id="recordings-start-time"
type="datetime-local"
value={startDateTime}
onChange={(value) =>
this.handleDateFilterChange('startDateTime', value)
}
/>
</div>
<div className="media-filters__field">
<label htmlFor="recordings-end-time">End time</label>
<input
className="media-filters__input"
id="recordings-end-time"
type="datetime-local"
value={endDateTime}
onChange={(value) =>
this.handleDateFilterChange('endDateTime', value)
}
/>
</div>
</div>
</ControlBar>
</div>
<div className="stats grid-container --four-columns">
{events.map((event) => (
<div
@@ -275,11 +123,6 @@ class Media extends React.Component {
</div>
))}
</div>
{events.length === 0 && eventsLoaded === 0 && (
<div className="media-empty-state">
No recordings found in the selected time range.
</div>
)}
{open && (
<Modal>
<ModalHeader
@@ -339,13 +182,13 @@ const mapStateToProps = (state /* , ownProps */) => ({
});
const mapDispatchToProps = (dispatch) => ({
dispatchGetEvents: (eventFilter, success, error, append) =>
dispatch(getEvents(eventFilter, success, error, append)),
dispatchGetEvents: (eventFilter, success, error) =>
dispatch(getEvents(eventFilter, success, error)),
});
Media.propTypes = {
t: PropTypes.func.isRequired,
events: PropTypes.arrayOf(PropTypes.object).isRequired,
events: PropTypes.objectOf(PropTypes.object).isRequired,
eventsLoaded: PropTypes.number.isRequired,
dispatchGetEvents: PropTypes.func.isRequired,
};
-99
View File
@@ -1,103 +1,4 @@
#media {
.media-control-bar {
.control-bar {
display: block;
padding: 0 var(--main-content-gutter);
}
.control-bar .filtering {
display: block;
}
.control-bar .filtering > * {
border-right: 0;
flex: 1 1 100% !important;
max-width: none;
width: 100%;
}
}
.media-filters {
align-items: stretch;
display: grid;
gap: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
width: 100%;
}
.media-filters__field {
border-right: 1px solid var(--bg-muted);
min-width: 0;
padding: 16px 24px;
label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
white-space: nowrap;
}
}
.media-filters__field:first-child {
padding-left: 0;
}
.media-filters__input {
appearance: none;
background: var(--white);
border: 1px solid var(--grey-light);
border-radius: 8px;
box-sizing: border-box;
color: var(--black);
font-size: 16px;
min-height: 48px;
padding: 0 14px 0 0;
width: 100%;
}
.media-filters__input::-webkit-datetime-edit,
.media-filters__input::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
.media-filters__input:focus {
border-color: var(--oss);
outline: 0;
}
.media-filters__field:first-child .media-filters__input {
padding-left: 0;
}
.media-filters__field:last-child {
border-right: 0;
padding-right: 0;
}
@media (max-width: 700px) {
.media-filters {
grid-template-columns: 1fr;
}
.media-filters__field {
border-right: 0;
border-bottom: 1px solid var(--bg-muted);
padding-left: 0;
padding-right: 0;
}
.media-filters__field:last-child {
border-bottom: 0;
}
}
.media-empty-state {
margin: 24px 0;
opacity: 0.8;
text-align: center;
}
#loader {
display: flex;
+1 -265
View File
@@ -33,7 +33,6 @@ import {
verifyCamera,
verifyHub,
verifyPersistence,
verifySecondaryPersistence,
getConfig,
updateConfig,
} from '../../actions/agent';
@@ -64,9 +63,6 @@ class Settings extends React.Component {
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifyPersistenceMessage: '',
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
verifySecondaryPersistenceMessage: '',
verifyCameraSuccess: false,
verifyCameraError: false,
verifyCameraMessage: '',
@@ -74,7 +70,6 @@ class Settings extends React.Component {
verifyOnvifError: false,
verifyOnvifErrorMessage: '',
loading: false,
loadingSecondary: false,
loadingHub: false,
loadingCamera: false,
};
@@ -130,8 +125,6 @@ class Settings extends React.Component {
this.onUpdateTimeline = this.onUpdateTimeline.bind(this);
this.initialiseLiveview = this.initialiseLiveview.bind(this);
this.verifyPersistenceSettings = this.verifyPersistenceSettings.bind(this);
this.verifySecondaryPersistenceSettings =
this.verifySecondaryPersistenceSettings.bind(this);
this.verifyHubSettings = this.verifyHubSettings.bind(this);
this.verifyCameraSettings = this.verifyCameraSettings.bind(this);
this.verifySubCameraSettings = this.verifySubCameraSettings.bind(this);
@@ -159,10 +152,7 @@ class Settings extends React.Component {
componentWillUnmount() {
document.removeEventListener('keydown', this.escFunction, false);
if (this.requestStreamSubscription) {
this.requestStreamSubscription.unsubscribe();
this.requestStreamSubscription = null;
}
clearInterval(this.interval);
const { dispatchSend } = this.props;
const message = {
@@ -360,8 +350,6 @@ class Settings extends React.Component {
configSuccess: false,
configError: false,
loadingCamera: false,
loading: false,
loadingSecondary: false,
loadingOnvif: true,
});
@@ -402,8 +390,6 @@ class Settings extends React.Component {
configError: false,
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
verifyHubSuccess: false,
verifyHubError: false,
verifyHubErrorMessage: '',
@@ -415,8 +401,6 @@ class Settings extends React.Component {
verifyOnvifSuccess: false,
verifyOnvifError: false,
loadingHub: true,
loading: false,
loadingSecondary: false,
});
// .... test fields
@@ -457,8 +441,6 @@ class Settings extends React.Component {
verifyHubError: false,
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
persistenceSuccess: false,
persistenceError: false,
verifyCameraSuccess: false,
@@ -467,7 +449,6 @@ class Settings extends React.Component {
verifyOnvifError: false,
verifyCameraErrorMessage: '',
loading: true,
loadingSecondary: false,
});
dispatchVerifyPersistence(
@@ -480,7 +461,6 @@ class Settings extends React.Component {
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
},
(error) => {
@@ -491,58 +471,6 @@ class Settings extends React.Component {
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
}
);
}
}
verifySecondaryPersistenceSettings() {
const { config, dispatchVerifySecondaryPersistence } = this.props;
if (config) {
this.setState({
configSuccess: false,
configError: false,
verifyHubSuccess: false,
verifyHubError: false,
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
persistenceSuccess: false,
persistenceError: false,
verifyCameraSuccess: false,
verifyCameraError: false,
verifyOnvifSuccess: false,
verifyOnvifError: false,
verifyCameraErrorMessage: '',
loading: false,
loadingSecondary: true,
});
dispatchVerifySecondaryPersistence(
config.config,
() => {
this.setState({
verifySecondaryPersistenceSuccess: true,
verifySecondaryPersistenceError: false,
verifySecondaryPersistenceMessage: '',
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
},
(error) => {
this.setState({
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: true,
verifySecondaryPersistenceMessage: error,
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
}
);
@@ -609,9 +537,6 @@ class Settings extends React.Component {
verifyPersistenceSuccess,
verifyPersistenceError,
verifyPersistenceMessage,
verifySecondaryPersistenceSuccess,
verifySecondaryPersistenceError,
verifySecondaryPersistenceMessage,
verifyCameraSuccess,
verifyCameraError,
verifyCameraErrorMessage,
@@ -621,7 +546,6 @@ class Settings extends React.Component {
verifyOnvifErrorMessage,
loadingCamera,
loading,
loadingSecondary,
loadingHub,
} = this.state;
@@ -874,20 +798,6 @@ class Settings extends React.Component {
)} :${verifyPersistenceMessage}`}
/>
)}
{verifySecondaryPersistenceSuccess && (
<InfoBar
type="success"
message={t('settings.info.verify_persistence_success')}
/>
)}
{verifySecondaryPersistenceError && (
<InfoBar
type="alert"
message={`${t(
'settings.info.verify_persistence_error'
)} :${verifySecondaryPersistenceMessage}`}
/>
)}
<div className="stats grid-container --two-columns">
<div>
{/* General settings block */}
@@ -2539,43 +2449,6 @@ class Settings extends React.Component {
)
}
/>
<Input
noPadding
label={t(
'settings.persistence.kerberosvault_maxretries'
)}
placeholder={t(
'settings.persistence.kerberosvault_description_maxretries'
)}
value={
config.kstorage ? config.kstorage.max_retries : ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage',
'max_retries',
value,
config.kstorage
)
}
/>
<Input
noPadding
label={t('settings.persistence.kerberosvault_timeout')}
placeholder={t(
'settings.persistence.kerberosvault_description_timeout'
)}
value={config.kstorage ? config.kstorage.timeout : ''}
onChange={(value) =>
this.onUpdateField(
'kstorage',
'timeout',
value,
config.kstorage
)
}
/>
</>
)}
{config.cloud === this.DROPBOX && (
@@ -2635,140 +2508,6 @@ class Settings extends React.Component {
</BlockFooter>
</Block>
)}
{/* Secondary Vault block */}
{showPersistenceSection && config.cloud === this.KERBEROS_VAULT && (
<Block>
<BlockHeader>
<h4>{t('settings.persistence.secondary_persistence')}</h4>
</BlockHeader>
<BlockBody>
<p>
{t(
'settings.persistence.description_secondary_persistence'
)}
</p>
<Input
noPadding
label={t('settings.persistence.kerberosvault_apiurl')}
placeholder={t(
'settings.persistence.kerberosvault_description_apiurl'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.uri
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'uri',
value,
config.kstorage_secondary
)
}
/>
<Input
noPadding
label={t('settings.persistence.kerberosvault_provider')}
placeholder={t(
'settings.persistence.kerberosvault_description_provider'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.provider
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'provider',
value,
config.kstorage_secondary
)
}
/>
<Input
noPadding
label={t('settings.persistence.kerberosvault_directory')}
placeholder={t(
'settings.persistence.kerberosvault_description_directory'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.directory
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'directory',
value,
config.kstorage_secondary
)
}
/>
<Input
type="password"
iconright="activity"
label={t('settings.persistence.kerberosvault_accesskey')}
placeholder={t(
'settings.persistence.kerberosvault_description_accesskey'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.access_key
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'access_key',
value,
config.kstorage_secondary
)
}
/>
<Input
type="password"
iconright="activity"
label={t('settings.persistence.kerberosvault_secretkey')}
placeholder={t(
'settings.persistence.kerberosvault_description_secretkey'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.secret_access_key
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'secret_access_key',
value,
config.kstorage_secondary
)
}
/>
</BlockBody>
<BlockFooter>
<Button
label={t('settings.persistence.verify_connection')}
disabled={loadingSecondary}
onClick={this.verifySecondaryPersistenceSettings}
type={loadingSecondary ? 'neutral' : 'default'}
icon="verify"
/>
<Button
label="Save"
type="submit"
onClick={this.saveConfig}
buttonType="submit"
icon="pencil"
/>
</BlockFooter>
</Block>
)}
</div>
</div>
</div>
@@ -2793,8 +2532,6 @@ const mapDispatchToProps = (dispatch /* , ownProps */) => ({
dispatch(verifyHub(config, success, error)),
dispatchVerifyPersistence: (config, success, error) =>
dispatch(verifyPersistence(config, success, error)),
dispatchVerifySecondaryPersistence: (config, success, error) =>
dispatch(verifySecondaryPersistence(config, success, error)),
dispatchGetConfig: (callback) => dispatch(getConfig(callback)),
dispatchUpdateConfig: (field, value) => dispatch(updateConfig(field, value)),
dispatchSaveConfig: (config, success, error) =>
@@ -2812,7 +2549,6 @@ Settings.propTypes = {
images: PropTypes.array.isRequired,
dispatchVerifyHub: PropTypes.func.isRequired,
dispatchVerifyPersistence: PropTypes.func.isRequired,
dispatchVerifySecondaryPersistence: PropTypes.func.isRequired,
dispatchGetConfig: PropTypes.func.isRequired,
dispatchUpdateConfig: PropTypes.func.isRequired,
dispatchSaveConfig: PropTypes.func.isRequired,
+6 -2
View File
@@ -123,12 +123,16 @@ const agent = (
};
case 'GET_EVENTS':
const { timestamp_offset_end } = action.filter;
const { events } = action;
return {
...state,
eventsLoaded: events.length,
events: action.append ? [...state.events, ...events] : [...events],
eventfilter: action.filter,
events:
timestamp_offset_end === 0
? [...events]
: [...state.events, ...events],
eventfilter: action.eventfilter,
};
default:
+4 -4
View File
@@ -1715,10 +1715,10 @@
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@kerberos-io/ui@^1.76.0":
version "1.77.0"
resolved "https://registry.yarnpkg.com/@kerberos-io/ui/-/ui-1.77.0.tgz#b748b2a9abf793ff2a9ba64ee41f84debc0ca9dc"
integrity sha512-CHh4jeLKwrYvJRL5PM3UEN4p2k1fqwMKgSF2U6IR4v0fE2FwPc/2Ry4zGk6pvLDFHbDpR9jUkHX+iNphvStoyQ==
"@kerberos-io/ui@^1.71.0":
version "1.71.0"
resolved "https://registry.yarnpkg.com/@kerberos-io/ui/-/ui-1.71.0.tgz#06914c94e8b0982068d2099acf8158917a511bfc"
integrity sha512-pHCTn/iQTcQEPoCK82eJHGRn6BgzW3wgV4C+mNqdKOtLTquxL+vh7molEgC66tl3DGf7HyjSNa8LuoxYbt9TEg==
dependencies:
"@emotion/react" "^11.10.4"
"@emotion/styled" "^11.10.4"